Repository: canonical/snapcraft

Issue:
catkin pip support
[design](https://forum.snapcraft.io/t/proposal-catkin-pip-support/2083)


Discussion:
#1479 and LP: #1683036 make progress on by adding the ability for rosdep to parse multiple dependency types. It also updates the catkin plugin to support the new API, but it still only supports debs right now (it'll raise an exception if the dependency type is anything else). This is paving the way for support of pip dependencies coming from rosdep. Actually supporting this will require some experimentation, and of course, a design proposal.
More progress in #1540, where some helpers are being moved out of the Python plugin into a place where Catkin can also use them.
After the pip extraction lands, there's only one more PR as part of this. I expect this feature to be done this week.

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -43,6 +43,7 @@
     'snapcraft.internal.states',
     'snapcraft.plugins',
     'snapcraft.plugins._ros',
+    'snapcraft.plugins._python',
     'snapcraft.storeapi'
 ]
 package_data = {'snapcraft.internal.repo': ['manifest.txt']}
diff --git a/snapcraft/plugins/_python/_pip.py b/snapcraft/plugins/_python/_pip.py
--- a/snapcraft/plugins/_python/_pip.py
+++ b/snapcraft/plugins/_python/_pip.py
@@ -20,6 +20,7 @@
 import logging
 import os
 import shutil
+import stat
 import subprocess
 import sys
 import tempfile
