Merge lp:~salgado/linaro-image-tools/cmd-runner into lp:linaro-image-tools/11.11
- cmd-runner
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 179 |
Proposed branch: | lp:~salgado/linaro-image-tools/cmd-runner |
Merge into: | lp:linaro-image-tools/11.11 |
Diff against target: |
129 lines (+90/-4) 2 files modified
media_create/cmd_runner.py (+37/-0) media_create/tests/test_media_create.py (+53/-4) |
To merge this branch: | bzr merge lp:~salgado/linaro-image-tools/cmd-runner |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
James Westby (community) | Approve | ||
Review via email: mp+42292@code.launchpad.net |
Commit message
Description of the change
In several places we will need to use subprocess.Popen to run external
commands, but we don't really want to run these commands when testing, so this
new function will make it easy for us to test these things.
- 176. By Guilherme Salgado
-
Add an XXX for a question to the reviewer
James Westby (james-w) wrote : | # |
Peter Maydell (pmaydell) wrote : | # |
12 + :param shell: Should the given command be run in a shell?
I don't think we should have this, we should never be running things via the shell. subprocess.Popen() should give you everything you need.
15 + if isinstance(command, (list, tuple)):
16 + command = " ".join(command)
This is going to do the wrong thing if Shell=false -- in that case you must pass Popen a list of program and arguments, you can't pass it a string. And as James says there are quoting issues.
24 + # XXX: Should we raise an error when the return code is not 0, so that it
25 + # behaves like the original shell script which was run with 'set -e'?
26 + return do_run(command, shell=shell)
"set -e" is better than "blithely ignore errors and continue" which is the only other behaviour that's straightforward to obtain in shell scripts. However it means that the error reporting is very poor, and it tends to fail silently or incomprehensibly rather than with some useful error. One of the advantages of moving to python is that we can do better than this. So run() should report failures in whatever the sensible pythonic fashion is, and all its callers should handle them. The obvious choice is just to follow the same semantics as subprocess.Popen(), ie return codes are returned but things like "couldn't find that command binary" throw exceptions.
Are we going to want to do more advanced things than just "run this command", like "run this command and capture the output", or "run command A and pipe the output to B"? If so then perhaps we should just have a wrapper around Popen() which provides exactly the same semantics with extra logging.
29 +def do_run(command, shell):
30 + proc = subprocess.
31 + proc.wait()
32 + return proc.returncode
Am I missing something, or is this function just equivalent to subprocess.call() ?
Peter Maydell (pmaydell) wrote : | # |
> with extra logging
...er, or stubbing out for running in test mode.
James Westby (james-w) wrote : | # |
On Tue, 30 Nov 2010 20:27:28 -0000, James Westby <email address hidden> wrote:
> Do we need shell=True? I assume we do, but using it as little as possible
> would be good.
Sorry, seems I wasn't really paying attention to that part of the code.
Thanks,
James
Paul Larson (pwlars) wrote : | # |
Do you think this might ever be used to run something that takes a while to complete? If so, you are not going to see any of the output until the whole command completes. Unfortunately, the only solutions I've seen to that are a little ugly and choke with sudo unless you handle it specifically.
James Westby (james-w) wrote : | # |
On Wed, 01 Dec 2010 05:31:55 -0000, Paul Larson <email address hidden> wrote:
> Do you think this might ever be used to run something that takes a
> while to complete? If so, you are not going to see any of the output
> until the whole command completes.
Really? That's not my experience. Am I missing something in this code
that will cause that?
Thanks,
James
Guilherme Salgado (salgado) wrote : | # |
On Tue, 2010-11-30 at 20:52 +0000, Peter Maydell wrote:
> 12 + :param shell: Should the given command be run in a shell?
>
> I don't think we should have this, we should never be running things via the shell. subprocess.Popen() should give you everything you need.
>
> 15 + if isinstance(command, (list, tuple)):
> 16 + command = " ".join(command)
>
> This is going to do the wrong thing if Shell=false -- in that case you must pass Popen a list of program and arguments, you can't pass it a string. And as James says there are quoting issues.
>
I'm happy to drop the shell argument. I don't know why I added it in
the first place as none of the existing callsites for subprocess.Popen()
use it anyway.
> 24 + # XXX: Should we raise an error when the return code is not 0, so that it
> 25 + # behaves like the original shell script which was run with 'set -e'?
> 26 + return do_run(command, shell=shell)
>
> "set -e" is better than "blithely ignore errors and continue" which is the only other behaviour that's straightforward to obtain in shell scripts. However it means that the error reporting is very poor, and it tends to fail silently or incomprehensibly rather than with some useful error. One of the advantages of moving to python is that we can do better than this. So run() should report failures in whatever the sensible pythonic fashion is, and all its callers should handle them. The obvious choice is just to follow the same semantics as subprocess.Popen(), ie return codes are returned but things like "couldn't find that command binary" throw exceptions.
>
Right, but my point here is that callsites may forget to check the
return-value, silently ignoring errors in sub-processes. If we agree
it's fair to assume that any sub-process that returns non-zero should
cause the script to abort, then in that case raising an exception on
non-zero return codes is the best way to ensure it always happens.
Callsites can (and should) catch these exceptions and report meaningful
errors but I don't like the idea of relying solely on callsites for
that.
> Are we going to want to do more advanced things than just "run this command", like "run this command and capture the output", or "run command A and pipe the output to B"? If so then perhaps we should just have a wrapper around Popen() which provides exactly the same semantics with extra logging.
That's possible, but I'd prefer if we add these new bits as they become
necessary.
>
> 29 +def do_run(command, shell):
> 30 + proc = subprocess.
> 31 + proc.wait()
> 32 + return proc.returncode
>
> Am I missing something, or is this function just equivalent to subprocess.call() ?
Looks like it is; I'll use it here instead of the above.
James Westby (james-w) wrote : | # |
On Wed, 01 Dec 2010 17:43:52 -0000, Guilherme Salgado <email address hidden> wrote:
> Right, but my point here is that callsites may forget to check the
> return-value, silently ignoring errors in sub-processes. If we agree
> it's fair to assume that any sub-process that returns non-zero should
> cause the script to abort, then in that case raising an exception on
> non-zero return codes is the best way to ensure it always happens.
>
> Callsites can (and should) catch these exceptions and report meaningful
> errors but I don't like the idea of relying solely on callsites for
> that.
+1
> That's possible, but I'd prefer if we add these new bits as they become
> necessary.
+1
Thanks,
James
- 177. By Guilherme Salgado
-
Refactor a bunch of things taking into account Peter's and James' comments
Guilherme Salgado (salgado) wrote : | # |
This looks much better now. Do you guys think it's good enough to land?
James Westby (james-w) wrote : | # |
Yes, I do.
I'm not sure there is much point in returning the return code now, but
it's not a problem to do so.
Thanks,
James
Preview Diff
1 | === added file 'media_create/cmd_runner.py' |
2 | --- media_create/cmd_runner.py 1970-01-01 00:00:00 +0000 |
3 | +++ media_create/cmd_runner.py 2010-12-01 19:11:36 +0000 |
4 | @@ -0,0 +1,37 @@ |
5 | +import subprocess |
6 | + |
7 | + |
8 | +def run(args, as_root=False): |
9 | + """Run the given command as a sub process. |
10 | + |
11 | + :param command: A list or tuple containing the command to run and the |
12 | + arguments that should be passed to it. |
13 | + :param as_root: Should the given command be run as root (with sudo)? |
14 | + """ |
15 | + assert isinstance(args, (list, tuple)), ( |
16 | + "The command to run must be a list or tuple, found: %s" % type(args)) |
17 | + # TODO: We might want to always use 'sudo -E' here to avoid problems like |
18 | + # https://launchpad.net/bugs/673570 |
19 | + if as_root: |
20 | + args = args[:] |
21 | + args.insert(0, 'sudo') |
22 | + return_value = do_run(args) |
23 | + if return_value != 0: |
24 | + raise SubcommandNonZeroReturnValue(args, return_value) |
25 | + return return_value |
26 | + |
27 | + |
28 | +def do_run(args): |
29 | + """A wrapper around subprocess.call() to make testing easier.""" |
30 | + return subprocess.call(args) |
31 | + |
32 | + |
33 | +class SubcommandNonZeroReturnValue(Exception): |
34 | + |
35 | + def __init__(self, command, return_value): |
36 | + self.command = command |
37 | + self.retval = return_value |
38 | + |
39 | + def __str__(self): |
40 | + return 'Sub process "%s" returned a non-zero value: %d' % ( |
41 | + self.command, self.retval) |
42 | |
43 | === modified file 'media_create/tests/test_media_create.py' |
44 | --- media_create/tests/test_media_create.py 2010-11-30 12:35:27 +0000 |
45 | +++ media_create/tests/test_media_create.py 2010-12-01 19:11:36 +0000 |
46 | @@ -8,6 +8,7 @@ |
47 | from hwpack.testing import TestCaseWithFixtures |
48 | |
49 | from media_create.boot_cmd import create_boot_cmd |
50 | +from media_create import cmd_runner |
51 | from media_create import ensure_command |
52 | |
53 | from media_create.remove_binary_dir import remove_binary_dir |
54 | @@ -75,7 +76,7 @@ |
55 | super(TestRemoveBinaryDir, self).setUp() |
56 | self.temp_dir_fixture = CreateTempDirFixture() |
57 | self.useFixture(self.temp_dir_fixture) |
58 | - |
59 | + |
60 | def test_remove_binary_dir(self): |
61 | rc = remove_binary_dir( |
62 | binary_dir=self.temp_dir_fixture.get_temp_dir(), |
63 | @@ -89,15 +90,63 @@ |
64 | |
65 | def setUp(self): |
66 | super(TestUnpackBinaryTarball, self).setUp() |
67 | - |
68 | + |
69 | self.temp_dir_fixture = CreateTempDirFixture() |
70 | self.useFixture(self.temp_dir_fixture) |
71 | - |
72 | + |
73 | self.tarball_fixture = CreateTarballFixture( |
74 | self.temp_dir_fixture.get_temp_dir()) |
75 | self.useFixture(self.tarball_fixture) |
76 | - |
77 | + |
78 | def test_unpack_binary_tarball(self): |
79 | rc = unpack_binary_tarball(self.tarball_fixture.get_tarball(), |
80 | as_root=False) |
81 | self.assertEqual(rc, 0) |
82 | + |
83 | + |
84 | +@contextmanager |
85 | +def do_run_mocked(mock): |
86 | + orig_func = cmd_runner.do_run |
87 | + cmd_runner.do_run = mock |
88 | + yield |
89 | + cmd_runner.do_run = orig_func |
90 | + |
91 | + |
92 | +class MockDoRun(object): |
93 | + """A mock for do_run() which just stores the args given to it.""" |
94 | + args = None |
95 | + def __call__(self, args): |
96 | + self.args = args |
97 | + return 0 |
98 | + |
99 | + |
100 | +class TestCmdRunner(TestCase): |
101 | + |
102 | + def test_run(self): |
103 | + mock = MockDoRun() |
104 | + with do_run_mocked(mock): |
105 | + return_code = cmd_runner.run(['foo', 'bar', 'baz']) |
106 | + self.assertEqual(0, return_code) |
107 | + self.assertEqual(['foo', 'bar', 'baz'], mock.args) |
108 | + |
109 | + def test_run_as_root(self): |
110 | + mock = MockDoRun() |
111 | + with do_run_mocked(mock): |
112 | + cmd_runner.run(['foo', 'bar'], as_root=True) |
113 | + self.assertEqual(['sudo', 'foo', 'bar'], mock.args) |
114 | + |
115 | + def test_run_succeeds_on_zero_return_code(self): |
116 | + return_code = cmd_runner.run(['true']) |
117 | + self.assertEqual(0, return_code) |
118 | + |
119 | + def test_run_raises_exception_on_non_zero_return_code(self): |
120 | + self.assertRaises( |
121 | + cmd_runner.SubcommandNonZeroReturnValue, |
122 | + cmd_runner.run, ['false']) |
123 | + |
124 | + def test_run_must_be_given_list_as_args(self): |
125 | + self.assertRaises(AssertionError, cmd_runner.run, 'true') |
126 | + |
127 | + def test_do_run(self): |
128 | + return_code = cmd_runner.do_run('true') |
129 | + self.assertEqual(0, return_code) |
Hi,
15 + if isinstance(command, (list, tuple)):
16 + command = " ".join(command)
Do we want to do this and risk bugs with shell quoting?
24 + # XXX: Should we raise an error when the return code is not 0, so that it
25 + # behaves like the original shell script which was run with 'set -e'?
I think so.
Do we need shell=True? I assume we do, but using it as little as possible
would be good.
Thanks,
James