Merge lp:~salgado/linaro-image-tools/cmd-runner into lp:linaro-image-tools/11.11

Proposed by Guilherme Salgado
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
Reviewer Review Type Date Requested Status
James Westby (community) Approve
Review via email: mp+42292@code.launchpad.net

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.

To post a comment you must log in.
176. By Guilherme Salgado

Add an XXX for a question to the reviewer

Revision history for this message
James Westby (james-w) wrote :

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

Revision history for this message
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.Popen(command, shell=shell)
31 + proc.wait()
32 + return proc.returncode

Am I missing something, or is this function just equivalent to subprocess.call() ?

Revision history for this message
Peter Maydell (pmaydell) wrote :

> with extra logging

...er, or stubbing out for running in test mode.

Revision history for this message
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

Revision history for this message
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.

Revision history for this message
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

Revision history for this message
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.Popen(command, shell=shell)
> 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.

Revision history for this message
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

Revision history for this message
Guilherme Salgado (salgado) wrote :

This looks much better now. Do you guys think it's good enough to land?

Revision history for this message
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

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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)

Subscribers

People subscribed via source and target branches