@@ -56,6 +57,30 @@ def _process_common_args(*, packages, constraints,
     return args
 
 
+def _replicate_owner_mode(path):
+    # Don't bother with a path that doesn't exist or is a symlink. The target
+    # of the symlink will either be updated anyway, or we won't have permission
+    # to change it.
+    if not os.path.exists(path) or os.path.islink(path):
+        return
+
+    file_mode = os.stat(path).st_mode
+    new_mode = file_mode & stat.S_IWUSR
+    if file_mode & stat.S_IXUSR:
+        new_mode |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
+    if file_mode & stat.S_IRUSR:
+        new_mode |= stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH
+    os.chmod(path, new_mode)
+
+
+def _fix_permissions(path):
+    for root, dirs, files in os.walk(path):
+        for filename in files:
+            _replicate_owner_mode(os.path.join(root, filename))
+        for dirname in dirs:
+            _replicate_owner_mode(os.path.join(root, dirname))
+
+
 class Pip:
     """Wrapper for pip abstracting the args necessary for use in a part.
 
@@ -71,8 +96,7 @@ def __init__(self, *, python_major_version, part_dir, install_dir,
                  stage_dir):
         """Initialize pip.
 
-        Check to see if pip has already been installed. If not, fetch pip,
-        setuptools, and wheel, and install them so they can be used.
+        You must call setup() before you can actually use pip.
 
         :param str python_major_version: The python major version to find (2 or
                                          3)
@@ -99,12 +123,15 @@ def __init__(self, *, python_major_version, part_dir, install_dir,
             self._python_major_version, stage_dir=self._stage_dir,
             install_dir=self._install_dir)
 
-        self._setup()
+    def setup(self):
+        """Install pip and dependencies.
 
-    def _setup(self):
+        Check to see if pip has already been installed. If not, fetch pip,
+        setuptools, and wheel, and install them so they can be used.
+        """
         # Check to see if we have our own pip, yet. If not, we need to use the
         # pip on the host (installed via build-packages) to grab our own.
-        if not self._is_pip_installed():
+        if not self.is_setup():
             logger.info('Fetching pip, setuptools, and wheel...')
 
             real_python_home = self._python_home
@@ -124,7 +151,9 @@ def _setup(self):
                 # Now that we have our own pip, reset the python home
                 self._python_home = real_python_home
 
-    def _is_pip_installed(self):
+    def is_setup(self):
+        """Return true if this class has already been setup."""
+
         try:
             # We're expecting an error here at least once complaining about
             # pip not being installed. In order to verify that the error is the
@@ -229,6 +258,10 @@ def install(self, packages, *, setup_py_dir=None, constraints=None,
         self._run(['install', '--user', '--no-compile', '--no-index',
                    '--find-links', self._python_package_dir] + args, cwd=cwd)
 
+        # Installing with --user results in a directory with 700 permissions.
+        # We need it a bit more open than that, so open it up.
+        _fix_permissions(self._install_dir)
+
     def wheel(self, packages, *, setup_py_dir=None, constraints=None,
               requirements=None, process_dependency_links=False):
         """Build wheels of packages in the cache.
diff --git a/snapcraft/plugins/catkin.py b/snapcraft/plugins/catkin.py
--- a/snapcraft/plugins/catkin.py
+++ b/snapcraft/plugins/catkin.py
@@ -68,6 +68,7 @@
 
 import snapcraft
 from snapcraft.plugins import _ros
+from snapcraft.plugins import _python
 from snapcraft import (
     common,
     file_utils,
@@ -88,6 +89,7 @@
 
 _SUPPORTED_DEPENDENCY_TYPES = {
     'apt',
+    'pip',
 }
 
 
@@ -195,6 +197,17 @@ def get_build_properties(cls):
         # change in the YAML Snapcraft will consider the build step dirty.
         return ['build-attributes', 'catkin-cmake-args']
 
+    @property
+    def _pip(self):
+        if not self.__pip:
+            self.__pip = _python.Pip(
+                python_major_version='2',  # ROS1 only supports python2
+                part_dir=self.partdir,
+                install_dir=self.installdir,
+                stage_dir=self.project.stage_dir)
+
+        return self.__pip
+
     @property
     def PLUGIN_STAGE_SOURCES(self):
         return """
@@ -207,7 +220,8 @@ def PLUGIN_STAGE_SOURCES(self):
 
     def __init__(self, name, options, project):
         super().__init__(name, options, project)
-        self.build_packages.extend(['libc6-dev', 'make'])
+        self.build_packages.extend(['libc6-dev', 'make', 'python-pip'])
+        self.__pip = None
 
         # roslib is the base requiremet to actually create a workspace with
         # setup.sh and the necessary hooks.
@@ -413,27 +427,42 @@ def _setup_dependencies(self, rosdep, catkin):
                     'Unable to determine system dependency for roscore')
 
         # Pull down and install any apt dependencies that were discovered
-        apt_dependencies = system_dependencies.get('apt')
+        self._setup_apt_dependencies(system_dependencies.get('apt'))
+
+        # Pull down and install any pip dependencies that were discovered
+        self._setup_pip_dependencies(system_dependencies.get('pip'))
+
+    def _setup_apt_dependencies(self, apt_dependencies):
         if apt_dependencies:
             ubuntudir = os.path.join(self.partdir, 'ubuntu')
             os.makedirs(ubuntudir, exist_ok=True)
 
-            logger.info('Preparing to fetch package dependencies...')
+            logger.info('Preparing to fetch apt dependencies...')
             ubuntu = repo.Ubuntu(ubuntudir,
                                  sources=self.PLUGIN_STAGE_SOURCES,
                                  project_options=self.project)
 
-            logger.info('Fetching package dependencies...')
+            logger.info('Fetching apt dependencies...')
             try:
                 ubuntu.get(apt_dependencies)
             except repo.errors.PackageNotFoundError as e:
                 raise RuntimeError(
-                    'Failed to fetch system dependencies: {}'.format(
+                    'Failed to fetch apt dependencies: {}'.format(
                         e.message))
 
-            logger.info('Installing package dependencies...')
+            logger.info('Installing apt dependencies...')
             ubuntu.unpack(self.installdir)
 
+    def _setup_pip_dependencies(self, pip_dependencies):
+        if pip_dependencies:
+            self._pip.setup()
+
+            logger.info('Fetching pip dependencies...')
+            self._pip.download(pip_dependencies)
+
+            logger.info('Installing pip dependencies...')
+            self._pip.install(pip_dependencies)
+
     def clean_pull(self):
         super().clean_pull()
 
@@ -449,6 +478,9 @@ def clean_pull(self):
         with contextlib.suppress(FileNotFoundError):
             shutil.rmtree(self._catkin_path)
 
+        # Clean pip packages, if any
+        self._pip.clean_packages()
+
     def _source_setup_sh(self, root, underlay_path):
         rosdir = os.path.join(root, 'opt', 'ros', self.options.rosdistro)
         if underlay_path:
@@ -608,6 +640,13 @@ def _new_path(path):
             underlay_run_path = self.options.underlay['run-path']
             self._generate_snapcraft_setup_sh('$SNAP', underlay_run_path)
 
+        # If pip dependencies were installed, generate a sitecustomize that
+        # allows access to them.
+        if self._pip.is_setup() and self._pip.list():
+            _python.generate_sitecustomize(
+                '2', stage_dir=self.project.stage_dir,
+                install_dir=self.installdir)
+
     def _use_in_snap_python(self):
         # Fix all shebangs to use the in-snap python.
         file_utils.replace_in_file(self.rosdir, re.compile(r''),


Test patch:
diff --git a/integration_tests/snapd/test_catkin_snap.py b/integration_tests/snapd/test_catkin_snap.py
new file mode 100644
--- /dev/null
+++ b/integration_tests/snapd/test_catkin_snap.py
@@ -0,0 +1,37 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright (C) 2017 Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import subprocess
+
+from testtools.matchers import Contains
+
+import integration_tests
+from snapcraft.tests import fixture_setup
+
+
+class CatkinTestCase(integration_tests.SnapdIntegrationTestCase):
+
+    def test_catkin_pip_support(self):
+        with fixture_setup.WithoutSnapInstalled('ros-pip-example'):
+            self.run_snapcraft(project_dir='ros-pip')
+            self.install_snap()
+
+            # If pip support didn't work properly, the import should fail.
+            self.assertThat(
+                subprocess.check_output(
+                    ['ros-pip-example.launch-project'],
+                    universal_newlines=True, stderr=subprocess.STDOUT),
+                Contains("Local timezone:"))
diff --git a/integration_tests/snaps/ros-pip/snap/snapcraft.yaml b/integration_tests/snaps/ros-pip/snap/snapcraft.yaml
new file mode 100644
--- /dev/null
+++ b/integration_tests/snaps/ros-pip/snap/snapcraft.yaml
@@ -0,0 +1,20 @@
+name: ros-pip-example
+version: 1.0
+summary: ROS Example using pip dependencies
+description: Contains a simple pip test and a .launch file.
+confinement: strict
+grade: devel
+
+apps:
+  launch-project:
+    command: roslaunch timezone_test test.launch
+    plugs: [network-bind]
+
+parts:
+  ros-project:
+    plugin: catkin
+    source: .
+    rosdistro: kinetic
+    catkin-packages:
+      - timezone_test
+    include-roscore: true
diff --git a/integration_tests/snaps/ros-pip/src/CMakeLists.txt b/integration_tests/snaps/ros-pip/src/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/integration_tests/snaps/ros-pip/src/CMakeLists.txt
@@ -0,0 +1,47 @@
+# toplevel CMakeLists.txt for a catkin workspace
+# catkin/cmake/toplevel.cmake
+
+cmake_minimum_required(VERSION 2.8.3)
+
+# optionally provide a cmake file in the workspace to override arbitrary stuff
+include(workspace.cmake OPTIONAL)
+
+set(CATKIN_TOPLEVEL TRUE)
+
+# include catkin directly or via find_package()
+if(EXISTS "${CMAKE_SOURCE_DIR}/catkin/cmake/all.cmake" AND EXISTS "${CMAKE_SOURCE_DIR}/catkin/CMakeLists.txt")
+  set(catkin_EXTRAS_DIR "${CMAKE_SOURCE_DIR}/catkin/cmake")
+  # include all.cmake without add_subdirectory to let it operate in same scope
+  include(catkin/cmake/all.cmake NO_POLICY_SCOPE)
+  add_subdirectory(catkin)
+
+else()
+  # use either CMAKE_PREFIX_PATH explicitly passed to CMake as a command line argument
+  # or CMAKE_PREFIX_PATH from the environment
+  if(NOT DEFINED CMAKE_PREFIX_PATH)
+    if(NOT "$ENV{CMAKE_PREFIX_PATH}" STREQUAL "")
+      string(REPLACE ":" ";" CMAKE_PREFIX_PATH $ENV{CMAKE_PREFIX_PATH})
+    endif()
+  endif()
+
+  # list of catkin workspaces
+  set(catkin_search_path "")
+  foreach(path ${CMAKE_PREFIX_PATH})
+    if(EXISTS "${path}/.CATKIN_WORKSPACE")
+      list(FIND catkin_search_path ${path} _index)
+      if(_index EQUAL -1)
+        list(APPEND catkin_search_path ${path})
+      endif()
+    endif()
+  endforeach()
+
+  # search for catkin in all workspaces
+  set(CATKIN_TOPLEVEL_FIND_PACKAGE TRUE)
+  find_package(catkin REQUIRED
+    NO_POLICY_SCOPE
+    PATHS ${catkin_search_path}
+    NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH)
+  unset(CATKIN_TOPLEVEL_FIND_PACKAGE)
+endif()
+
+catkin_workspace()
diff --git a/integration_tests/snaps/ros-pip/src/timezone_test/CMakeLists.txt b/integration_tests/snaps/ros-pip/src/timezone_test/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/integration_tests/snaps/ros-pip/src/timezone_test/CMakeLists.txt
@@ -0,0 +1,16 @@
+cmake_minimum_required(VERSION 2.8.3)
+project(timezone_test)
+
+find_package(catkin REQUIRED COMPONENTS rospy)
+
+catkin_package()
+
+install(PROGRAMS
+  scripts/timezone_test_node
+  DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
+)
+
+install(FILES
+  test.launch
+  DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION}
+)
diff --git a/integration_tests/snaps/ros-pip/src/timezone_test/package.xml b/integration_tests/snaps/ros-pip/src/timezone_test/package.xml
new file mode 100644
--- /dev/null
+++ b/integration_tests/snaps/ros-pip/src/timezone_test/package.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0"?>
+<package>
+  <name>timezone_test</name>
+  <version>0.0.0</version>
+  <description>The timezone_test package</description>
+  <maintainer email="me@example.com">me</maintainer>
+  <license>GPLv3</license>
+  <buildtool_depend>catkin</buildtool_depend>
+  <build_depend>rospy</build_depend>
+  <build_depend>python-tzlocal-pip</build_depend>
+  <run_depend>rospy</run_depend>
+  <run_depend>python-tzlocal-pip</run_depend>
+</package>
diff --git a/integration_tests/snaps/ros-pip/src/timezone_test/scripts/timezone_test_node b/integration_tests/snaps/ros-pip/src/timezone_test/scripts/timezone_test_node
new file mode 100755
--- /dev/null
+++ b/integration_tests/snaps/ros-pip/src/timezone_test/scripts/timezone_test_node
@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+
+import rospy
+import tzlocal
+
+
+def main():
+    # This line verifies that we can connect to ROS master
+    rospy.init_node('timezone_test')
+
+    # This line verifies that we can use tzlocal, which is a pip dependency
+    print('Local timezone: {}'.format(tzlocal.get_localzone()))
+
+if __name__ == '__main__':
+    main()
diff --git a/integration_tests/snaps/ros-pip/src/timezone_test/test.launch b/integration_tests/snaps/ros-pip/src/timezone_test/test.launch
new file mode 100644
--- /dev/null
+++ b/integration_tests/snaps/ros-pip/src/timezone_test/test.launch
@@ -0,0 +1,4 @@
+<launch>
+    <node name = "timezone_test" pkg = "timezone_test" type = "timezone_test_node"
+          required = "true" output = "screen" />
+</launch>
diff --git a/snapcraft/tests/plugins/python/test_pip.py b/snapcraft/tests/plugins/python/test_pip.py
--- a/snapcraft/tests/plugins/python/test_pip.py
+++ b/snapcraft/tests/plugins/python/test_pip.py
@@ -48,7 +48,7 @@ def _assert_expected_enviroment(self, expected_python, headers_path):
             python_major_version='test',
             part_dir='part_dir',
             install_dir='install_dir',
-            stage_dir='stage_dir')
+            stage_dir='stage_dir').setup()
 
         class check_env():
             def __init__(self, test):
