From 4d606abfde62fff8eb0e31286190b010d6a3555b Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Thu, 23 Oct 2025 23:45:00 -0500 Subject: [PATCH 1/3] Add framework for enumerating installed Python files In order for colcon to consume wheels produced by PEP 517 build backends, we'll need to explicitly uninstall existing Python packages from the install space. This primarily stems from the differences in metadata representation, where the previous setuptools-only Python build mechanisms wrote `.egg-link` and `.egg-info` metadata, and wheels will use `.dist-info` metadata. There should be only one metadata representation in the install space for a given package. The legacy setuptools build logic already has a special case for dealing with a switch from `.egg-info` to `.egg-link` and vice-versa. As we toss `.dist-info` into the mix, the need arises for a more robust solution. This new functionality is not currently used by colcon, but will be used as part of the transition to standards-based Python build support. --- colcon_core/python_project/distribution.py | 304 ++++++++++++++++++ .../Scripts/typical_egg_info.exe | 0 .../Scripts/typical_egg_link.exe | 0 test/mock_distributions/bin/typical_egg_info | 8 + test/mock_distributions/bin/typical_egg_link | 7 + .../site-packages/typical-egg-link.egg-link | 2 + .../METADATA | 3 + .../typical_dist_info-0.0.0.dist-info/RECORD | 7 + .../typical_dist_info/__init__.py | 0 .../typical_dist_info/submodule/__init__.py | 0 .../site-packages/typical_dist_info_again.py | 0 .../typical_egg_info-0.0.0.egg-info/PKG-INFO | 3 + .../entry_points.txt | 3 + .../top_level.txt | 4 + .../typical_egg_info/__init__.py | 5 + .../typical_egg_info/submodule/__init__.py | 0 .../site-packages/typical_egg_info_again.py | 0 .../src/typical_egg_link/setup.py | 19 ++ .../typical_egg_link.egg-info/PKG-INFO | 3 + .../entry_points.txt | 2 + .../typical_egg_link/__init__.py | 5 + .../typical_egg_link/submodule/__init__.py | 0 .../typical_egg_link_again.py | 0 test/spell_check.words | 5 + test/test_distribution.py | 162 ++++++++++ 25 files changed, 542 insertions(+) create mode 100644 colcon_core/python_project/distribution.py create mode 100644 test/mock_distributions/Scripts/typical_egg_info.exe create mode 100644 test/mock_distributions/Scripts/typical_egg_link.exe create mode 100755 test/mock_distributions/bin/typical_egg_info create mode 100755 test/mock_distributions/bin/typical_egg_link create mode 100644 test/mock_distributions/lib/python/site-packages/typical-egg-link.egg-link create mode 100644 test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/METADATA create mode 100644 test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/RECORD create mode 100644 test/mock_distributions/lib/python/site-packages/typical_dist_info/__init__.py create mode 100644 test/mock_distributions/lib/python/site-packages/typical_dist_info/submodule/__init__.py create mode 100644 test/mock_distributions/lib/python/site-packages/typical_dist_info_again.py create mode 100644 test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/PKG-INFO create mode 100644 test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/entry_points.txt create mode 100644 test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/top_level.txt create mode 100644 test/mock_distributions/lib/python/site-packages/typical_egg_info/__init__.py create mode 100644 test/mock_distributions/lib/python/site-packages/typical_egg_info/submodule/__init__.py create mode 100644 test/mock_distributions/lib/python/site-packages/typical_egg_info_again.py create mode 100644 test/mock_distributions/src/typical_egg_link/setup.py create mode 100644 test/mock_distributions/src/typical_egg_link/typical_egg_link.egg-info/PKG-INFO create mode 100644 test/mock_distributions/src/typical_egg_link/typical_egg_link.egg-info/entry_points.txt create mode 100644 test/mock_distributions/src/typical_egg_link/typical_egg_link/__init__.py create mode 100644 test/mock_distributions/src/typical_egg_link/typical_egg_link/submodule/__init__.py create mode 100644 test/mock_distributions/src/typical_egg_link/typical_egg_link_again.py create mode 100644 test/test_distribution.py diff --git a/colcon_core/python_project/distribution.py b/colcon_core/python_project/distribution.py new file mode 100644 index 000000000..29c4b7d43 --- /dev/null +++ b/colcon_core/python_project/distribution.py @@ -0,0 +1,304 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# Licensed under the Apache License, Version 2.0 + +from functools import lru_cache +from importlib.machinery import PathFinder +from importlib.util import cache_from_source +import itertools +import os +from pathlib import Path +from pathlib import PurePosixPath +import sys + +from colcon_core.python_install_path import get_python_install_path +from distlib.scripts import ScriptMaker + +try: + from importlib.metadata import Distribution + from importlib.metadata import DistributionFinder +except ImportError: + from importlib_metadata import Distribution + from importlib_metadata import DistributionFinder + + +def _get_install_path(key, install_base): + return get_python_install_path(key, { + 'base': str(install_base), + 'platbase': str(install_base), + }) + + +def _enumerate_files(path, _depth=1): + if path.is_symlink() or path.is_file(): + yield PurePosixPath(*path.parts[-_depth:]) + elif path.is_dir(): + for child in path.iterdir(): + yield from _enumerate_files(child, _depth + 1) + + +def _find_prefix_path(metadata_path): + # Walk the metadata path looking for either lib, Lib, or + # sys.platlibdir. We can match that to determine our base directory. + # NOTE: sys.platlibdir was introduced in Python 3.9. + for i, part in enumerate(reversed(metadata_path.parent.parts), start=2): + if part in ('lib', 'Lib', getattr(sys, 'platlibdir', 'lib64')): + return Path(*metadata_path.parts[:-i]) + + +class PathLikeDistribution(Distribution): + """ + A Python Distribution identified by a metadata path. + + This is similar to the private :class:`importlib.metadata.Distribution` + implementation for :class:`importlib.metadata.PathDistribution`, but it + exposes a property for the metadata path because that information is + private to :class:`importlib.metadata.PathDistribution`. + + Because :class:`importlib.metadata.PathDistribution` is private and we + don't really care what information provides the underlying implementation + for representing the distribution, we call the public method + :meth:`importlib.metadata.Distribution.at` to get an instance and then + perform a sort of "cast" to this class type along with the path which was + used to create the instance to begin with. + """ + + def __new__(cls, path, *args, **kwargs): + """ + Create a new PathLikeDistribution object. + + :param path: The path to the distribution metadata directory + + :returns: A new object + :rtype: PathLikeDistribution + """ + res = Distribution.at(path) + assert isinstance(res, Distribution) + res.__class__ = cls._make_class(res.__class__) + return res + + def __init__(self, path): + """ + Construct a PathLikeDistribution. + + :param path: The path to the distribution metadata directory + """ + self._metadata_path = Path(path) + # NOTE: We do not call our super().__init__() because it was already + # called as part of __new__(). + + @classmethod + @lru_cache + def _make_class(cls, distribution_class): + """ + Create a new dynamic subclass from PathLikeDistribution. + + :param distribution_class: The secondary class type to inherit from + + :returns: The new subclass type + :rtype: Type + """ + return type( + '_Realized' + cls.__name__, + (cls, distribution_class), + {}, + ) + + @staticmethod + def at(path): # noqa: D102 + return PathLikeDistribution(path) + + @classmethod + def discover(cls, *, context=None, **kwargs): # noqa: D102 + if context and kwargs: + raise ValueError('cannot accept context and kwargs') + context = context or DistributionFinder.Context(**kwargs) + return itertools.chain.from_iterable( + cls.survey(child) + for path in context.path + for child in Path(path).iterdir() + ) + + @classmethod + def survey(cls, path, *args, **kwargs): + """ + Survey a path for any compatible distribution metadata. + + Remaining arguments to this function are passed to the ``at`` function + during for any created distribution instances. + + :param path: Candidate path to consider. + """ + if path.suffix.lower() in ('.dist-info', '.egg-info'): + yield cls.at(path, *args, **kwargs) + + @property + def name(self): + """ + Return the 'Name' metadata for the distribution package. + + This property can can be dropped when the minimum Python version is + bumped to at least Python 3.10, where it was added to the + ``Distribution`` class. + """ + return self.metadata['Name'] + + @property + def path(self): + """Get the path to the distribution metadata directory.""" + return self._metadata_path + + +class InstalledDistribution(PathLikeDistribution): + """ + A Python Distribution which enumerates all installed files. + + The typical :class:`importlib.metadata.Distribution` implementation only + enumerates files which the distribution declares are a part of it, but + there are circumstances which may lead to additional files being created + during or after installation which are a part of the distribution but + are not declared as such. + """ + + def __init__(self, path, *, _link_path=None): + """ + Construct a InstalledDistribution. + + :param path: The path to the distribution metadata directory + :param _link_path: The path to file which linked to this distribution + during discovery, if any + """ + super().__init__(path) + self._link_path = _link_path + + @staticmethod + def at(path, *, _link_path=None): # noqa: D102 + return InstalledDistribution(path, _link_path=_link_path) + + @classmethod + def survey(cls, path, *args, **kwargs): # noqa: D102 + if path.is_file() and path.suffix.lower() == '.egg-link': + egg_link = [line for line in path.read_text().splitlines() if line] + search_dir = (path.parent / egg_link[0]).resolve() + tgt = super().survey + yield from itertools.chain.from_iterable( + tgt(child, *args, _link_path=path, **kwargs) + for child in search_dir.iterdir() + ) + else: + yield from super().survey(path, *args, **kwargs) + + @property + def files(self): # noqa: D102 + if not self._link_path: + return super().files + + @property + def path(self): # noqa: D102 + return self._link_path or super().path + + def _enumerate_top_level(self): + top_level = (self.read_text('top_level.txt') or '').strip() + if not top_level: + return + + finder = PathFinder() + path = (str(self.path.parent),) + for module in (m.strip() for m in top_level.splitlines()): + if not module: + continue + spec = finder.find_spec(module, path=path) + if not spec or not spec.origin: + continue + origin = Path(spec.origin) + if origin.name == '__init__.py': + origin = origin.parent + # Safety check, packages should always be a child of our + # search directory. + if origin.parent == self.path.parent: + yield from _enumerate_files(origin) + + @classmethod + @lru_cache + def _get_script_maker(cls, script_dir): + sm = ScriptMaker(None, script_dir, dry_run=True) + sm.clobber = True + sm.variants = {''} + return sm + + def _enumerate_console_scripts(self): + prefix_path = _find_prefix_path(self.path) + if not prefix_path: + return + entry_points = { + ep for ep in self.entry_points + if ep.group == 'console_scripts' + } + if not entry_points: + return + script_dir = _get_install_path('scripts', prefix_path) + if not script_dir.is_dir(): + return + script_maker = self._get_script_maker(script_dir) + specs = [ + f'{script.name} = {script.value}' + for script in entry_points + ] + for full_path in script_maker.make_multiple(specs): + file = Path(full_path) + if file.is_file(): + file_relative = os.path.relpath( + str(file), start=str(self.path.parent)) + yield PurePosixPath(Path(file_relative)) + + def get_installed_files(self): + """ + Superset of :py:attr:`files`, including additional undeclared files. + + Unlike :py:attr:`files`, this property will never be `None`. + + :returns: List of paths relative to the installation directory. + :rtype: List[PurePosixPath] + """ + files = { + PurePosixPath(Path(file)) for file in self.files or () + if self.locate_file(file).exists() + } + + # If there is no declarative file list at all, try to use + # top_level.txt to remove the installed Python modules + if not files: + files.update(self._enumerate_top_level()) + + # If the file list doesn't contain anything related to the metadata, + # include all of the metadata we can find on disk + metadata_path = PurePosixPath(self.path.name) + if metadata_path not in files and not any( + f.parent == metadata_path for f in files + ): + files.update(_enumerate_files(self.path)) + + # Add any missing executables + files.update(self._enumerate_console_scripts()) + + # Add any missing __pycache__ files + py_files = tuple(f for f in files if f.suffix == '.py') + for file in py_files: + file_cache = PurePosixPath(Path(cache_from_source(file))) + if file_cache in files: + continue + if not self.locate_file(file_cache).exists(): + continue + files.add(file_cache) + + return list(files) + + +if __name__ == '__main__': + try: + target = sys.argv[1] + except IndexError: + target = os.getcwd() + for dist in InstalledDistribution.discover(path=[target]): + print(f'# {dist.name}@{dist.version}') + for f in sorted(dist.get_installed_files()): + print(f) diff --git a/test/mock_distributions/Scripts/typical_egg_info.exe b/test/mock_distributions/Scripts/typical_egg_info.exe new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/Scripts/typical_egg_link.exe b/test/mock_distributions/Scripts/typical_egg_link.exe new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/bin/typical_egg_info b/test/mock_distributions/bin/typical_egg_info new file mode 100755 index 000000000..893b7fd13 --- /dev/null +++ b/test/mock_distributions/bin/typical_egg_info @@ -0,0 +1,8 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from typical_egg_info import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/test/mock_distributions/bin/typical_egg_link b/test/mock_distributions/bin/typical_egg_link new file mode 100755 index 000000000..b730f1bae --- /dev/null +++ b/test/mock_distributions/bin/typical_egg_link @@ -0,0 +1,7 @@ +#!/usr/bin/python3 + +import sys +from typical_egg_link import main + + +sys.exit(main()) diff --git a/test/mock_distributions/lib/python/site-packages/typical-egg-link.egg-link b/test/mock_distributions/lib/python/site-packages/typical-egg-link.egg-link new file mode 100644 index 000000000..49ef95418 --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical-egg-link.egg-link @@ -0,0 +1,2 @@ +../../../src/typical_egg_link +. \ No newline at end of file diff --git a/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/METADATA b/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/METADATA new file mode 100644 index 000000000..07f7d3d8f --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/METADATA @@ -0,0 +1,3 @@ +Metadata-Version: 1.2 +Name: typical-dist-info +Version: 0.0.0 diff --git a/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/RECORD b/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/RECORD new file mode 100644 index 000000000..c0345f48e --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/RECORD @@ -0,0 +1,7 @@ +typical_dist_info/__init__.py,, +typical_dist_info/does_not_exist_for_some_reason.py,, +typical_dist_info/submodule/__init__.py,, +typical_dist_info_again.py,, +typical_dist_info-0.0.0.dist-info/METADATA,, +typical_dist_info-0.0.0.dist-info/RECORD,, +typical_dist_info-0.0.0.dist-info/top_level.txt,, diff --git a/test/mock_distributions/lib/python/site-packages/typical_dist_info/__init__.py b/test/mock_distributions/lib/python/site-packages/typical_dist_info/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/lib/python/site-packages/typical_dist_info/submodule/__init__.py b/test/mock_distributions/lib/python/site-packages/typical_dist_info/submodule/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/lib/python/site-packages/typical_dist_info_again.py b/test/mock_distributions/lib/python/site-packages/typical_dist_info_again.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/PKG-INFO b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/PKG-INFO new file mode 100644 index 000000000..aafc127c9 --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/PKG-INFO @@ -0,0 +1,3 @@ +Metadata-Version: 1.2 +Name: typical-egg-info +Version: 0.0.0 diff --git a/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/entry_points.txt b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/entry_points.txt new file mode 100644 index 000000000..05f39b6fb --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +does_not_exist = typical_egg_info:main +typical_egg_info = typical_egg_info:main diff --git a/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/top_level.txt b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/top_level.txt new file mode 100644 index 000000000..2d63e5276 --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/top_level.txt @@ -0,0 +1,4 @@ +does_not_exist_for_some_reason +typical_egg_info + +typical_egg_info_again diff --git a/test/mock_distributions/lib/python/site-packages/typical_egg_info/__init__.py b/test/mock_distributions/lib/python/site-packages/typical_egg_info/__init__.py new file mode 100644 index 000000000..a3ef44cc9 --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_egg_info/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# Licensed under the Apache License, Version 2.0 + +def main(): + print('Hello, World!') diff --git a/test/mock_distributions/lib/python/site-packages/typical_egg_info/submodule/__init__.py b/test/mock_distributions/lib/python/site-packages/typical_egg_info/submodule/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/lib/python/site-packages/typical_egg_info_again.py b/test/mock_distributions/lib/python/site-packages/typical_egg_info_again.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/src/typical_egg_link/setup.py b/test/mock_distributions/src/typical_egg_link/setup.py new file mode 100644 index 000000000..39c1f291f --- /dev/null +++ b/test/mock_distributions/src/typical_egg_link/setup.py @@ -0,0 +1,19 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# Licensed under the Apache License, Version 2.0 + +from setuptools import setup + + +setup( + name='typical-egg-link', + version='0.0.0', + packages=[ + 'typical_egg_link', + 'typical_egg_link_again', + ], + entry_points={ + 'console_scripts': [ + 'typical_egg_link = typical_egg_link:main', + ], + }, +) diff --git a/test/mock_distributions/src/typical_egg_link/typical_egg_link.egg-info/PKG-INFO b/test/mock_distributions/src/typical_egg_link/typical_egg_link.egg-info/PKG-INFO new file mode 100644 index 000000000..af28fdf94 --- /dev/null +++ b/test/mock_distributions/src/typical_egg_link/typical_egg_link.egg-info/PKG-INFO @@ -0,0 +1,3 @@ +Metadata-Version: 1.2 +Name: typical-egg-link +Version: 0.0.0 diff --git a/test/mock_distributions/src/typical_egg_link/typical_egg_link.egg-info/entry_points.txt b/test/mock_distributions/src/typical_egg_link/typical_egg_link.egg-info/entry_points.txt new file mode 100644 index 000000000..b7c7bcc6c --- /dev/null +++ b/test/mock_distributions/src/typical_egg_link/typical_egg_link.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +typical_egg_link = typical_egg_link:main diff --git a/test/mock_distributions/src/typical_egg_link/typical_egg_link/__init__.py b/test/mock_distributions/src/typical_egg_link/typical_egg_link/__init__.py new file mode 100644 index 000000000..a3ef44cc9 --- /dev/null +++ b/test/mock_distributions/src/typical_egg_link/typical_egg_link/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# Licensed under the Apache License, Version 2.0 + +def main(): + print('Hello, World!') diff --git a/test/mock_distributions/src/typical_egg_link/typical_egg_link/submodule/__init__.py b/test/mock_distributions/src/typical_egg_link/typical_egg_link/submodule/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/src/typical_egg_link/typical_egg_link_again.py b/test/mock_distributions/src/typical_egg_link/typical_egg_link_again.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/spell_check.words b/test/spell_check.words index 1dce75b3c..2c24984d5 100644 --- a/test/spell_check.words +++ b/test/spell_check.words @@ -16,8 +16,10 @@ changelog classname colcon coloredlogs +compileall configparser contextlib +copytree coroutine coroutines cpython @@ -33,6 +35,7 @@ depreated deps descs distlib +dists docstring executables exitstatus @@ -80,6 +83,7 @@ pkgname pkgs platbase platlib +platlibdir plugin popitem prepend @@ -87,6 +91,7 @@ prepended prepending proactor purelib +pycache pydocstyle pyproject pytest diff --git a/test/test_distribution.py b/test/test_distribution.py new file mode 100644 index 000000000..94b0110a2 --- /dev/null +++ b/test/test_distribution.py @@ -0,0 +1,162 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# Licensed under the Apache License, Version 2.0 + +import compileall +from pathlib import Path +import shutil +import subprocess +import sys + +import colcon_core.python_project.distribution +from colcon_core.python_project.distribution import InstalledDistribution +import pytest + + +TEST_DISTS_ROOT = Path(__file__).parent / 'mock_distributions' +TEST_DISTS_PYTHONPATH = TEST_DISTS_ROOT / 'lib' / 'python' / 'site-packages' + + +def assert_path_patterns(candidates, patterns): + candidates = set(candidates) + for pattern in patterns: + matches = {path for path in candidates if path.match(pattern)} + assert matches, f"No matching path for pattern '{pattern}'" + candidates.difference_update(matches) + assert not candidates, 'Unexpected paths not matching any patterns' + + +def expected_bin_path(name): + if sys.platform == 'win32': + return 'Scripts/' + name + '.exe' + return 'bin/' + name + + +@pytest.fixture +def dist_info_compiled(tmp_path): + site_path = tmp_path / 'lib' / 'python' / 'site-packages' + site_path.mkdir(parents=True) + + metadata_path = site_path / 'typical_dist_info-0.0.0.dist-info' + + shutil.copytree( + TEST_DISTS_PYTHONPATH / 'typical_dist_info-0.0.0.dist-info', + metadata_path) + shutil.copytree( + TEST_DISTS_PYTHONPATH / 'typical_dist_info', + site_path / 'typical_dist_info') + shutil.copyfile( + TEST_DISTS_PYTHONPATH / 'typical_dist_info_again.py', + site_path / 'typical_dist_info_again.py') + compileall.compile_dir(site_path, quiet=1) + + yield metadata_path + + +@pytest.fixture +def dist_info_compiled_and_listed(dist_info_compiled): + tmp_path = dist_info_compiled.parent + compiled = set(tmp_path.rglob('__pycache__/*.pyc')) + compiled_relative = sorted(pyc.relative_to(tmp_path) for pyc in compiled) + with (dist_info_compiled / 'RECORD').open('a') as f: + f.writelines(f'{pyc},,\n' for pyc in compiled_relative) + yield dist_info_compiled + + +def test_discover(): + dists = list(InstalledDistribution.discover(path=[TEST_DISTS_PYTHONPATH])) + assert all(isinstance(d, (InstalledDistribution,)) for d in dists) + assert all(d.name for d in dists) + assert {d.name for d in dists} == { + 'typical-dist-info', + 'typical-egg-info', + 'typical-egg-link', + } + + +def test_dist_info(): + meta_path = TEST_DISTS_PYTHONPATH / 'typical_dist_info-0.0.0.dist-info' + dist = InstalledDistribution.at(meta_path) + assert dist.name + assert_path_patterns(dist.get_installed_files(), ( + 'typical_dist_info/__init__.py', + 'typical_dist_info/submodule/__init__.py', + 'typical_dist_info-0.0.0.dist-info/METADATA', + 'typical_dist_info-0.0.0.dist-info/RECORD', + 'typical_dist_info_again.py', + )) + + +def test_dist_info_compiled(dist_info_compiled): + dist = InstalledDistribution.at(dist_info_compiled) + assert dist.name + assert_path_patterns(dist.get_installed_files(), ( + '__pycache__/typical_dist_info_again.*.pyc', + 'typical_dist_info/__init__.py', + 'typical_dist_info/__pycache__/__init__.*.pyc', + 'typical_dist_info/submodule/__init__.py', + 'typical_dist_info/submodule/__pycache__/__init__.*.pyc', + 'typical_dist_info-0.0.0.dist-info/METADATA', + 'typical_dist_info-0.0.0.dist-info/RECORD', + 'typical_dist_info_again.py', + )) + + +def test_dist_info_compiled_and_listed(dist_info_compiled_and_listed): + dist = InstalledDistribution.at(dist_info_compiled_and_listed) + assert dist.name + assert_path_patterns(dist.get_installed_files(), ( + '__pycache__/typical_dist_info_again.*.pyc', + 'typical_dist_info/__init__.py', + 'typical_dist_info/__pycache__/__init__.*.pyc', + 'typical_dist_info/submodule/__init__.py', + 'typical_dist_info/submodule/__pycache__/__init__.*.pyc', + 'typical_dist_info-0.0.0.dist-info/METADATA', + 'typical_dist_info-0.0.0.dist-info/RECORD', + 'typical_dist_info_again.py', + )) + + +def test_egg_info(): + meta_path = TEST_DISTS_PYTHONPATH / 'typical_egg_info-0.0.0.egg-info' + dist = InstalledDistribution.at(meta_path) + assert dist.name + assert_path_patterns(dist.get_installed_files(), ( + '../../../' + expected_bin_path('typical_egg_info'), + 'typical_egg_info/__init__.py', + 'typical_egg_info/submodule/__init__.py', + 'typical_egg_info-0.0.0.egg-info/PKG-INFO', + 'typical_egg_info-0.0.0.egg-info/entry_points.txt', + 'typical_egg_info-0.0.0.egg-info/top_level.txt', + 'typical_egg_info_again.py', + )) + + +def test_egg_link(): + dists = InstalledDistribution.discover(path=[TEST_DISTS_PYTHONPATH]) + dist = next(dist for dist in dists if dist.name == 'typical-egg-link') + assert dist.name + assert_path_patterns(dist.get_installed_files(), ( + '../../../' + expected_bin_path('typical_egg_link'), + 'typical-egg-link.egg-link', + )) + + +def test_debug_dump(): + """ + Smoke test for distribution.__main__. + + This function is really just used for debugging, so this test just + exercises the code and doesn't actually validate the output. + """ + meta_path = TEST_DISTS_PYTHONPATH + cmd = [ + sys.executable, + '-B', + colcon_core.python_project.distribution.__file__, + ] + res = subprocess.run(cmd, cwd=meta_path, check=True, capture_output=True) + assert res.stdout + + cmd.append(str(meta_path)) + res = subprocess.run(cmd, cwd=meta_path, check=True, capture_output=True) + assert res.stdout From 9589fb4a64b44f3b4d41d7aa99c6563b0d8893a0 Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Tue, 17 Mar 2026 10:31:32 -0500 Subject: [PATCH 2/3] Fix Python 3.6 support, a couple of bugs Gemini found --- colcon_core/python_project/distribution.py | 41 +++++++++++-------- .../shared_namespace/typical_dist_info.py | 0 .../shared_namespace/typical_egg_info.py | 0 .../typical_dist_info-0.0.0.dist-info/RECORD | 1 + .../top_level.txt | 1 + .../src/typical_egg_link/setup.py | 3 ++ test/spell_check.words | 1 + test/test_distribution.py | 32 ++++++++++++++- 8 files changed, 59 insertions(+), 20 deletions(-) create mode 100644 test/mock_distributions/lib/python/site-packages/shared_namespace/typical_dist_info.py create mode 100644 test/mock_distributions/lib/python/site-packages/shared_namespace/typical_egg_info.py diff --git a/colcon_core/python_project/distribution.py b/colcon_core/python_project/distribution.py index 29c4b7d43..9c545e0b1 100644 --- a/colcon_core/python_project/distribution.py +++ b/colcon_core/python_project/distribution.py @@ -87,7 +87,7 @@ def __init__(self, path): # called as part of __new__(). @classmethod - @lru_cache + @lru_cache(maxsize=32) def _make_class(cls, distribution_class): """ Create a new dynamic subclass from PathLikeDistribution. @@ -124,7 +124,7 @@ def survey(cls, path, *args, **kwargs): Survey a path for any compatible distribution metadata. Remaining arguments to this function are passed to the ``at`` function - during for any created distribution instances. + for any created distribution instances. :param path: Candidate path to consider. """ @@ -136,9 +136,9 @@ def name(self): """ Return the 'Name' metadata for the distribution package. - This property can can be dropped when the minimum Python version is - bumped to at least Python 3.10, where it was added to the - ``Distribution`` class. + This property can be dropped when the minimum Python version is bumped + to at least Python 3.10, where it was added to the ``Distribution`` + class. """ return self.metadata['Name'] @@ -177,8 +177,13 @@ def at(path, *, _link_path=None): # noqa: D102 @classmethod def survey(cls, path, *args, **kwargs): # noqa: D102 if path.is_file() and path.suffix.lower() == '.egg-link': - egg_link = [line for line in path.read_text().splitlines() if line] - search_dir = (path.parent / egg_link[0]).resolve() + egg_link = next(( + line for line in path.read_text().splitlines() if line), None) + if not egg_link: + return + search_dir = (path.parent / egg_link).resolve() + if not search_dir.is_dir(): + return tgt = super().survey yield from itertools.chain.from_iterable( tgt(child, *args, _link_path=path, **kwargs) @@ -201,24 +206,24 @@ def _enumerate_top_level(self): if not top_level: return - finder = PathFinder() path = (str(self.path.parent),) for module in (m.strip() for m in top_level.splitlines()): if not module: continue - spec = finder.find_spec(module, path=path) - if not spec or not spec.origin: + spec = PathFinder.find_spec(module, path=path) + if not spec: continue - origin = Path(spec.origin) - if origin.name == '__init__.py': - origin = origin.parent - # Safety check, packages should always be a child of our - # search directory. - if origin.parent == self.path.parent: - yield from _enumerate_files(origin) + if spec.origin: + origin = Path(spec.origin) + if origin.parent == self.path.parent: + yield from _enumerate_files(origin) + for submodule in spec.submodule_search_locations or (): + submodule = Path(submodule) + if submodule.parent == self.path.parent: + yield from _enumerate_files(submodule) @classmethod - @lru_cache + @lru_cache(maxsize=32) def _get_script_maker(cls, script_dir): sm = ScriptMaker(None, script_dir, dry_run=True) sm.clobber = True diff --git a/test/mock_distributions/lib/python/site-packages/shared_namespace/typical_dist_info.py b/test/mock_distributions/lib/python/site-packages/shared_namespace/typical_dist_info.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/lib/python/site-packages/shared_namespace/typical_egg_info.py b/test/mock_distributions/lib/python/site-packages/shared_namespace/typical_egg_info.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/RECORD b/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/RECORD index c0345f48e..1c8a7ceea 100644 --- a/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/RECORD +++ b/test/mock_distributions/lib/python/site-packages/typical_dist_info-0.0.0.dist-info/RECORD @@ -1,3 +1,4 @@ +shared_namespace/typical_dist_info.py,, typical_dist_info/__init__.py,, typical_dist_info/does_not_exist_for_some_reason.py,, typical_dist_info/submodule/__init__.py,, diff --git a/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/top_level.txt b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/top_level.txt index 2d63e5276..7cbd6ac19 100644 --- a/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/top_level.txt +++ b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/top_level.txt @@ -1,4 +1,5 @@ does_not_exist_for_some_reason +shared_namespace typical_egg_info typical_egg_info_again diff --git a/test/mock_distributions/src/typical_egg_link/setup.py b/test/mock_distributions/src/typical_egg_link/setup.py index 39c1f291f..b3074c20a 100644 --- a/test/mock_distributions/src/typical_egg_link/setup.py +++ b/test/mock_distributions/src/typical_egg_link/setup.py @@ -9,6 +9,9 @@ version='0.0.0', packages=[ 'typical_egg_link', + ], + py_modules=[ + 'shared_namespace.typical_egg_link', 'typical_egg_link_again', ], entry_points={ diff --git a/test/spell_check.words b/test/spell_check.words index 2c24984d5..3fbd888b0 100644 --- a/test/spell_check.words +++ b/test/spell_check.words @@ -67,6 +67,7 @@ lineno linter linux lstrip +maxsize minversion mkdtemp monkeypatch diff --git a/test/test_distribution.py b/test/test_distribution.py index 94b0110a2..712f29f6f 100644 --- a/test/test_distribution.py +++ b/test/test_distribution.py @@ -38,6 +38,10 @@ def dist_info_compiled(tmp_path): metadata_path = site_path / 'typical_dist_info-0.0.0.dist-info' + (site_path / 'shared_namespace').mkdir(parents=True) + shutil.copyfile( + TEST_DISTS_PYTHONPATH / 'shared_namespace' / 'typical_dist_info.py', + site_path / 'shared_namespace' / 'typical_dist_info.py') shutil.copytree( TEST_DISTS_PYTHONPATH / 'typical_dist_info-0.0.0.dist-info', metadata_path) @@ -78,6 +82,7 @@ def test_dist_info(): dist = InstalledDistribution.at(meta_path) assert dist.name assert_path_patterns(dist.get_installed_files(), ( + 'shared_namespace/typical_dist_info.py', 'typical_dist_info/__init__.py', 'typical_dist_info/submodule/__init__.py', 'typical_dist_info-0.0.0.dist-info/METADATA', @@ -91,6 +96,8 @@ def test_dist_info_compiled(dist_info_compiled): assert dist.name assert_path_patterns(dist.get_installed_files(), ( '__pycache__/typical_dist_info_again.*.pyc', + 'shared_namespace/__pycache__/typical_dist_info.*.pyc', + 'shared_namespace/typical_dist_info.py', 'typical_dist_info/__init__.py', 'typical_dist_info/__pycache__/__init__.*.pyc', 'typical_dist_info/submodule/__init__.py', @@ -106,6 +113,8 @@ def test_dist_info_compiled_and_listed(dist_info_compiled_and_listed): assert dist.name assert_path_patterns(dist.get_installed_files(), ( '__pycache__/typical_dist_info_again.*.pyc', + 'shared_namespace/__pycache__/typical_dist_info.*.pyc', + 'shared_namespace/typical_dist_info.py', 'typical_dist_info/__init__.py', 'typical_dist_info/__pycache__/__init__.*.pyc', 'typical_dist_info/submodule/__init__.py', @@ -122,6 +131,11 @@ def test_egg_info(): assert dist.name assert_path_patterns(dist.get_installed_files(), ( '../../../' + expected_bin_path('typical_egg_info'), + 'shared_namespace/typical_egg_info.py', + # Due to limitations with top_level enumeration, this distribution + # will see all of the namespace packages, even those it doesn't own. + # Same behavior with `pip uninstall`. + 'shared_namespace/*.py', 'typical_egg_info/__init__.py', 'typical_egg_info/submodule/__init__.py', 'typical_egg_info-0.0.0.egg-info/PKG-INFO', @@ -141,6 +155,18 @@ def test_egg_link(): )) +def test_bad_egg_links(tmp_path): + site_path = tmp_path / 'lib' / 'python' / 'site-packages' + site_path.mkdir(parents=True) + + (site_path / 'broken.egg-link').write_text('does_not_exist\n.') + (site_path / 'no_path.egg-link').write_text('\n') + (site_path / 'empty.egg-link').write_text('') + + dists = InstalledDistribution.discover(path=[site_path]) + assert not any(dists) + + def test_debug_dump(): """ Smoke test for distribution.__main__. @@ -154,9 +180,11 @@ def test_debug_dump(): '-B', colcon_core.python_project.distribution.__file__, ] - res = subprocess.run(cmd, cwd=meta_path, check=True, capture_output=True) + res = subprocess.run( + cmd, cwd=meta_path, check=True, stdout=subprocess.PIPE) assert res.stdout cmd.append(str(meta_path)) - res = subprocess.run(cmd, cwd=meta_path, check=True, capture_output=True) + res = subprocess.run( + cmd, cwd=meta_path, check=True, stdout=subprocess.PIPE) assert res.stdout From 98ada1bc47d82bdce29cb37a6038ffebefbce36f Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Tue, 17 Mar 2026 13:08:40 -0500 Subject: [PATCH 3/3] Additional fixes and refactoring --- colcon_core/python_project/distribution.py | 206 +++++++++--------- .../__editable__.typical_pep660-0.0.0.pth | 0 .../namespace_packages.txt | 1 + .../typical_pep660-0.0.0.dist-info/METADATA | 3 + .../typical_pep660-0.0.0.dist-info/RECORD | 4 + .../direct_url.json | 0 test/spell_check.words | 3 +- test/test_distribution.py | 99 ++++++--- 8 files changed, 179 insertions(+), 137 deletions(-) create mode 100644 test/mock_distributions/lib/python/site-packages/__editable__.typical_pep660-0.0.0.pth create mode 100644 test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/namespace_packages.txt create mode 100644 test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/METADATA create mode 100644 test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/RECORD create mode 100644 test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/direct_url.json diff --git a/colcon_core/python_project/distribution.py b/colcon_core/python_project/distribution.py index 9c545e0b1..1ec284dc3 100644 --- a/colcon_core/python_project/distribution.py +++ b/colcon_core/python_project/distribution.py @@ -2,12 +2,12 @@ # Licensed under the Apache License, Version 2.0 from functools import lru_cache -from importlib.machinery import PathFinder +import importlib.machinery from importlib.util import cache_from_source -import itertools import os from pathlib import Path from pathlib import PurePosixPath +import re import sys from colcon_core.python_install_path import get_python_install_path @@ -36,45 +36,19 @@ def _enumerate_files(path, _depth=1): yield from _enumerate_files(child, _depth + 1) -def _find_prefix_path(metadata_path): - # Walk the metadata path looking for either lib, Lib, or - # sys.platlibdir. We can match that to determine our base directory. - # NOTE: sys.platlibdir was introduced in Python 3.9. - for i, part in enumerate(reversed(metadata_path.parent.parts), start=2): - if part in ('lib', 'Lib', getattr(sys, 'platlibdir', 'lib64')): - return Path(*metadata_path.parts[:-i]) - - class PathLikeDistribution(Distribution): """ A Python Distribution identified by a metadata path. - This is similar to the private :class:`importlib.metadata.Distribution` - implementation for :class:`importlib.metadata.PathDistribution`, but it - exposes a property for the metadata path because that information is - private to :class:`importlib.metadata.PathDistribution`. - - Because :class:`importlib.metadata.PathDistribution` is private and we - don't really care what information provides the underlying implementation - for representing the distribution, we call the public method - :meth:`importlib.metadata.Distribution.at` to get an instance and then - perform a sort of "cast" to this class type along with the path which was - used to create the instance to begin with. - """ - - def __new__(cls, path, *args, **kwargs): - """ - Create a new PathLikeDistribution object. + This class wraps a :class:`importlib.metadata.Distribution` instance + obtained via :meth:`importlib.metadata.Distribution.at`. - :param path: The path to the distribution metadata directory - - :returns: A new object - :rtype: PathLikeDistribution - """ - res = Distribution.at(path) - assert isinstance(res, Distribution) - res.__class__ = cls._make_class(res.__class__) - return res + Composition is used here instead of inheritance or dynamic class mutation + (like ``__class__`` assignment) to ensure compatibility with all + implementations of the ``Distribution`` ABC, including those which might + be implemented in C or use ``__slots__``, which would prevent dynamic + subclassing or class reassignment. + """ def __init__(self, path): """ @@ -83,25 +57,13 @@ def __init__(self, path): :param path: The path to the distribution metadata directory """ self._metadata_path = Path(path) - # NOTE: We do not call our super().__init__() because it was already - # called as part of __new__(). + self._dist = Distribution.at(path) - @classmethod - @lru_cache(maxsize=32) - def _make_class(cls, distribution_class): - """ - Create a new dynamic subclass from PathLikeDistribution. + def read_text(self, filename): # noqa: D102 + return self._dist.read_text(filename) - :param distribution_class: The secondary class type to inherit from - - :returns: The new subclass type - :rtype: Type - """ - return type( - '_Realized' + cls.__name__, - (cls, distribution_class), - {}, - ) + def locate_file(self, path): # noqa: D102 + return self._dist.locate_file(path) @staticmethod def at(path): # noqa: D102 @@ -112,24 +74,24 @@ def discover(cls, *, context=None, **kwargs): # noqa: D102 if context and kwargs: raise ValueError('cannot accept context and kwargs') context = context or DistributionFinder.Context(**kwargs) - return itertools.chain.from_iterable( - cls.survey(child) - for path in context.path - for child in Path(path).iterdir() - ) + for path in (Path(p) for p in context.path): + if not path.is_dir(): + continue + try: + for child in path.iterdir(): + yield from cls.survey(child) + except OSError: + continue @classmethod - def survey(cls, path, *args, **kwargs): + def survey(cls, path): """ Survey a path for any compatible distribution metadata. - Remaining arguments to this function are passed to the ``at`` function - for any created distribution instances. - :param path: Candidate path to consider. """ if path.suffix.lower() in ('.dist-info', '.egg-info'): - yield cls.at(path, *args, **kwargs) + yield cls.at(path) @property def name(self): @@ -159,23 +121,33 @@ class InstalledDistribution(PathLikeDistribution): are not declared as such. """ - def __init__(self, path, *, _link_path=None): + def __init__(self, path): """ Construct a InstalledDistribution. :param path: The path to the distribution metadata directory - :param _link_path: The path to file which linked to this distribution - during discovery, if any """ super().__init__(path) - self._link_path = _link_path + self._link_path = None + self._prefix_path = None @staticmethod - def at(path, *, _link_path=None): # noqa: D102 - return InstalledDistribution(path, _link_path=_link_path) + def at(path, *, prefix_path=None): # noqa: D102 + dist = InstalledDistribution(path) + dist._prefix_path = Path(prefix_path) if prefix_path else None + return dist @classmethod - def survey(cls, path, *args, **kwargs): # noqa: D102 + def discover( # noqa: D102 + cls, *, context=None, prefix_path=None, **kwargs + ): + for dist in super().discover(context=context, **kwargs): + dist._prefix_path = Path(prefix_path) if prefix_path else None + yield dist + + @classmethod + def survey(cls, path, *args, prefix_path=None, **kwargs): # noqa: D102 + prefix_path = Path(prefix_path) if prefix_path else None if path.is_file() and path.suffix.lower() == '.egg-link': egg_link = next(( line for line in path.read_text().splitlines() if line), None) @@ -184,13 +156,15 @@ def survey(cls, path, *args, **kwargs): # noqa: D102 search_dir = (path.parent / egg_link).resolve() if not search_dir.is_dir(): return - tgt = super().survey - yield from itertools.chain.from_iterable( - tgt(child, *args, _link_path=path, **kwargs) - for child in search_dir.iterdir() - ) + for child in search_dir.iterdir(): + for dist in super().survey(child): + dist._link_path = path + dist._prefix_path = prefix_path + yield dist else: - yield from super().survey(path, *args, **kwargs) + for dist in super().survey(path): + dist._prefix_path = prefix_path + yield dist @property def files(self): # noqa: D102 @@ -202,25 +176,35 @@ def path(self): # noqa: D102 return self._link_path or super().path def _enumerate_top_level(self): - top_level = (self.read_text('top_level.txt') or '').strip() + top_level = { + name.strip() for name in + (self.read_text('top_level.txt') or '').splitlines() + } if not top_level: return - path = (str(self.path.parent),) - for module in (m.strip() for m in top_level.splitlines()): + namespaces = { + name.strip() for name in + (self.read_text('namespace_packages.txt') or '').splitlines() + } + base_path = self.path.parent + suffixes = tuple(importlib.machinery.all_suffixes()) + + for module in top_level - namespaces: if not module: continue - spec = PathFinder.find_spec(module, path=path) - if not spec: + + module_path = base_path / module + + # Check if it's a package directory + if module_path.is_dir(): + yield from _enumerate_files(module_path) continue - if spec.origin: - origin = Path(spec.origin) - if origin.parent == self.path.parent: - yield from _enumerate_files(origin) - for submodule in spec.submodule_search_locations or (): - submodule = Path(submodule) - if submodule.parent == self.path.parent: - yield from _enumerate_files(submodule) + + # Check if it's a single-file module (including extensions) + for module_file in map(module_path.with_suffix, suffixes): + if module_file.is_file(): + yield from _enumerate_files(module_file) @classmethod @lru_cache(maxsize=32) @@ -231,8 +215,7 @@ def _get_script_maker(cls, script_dir): return sm def _enumerate_console_scripts(self): - prefix_path = _find_prefix_path(self.path) - if not prefix_path: + if not self._prefix_path: return entry_points = { ep for ep in self.entry_points @@ -240,10 +223,24 @@ def _enumerate_console_scripts(self): } if not entry_points: return - script_dir = _get_install_path('scripts', prefix_path) + script_dir = _get_install_path('scripts', self._prefix_path) if not script_dir.is_dir(): return - script_maker = self._get_script_maker(script_dir) + + script_paths = set() + + script_names = {ep.name for ep in entry_points} + pattern = re.compile( + r'^(' + '|'.join(map(re.escape, script_names)) + r')' + r'(?:-\d+\.\d+|-script\.pyw?|\.exe(?:\.manifest)?|\.bat|\.cmd)?$' + ) + + script_paths.update( + f for f in script_dir.iterdir() + if f.is_file() and pattern.match(f.name) + ) + + script_maker = self._get_script_maker(str(script_dir)) specs = [ f'{script.name} = {script.value}' for script in entry_points @@ -251,9 +248,12 @@ def _enumerate_console_scripts(self): for full_path in script_maker.make_multiple(specs): file = Path(full_path) if file.is_file(): - file_relative = os.path.relpath( - str(file), start=str(self.path.parent)) - yield PurePosixPath(Path(file_relative)) + script_paths.add(file) + + for file in script_paths: + file_relative = os.path.relpath( + str(file), start=str(self.path.parent)) + yield PurePosixPath(Path(file_relative)) def get_installed_files(self): """ @@ -270,7 +270,7 @@ def get_installed_files(self): } # If there is no declarative file list at all, try to use - # top_level.txt to remove the installed Python modules + # top_level.txt to recover the installed Python modules if not files: files.update(self._enumerate_top_level()) @@ -288,12 +288,14 @@ def get_installed_files(self): # Add any missing __pycache__ files py_files = tuple(f for f in files if f.suffix == '.py') for file in py_files: - file_cache = PurePosixPath(Path(cache_from_source(file))) - if file_cache in files: - continue - if not self.locate_file(file_cache).exists(): + file_cache = Path(cache_from_source(file)) + cache_dir = file_cache.parent + abs_cache_dir = self.locate_file(cache_dir) + if not abs_cache_dir.is_dir(): continue - files.add(file_cache) + for cache_file in abs_cache_dir.glob(f'{file_cache.stem}*.pyc'): + rel_cache = PurePosixPath(cache_dir / cache_file.name) + files.add(rel_cache) return list(files) diff --git a/test/mock_distributions/lib/python/site-packages/__editable__.typical_pep660-0.0.0.pth b/test/mock_distributions/lib/python/site-packages/__editable__.typical_pep660-0.0.0.pth new file mode 100644 index 000000000..e69de29bb diff --git a/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/namespace_packages.txt b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/namespace_packages.txt new file mode 100644 index 000000000..ec5406743 --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_egg_info-0.0.0.egg-info/namespace_packages.txt @@ -0,0 +1 @@ +shared_namespace diff --git a/test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/METADATA b/test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/METADATA new file mode 100644 index 000000000..e30cf6d2a --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/METADATA @@ -0,0 +1,3 @@ +Metadata-Version: 2.1 +Name: typical-pep660 +Version: 0.0.0 diff --git a/test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/RECORD b/test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/RECORD new file mode 100644 index 000000000..523edcc4e --- /dev/null +++ b/test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/RECORD @@ -0,0 +1,4 @@ +typical_pep660-0.0.0.dist-info/METADATA,, +__editable__.typical_pep660-0.0.0.pth,, +typical_pep660-0.0.0.dist-info/RECORD,, +typical_pep660-0.0.0.dist-info/direct_url.json,, diff --git a/test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/direct_url.json b/test/mock_distributions/lib/python/site-packages/typical_pep660-0.0.0.dist-info/direct_url.json new file mode 100644 index 000000000..e69de29bb diff --git a/test/spell_check.words b/test/spell_check.words index 3fbd888b0..8e2e6539b 100644 --- a/test/spell_check.words +++ b/test/spell_check.words @@ -72,6 +72,7 @@ minversion mkdtemp monkeypatch namedtuple +namespaces nargs noop noops @@ -84,7 +85,6 @@ pkgname pkgs platbase platlib -platlibdir plugin popitem prepend @@ -135,6 +135,7 @@ stdeb stringify stylizer stylizers +subclassing subparser subparsers subprocesses diff --git a/test/test_distribution.py b/test/test_distribution.py index 712f29f6f..5e3e5da7e 100644 --- a/test/test_distribution.py +++ b/test/test_distribution.py @@ -2,10 +2,12 @@ # Licensed under the Apache License, Version 2.0 import compileall +import io from pathlib import Path +import runpy import shutil -import subprocess import sys +from unittest.mock import patch import colcon_core.python_project.distribution from colcon_core.python_project.distribution import InstalledDistribution @@ -52,6 +54,7 @@ def dist_info_compiled(tmp_path): TEST_DISTS_PYTHONPATH / 'typical_dist_info_again.py', site_path / 'typical_dist_info_again.py') compileall.compile_dir(site_path, quiet=1) + compileall.compile_dir(site_path, quiet=1, optimize=1) yield metadata_path @@ -67,19 +70,27 @@ def dist_info_compiled_and_listed(dist_info_compiled): def test_discover(): - dists = list(InstalledDistribution.discover(path=[TEST_DISTS_PYTHONPATH])) + dists = list(InstalledDistribution.discover( + path=[TEST_DISTS_PYTHONPATH], prefix_path=TEST_DISTS_ROOT)) assert all(isinstance(d, (InstalledDistribution,)) for d in dists) assert all(d.name for d in dists) assert {d.name for d in dists} == { 'typical-dist-info', 'typical-egg-info', 'typical-egg-link', + 'typical-pep660', } +def test_discover_nonexistent_path(tmp_path): + path = tmp_path / 'does_not_exist' + dists = list(InstalledDistribution.discover(path=[str(path)])) + assert not dists + + def test_dist_info(): meta_path = TEST_DISTS_PYTHONPATH / 'typical_dist_info-0.0.0.dist-info' - dist = InstalledDistribution.at(meta_path) + dist = InstalledDistribution.at(meta_path, prefix_path=TEST_DISTS_ROOT) assert dist.name assert_path_patterns(dist.get_installed_files(), ( 'shared_namespace/typical_dist_info.py', @@ -92,16 +103,22 @@ def test_dist_info(): def test_dist_info_compiled(dist_info_compiled): - dist = InstalledDistribution.at(dist_info_compiled) + dist = InstalledDistribution.at( + dist_info_compiled, prefix_path=TEST_DISTS_ROOT) assert dist.name + tag = sys.implementation.cache_tag assert_path_patterns(dist.get_installed_files(), ( - '__pycache__/typical_dist_info_again.*.pyc', - 'shared_namespace/__pycache__/typical_dist_info.*.pyc', + f'__pycache__/typical_dist_info_again.{tag}.opt-1.pyc', + f'__pycache__/typical_dist_info_again.{tag}.pyc', + f'shared_namespace/__pycache__/typical_dist_info.{tag}.opt-1.pyc', + f'shared_namespace/__pycache__/typical_dist_info.{tag}.pyc', 'shared_namespace/typical_dist_info.py', 'typical_dist_info/__init__.py', - 'typical_dist_info/__pycache__/__init__.*.pyc', + f'typical_dist_info/__pycache__/__init__.{tag}.opt-1.pyc', + f'typical_dist_info/__pycache__/__init__.{tag}.pyc', 'typical_dist_info/submodule/__init__.py', - 'typical_dist_info/submodule/__pycache__/__init__.*.pyc', + f'typical_dist_info/submodule/__pycache__/__init__.{tag}.opt-1.pyc', + f'typical_dist_info/submodule/__pycache__/__init__.{tag}.pyc', 'typical_dist_info-0.0.0.dist-info/METADATA', 'typical_dist_info-0.0.0.dist-info/RECORD', 'typical_dist_info_again.py', @@ -109,16 +126,22 @@ def test_dist_info_compiled(dist_info_compiled): def test_dist_info_compiled_and_listed(dist_info_compiled_and_listed): - dist = InstalledDistribution.at(dist_info_compiled_and_listed) + dist = InstalledDistribution.at( + dist_info_compiled_and_listed, prefix_path=TEST_DISTS_ROOT) assert dist.name + tag = sys.implementation.cache_tag assert_path_patterns(dist.get_installed_files(), ( - '__pycache__/typical_dist_info_again.*.pyc', - 'shared_namespace/__pycache__/typical_dist_info.*.pyc', + f'__pycache__/typical_dist_info_again.{tag}.opt-1.pyc', + f'__pycache__/typical_dist_info_again.{tag}.pyc', + f'shared_namespace/__pycache__/typical_dist_info.{tag}.opt-1.pyc', + f'shared_namespace/__pycache__/typical_dist_info.{tag}.pyc', 'shared_namespace/typical_dist_info.py', 'typical_dist_info/__init__.py', - 'typical_dist_info/__pycache__/__init__.*.pyc', + f'typical_dist_info/__pycache__/__init__.{tag}.opt-1.pyc', + f'typical_dist_info/__pycache__/__init__.{tag}.pyc', 'typical_dist_info/submodule/__init__.py', - 'typical_dist_info/submodule/__pycache__/__init__.*.pyc', + f'typical_dist_info/submodule/__pycache__/__init__.{tag}.opt-1.pyc', + f'typical_dist_info/submodule/__pycache__/__init__.{tag}.pyc', 'typical_dist_info-0.0.0.dist-info/METADATA', 'typical_dist_info-0.0.0.dist-info/RECORD', 'typical_dist_info_again.py', @@ -127,27 +150,24 @@ def test_dist_info_compiled_and_listed(dist_info_compiled_and_listed): def test_egg_info(): meta_path = TEST_DISTS_PYTHONPATH / 'typical_egg_info-0.0.0.egg-info' - dist = InstalledDistribution.at(meta_path) + dist = InstalledDistribution.at(meta_path, prefix_path=TEST_DISTS_ROOT) assert dist.name assert_path_patterns(dist.get_installed_files(), ( '../../../' + expected_bin_path('typical_egg_info'), - 'shared_namespace/typical_egg_info.py', - # Due to limitations with top_level enumeration, this distribution - # will see all of the namespace packages, even those it doesn't own. - # Same behavior with `pip uninstall`. - 'shared_namespace/*.py', 'typical_egg_info/__init__.py', 'typical_egg_info/submodule/__init__.py', 'typical_egg_info-0.0.0.egg-info/PKG-INFO', 'typical_egg_info-0.0.0.egg-info/entry_points.txt', + 'typical_egg_info-0.0.0.egg-info/namespace_packages.txt', 'typical_egg_info-0.0.0.egg-info/top_level.txt', 'typical_egg_info_again.py', )) def test_egg_link(): - dists = InstalledDistribution.discover(path=[TEST_DISTS_PYTHONPATH]) - dist = next(dist for dist in dists if dist.name == 'typical-egg-link') + meta_path = TEST_DISTS_PYTHONPATH / 'typical-egg-link.egg-link' + (dist,) = InstalledDistribution.survey( + meta_path, prefix_path=TEST_DISTS_ROOT) assert dist.name assert_path_patterns(dist.get_installed_files(), ( '../../../' + expected_bin_path('typical_egg_link'), @@ -155,6 +175,18 @@ def test_egg_link(): )) +def test_pep660(): + meta_path = TEST_DISTS_PYTHONPATH / 'typical_pep660-0.0.0.dist-info' + dist = InstalledDistribution.at(meta_path, prefix_path=TEST_DISTS_ROOT) + assert dist.name + assert_path_patterns(dist.get_installed_files(), ( + '__editable__.typical_pep660-0.0.0.pth', + 'typical_pep660-0.0.0.dist-info/METADATA', + 'typical_pep660-0.0.0.dist-info/RECORD', + 'typical_pep660-0.0.0.dist-info/direct_url.json', + )) + + def test_bad_egg_links(tmp_path): site_path = tmp_path / 'lib' / 'python' / 'site-packages' site_path.mkdir(parents=True) @@ -175,16 +207,15 @@ def test_debug_dump(): exercises the code and doesn't actually validate the output. """ meta_path = TEST_DISTS_PYTHONPATH - cmd = [ - sys.executable, - '-B', - colcon_core.python_project.distribution.__file__, - ] - res = subprocess.run( - cmd, cwd=meta_path, check=True, stdout=subprocess.PIPE) - assert res.stdout - - cmd.append(str(meta_path)) - res = subprocess.run( - cmd, cwd=meta_path, check=True, stdout=subprocess.PIPE) - assert res.stdout + dist_file = colcon_core.python_project.distribution.__file__ + + with patch('sys.argv', [dist_file]), \ + patch('os.getcwd', return_value=str(meta_path)), \ + patch('sys.stdout', new_callable=io.StringIO) as stdout: + runpy.run_path(dist_file, run_name='__main__') + assert stdout.getvalue() + + with patch('sys.argv', [dist_file, str(meta_path)]), \ + patch('sys.stdout', new_callable=io.StringIO) as stdout: + runpy.run_path(dist_file, run_name='__main__') + assert stdout.getvalue()