Merge lp:~mikemc/dirspec/add-exefind into lp:dirspec

Proposed by Mike McCracken on 2012-07-06
Status: Merged
Merged at revision: 8
Proposed branch: lp:~mikemc/dirspec/add-exefind
Merge into: lp:dirspec
Diff against target: 257 lines (+215/-1)
2 files modified
dirspec/tests/test_utils.py (+141/-1)
dirspec/utils.py (+74/-0)
To merge this branch: bzr merge lp:~mikemc/dirspec/add-exefind
Reviewer Review Type Date Requested Status
dobey (community) 2012-07-06 Needs Fixing on 2012-07-06
Review via email: mp+113782@code.launchpad.net

Commit Message

- Add function for getting executable command lines across projects and platforms and packagings (LP: #1021833)

Description of the Change

- Add function for getting executable command lines across projects and platforms and packagings (LP: #1021833)

To post a comment you must log in.
dobey (dobey) wrote :

First thing I notice this will need is an __all__ to only export the public method.

Also, I don't like the naming, and I think we should probably have a better fallback for Linux/BSD/etc… We'll also need to deal with varying python executable name, such as python3 instead of python.

review: Needs Fixing
lp:~mikemc/dirspec/add-exefind updated on 2012-07-09
9. By Mike McCracken on 2012-07-09

Add support for installed linux using generated-at-install module.constants to find BIN_DIR
Avoid using 'python ' + exe on non-mac/win
Refactor tests.

Mike McCracken (mikemc) wrote :

rev 9 addresses the __all__ attr, and the issue of a linux fallback.

On linux, the difference between installed and not installed is not the sys.frozen attribute.
I've adapted the approach from ubuntu_sso.utils.get_bin_dir:
On linux, if we are running from source, we find the path relative to the module, eg. srctree/bin next to srctree/ubuntu_sso. If there's something there, we use it, otherwise we look at the generated constants.py that is generated when setup.py is run.

Clients of this code other than ubuntu-sso-client would need to add a constants.py with a BIN_DIR attribute to work the same way. However, only sso-client currently launches any processes using Popen on linux - the other projects use DBus.

I am still open to suggestions about what to do wrt. the python exe name - I don't completely understand when & why that'll change.

Mike McCracken (mikemc) wrote :

Also, please elaborate on your naming concerns. I'm open to suggestions!

lp:~mikemc/dirspec/add-exefind updated on 2012-07-09
10. By Mike McCracken on 2012-07-09

Remove an unused line

dobey (dobey) wrote :

I don't like the name, because it's platform-specific, as there are no .exes on OSX or Linux generally (C# and things under WINE, notwithstanding). Also, dirspec already has a utils.py module, and this is but a utility function, albeit with a few additional private helper functions.

Also, I'm really not liking the API, now that I've thought about it more. The idea of having a special module in one's code that gets passed in, and must have specific platform-dependent constants in it, is rather nasty. I think all these things the code is poking at a module for, would be better passed in as kwargs on the entry point method.

As for the python prefix issue, we can't just prepend it anyway. It won't work very well for things which aren't written in Python. If someone else wanted to use the library in their code to launch a non-python application, for whatever reason, prefixing python here would break that. So I'd rather avoid us having that here if possible. It would be nice if we could find some other way to solve this problem for running programs from tree, on Windows/OS X. Maybe I don't understand that problem fully enough yet, and need to have it explained better to me, but it seems like a separate problem from the need to avoid duplicating the code to find the files on disk to run.

Mike McCracken (mikemc) wrote :

naming - no prob moving it to utils. call it utils.get_cmdline

originally wanted an API as smart as possible, otherwise we're not removing much complexity from the callsites.
But I don't disagree with the comments about the module pokery. It's
not a very intuitive API.

So, here's what it'd look like if we avoided module pokery:

We need to tell it the name of the executable we're looking for, and
three extra things:

- what directory to look in , or near (best we can do is pass in
       __file__ from something)
-- Unless we give it more info, like I was by passing the module, all
   it can do is look for a bin/ in cwd, then cwd/.. , etc...

- only for darwin: what the name of the sub-app will be if packaged.

- only for linux: we need to give it a way to get the compiled-in
  BIN_DIR constant for SSO, which is at ubuntu_sso.constants.BIN_DIR

Giving all this explicitly (and we always need to give all the args,
because the same code should work on all platforms) makes a kind of
messy call site:

cmdline = get_cmdline('ubuntuone-syncdaemon',
  ubuntuone.syncdaemon.__file__,
  "Ubuntu One Syncdaemon.app") # no constants module

cmdline = get_cmdline('ubuntu-sso-login-qt,
  ubuntu_sso.__file__,
  "Ubuntu SSO.app", # or, probably DARWIN_APP_NAMES['ubuntu-sso-login-qt']
  dir_constants=ubuntu_sso.constants)

# here's what the function's signature would look like, with some overly descriptive names that I didn't think about too hard:

def get_cmdline(executable_name, some_path_in_same_project_tree, darwin_subapp_name, linux_installed_constants_module=None)

Mike McCracken (mikemc) wrote :

Regarding prepending 'python ', the issue is that the scripts now have
an absolute path to /usr/bin/python, but on windows and darwin we have
a buildout-generated python wrapper that sets up PYTHONPATH to point
to the eggs in the buildout directory, etc, so we need to use that.

One option would be to avoid using the buildout python, and either
just set PYTHONPATH to include all the eggs while developing, or use
virtualenv, or something. However, that's a rather big change.

If we want to stick with the buildout python, we need to just prepend
'python' to the cmdline and use Popen(shell=True).

All that said, I think it'd be fine to move that out of this general
library function. There'd just be a bit of extra code after we call it
each time:

cmdline = get_cmdline(blah)
if getattr(sys, "frozen", None) is None and
   sys.platform in ('win32','darwin'):
   # then we are in a source tree, using the buildout python wrapper,
   # and we need to make sure we run it using that, so that its path
   # makes sense.
   cmdline = 'python ' + cmdline

for ease of writing tests, we might also want to wrap that in a
function on the client end (ie, not in dirspec)

lp:~mikemc/dirspec/add-exefind updated on 2012-07-09
11. By Mike McCracken on 2012-07-09

- Move get_cmdline into utils.py

- Don't prepend 'python ' to cmdline, this is too specific to buildout
  python and should be done by clients of this code.

dobey (dobey) wrote :

So, for the case where all the arguments are passed in, the API doesn't have to be overly complex to allow for it. We can simply require them to be passed in **kwargs, and document what they are called. This can keep the basic API nice and simple, and we can have additional things for the cases where we need them.

dobey (dobey) wrote :

This is more what I was thinking, in terms of having a 'smarter' API, that we could just use via kwargs, and simply state in the docstring what kwargs are available for use, though they aren't required: http://bazaar.launchpad.net/~dobey/dirspec/add-exefind/revision/12

I haven't updated the docstring in that revision, but there are the fallback_dirs= and app_names= kwargs, to specify a list of fallback directories, and a dict of app names for darwin.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'dirspec/tests/test_utils.py'
2--- dirspec/tests/test_utils.py 2012-06-26 15:25:21 +0000
3+++ dirspec/tests/test_utils.py 2012-07-09 22:27:20 +0000
4@@ -14,11 +14,15 @@
5 # You should have received a copy of the GNU Lesser General Public License
6 # along with this program. If not, see <http://www.gnu.org/licenses/>.
7 """Tests for utilities for the base directory implementation."""
8+import imp
9 import os
10 import sys
11
12+from twisted.trial.unittest import TestCase
13+
14 from dirspec import basedir
15-from dirspec.utils import get_env_path, get_special_folders, user_home
16+from dirspec.utils import (get_env_path, get_special_folders,
17+ user_home, get_cmdline)
18 from dirspec.tests import BaseTestCase
19
20
21@@ -150,3 +154,139 @@
22 self.assertEqual(get_env_path(fake_env_var, default), default)
23 test_get_env_path_var.skip = 'UnicodeEncodeError: bug #907053'
24 test_get_env_path_no_var.skip = test_get_env_path_var.skip
25+
26+
27+class ExeFindBaseTestCase(TestCase):
28+ """Base class for testing the executable finder."""
29+
30+ def setUp(self):
31+ """Set up fake modules."""
32+ super(ExeFindBaseTestCase, self).setUp()
33+ self.fakemod = imp.new_module("fun.bar.baz")
34+ self.fakemod.__file__ = "/path/to/fun/bar/baz"
35+ self.faketoplevelmod = imp.new_module("toplevel")
36+ self.faketoplevelmod.__file__ = "/path/to/toplevel"
37+ self.patch(os.path, "exists", lambda x: True)
38+
39+
40+class UnfrozenSrcTestCase(ExeFindBaseTestCase):
41+ """Test nonlinux cmdline using a wrapped python."""
42+
43+ def setUp(self):
44+ super(UnfrozenSrcTestCase, self).setUp()
45+ self.patch(sys, "platform", "darwin")
46+
47+ def test_unfrozen_dev_submodule(self):
48+ """Not frozen, return path to bin dir."""
49+ path = get_cmdline("foo", self.fakemod)
50+ self.assertEquals(path, "/path/to/bin/foo")
51+
52+ def test_unfrozen_dev_toplevel(self):
53+ """Not frozen, return python + path to bin dir."""
54+ path = get_cmdline("foo", self.faketoplevelmod)
55+ self.assertEquals(path, "/path/to/bin/foo")
56+
57+ def test_unfrozen_dev_toplevel_raises_nopath(self):
58+ """Not frozen, raise OSError when the path doesn't exist."""
59+ self.patch(os.path, "exists", lambda x: False)
60+ self.assertRaises(OSError, get_cmdline, "foo",
61+ self.faketoplevelmod)
62+
63+
64+class DarwinPkgdTestCase(ExeFindBaseTestCase):
65+ """Test cmdline for running packaged on darwin."""
66+
67+ def setUp(self):
68+ """SetUp to mimic frozen darwin."""
69+ super(DarwinPkgdTestCase, self).setUp()
70+ self.patch(sys, "platform", "darwin")
71+ sys.frozen = True
72+
73+ self.fakeappmod = imp.new_module("modinapp")
74+ self.fakeappmod.__file__ = "/path/to/Main.app/subpath/to/code"
75+ self.fakeappmod.DARWIN_APP_NAMES = {"foo": "Foo.app"}
76+
77+ def tearDown(self):
78+ """tearDown, Remove frozen attr"""
79+ del sys.frozen
80+
81+ def test_darwin_pkgd(self):
82+ """Return sub-app path on darwin when frozen."""
83+ path = get_cmdline("foo", self.fakeappmod)
84+ expectedpath = os.path.join("/path/to/Main.app/Contents/Resources/",
85+ "Foo.app/Contents/MacOS/foo")
86+ self.assertEquals(path, expectedpath)
87+
88+ def test_darwin_pkgd_raises_on_no_appnames(self):
89+ """Raise AttributeError when no APP_NAMES dict is in the module."""
90+ self.assertRaises(AttributeError, get_cmdline,
91+ "foo", self.fakemod)
92+
93+ def test_darwin_pkgd_raises_nopath(self):
94+ """Frozen, raise OSError when the path doesn't exist."""
95+ self.patch(os.path, "exists", lambda x: False)
96+ self.assertRaises(OSError, get_cmdline, "foo",
97+ self.fakeappmod)
98+
99+
100+class Win32PkgdTestCase(ExeFindBaseTestCase):
101+ """Test cmdline for running packaged on windows."""
102+
103+ def setUp(self):
104+ """SetUp to mimic frozen windows."""
105+ super(Win32PkgdTestCase, self).setUp()
106+ self.patch(sys, "platform", "win32")
107+ sys.frozen = True
108+
109+ def tearDown(self):
110+ """tearDown, Remove frozen attr"""
111+ del sys.frozen
112+
113+ def test_windows_pkgd(self):
114+ """Return sub-app path on windows when frozen."""
115+
116+ self.patch(sys, "executable", os.path.join("C:\\path", "to",
117+ "current.exe"))
118+ # patch abspath to let us run this tests on non-windows:
119+ self.patch(os.path, "abspath", lambda x: x)
120+ path = get_cmdline("foo", None)
121+ expectedpath = os.path.join("C:\\path", "to", "foo.exe")
122+ self.assertEquals(path, expectedpath)
123+
124+ def test_windows_pkgd_raises_nopath(self):
125+ """Frozen, raise OSError when the path doesn't exist."""
126+ self.patch(os.path, "exists", lambda x: False)
127+ self.assertRaises(OSError, get_cmdline, "foo",
128+ self.faketoplevelmod)
129+
130+
131+class LinuxTestCase(ExeFindBaseTestCase):
132+ """Test cmdline for running on linux."""
133+
134+ def setUp(self):
135+ """SetUp to mimic linux2."""
136+ super(LinuxTestCase, self).setUp()
137+ self.patch(sys, "platform", "linux2")
138+
139+ def test_linux_src_relative_path_exists(self):
140+ """linux unfrozen, return source relative path if it exists."""
141+ path = get_cmdline("foo", self.fakemod)
142+ expectedpath = "/path/to/bin/foo"
143+ self.assertEquals(path, expectedpath)
144+
145+ def test_linux_no_src_relative_path_yes_constants(self):
146+ """linux unfrozen, return value from constants.py."""
147+ self.patch(os.path, "exists", lambda x: False)
148+ consts = imp.new_module("constants")
149+ consts.BIN_DIR = "/path/to/bin"
150+ self.fakemod.constants = consts
151+ self.addCleanup(delattr, self.fakemod, "constants")
152+ path = get_cmdline("foo", self.fakemod)
153+ expectedpath = "/path/to/bin/foo"
154+ self.assertEquals(path, expectedpath)
155+
156+ def test_linux_no_src_relative_path_no_constants(self):
157+ """raise if no src rel path and no constants module."""
158+ self.patch(os.path, "exists", lambda x: False)
159+ self.assertRaises(OSError, get_cmdline,
160+ "foo", self.faketoplevelmod)
161
162=== modified file 'dirspec/utils.py'
163--- dirspec/utils.py 2011-12-17 00:46:26 +0000
164+++ dirspec/utils.py 2012-07-09 22:27:20 +0000
165@@ -15,6 +15,7 @@
166 # along with this program. If not, see <http://www.gnu.org/licenses/>.
167 """Utilities for multiplatform support of XDG directory handling."""
168
169+import errno
170 import os
171 import sys
172
173@@ -24,11 +25,84 @@
174 'default_config_path',
175 'default_data_home',
176 'default_data_path',
177+ 'get_cmdline',
178 'get_env_path',
179 'unicode_path',
180 ]
181
182
183+def _get_exe_path_frozen_win32(exe_name):
184+ """Get path to the helper .exe on packaged windows."""
185+ # all the .exes are in the same place on windows:
186+ cur_exec_path = os.path.abspath(sys.executable)
187+ exe_dir = os.path.dirname(cur_exec_path)
188+ return os.path.join(exe_dir, exe_name + ".exe")
189+
190+
191+def _get_exe_path_frozen_darwin(exe_name, module):
192+ """Get path to the sub-app executable on packaged darwin."""
193+
194+ sub_app_name = module.DARWIN_APP_NAMES[exe_name]
195+
196+ main_app_dir = "".join(module.__file__.partition(".app")[:-1])
197+ main_app_resources_dir = os.path.join(main_app_dir,
198+ "Contents",
199+ "Resources")
200+ exe_bin = os.path.join(main_app_resources_dir,
201+ sub_app_name,
202+ "Contents", "MacOS",
203+ exe_name)
204+ return exe_bin
205+
206+
207+def _get_exe_path_from_constants(exe_name, module):
208+ """Get path to executable from module's constants submodule."""
209+ try:
210+ bin_dir = getattr(module.constants, 'BIN_DIR')
211+ return os.path.join(bin_dir, exe_name)
212+ except (ImportError, AttributeError):
213+ msg = '_get_exe_path_from_constants: cannot build a valid path. ' \
214+ 'constants module not available.'
215+ raise OSError(errno.ENOENT, msg)
216+
217+
218+def get_cmdline(exe_name, module):
219+ """Get the command line to execute helper executable exe_name.
220+
221+ Given an exe name and a module that is in the same project as the
222+ exe, returns the appropriate command line to run that exe.
223+
224+ Raises OSError if the exe does not exist. Some clients may wish to
225+ trap and ignore this error.
226+ """
227+ if getattr(sys, "frozen", None) is not None:
228+
229+ if sys.platform == 'win32':
230+ exe_path = _get_exe_path_frozen_win32(exe_name)
231+ elif sys.platform == 'darwin':
232+ exe_path = _get_exe_path_frozen_darwin(exe_name, module)
233+ else:
234+ raise Exception("Unsupported platform for frozen execution: %r" %
235+ sys.platform)
236+ else:
237+ module_depth = module.__name__.count('.')
238+ parent_dir_index = -1 * (module_depth + 1)
239+ parent_dir_l = module.__file__.split(os.path.sep)[:parent_dir_index]
240+ parent_dir = os.path.sep.join(parent_dir_l)
241+ exe_path = os.path.join(parent_dir, 'bin', exe_name)
242+
243+ if not os.path.exists(exe_path):
244+ if sys.platform in ('win32', 'darwin'):
245+ # these platforms don't have meaningful values in
246+ # constants.py, so just raise:
247+ raise OSError(errno.ENOENT,
248+ "Could not find executable at path %r" % exe_path)
249+
250+ exe_path = _get_exe_path_from_constants(exe_name, module)
251+
252+ return exe_path
253+
254+
255 def get_env_path(key, default):
256 """Get a UTF-8 encoded path from an environment variable."""
257 if key in os.environ:

Subscribers

People subscribed via source and target branches