@@ -151,7 +151,7 @@ def test_with_extra_cppflags(self):
             python_major_version='test',
             part_dir='part_dir',
             install_dir='install_dir',
-            stage_dir='stage_dir')
+            stage_dir='stage_dir').setup()
 
         class check_env():
             def __init__(self, test):
@@ -169,14 +169,14 @@ def __eq__(self, env):
                       stderr=subprocess.STDOUT)])
 
 
-class InitTestCase(PipRunTestCase):
+class SetupTestCase(PipRunTestCase):
 
     def setUp(self):
         super().setUp()
 
         self.command = [self._create_python_binary('install_dir'), '-m', 'pip']
 
-    def test_init_with_pip_installed(self):
+    def test_setup_with_pip_installed(self):
         """Test that no attempt is made to reinstall pip"""
 
         # Since _run doesn't raise an exception indicating pip isn't installed,
@@ -187,12 +187,12 @@ def test_init_with_pip_installed(self):
             python_major_version='test',
             part_dir='part_dir',
             install_dir='install_dir',
-            stage_dir='stage_dir')
+            stage_dir='stage_dir').setup()
 
         self.mock_run_output.assert_called_once_with(
             self.command, stderr=subprocess.STDOUT, env=mock.ANY)
 
-    def test_init_without_pip_installed(self):
+    def test_setup_without_pip_installed(self):
         """Test that the system pip is used to install our own pip"""
 
         # Raise an exception indicating that pip isn't installed
