11# Copyright 2016-2019 Dirk Thomas
2+ # Copyright 2019 Rover Robotics
23# Licensed under the Apache License, Version 2.0
34
5+ import multiprocessing
6+ import os
7+ from typing import Optional
8+ import warnings
9+
410from colcon_core .dependency_descriptor import DependencyDescriptor
511from colcon_core .package_identification import logger
612from colcon_core .package_identification \
713 import PackageIdentificationExtensionPoint
814from colcon_core .plugin_system import satisfies_version
915from distlib .util import parse_requirement
1016from distlib .version import NormalizedVersion
11- try :
12- from setuptools .config import read_configuration
13- except ImportError as e :
14- from pkg_resources import get_distribution
15- from pkg_resources import parse_version
16- setuptools_version = get_distribution ('setuptools' ).version
17- minimum_version = '30.3.0'
18- if parse_version (setuptools_version ) < parse_version (minimum_version ):
19- e .msg += ', ' \
20- "'setuptools' needs to be at least version {minimum_version}, if" \
21- ' a newer version is not available from the package manager use ' \
22- "'pip3 install -U setuptools' to update to the latest version" \
23- .format_map (locals ())
24- raise
2517
2618
2719class PythonPackageIdentification (PackageIdentificationExtensionPoint ):
28- """Identify Python packages with `setup.cfg` files."""
20+ """Identify Python packages with `setup.py` and opt. `setup. cfg` files."""
2921
3022 def __init__ (self ): # noqa: D107
3123 super ().__init__ ()
@@ -41,69 +33,136 @@ def identify(self, desc): # noqa: D102
4133 if not setup_py .is_file ():
4234 return
4335
44- setup_cfg = desc .path / 'setup.cfg'
45- if not setup_cfg .is_file ():
46- return
36+ # after this point, we are convinced this is a Python package,
37+ # so we should fail with an Exception instead of silently
38+
39+ config = get_setup_result (setup_py , env = None )
4740
48- config = get_configuration (setup_cfg )
49- name = config .get ('metadata' , {}).get ('name' )
41+ name = config ['metadata' ].name
5042 if not name :
51- return
43+ raise RuntimeError (
44+ "The Python package in '{setup_py.parent}' has an invalid "
45+ 'package name' .format_map (locals ()))
5246
5347 desc .type = 'python'
5448 if desc .name is not None and desc .name != name :
55- msg = 'Package name already set to different value'
56- logger .error (msg )
57- raise RuntimeError (msg )
49+ raise RuntimeError (
50+ "The Python package in '{setup_py.parent}' has the name "
51+ "'{name}' which is different from the already set package "
52+ "name '{desc.name}'" .format_map (locals ()))
5853 desc .name = name
5954
60- version = config .get ('metadata' , {}).get ('version' )
61- desc .metadata ['version' ] = version
55+ desc .metadata ['version' ] = config ['metadata' ].version
6256
63- options = config .get ('options' , {})
64- dependencies = extract_dependencies (options )
65- for k , v in dependencies .items ():
66- desc .dependencies [k ] |= v
57+ for dependency_type , option_name in [
58+ ('build' , 'setup_requires' ),
59+ ('run' , 'install_requires' ),
60+ ('test' , 'tests_require' )
61+ ]:
62+ desc .dependencies [dependency_type ] = {
63+ create_dependency_descriptor (d )
64+ for d in config [option_name ] or ()}
6765
6866 def getter (env ):
69- nonlocal options
70- return options
67+ nonlocal setup_py
68+ return get_setup_result ( setup_py , env = env )
7169
7270 desc .metadata ['get_python_setup_options' ] = getter
7371
7472
7573def get_configuration (setup_cfg ):
7674 """
77- Read the setup.cfg file.
75+ Return the configuration values defined in the setup.cfg file.
76+
77+ The function exists for backward compatibility with older versions of
78+ colcon-ros.
7879
7980 :param setup_cfg: The path of the setup.cfg file
8081 :returns: The configuration data
8182 :rtype: dict
8283 """
83- return read_configuration (str (setup_cfg ))
84+ warnings .warn (
85+ 'colcon_core.package_identification.python.get_configuration() will '
86+ 'be removed in the future' , DeprecationWarning , stacklevel = 2 )
87+ config = get_setup_result (setup_cfg .parent / 'setup.py' , env = None )
88+ return {
89+ 'metadata' : {'name' : config ['metadata' ].name },
90+ 'options' : config
91+ }
8492
8593
86- def extract_dependencies ( options ):
94+ def get_setup_result ( setup_py , * , env : Optional [ dict ] ):
8795 """
88- Get the dependencies of the package .
96+ Spin up a subprocess to run setup.py, with the given environment .
8997
90- :param options: The dictionary from the options section of the setup.cfg
91- file
92- :returns: The dependencies
93- :rtype: dict(string, set(DependencyDescriptor))
98+ :param setup_py: Path to a setup.py script
99+ :param env: Environment variables to set before running setup.py
100+ :return: Dictionary of data describing the package.
101+ :raise: RuntimeError if the setup script encountered an error
94102 """
95- mapping = {
96- 'setup_requires' : 'build' ,
97- 'install_requires' : 'run' ,
98- 'tests_require' : 'test' ,
99- }
100- dependencies = {}
101- for option_name , dependency_type in mapping .items ():
102- dependencies [dependency_type ] = set ()
103- for dep in options .get (option_name , []):
104- dependencies [dependency_type ].add (
105- create_dependency_descriptor (dep ))
106- return dependencies
103+ env_copy = os .environ .copy ()
104+ if env is not None :
105+ env_copy .update (env )
106+
107+ conn_recv , conn_send = multiprocessing .Pipe (duplex = False )
108+ with conn_send :
109+ p = multiprocessing .Process (
110+ target = _get_setup_result_target ,
111+ args = (os .path .abspath (str (setup_py )), env_copy , conn_send ),
112+ )
113+ p .start ()
114+ p .join ()
115+ with conn_recv :
116+ result_or_exception_string = conn_recv .recv ()
117+
118+ if isinstance (result_or_exception_string , dict ):
119+ return result_or_exception_string
120+ raise RuntimeError (
121+ 'Failure when trying to run setup script {}:\n {}'
122+ .format (setup_py , result_or_exception_string ))
123+
124+
125+ def _get_setup_result_target (setup_py : str , env : dict , conn_send ):
126+ """
127+ Run setup.py in a modified environment.
128+
129+ Helper function for get_setup_metadata. The resulting dict or error
130+ will be sent via conn_send instead of returned or thrown.
131+
132+ :param setup_py: Absolute path to a setup.py script
133+ :param env: Environment variables to set before running setup.py
134+ :param conn_send: Connection to send the result as either a dict or an
135+ error string
136+ """
137+ import distutils .core
138+ import traceback
139+ try :
140+ # need to be in setup.py's parent dir to detect any setup.cfg
141+ os .chdir (os .path .dirname (setup_py ))
142+
143+ os .environ .clear ()
144+ os .environ .update (env )
145+
146+ result = distutils .core .run_setup (
147+ str (setup_py ), ('--dry-run' ,), stop_after = 'config' )
148+
149+ # could just return all attrs in result.__dict__, but we take this
150+ # opportunity to filter a few things that don't need to be there
151+ conn_send .send ({
152+ attr : value for attr , value in result .__dict__ .items ()
153+ if (
154+ # These *seem* useful but always have the value 0.
155+ # Look for their values in the 'metadata' object instead.
156+ attr not in result .display_option_names
157+ # Getter methods
158+ and not callable (value )
159+ # Private properties
160+ and not attr .startswith ('_' )
161+ # Objects that are generally not picklable
162+ and attr not in ('cmdclass' , 'distclass' , 'ext_modules' )
163+ )})
164+ except BaseException :
165+ conn_send .send (traceback .format_exc ())
107166
108167
109168def create_dependency_descriptor (requirement_string ):
0 commit comments