Merge pull request #21 from DiamondLightSource/SubprocessCompleted
Change the procrunner return object to a compatible derivate of the Python 3.5 subprocess.CompletedProcess object
Markus Gerstel authored 5 years ago
GitHub committed 5 years ago
5 | 5 | ------------------ |
6 | 6 | |
7 | 7 | * Support file system path objects (PEP-519) in arguments |
8 | * Change the return object to make it similar to | |
9 | subprocess.CompletedProcess, introduced with Python 3.5+ | |
8 | 10 | |
9 | 11 | 0.9.1 (2019-02-22) |
10 | 12 | ------------------ |
330 | 330 | return command |
331 | 331 | |
332 | 332 | |
333 | if sys.version_info < (3, 5): | |
334 | ||
335 | class _ReturnObjectParent(object): | |
336 | def check_returncode(self): | |
337 | if self.returncode: | |
338 | raise Exception( | |
339 | "Call %r resulted in non-zero exit code %r" | |
340 | % (self.args, self.returncode) | |
341 | ) | |
342 | ||
343 | ||
344 | else: | |
345 | _ReturnObjectParent = subprocess.CompletedProcess | |
346 | ||
347 | ||
348 | class ReturnObject(dict, _ReturnObjectParent): | |
349 | """ | |
350 | A subprocess.CompletedProcess-like object containing the executed | |
351 | command, stdout and stderr (both as bytestrings), and the exitcode. | |
352 | Further values such as process runtime can be accessed as dictionary | |
353 | values. | |
354 | The check_returncode() function raises an exception if the process | |
355 | exited with a non-zero exit code. | |
356 | """ | |
357 | ||
358 | def __init__(self, *arg, **kw): | |
359 | super(ReturnObject, self).__init__(*arg, **kw) | |
360 | self.args = self["command"] | |
361 | self.returncode = self["exitcode"] | |
362 | self.stdout = self["stdout"] | |
363 | self.stderr = self["stderr"] | |
364 | ||
365 | ||
333 | 366 | def run( |
334 | 367 | command, |
335 | 368 | timeout=None, |
369 | 402 | extension. |
370 | 403 | :param string working_directory: If specified, run the executable from |
371 | 404 | within this working directory. |
372 | :return: A dictionary containing stdout, stderr (both as bytestrings), | |
373 | runtime, exitcode, and more. | |
405 | :return: A ReturnObject() containing the executed command, stdout and stderr | |
406 | (both as bytestrings), and the exitcode. Further values such as | |
407 | process runtime can be accessed as dictionary values. | |
374 | 408 | """ |
375 | 409 | |
376 | 410 | time_start = time.strftime("%Y-%m-%d %H:%M:%S GMT", time.gmtime()) |
518 | 552 | stderr = stderr.get_output() |
519 | 553 | time_end = time.strftime("%Y-%m-%d %H:%M:%S GMT", time.gmtime()) |
520 | 554 | |
521 | result = { | |
522 | "exitcode": p.returncode, | |
523 | "command": command, | |
524 | "stdout": stdout, | |
525 | "stderr": stderr, | |
526 | "timeout": timeout_encountered, | |
527 | "runtime": runtime, | |
528 | "time_start": time_start, | |
529 | "time_end": time_end, | |
530 | } | |
555 | result = ReturnObject( | |
556 | { | |
557 | "exitcode": p.returncode, | |
558 | "command": command, | |
559 | "stdout": stdout, | |
560 | "stderr": stderr, | |
561 | "timeout": timeout_encountered, | |
562 | "runtime": runtime, | |
563 | "time_start": time_start, | |
564 | "time_end": time_end, | |
565 | } | |
566 | ) | |
531 | 567 | if stdin is not None: |
532 | 568 | result.update( |
533 | 569 | { |
551 | 587 | time_start = time.strftime("%Y-%m-%d %H:%M:%S GMT", time.gmtime()) |
552 | 588 | logger.info("run_process is disabled. Requested command: %s", command) |
553 | 589 | |
554 | result = { | |
555 | "exitcode": 0, | |
556 | "command": command, | |
557 | "stdout": "", | |
558 | "stderr": "", | |
559 | "timeout": False, | |
560 | "runtime": 0, | |
561 | "time_start": time_start, | |
562 | "time_end": time_start, | |
563 | } | |
590 | result = ReturnObject( | |
591 | { | |
592 | "exitcode": 0, | |
593 | "command": command, | |
594 | "stdout": "", | |
595 | "stderr": "", | |
596 | "timeout": False, | |
597 | "runtime": 0, | |
598 | "time_start": time_start, | |
599 | "time_end": time_start, | |
600 | } | |
601 | ) | |
564 | 602 | if kwargs.get("stdin") is not None: |
565 | 603 | result.update( |
566 | 604 | {"stdin_bytes_sent": len(kwargs["stdin"]), "stdin_bytes_remain": 0} |
93 | 93 | ) |
94 | 94 | assert not mock_process.terminate.called |
95 | 95 | assert not mock_process.kill.called |
96 | assert actual == expected | |
96 | for key in expected: | |
97 | assert actual[key] == expected[key] | |
98 | assert actual.args == tuple(command) | |
99 | assert actual.returncode == mock_process.returncode | |
100 | assert actual.stdout == mock.sentinel.proc_stdout | |
101 | assert actual.stderr == mock.sentinel.proc_stderr | |
97 | 102 | |
98 | 103 | |
99 | 104 | @mock.patch("procrunner.subprocess") |
253 | 258 | callback.assert_not_called() |
254 | 259 | aggregator.flush() |
255 | 260 | callback.assert_called_once_with("morestuff") |
261 | ||
262 | ||
263 | def test_return_object_semantics(): | |
264 | ro = procrunner.ReturnObject( | |
265 | { | |
266 | "command": mock.sentinel.command, | |
267 | "exitcode": 0, | |
268 | "stdout": mock.sentinel.stdout, | |
269 | "stderr": mock.sentinel.stderr, | |
270 | } | |
271 | ) | |
272 | assert ro["command"] == mock.sentinel.command | |
273 | assert ro.args == mock.sentinel.command | |
274 | assert ro["exitcode"] == 0 | |
275 | assert ro.returncode == 0 | |
276 | assert ro["stdout"] == mock.sentinel.stdout | |
277 | assert ro.stdout == mock.sentinel.stdout | |
278 | assert ro["stderr"] == mock.sentinel.stderr | |
279 | assert ro.stderr == mock.sentinel.stderr | |
280 | ||
281 | with pytest.raises(KeyError): | |
282 | ro["unknownkey"] | |
283 | ro.update({"unknownkey": mock.sentinel.key}) | |
284 | assert ro["unknownkey"] == mock.sentinel.key | |
285 | ||
286 | ||
287 | def test_return_object_check_function_passes_on_success(): | |
288 | ro = procrunner.ReturnObject( | |
289 | { | |
290 | "command": mock.sentinel.command, | |
291 | "exitcode": 0, | |
292 | "stdout": mock.sentinel.stdout, | |
293 | "stderr": mock.sentinel.stderr, | |
294 | } | |
295 | ) | |
296 | ro.check_returncode() | |
297 | ||
298 | ||
299 | def test_return_object_check_function_raises_on_error(): | |
300 | ro = procrunner.ReturnObject( | |
301 | { | |
302 | "command": mock.sentinel.command, | |
303 | "exitcode": 1, | |
304 | "stdout": mock.sentinel.stdout, | |
305 | "stderr": mock.sentinel.stderr, | |
306 | } | |
307 | ) | |
308 | with pytest.raises(Exception) as e: | |
309 | ro.check_returncode() | |
310 | assert repr(mock.sentinel.command) in str(e.value) | |
311 | assert "1" in str(e.value) |
14 | 14 | |
15 | 15 | result = procrunner.run(command) |
16 | 16 | |
17 | assert result["exitcode"] == 0 | |
18 | assert result["stdout"] == b"hello" + os.linesep.encode("utf-8") | |
19 | assert result["stderr"] == b"" | |
17 | assert result.returncode == 0 | |
18 | assert result.stdout == b"hello" + os.linesep.encode("utf-8") | |
19 | assert result.stderr == b"" | |
20 | 20 | |
21 | 21 | |
22 | 22 | def test_decode_invalid_utf8_input(capsys): |
27 | 27 | else: |
28 | 28 | command = ["cat"] |
29 | 29 | result = procrunner.run(command, stdin=test_string) |
30 | assert result["exitcode"] == 0 | |
31 | assert not result["stderr"] | |
30 | assert result.returncode == 0 | |
31 | assert not result.stderr | |
32 | 32 | if os.name == "nt": |
33 | 33 | # Windows modifies line endings |
34 | assert result["stdout"] == test_string[:-1] + b"\r\n" | |
34 | assert result.stdout == test_string[:-1] + b"\r\n" | |
35 | 35 | else: |
36 | assert result["stdout"] == test_string | |
36 | assert result.stdout == test_string | |
37 | 37 | out, err = capsys.readouterr() |
38 | 38 | assert out == u"test\ufffdstring\n" |
39 | 39 | assert err == u"" |
48 | 48 | if e.errno == 2: |
49 | 49 | pytest.skip("wget not available") |
50 | 50 | raise |
51 | assert result["exitcode"] == 0 | |
52 | assert b"http" in result["stderr"] | |
53 | assert b"google" in result["stdout"] | |
51 | assert result.returncode == 0 | |
52 | assert b"http" in result.stderr | |
53 | assert b"google" in result.stdout | |
54 | 54 | |
55 | 55 | |
56 | 56 | def test_path_object_resolution(tmpdir): |
57 | sentinel_value = "sentinel" | |
57 | sentinel_value = b"sentinel" | |
58 | 58 | tmpdir.join("tempfile").write(sentinel_value) |
59 | 59 | tmpdir.join("reader.py").write("print(open('tempfile').read())") |
60 | command = [sys.executable, tmpdir.join("reader.py")] | |
61 | 60 | result = procrunner.run( |
62 | command, | |
61 | [sys.executable, tmpdir.join("reader.py")], | |
63 | 62 | environment_override={"PYTHONHASHSEED": "random"}, |
64 | 63 | working_directory=tmpdir, |
65 | 64 | ) |
66 | assert result["exitcode"] == 0 | |
67 | assert not result["stderr"] | |
68 | assert sentinel_value == result["stdout"].strip().decode() | |
65 | assert result.returncode == 0 | |
66 | assert not result.stderr | |
67 | assert sentinel_value == result.stdout.strip() |