@@ -207,7 +207,7 @@ def fake_run(command, **kwargs):
             python_major_version='test',
             part_dir='part_dir',
             install_dir='install_dir',
-            stage_dir='stage_dir')
+            stage_dir='stage_dir').setup()
 
         part_pythonhome = os.path.join('install_dir', 'usr')
         host_pythonhome = os.path.join(os.path.sep, 'usr')
@@ -232,18 +232,21 @@ def fake_run(command, **kwargs):
                 env=_CheckPythonhomeEnv(self, host_pythonhome), cwd=None),
         ])
 
-    def test_init_unexpected_error(self):
+    def test_setup_unexpected_error(self):
         """Test that pip initialization doesn't eat legit errors"""
 
         # Raises an exception indicating something bad happened
         self.mock_run_output.side_effect = subprocess.CalledProcessError(
             1, 'foo', b'no good, very bad')
 
+        pip = _pip.Pip(
+            python_major_version='test',
+            part_dir='part_dir',
+            install_dir='install_dir',
+            stage_dir='stage_dir')
+
         # Verify that pip lets that exception through
-        self.assertRaises(
-            subprocess.CalledProcessError, _pip.Pip,
-            python_major_version='test', part_dir='part_dir',
-            install_dir='install_dir', stage_dir='stage_dir')
+        self.assertRaises(subprocess.CalledProcessError, pip.setup)
 
 
 class PipTestCase(PythonBaseTestCase):
