Python pkg_resources only works in certain paths

I’ve built a snap with a number of parts, including a python and a catkin part, and I’m getting some weird behavior. I’m running it on Ubuntu 16.04.2 with snap 2.25. My test app simply calls python, and when I run my-snap.python -c "import pkg_resources" I get the following Exception:

Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/snap/my-snap/x9/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 2927, in <module>
    @_call_aside
  File "/snap/my-snap/x9/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 2913, in _call_aside
    f(*args, **kwargs)
  File "/snap/my-snap/x9/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 2940, in _initialize_master_working_set
    working_set = WorkingSet._build_master()
  File "/snap/my-snap/x9/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 626, in _build_master
    ws = cls()
  File "/snap/my-snap/x9/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 619, in __init__
    self.add_entry(entry)
  File "/snap/my-snap/x9/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 675, in add_entry
    for dist in find_distributions(entry, True):
  File "/snap/my-snap/x9/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 1972, in find_on_path
    for entry in os.listdir(path_item):
OSError: [Errno 13] Permission denied: '/home/ruddick'

However when I run the same command from /snap/my-snap it runs fine. Any ideas what could cause this behavior?

It turns out the issue was that pkg_resources requires read access to all of the directories on sys.path (not too surprising), which included the CWD. Since the snap does not have the home interface, it does not have permission to my general home directory.

What was throwing me was that when the snap was built in a different way, everything worked, but that’s because the CWD was not on sys.path in that build. Obviously one of the parts is configuring python differently.

Yeah this is weird. Can you elaborate on “when the snap was built in a different way,” please? What did you do differently?

I did some more debugging and the problem is the confluence of a few factors (whether they’re necessarily bugs is a question).

  1. pkg_resources, and therefore any package that depends on it, even implicitly (I specifically found the problem in rosbridge_server -> twisted -> zope -> pkg_resources) requires read access to all of the paths on sys.path
  2. When a snap is run from a user’s home directory or below, the CWD is not readable by the snap (unless it has the home interface for some other reason)
  3. The CWD is added to sys.path in a few of cases, specifically if it is the directory from which the Python interpreter is called, or $SNAP/usr/lib/python2.7/dist-packages is on the PYTHONPATH (I don’t know why the latter is true; it’s beyond my Python-fu paygrade)
  4. The Snapcraft Catkin plugin adds $SNAP/usr/lib/python2.7/dist-packages to the PYTHONPATH in env()

When I was building previously, I was using my own plugin based on CMake, not catkin, so the CWD did not end up on my sys.path.

To me, there are two questions this brings up:

  1. What’s the right way to address the fact that CWD frequently ends up on the Python path, and snaps are frequently run from the user’s home directory, breaking any code that tries to read the directories on the Python path
  2. As an immediate fix for the Catkin plugin, can we remove adding $SNAP/usr/lib/python2.7/dist-packages to the PYTHONPATH? The comments say it’s to expose rospkg, etc, but a quick test snap built without it can import rospkg just fine (presumably sourcing setup.sh sets up any necessary paths). However, I don’t know if there are other reasons it’s in there. I can make a PR for this if it’s the direction we’d like to go.

I’m unable to duplicate this ignoring snaps and snapcraft:

$ python
Python 2.7.12 (default, Nov 19 2016, 06:48:10) 
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.path
['', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/lib-old', '/usr/lib/python2.7/lib-dynload', '/usr/local/lib/python2.7/dist-packages', '/usr/lib/python2.7/dist-packages', '/usr/lib/python2.7/dist-packages/gtk-2.0']
>>> 
$ PYTHONPATH=/usr/lib/python2.7/dist-packages python
Python 2.7.12 (default, Nov 19 2016, 06:48:10) 
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.path
['', '/usr/lib/python2.7/dist-packages', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/lib-old', '/usr/lib/python2.7/lib-dynload', '/usr/local/lib/python2.7/dist-packages', '/usr/lib/python2.7/dist-packages/gtk-2.0']

Can I see exactly what your sys.path IS?

You’re right, the dist-packages is actually a red-herring; it turns out the problem is actually with a trailing colon in PYTHONPATH, which happens when PYTHONPATH is not set and PYTHONPATH=/some/path:$PYTHONPATH is assigned:

$ cd /usr/src
$ PYTHONPATH=/usr/lib/python2.7/dist-packages python -c "import sys; print sys.path"
['', '/usr/lib/python2.7/dist-packages', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/lib-old', '/usr/lib/python2.7/lib-dynload', '/usr/local/lib/python2.7/dist-packages']
$ PYTHONPATH=/usr/lib/python2.7/dist-packages: python -c "import sys; print sys.path"
['', '/usr/lib/python2.7/dist-packages', '/usr/src', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/lib-old', '/usr/lib/python2.7/lib-dynload', '/usr/local/lib/python2.7/dist-packages']

In the second call, there is a trailing colon, and /usr/src is added to the Python path.

I could not get importing pkg_resources to fail outside of a snap for some reason (the reason why this is an issue). Below is a snapcraft.yaml that can reproduce it:

name: python-test
version: "0.0.0"
summary: Testing Python
description: Testing Python
confinement: strict

apps:
  python-test:
    command: python

parts:
  example:
    plugin: catkin
    rosdistro: kinetic
    catkin-packages: []
    include-roscore: true

Then I get the following error when running in my home directory:

$ python-test -c "import pkg_resources"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/snap/python-test/x5/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 2927, in <module>
    @_call_aside
  File "/snap/python-test/x5/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 2913, in _call_aside
    f(*args, **kwargs)
  File "/snap/python-test/x5/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 2940, in _initialize_master_working_set
    working_set = WorkingSet._build_master()
  File "/snap/python-test/x5/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 626, in _build_master
    ws = cls()
  File "/snap/python-test/x5/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 619, in __init__
    self.add_entry(entry)
  File "/snap/python-test/x5/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 675, in add_entry
    for dist in find_distributions(entry, True):
  File "/snap/python-test/x5/usr/lib/python2.7/dist-packages/pkg_resources/__init__.py", line 1972, in find_on_path
    for entry in os.listdir(path_item):
OSError: [Errno 13] Permission denied: '/home/ruddick'

The Python path it’s using is:

$ python-test -c "import sys; print sys.path"
['', '/snap/python-test/x5/opt/ros/kinetic/lib/python2.7/dist-packages', '/snap/python-test/x5/usr/lib/python2.7/dist-packages', '/home/ruddick', '/snap/python-test/x5/usr/lib/python2.7', '/snap/python-test/x5/usr/lib/python2.7/plat-x86_64-linux-gnu', '/snap/python-test/x5/usr/lib/python2.7/lib-tk', '/snap/python-test/x5/usr/lib/python2.7/lib-old', '/snap/python-test/x5/usr/lib/python2.7/lib-dynload']

Again, I’m not sure what the right way to address this is since it doesn’t really seem to be a bug except that it’s likely to cause problems for some snapped Python programs that don’t need the home interface.

Yikes, good catch @mrjogo! You’re right, empty segments in PYTHONPATH get turned into ., which is totally undesired (e.g. you’ll notice the same behavior if you begin PYTHONPATH with a colon). I’ve never heard of such behavior!

That’s an easy bug to fix, but we end up with the same behavior due to how sys.path works:

As initialized upon program startup, the first item of this list, path[0], is the directory containing the script that was used to invoke the Python interpreter. If the script directory is not available (e.g. if the interpreter is invoked interactively or if the script is read from standard input), path[0] is the empty string, which directs Python to search modules in the current directory first. Notice that the script directory is inserted before the entries inserted as a result of PYTHONPATH.

It always seems to be an empty string with our test case since we’re invoking it interactively. We might need a better test case.

Since the empty string is only added interactively or for interpreter, it’s not actually a problem when calling a real script.

Here’s an updated example:

$ cd /usr/src
$ mkdir subdirectory
$ echo "import sys; print sys.path" > subdirectory/script.py
$ python subdirectory/script.py
['/usr/src/subdirectory', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/lib-old', '/usr/lib/python2.7/lib-dynload', '/usr/local/lib/python2.7/dist-packages', '/usr/lib/python2.7/dist-packages']
$ PYTHONPATH=: python subdirectory/script.py 
['/usr/src/subdirectory', '/usr/src', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/lib-old', '/usr/lib/python2.7/lib-dynload', '/usr/local/lib/python2.7/dist-packages', '/usr/lib/python2.7/dist-packages']

Yeah, that’s essentially what I just did as well. I’ve got a fix for you then, PR coming momentarily.

PR is here:

https://github.com/snapcore/snapcraft/pull/1522

I’d greatly appreciate if you could give it a test.