diff --git a/snapcraft/tests/plugins/test_catkin.py b/snapcraft/tests/plugins/test_catkin.py
--- a/snapcraft/tests/plugins/test_catkin.py
+++ b/snapcraft/tests/plugins/test_catkin.py
@@ -30,9 +30,11 @@
 from testtools.matchers import (
     Contains,
     Equals,
+    FileExists,
     HasLength,
     LessThan,
     MatchesRegex,
+    Not,
 )
 
 import snapcraft
@@ -89,12 +91,12 @@ class props:
         self.addCleanup(patcher.stop)
 
         patcher = mock.patch(
-            'snapcraft.plugins.catkin._find_system_dependencies')
+            'snapcraft.plugins.catkin._find_system_dependencies',
+            return_value={})
         self.dependencies_mock = patcher.start()
         self.addCleanup(patcher.stop)
 
-        patcher = mock.patch(
-            'snapcraft.plugins._ros.rosdep.Rosdep')
+        patcher = mock.patch('snapcraft.plugins._ros.rosdep.Rosdep')
         self.rosdep_mock = patcher.start()
         self.addCleanup(patcher.stop)
 
@@ -106,6 +108,11 @@ class props:
         self.wstool_mock = patcher.start()
         self.addCleanup(patcher.stop)
 
+        patcher = mock.patch('snapcraft.plugins._python.Pip')
+        self.pip_mock = patcher.start()
+        self.addCleanup(patcher.stop)
+        self.pip_mock.return_value.list.return_value = {}
+
     def assert_rosdep_setup(self, rosdistro, package_path, rosdep_path,
                             ubuntu_distro, sources):
         self.rosdep_mock.assert_has_calls([
@@ -124,6 +131,16 @@ def assert_wstool_setup(self, package_path, wstool_path, sources):
                       self.project_options),
             mock.call().setup()])
 
+    def assert_pip_setup(self, python_major_version, part_dir, install_dir,
+                         stage_dir):
+        self.pip_mock.assert_has_calls([
+            mock.call(
+                python_major_version=python_major_version,
+                part_dir=part_dir,
+                install_dir=install_dir,
+                stage_dir=stage_dir),
+            mock.call().setup()])
+
 
 class CatkinPluginTestCase(CatkinPluginBaseTestCase):
 
@@ -331,7 +348,7 @@ def test_pull_invalid_dependency(self, compilers_mock):
             plugin.pull)
 
         self.assertThat(str(raised),
-                        Equals('Failed to fetch system dependencies: The '
+                        Equals('Failed to fetch apt dependencies: The '
                                "package 'foo' was not found."))
 
     def test_pull_unable_to_resolve_roscore(self):
@@ -1025,6 +1042,49 @@ def test_pull_with_rosinstall_files(self, generate_setup_mock):
         self.assertTrue(mock.call().unpack(plugin.installdir) not in
                         self.ubuntu_mock.mock_calls)
 
+    @mock.patch.object(catkin.CatkinPlugin, '_generate_snapcraft_setup_sh')
+    def test_pull_pip_dependencies(self, generate_setup_mock):
+        plugin = catkin.CatkinPlugin('test-part', self.properties,
+                                     self.project_options)
+        os.makedirs(os.path.join(plugin.sourcedir, 'src'))
+
+        self.dependencies_mock.return_value = {'pip': {'foo', 'bar', 'baz'}}
+
+        plugin.pull()
+
+        self.assert_rosdep_setup(
+            self.properties.rosdistro,
+            os.path.join(plugin.sourcedir, 'src'),
+            os.path.join(plugin.partdir, 'rosdep'),
+            self.properties.ubuntu_distro,
+            plugin.PLUGIN_STAGE_SOURCES)
+
+        self.wstool_mock.assert_not_called()
+
+        self.assert_pip_setup(
+            '2', plugin.partdir, plugin.installdir, plugin.project.stage_dir)
+
+        # This shouldn't be called unless there's an underlay
+        if self.properties.underlay:
+            generate_setup_mock.assert_called_once_with(
+                plugin.installdir, self.expected_underlay_path)
+        else:
+            generate_setup_mock.assert_not_called()
+
+        # Verify that dependencies were found as expected. TODO: Would really
+        # like to use ANY here instead of verifying explicit arguments, but
+        # Python issue #25195 won't let me.
+        self.assertThat(self.dependencies_mock.call_count, Equals(1))
+        self.assertThat(
+            self.dependencies_mock.call_args[0][0],
+            Equals({'my_package'}))
+
+        # Verify that the pip dependencies were installed
+        self.pip_mock.return_value.download.assert_called_once_with(
+            {'foo', 'bar', 'baz'})
+        self.pip_mock.return_value.install.assert_called_once_with(
+            {'foo', 'bar', 'baz'})
+
 
 class BuildTestCase(CatkinPluginBaseTestCase):
 
@@ -1191,6 +1251,11 @@ def test_finish_build_cmake_paths(self, use_python_mock,
             with open(path, 'r') as f:
                 self.assertThat(f.read(), Equals(file_info['expected']))
 
+        # Verify that no sitecustomize.py was generated
+        self.assertThat(os.path.join(
+            self.plugin.installdir, 'usr', 'lib', 'python2.7',
+            'sitecustomize.py'), Not(FileExists()))
+
     @mock.patch.object(catkin.CatkinPlugin, '_generate_snapcraft_setup_sh')
     @mock.patch.object(catkin.CatkinPlugin, 'run')
     @mock.patch.object(catkin.CatkinPlugin, 'run_output', return_value='foo')
@@ -1224,6 +1289,40 @@ def test_finish_build_cmake_prefix_path(self, use_python_mock,
                 'The absolute path to python or the CMAKE_PREFIX_PATH '
                 'was not replaced as expected')
 
+        # Verify that no sitecustomize.py was generated
+        self.assertThat(os.path.join(
+            self.plugin.installdir, 'usr', 'lib', 'python2.7',
+            'sitecustomize.py'), Not(FileExists()))
+
+    @mock.patch.object(catkin.CatkinPlugin, '_generate_snapcraft_setup_sh')
+    @mock.patch.object(catkin.CatkinPlugin, 'run')
+    @mock.patch.object(catkin.CatkinPlugin, 'run_output', return_value='foo')
+    @mock.patch.object(catkin.CatkinPlugin, '_use_in_snap_python')
+    def test_finish_build_python_sitecustomize(self, use_python_mock,
+                                               run_output_mock, run_mock,
+                                               generate_setup_mock):
+        self.pip_mock.return_value.list.return_value = {'test-package'}
+
+        # Create site.py, indicating that python2 was a stage-package
+        site_py_path = os.path.join(
+            self.plugin.installdir, 'usr', 'lib', 'python2.7', 'site.py')
+        os.makedirs(os.path.dirname(site_py_path), exist_ok=True)
+        open(site_py_path, 'w').close()
+
+        # Also create python2 site-packages, indicating that pip packages were
+        # installed.
+        os.makedirs(
+            os.path.join(
+                self.plugin.installdir, 'lib', 'python2.7', 'site-packages'),
+            exist_ok=True)
+
+        self.plugin._finish_build()
+
+        # Verify that sitecustomize.py was generated
+        self.assertThat(os.path.join(
+            self.plugin.installdir, 'usr', 'lib', 'python2.7',
+            'sitecustomize.py'), FileExists())
+
 
 class FindSystemDependenciesTestCase(tests.TestCase):
     def setUp(self):
@@ -1312,7 +1411,7 @@ def test_find_system_dependencies_missing_local_dependency(self):
 
     def test_find_system_dependencies_raises_if_unsupported_type(self):
         self.rosdep_mock.resolve_dependency.return_value = {
-            'pip': {'baz'},
+            'unsupported-type': {'baz'},
         }
 
         raised = self.assertRaises(
@@ -1322,7 +1421,7 @@ def test_find_system_dependencies_raises_if_unsupported_type(self):
 
         self.assertThat(str(raised), Equals(
             "Package 'bar' resolved to an unsupported type of dependency: "
-            "'pip'"))
+            "'unsupported-type'"))
 
 
 class HandleRosinstallFilesTestCase(tests.TestCase):