├── docs ├── __init__.py ├── history.rst ├── api.rst ├── index.rst ├── conf.py └── migration.rst ├── tests ├── __init__.py ├── data │ ├── __init__.py │ ├── sources │ │ ├── example │ │ │ ├── example │ │ │ │ └── __init__.py │ │ │ └── setup.py │ │ └── example2 │ │ │ ├── example2 │ │ │ └── __init__.py │ │ │ └── pyproject.toml │ ├── example-21.12-py3.6.egg │ ├── example-21.12-py3-none-any.whl │ └── example2-1.0.0-py3-none-any.whl ├── compat │ ├── __init__.py │ ├── py39.py │ ├── py312.py │ └── test_py39_compat.py ├── _context.py ├── test_integration.py ├── test_zip.py ├── _path.py ├── test_api.py ├── fixtures.py └── test_main.py ├── importlib_metadata ├── py.typed ├── compat │ ├── __init__.py │ ├── py311.py │ └── py39.py ├── _typing.py ├── diagnose.py ├── _collections.py ├── _compat.py ├── _meta.py ├── _text.py ├── _functools.py ├── _adapters.py ├── _itertools.py └── __init__.py ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── towncrier.toml ├── .pre-commit-config.yaml ├── SECURITY.md ├── .gitignore ├── .editorconfig ├── .readthedocs.yaml ├── .coveragerc ├── pytest.ini ├── mypy.ini ├── conftest.py ├── exercises.py ├── ruff.toml ├── tox.ini ├── pyproject.toml ├── README.rst └── NEWS.rst /docs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /importlib_metadata/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/compat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /importlib_metadata/compat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/importlib-metadata 2 | -------------------------------------------------------------------------------- /tests/data/sources/example/example/__init__.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | return 'example' 3 | -------------------------------------------------------------------------------- /tests/data/sources/example2/example2/__init__.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | return "example" 3 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | title_format = "{version}" 3 | directory = "newsfragments" # jaraco/skeleton#184 4 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. _changes: 4 | 5 | History 6 | ******* 7 | 8 | .. include:: ../NEWS (links).rst 9 | -------------------------------------------------------------------------------- /tests/data/example-21.12-py3.6.egg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/importlib_metadata/HEAD/tests/data/example-21.12-py3.6.egg -------------------------------------------------------------------------------- /tests/data/example-21.12-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/importlib_metadata/HEAD/tests/data/example-21.12-py3-none-any.whl -------------------------------------------------------------------------------- /tests/data/example2-1.0.0-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/importlib_metadata/HEAD/tests/data/example2-1.0.0-py3-none-any.whl -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.12.0 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | /coverage.xml 3 | /diffcov.html 4 | htmlcov 5 | importlib_metadata.egg-info 6 | .mypy_cache 7 | /.coverage 8 | /.DS_Store 9 | artifacts 10 | .eggs 11 | .doctrees 12 | dist 13 | pip-wheel-metadata 14 | -------------------------------------------------------------------------------- /tests/data/sources/example2/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = 'trampolim' 3 | requires = ['trampolim'] 4 | 5 | [project] 6 | name = 'example2' 7 | version = '1.0.0' 8 | 9 | [project.scripts] 10 | example = 'example2:main' 11 | -------------------------------------------------------------------------------- /tests/compat/py39.py: -------------------------------------------------------------------------------- 1 | from jaraco.test.cpython import from_test_support, try_import 2 | 3 | os_helper = try_import('os_helper') or from_test_support( 4 | 'FS_NONASCII', 'skip_unless_symlink', 'temp_dir' 5 | ) 6 | import_helper = try_import('import_helper') or from_test_support( 7 | 'modules_setup', 'modules_cleanup' 8 | ) 9 | -------------------------------------------------------------------------------- /tests/data/sources/example/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='example', 5 | version='21.12', 6 | license='Apache Software License', 7 | packages=['example'], 8 | entry_points={ 9 | 'console_scripts': ['example = example:main', 'Example=example:main'], 10 | }, 11 | ) 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.py] 11 | indent_style = space 12 | max_line_length = 88 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.rst] 19 | indent_style = space 20 | -------------------------------------------------------------------------------- /tests/_context.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | 4 | # from jaraco.context 4.3 5 | class suppress(contextlib.suppress, contextlib.ContextDecorator): 6 | """ 7 | A version of contextlib.suppress with decorator support. 8 | 9 | >>> @suppress(KeyError) 10 | ... def key_error(): 11 | ... {}[''] 12 | >>> key_error() 13 | """ 14 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | ``importlib_metadata`` module 6 | ----------------------------- 7 | 8 | .. automodule:: importlib_metadata 9 | :members: 10 | :undoc-members: 11 | :show-inheritance: 12 | 13 | .. automodule:: importlib_metadata._meta 14 | :members: 15 | :undoc-members: 16 | :show-inheritance: 17 | -------------------------------------------------------------------------------- /importlib_metadata/_typing.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import typing 3 | 4 | from ._meta import PackageMetadata 5 | 6 | md_none = functools.partial(typing.cast, PackageMetadata) 7 | """ 8 | Suppress type errors for optional metadata. 9 | 10 | Although Distribution.metadata can return None when metadata is corrupt 11 | and thus None, allow callers to assume it's not None and crash if 12 | that's the case. 13 | 14 | # python/importlib_metadata#493 15 | """ 16 | -------------------------------------------------------------------------------- /tests/compat/py312.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | from .py39 import import_helper 4 | 5 | 6 | @contextlib.contextmanager 7 | def isolated_modules(): 8 | """ 9 | Save modules on entry and cleanup on exit. 10 | """ 11 | (saved,) = import_helper.modules_setup() 12 | try: 13 | yield 14 | finally: 15 | import_helper.modules_cleanup(saved) 16 | 17 | 18 | vars(import_helper).setdefault('isolated_modules', isolated_modules) 19 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - doc 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # required boilerplate readthedocs/readthedocs.org#10401 12 | build: 13 | os: ubuntu-lts-latest 14 | tools: 15 | python: latest 16 | # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 17 | jobs: 18 | post_checkout: 19 | - git fetch --unshallow || true 20 | -------------------------------------------------------------------------------- /importlib_metadata/diagnose.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from . import Distribution 4 | 5 | 6 | def inspect(path): 7 | print("Inspecting", path) 8 | dists = list(Distribution.discover(path=[path])) 9 | if not dists: 10 | return 11 | print("Found", len(dists), "packages:", end=' ') 12 | print(', '.join(dist.name for dist in dists)) 13 | 14 | 15 | def run(): 16 | for path in sys.path: 17 | inspect(path) 18 | 19 | 20 | if __name__ == '__main__': 21 | run() 22 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # leading `*/` for pytest-dev/pytest-cov#456 4 | */.tox/* 5 | */pep517-build-env-* 6 | tests/* 7 | prepare/* 8 | */_itertools.py 9 | exercises.py 10 | */pip-run-* 11 | disable_warnings = 12 | couldnt-parse 13 | 14 | [report] 15 | show_missing = True 16 | exclude_also = 17 | # Exclude common false positives per 18 | # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion 19 | # Ref jaraco/skeleton#97 and jaraco/skeleton#135 20 | class .*\bProtocol\): 21 | if TYPE_CHECKING: 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts= 4 | --doctest-modules 5 | --import-mode importlib 6 | consider_namespace_packages=true 7 | filterwarnings= 8 | ## upstream 9 | 10 | # Ensure ResourceWarnings are emitted 11 | default::ResourceWarning 12 | 13 | # realpython/pytest-mypy#152 14 | ignore:'encoding' argument not specified::pytest_mypy 15 | 16 | # python/cpython#100750 17 | ignore:'encoding' argument not specified::platform 18 | 19 | # pypa/build#615 20 | ignore:'encoding' argument not specified::build.env 21 | 22 | # dateutil/dateutil#1284 23 | ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz 24 | 25 | ## end upstream 26 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Is the project well-typed? 3 | strict = False 4 | 5 | # Early opt-in even when strict = False 6 | warn_unused_ignores = True 7 | warn_redundant_casts = True 8 | enable_error_code = ignore-without-code 9 | 10 | # Support namespace packages per https://github.com/python/mypy/issues/14057 11 | explicit_package_bases = True 12 | 13 | disable_error_code = 14 | # Disable due to many false positives 15 | overload-overlap, 16 | 17 | # jaraco/pytest-perf#16 18 | [mypy-pytest_perf.*] 19 | ignore_missing_imports = True 20 | 21 | # jaraco/zipp#123 22 | [mypy-zipp.*] 23 | ignore_missing_imports = True 24 | 25 | # jaraco/jaraco.test#7 26 | [mypy-jaraco.test.*] 27 | ignore_missing_imports = True 28 | -------------------------------------------------------------------------------- /importlib_metadata/compat/py311.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import sys 4 | import types 5 | 6 | 7 | def wrap(path): # pragma: no cover 8 | """ 9 | Workaround for https://github.com/python/cpython/issues/84538 10 | to add backward compatibility for walk_up=True. 11 | An example affected package is dask-labextension, which uses 12 | jupyter-packaging to install JupyterLab javascript files outside 13 | of site-packages. 14 | """ 15 | 16 | def relative_to(root, *, walk_up=False): 17 | return pathlib.Path(os.path.relpath(path, root)) 18 | 19 | return types.SimpleNamespace(relative_to=relative_to) 20 | 21 | 22 | relative_fix = wrap if sys.version_info < (3, 12) else lambda x: x 23 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | collect_ignore = [ 4 | # this module fails mypy tests because 'setup.py' matches './setup.py' 5 | 'tests/data/sources/example/setup.py', 6 | ] 7 | 8 | 9 | def pytest_configure(): 10 | remove_importlib_metadata() 11 | 12 | 13 | def remove_importlib_metadata(): 14 | """ 15 | Ensure importlib_metadata is not imported yet. 16 | 17 | Because pytest or other modules might import 18 | importlib_metadata, the coverage reports are broken (#322). 19 | Work around the issue by undoing the changes made by a 20 | previous import of importlib_metadata (if any). 21 | """ 22 | sys.meta_path[:] = [ 23 | item 24 | for item in sys.meta_path 25 | if item.__class__.__name__ != 'MetadataPathFinder' 26 | ] 27 | for mod in list(sys.modules): 28 | if mod.startswith('importlib_metadata'): 29 | del sys.modules[mod] 30 | -------------------------------------------------------------------------------- /importlib_metadata/_collections.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import typing 3 | 4 | 5 | # from jaraco.collections 3.3 6 | class FreezableDefaultDict(collections.defaultdict): 7 | """ 8 | Often it is desirable to prevent the mutation of 9 | a default dict after its initial construction, such 10 | as to prevent mutation during iteration. 11 | 12 | >>> dd = FreezableDefaultDict(list) 13 | >>> dd[0].append('1') 14 | >>> dd.freeze() 15 | >>> dd[1] 16 | [] 17 | >>> len(dd) 18 | 1 19 | """ 20 | 21 | def __missing__(self, key): 22 | return getattr(self, '_frozen', super().__missing__)(key) 23 | 24 | def freeze(self): 25 | self._frozen = lambda key: self.default_factory() 26 | 27 | 28 | class Pair(typing.NamedTuple): 29 | name: str 30 | value: str 31 | 32 | @classmethod 33 | def parse(cls, text): 34 | return cls(*map(str.strip, text.split("=", 1))) 35 | -------------------------------------------------------------------------------- /exercises.py: -------------------------------------------------------------------------------- 1 | from pytest_perf.deco import extras 2 | 3 | 4 | @extras('perf') 5 | def discovery_perf(): 6 | "discovery" 7 | import importlib_metadata # end warmup 8 | 9 | importlib_metadata.distribution('ipython') 10 | 11 | 12 | def entry_points_perf(): 13 | "entry_points()" 14 | import importlib_metadata # end warmup 15 | 16 | importlib_metadata.entry_points() 17 | 18 | 19 | @extras('perf') 20 | def cached_distribution_perf(): 21 | "cached distribution" 22 | import importlib_metadata 23 | 24 | importlib_metadata.distribution('ipython') # end warmup 25 | importlib_metadata.distribution('ipython') 26 | 27 | 28 | @extras('perf') 29 | def uncached_distribution_perf(): 30 | "uncached distribution" 31 | import importlib 32 | 33 | import importlib_metadata 34 | 35 | # end warmup 36 | importlib.invalidate_caches() 37 | importlib_metadata.distribution('ipython') 38 | 39 | 40 | def entrypoint_regexp_perf(): 41 | import re 42 | 43 | import importlib_metadata 44 | 45 | input = '0' + ' ' * 2**10 + '0' # end warmup 46 | 47 | re.match(importlib_metadata.EntryPoint.pattern, input) 48 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to |project| documentation! 2 | =================================== 3 | 4 | .. sidebar-links:: 5 | :home: 6 | :pypi: 7 | 8 | ``importlib_metadata`` supplies a backport of :mod:`importlib.metadata`, 9 | enabling early access to features of future Python versions and making 10 | functionality available for older Python versions. Users are encouraged to 11 | use the Python standard library where suitable and fall back to 12 | this library for future compatibility. For general usage guidance, start 13 | with :mod:`importlib.metadata` but substitute ``importlib_metadata`` 14 | for ``importlib.metadata``. 15 | 16 | 17 | .. toctree:: 18 | :maxdepth: 1 19 | 20 | api 21 | migration 22 | history 23 | 24 | .. tidelift-referral-banner:: 25 | 26 | 27 | Project details 28 | =============== 29 | 30 | * Project home: https://github.com/python/importlib_metadata 31 | * Report bugs at: https://github.com/python/importlib_metadata/issues 32 | * Code hosting: https://github.com/python/importlib_metadata 33 | * Documentation: https://importlib-metadata.readthedocs.io/ 34 | 35 | 36 | Indices and tables 37 | ================== 38 | 39 | * :ref:`genindex` 40 | * :ref:`modindex` 41 | * :ref:`search` 42 | -------------------------------------------------------------------------------- /importlib_metadata/compat/py39.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compatibility layer with Python 3.8/3.9 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import TYPE_CHECKING, Any 8 | 9 | if TYPE_CHECKING: # pragma: no cover 10 | # Prevent circular imports on runtime. 11 | from .. import Distribution, EntryPoint 12 | else: 13 | Distribution = EntryPoint = Any 14 | 15 | from .._typing import md_none 16 | 17 | 18 | def normalized_name(dist: Distribution) -> str | None: 19 | """ 20 | Honor name normalization for distributions that don't provide ``_normalized_name``. 21 | """ 22 | try: 23 | return dist._normalized_name 24 | except AttributeError: 25 | from .. import Prepared # -> delay to prevent circular imports. 26 | 27 | return Prepared.normalize( 28 | getattr(dist, "name", None) or md_none(dist.metadata)['Name'] 29 | ) 30 | 31 | 32 | def ep_matches(ep: EntryPoint, **params) -> bool: 33 | """ 34 | Workaround for ``EntryPoint`` objects without the ``matches`` method. 35 | """ 36 | try: 37 | return ep.matches(**params) 38 | except AttributeError: 39 | from .. import EntryPoint # -> delay to prevent circular imports. 40 | 41 | # Reconstruct the EntryPoint object to make sure it is compatible. 42 | return EntryPoint(ep.name, ep.value, ep.group).matches(**params) 43 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | # upstream 4 | 5 | "C901", # complex-structure 6 | "I", # isort 7 | "PERF401", # manual-list-comprehension 8 | 9 | # Ensure modern type annotation syntax and best practices 10 | # Not including those covered by type-checkers or exclusive to Python 3.11+ 11 | "FA", # flake8-future-annotations 12 | "F404", # late-future-import 13 | "PYI", # flake8-pyi 14 | "UP006", # non-pep585-annotation 15 | "UP007", # non-pep604-annotation 16 | "UP010", # unnecessary-future-import 17 | "UP035", # deprecated-import 18 | "UP037", # quoted-annotation 19 | "UP043", # unnecessary-default-type-args 20 | 21 | # local 22 | ] 23 | ignore = [ 24 | # upstream 25 | 26 | # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, 27 | # irrelevant to this project. 28 | "PYI011", # typed-argument-default-in-stub 29 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 30 | "W191", 31 | "E111", 32 | "E114", 33 | "E117", 34 | "D206", 35 | "D300", 36 | "Q000", 37 | "Q001", 38 | "Q002", 39 | "Q003", 40 | "COM812", 41 | "COM819", 42 | 43 | # local 44 | ] 45 | 46 | [format] 47 | # Enable preview to get hugged parenthesis unwrapping and other nice surprises 48 | # See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 49 | preview = true 50 | # https://docs.astral.sh/ruff/settings/#format_quote-style 51 | quote-style = "preserve" 52 | -------------------------------------------------------------------------------- /importlib_metadata/_compat.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | 4 | __all__ = ['install', 'NullFinder'] 5 | 6 | 7 | def install(cls): 8 | """ 9 | Class decorator for installation on sys.meta_path. 10 | 11 | Adds the backport DistributionFinder to sys.meta_path and 12 | attempts to disable the finder functionality of the stdlib 13 | DistributionFinder. 14 | """ 15 | sys.meta_path.append(cls()) 16 | disable_stdlib_finder() 17 | return cls 18 | 19 | 20 | def disable_stdlib_finder(): 21 | """ 22 | Give the backport primacy for discovering path-based distributions 23 | by monkey-patching the stdlib O_O. 24 | 25 | See #91 for more background for rationale on this sketchy 26 | behavior. 27 | """ 28 | 29 | def matches(finder): 30 | return getattr( 31 | finder, '__module__', None 32 | ) == '_frozen_importlib_external' and hasattr(finder, 'find_distributions') 33 | 34 | for finder in filter(matches, sys.meta_path): # pragma: nocover 35 | del finder.find_distributions 36 | 37 | 38 | class NullFinder: 39 | """ 40 | A "Finder" (aka "MetaPathFinder") that never finds any modules, 41 | but may find distributions. 42 | """ 43 | 44 | @staticmethod 45 | def find_spec(*args, **kwargs): 46 | return None 47 | 48 | 49 | def pypy_partial(val): 50 | """ 51 | Adjust for variable stacklevel on partial under PyPy. 52 | 53 | Workaround for #327. 54 | """ 55 | is_pypy = platform.python_implementation() == 'PyPy' 56 | return val + is_pypy 57 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | description = perform primary checks (tests, style, types, coverage) 3 | deps = 4 | setenv = 5 | PYTHONWARNDEFAULTENCODING = 1 6 | commands = 7 | pytest {posargs} 8 | passenv = 9 | HOME 10 | usedevelop = True 11 | extras = 12 | test 13 | check 14 | cover 15 | enabler 16 | type 17 | 18 | [testenv:diffcov] 19 | description = run tests and check that diff from main is covered 20 | deps = 21 | {[testenv]deps} 22 | diff-cover 23 | commands = 24 | pytest {posargs} --cov-report xml 25 | diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html 26 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 27 | 28 | [testenv:docs] 29 | description = build the documentation 30 | extras = 31 | doc 32 | test 33 | changedir = docs 34 | commands = 35 | python -m sphinx -W --keep-going . {toxinidir}/build/html 36 | python -m sphinxlint 37 | 38 | [testenv:finalize] 39 | description = assemble changelog and tag a release 40 | skip_install = True 41 | deps = 42 | towncrier 43 | jaraco.develop >= 7.23 44 | pass_env = * 45 | commands = 46 | python -m jaraco.develop.finalize 47 | 48 | 49 | [testenv:release] 50 | description = publish the package to PyPI and GitHub 51 | skip_install = True 52 | deps = 53 | build 54 | twine>=3 55 | jaraco.develop>=7.1 56 | pass_env = 57 | TWINE_PASSWORD 58 | GITHUB_TOKEN 59 | setenv = 60 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 61 | commands = 62 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 63 | python -m build 64 | python -m twine upload dist/* 65 | python -m jaraco.develop.create-github-release 66 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test behaviors specific to importlib_metadata. 3 | 4 | These tests are excluded downstream in CPython as they 5 | test functionality only in importlib_metadata or require 6 | behaviors ('packaging') that aren't available in the 7 | stdlib. 8 | """ 9 | 10 | import unittest 11 | 12 | import packaging.requirements 13 | import packaging.version 14 | 15 | from importlib_metadata import ( 16 | _compat, 17 | version, 18 | ) 19 | 20 | from . import fixtures 21 | 22 | 23 | class IntegrationTests(fixtures.DistInfoPkg, unittest.TestCase): 24 | def test_package_spec_installed(self): 25 | """ 26 | Illustrate the recommended procedure to determine if 27 | a specified version of a package is installed. 28 | """ 29 | 30 | def is_installed(package_spec): 31 | req = packaging.requirements.Requirement(package_spec) 32 | return version(req.name) in req.specifier 33 | 34 | assert is_installed('distinfo-pkg==1.0') 35 | assert is_installed('distinfo-pkg>=1.0,<2.0') 36 | assert not is_installed('distinfo-pkg<1.0') 37 | 38 | 39 | class FinderTests(fixtures.Fixtures, unittest.TestCase): 40 | def test_finder_without_module(self): 41 | class ModuleFreeFinder: 42 | """ 43 | A finder without an __module__ attribute 44 | """ 45 | 46 | def find_module(self, name): 47 | pass 48 | 49 | def __getattribute__(self, name): 50 | if name == '__module__': 51 | raise AttributeError(name) 52 | return super().__getattribute__(name) 53 | 54 | self.fixtures.enter_context(fixtures.install_finder(ModuleFreeFinder())) 55 | _compat.disable_stdlib_finder() 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77", 4 | "setuptools_scm[toml]>=3.4.1", 5 | # jaraco/skeleton#174 6 | "coherent.licensed", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name = "importlib_metadata" 12 | authors = [ 13 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 14 | ] 15 | description = "Read metadata from Python packages" 16 | readme = "README.rst" 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Intended Audience :: Developers", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | ] 23 | requires-python = ">=3.9" 24 | license = "Apache-2.0" 25 | dependencies = [ 26 | "zipp>=3.20", 27 | ] 28 | dynamic = ["version"] 29 | 30 | [project.urls] 31 | Source = "https://github.com/python/importlib_metadata" 32 | 33 | [project.optional-dependencies] 34 | test = [ 35 | # upstream 36 | "pytest >= 6, != 8.1.*", 37 | 38 | # local 39 | "packaging", 40 | "pyfakefs", 41 | "flufl.flake8", 42 | "pytest-perf >= 0.9.2", 43 | "jaraco.test >= 5.4", 44 | ] 45 | 46 | doc = [ 47 | # upstream 48 | "sphinx >= 3.5", 49 | "jaraco.packaging >= 9.3", 50 | "rst.linker >= 1.9", 51 | "furo", 52 | "sphinx-lint", 53 | 54 | # tidelift 55 | "jaraco.tidelift >= 1.4", 56 | 57 | # local 58 | ] 59 | perf = ["ipython"] 60 | 61 | check = [ 62 | "pytest-checkdocs >= 2.4", 63 | "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", 64 | ] 65 | 66 | cover = [ 67 | "pytest-cov", 68 | ] 69 | 70 | enabler = [ 71 | "pytest-enabler >= 3.4", 72 | ] 73 | 74 | type = [ 75 | # upstream 76 | "pytest-mypy >= 1.0.1", 77 | 78 | ## workaround for python/mypy#20454 79 | "mypy < 1.19; python_implementation == 'PyPy'", 80 | 81 | # local 82 | ] 83 | 84 | 85 | [tool.setuptools_scm] 86 | -------------------------------------------------------------------------------- /importlib_metadata/_meta.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from collections.abc import Iterator 5 | from typing import ( 6 | Any, 7 | Protocol, 8 | TypeVar, 9 | overload, 10 | ) 11 | 12 | _T = TypeVar("_T") 13 | 14 | 15 | class PackageMetadata(Protocol): 16 | def __len__(self) -> int: ... # pragma: no cover 17 | 18 | def __contains__(self, item: str) -> bool: ... # pragma: no cover 19 | 20 | def __getitem__(self, key: str) -> str: ... # pragma: no cover 21 | 22 | def __iter__(self) -> Iterator[str]: ... # pragma: no cover 23 | 24 | @overload 25 | def get( 26 | self, name: str, failobj: None = None 27 | ) -> str | None: ... # pragma: no cover 28 | 29 | @overload 30 | def get(self, name: str, failobj: _T) -> str | _T: ... # pragma: no cover 31 | 32 | # overload per python/importlib_metadata#435 33 | @overload 34 | def get_all( 35 | self, name: str, failobj: None = None 36 | ) -> list[Any] | None: ... # pragma: no cover 37 | 38 | @overload 39 | def get_all(self, name: str, failobj: _T) -> list[Any] | _T: 40 | """ 41 | Return all values associated with a possibly multi-valued key. 42 | """ 43 | 44 | @property 45 | def json(self) -> dict[str, str | list[str]]: 46 | """ 47 | A JSON-compatible form of the metadata. 48 | """ 49 | 50 | 51 | class SimplePath(Protocol): 52 | """ 53 | A minimal subset of pathlib.Path required by Distribution. 54 | """ 55 | 56 | def joinpath( 57 | self, other: str | os.PathLike[str] 58 | ) -> SimplePath: ... # pragma: no cover 59 | 60 | def __truediv__( 61 | self, other: str | os.PathLike[str] 62 | ) -> SimplePath: ... # pragma: no cover 63 | 64 | @property 65 | def parent(self) -> SimplePath: ... # pragma: no cover 66 | 67 | def read_text(self, encoding=None) -> str: ... # pragma: no cover 68 | 69 | def read_bytes(self) -> bytes: ... # pragma: no cover 70 | 71 | def exists(self) -> bool: ... # pragma: no cover 72 | -------------------------------------------------------------------------------- /importlib_metadata/_text.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from ._functools import method_cache 4 | 5 | 6 | # from jaraco.text 3.5 7 | class FoldedCase(str): 8 | """ 9 | A case insensitive string class; behaves just like str 10 | except compares equal when the only variation is case. 11 | 12 | >>> s = FoldedCase('hello world') 13 | 14 | >>> s == 'Hello World' 15 | True 16 | 17 | >>> 'Hello World' == s 18 | True 19 | 20 | >>> s != 'Hello World' 21 | False 22 | 23 | >>> s.index('O') 24 | 4 25 | 26 | >>> s.split('O') 27 | ['hell', ' w', 'rld'] 28 | 29 | >>> sorted(map(FoldedCase, ['GAMMA', 'alpha', 'Beta'])) 30 | ['alpha', 'Beta', 'GAMMA'] 31 | 32 | Sequence membership is straightforward. 33 | 34 | >>> "Hello World" in [s] 35 | True 36 | >>> s in ["Hello World"] 37 | True 38 | 39 | You may test for set inclusion, but candidate and elements 40 | must both be folded. 41 | 42 | >>> FoldedCase("Hello World") in {s} 43 | True 44 | >>> s in {FoldedCase("Hello World")} 45 | True 46 | 47 | String inclusion works as long as the FoldedCase object 48 | is on the right. 49 | 50 | >>> "hello" in FoldedCase("Hello World") 51 | True 52 | 53 | But not if the FoldedCase object is on the left: 54 | 55 | >>> FoldedCase('hello') in 'Hello World' 56 | False 57 | 58 | In that case, use in_: 59 | 60 | >>> FoldedCase('hello').in_('Hello World') 61 | True 62 | 63 | >>> FoldedCase('hello') > FoldedCase('Hello') 64 | False 65 | """ 66 | 67 | def __lt__(self, other): 68 | return self.lower() < other.lower() 69 | 70 | def __gt__(self, other): 71 | return self.lower() > other.lower() 72 | 73 | def __eq__(self, other): 74 | return self.lower() == other.lower() 75 | 76 | def __ne__(self, other): 77 | return self.lower() != other.lower() 78 | 79 | def __hash__(self): 80 | return hash(self.lower()) 81 | 82 | def __contains__(self, other): 83 | return super().lower().__contains__(other.lower()) 84 | 85 | def in_(self, other): 86 | "Does self appear in other?" 87 | return self in FoldedCase(other) 88 | 89 | # cache lower since it's likely to be called frequently. 90 | @method_cache 91 | def lower(self): 92 | return super().lower() 93 | 94 | def index(self, sub): 95 | return self.lower().index(sub.lower()) 96 | 97 | def split(self, splitter=' ', maxsplit=0): 98 | pattern = re.compile(re.escape(splitter), re.I) 99 | return pattern.split(self, maxsplit) 100 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'jaraco.packaging.sphinx', 6 | ] 7 | 8 | master_doc = "index" 9 | html_theme = "furo" 10 | 11 | # Link dates and other references in the changelog 12 | extensions += ['rst.linker'] 13 | link_files = { 14 | '../NEWS.rst': dict( 15 | using=dict(GH='https://github.com'), 16 | replace=[ 17 | dict( 18 | pattern=r'(Issue #|\B#)(?P\d+)', 19 | url='{package_url}/issues/{issue}', 20 | ), 21 | dict( 22 | pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', 23 | with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', 24 | ), 25 | dict( 26 | pattern=r'PEP[- ](?P\d+)', 27 | url='https://peps.python.org/pep-{pep_number:0>4}/', 28 | ), 29 | dict( 30 | pattern=r'(python/cpython#|Python #)(?P\d+)', 31 | url='https://github.com/python/cpython/issues/{python}', 32 | ), 33 | dict( 34 | pattern=r'bpo-(?P\d+)', 35 | url='http://bugs.python.org/issue{bpo}', 36 | ), 37 | ], 38 | ) 39 | } 40 | 41 | # Be strict about any broken references 42 | nitpicky = True 43 | nitpick_ignore: list[tuple[str, str]] = [] 44 | 45 | # Include Python intersphinx mapping to prevent failures 46 | # jaraco/skeleton#51 47 | extensions += ['sphinx.ext.intersphinx'] 48 | intersphinx_mapping = { 49 | 'python': ('https://docs.python.org/3', None), 50 | } 51 | 52 | # Preserve authored syntax for defaults 53 | autodoc_preserve_defaults = True 54 | 55 | # Add support for linking usernames, PyPI projects, Wikipedia pages 56 | github_url = 'https://github.com/' 57 | extlinks = { 58 | 'user': (f'{github_url}%s', '@%s'), 59 | 'pypi': ('https://pypi.org/project/%s', '%s'), 60 | 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), 61 | } 62 | extensions += ['sphinx.ext.extlinks'] 63 | 64 | # local 65 | 66 | extensions += ['jaraco.tidelift'] 67 | 68 | intersphinx_mapping.update( 69 | importlib_resources=( 70 | 'https://importlib-resources.readthedocs.io/en/latest/', 71 | None, 72 | ), 73 | ) 74 | 75 | intersphinx_mapping.update( 76 | packaging=( 77 | 'https://packaging.python.org/en/latest/', 78 | None, 79 | ), 80 | ) 81 | 82 | nitpick_ignore += [ 83 | # Workaround for #316 84 | ('py:class', 'importlib_metadata.EntryPoints'), 85 | ('py:class', 'importlib_metadata.PackagePath'), 86 | ('py:class', 'importlib_metadata.SelectableGroups'), 87 | ('py:class', 'importlib_metadata._meta._T'), 88 | # Workaround for #435 89 | ('py:class', '_T'), 90 | ] 91 | -------------------------------------------------------------------------------- /tests/compat/test_py39_compat.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | import unittest 4 | 5 | from importlib_metadata import ( 6 | distribution, 7 | distributions, 8 | entry_points, 9 | metadata, 10 | version, 11 | ) 12 | 13 | from .. import fixtures 14 | 15 | 16 | class OldStdlibFinderTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): 17 | def setUp(self): 18 | if sys.version_info >= (3, 10): 19 | self.skipTest("Tests specific for Python 3.8/3.9") 20 | super().setUp() 21 | 22 | def _meta_path_finder(self): 23 | from importlib.metadata import ( 24 | Distribution, 25 | DistributionFinder, 26 | PathDistribution, 27 | ) 28 | from importlib.util import spec_from_file_location 29 | 30 | path = pathlib.Path(self.site_dir) 31 | 32 | class CustomDistribution(Distribution): 33 | def __init__(self, name, path): 34 | self.name = name 35 | self._path_distribution = PathDistribution(path) 36 | 37 | def read_text(self, filename): 38 | return self._path_distribution.read_text(filename) 39 | 40 | def locate_file(self, path): 41 | return self._path_distribution.locate_file(path) 42 | 43 | class CustomFinder: 44 | @classmethod 45 | def find_spec(cls, fullname, _path=None, _target=None): 46 | candidate = pathlib.Path(path, *fullname.split(".")).with_suffix(".py") 47 | if candidate.exists(): 48 | return spec_from_file_location(fullname, candidate) 49 | 50 | @classmethod 51 | def find_distributions(self, context=DistributionFinder.Context()): 52 | for dist_info in path.glob("*.dist-info"): 53 | yield PathDistribution(dist_info) 54 | name, _, _ = str(dist_info).partition("-") 55 | yield CustomDistribution(name + "_custom", dist_info) 56 | 57 | return CustomFinder 58 | 59 | def test_compatibility_with_old_stdlib_path_distribution(self): 60 | """ 61 | Given a custom finder that uses Python 3.8/3.9 importlib.metadata is installed, 62 | when importlib_metadata functions are called, there should be no exceptions. 63 | Ref python/importlib_metadata#396. 64 | """ 65 | self.fixtures.enter_context(fixtures.install_finder(self._meta_path_finder())) 66 | 67 | assert list(distributions()) 68 | assert distribution("distinfo_pkg") 69 | assert distribution("distinfo_pkg_custom") 70 | assert version("distinfo_pkg") > "0" 71 | assert version("distinfo_pkg_custom") > "0" 72 | assert list(metadata("distinfo_pkg")) 73 | assert list(metadata("distinfo_pkg_custom")) 74 | assert list(entry_points(group="entries")) 75 | -------------------------------------------------------------------------------- /tests/test_zip.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import os 3 | import sys 4 | import unittest 5 | 6 | from importlib_metadata import ( 7 | FastPath, 8 | PackageNotFoundError, 9 | distribution, 10 | distributions, 11 | entry_points, 12 | files, 13 | version, 14 | ) 15 | 16 | from . import fixtures 17 | 18 | 19 | class TestZip(fixtures.ZipFixtures, unittest.TestCase): 20 | def setUp(self): 21 | super().setUp() 22 | self._fixture_on_path('example-21.12-py3-none-any.whl') 23 | 24 | def test_zip_version(self): 25 | self.assertEqual(version('example'), '21.12') 26 | 27 | def test_zip_version_does_not_match(self): 28 | with self.assertRaises(PackageNotFoundError): 29 | version('definitely-not-installed') 30 | 31 | def test_zip_entry_points(self): 32 | scripts = entry_points(group='console_scripts') 33 | entry_point = scripts['example'] 34 | self.assertEqual(entry_point.value, 'example:main') 35 | entry_point = scripts['Example'] 36 | self.assertEqual(entry_point.value, 'example:main') 37 | 38 | def test_missing_metadata(self): 39 | self.assertIsNone(distribution('example').read_text('does not exist')) 40 | 41 | def test_case_insensitive(self): 42 | self.assertEqual(version('Example'), '21.12') 43 | 44 | def test_files(self): 45 | for file in files('example'): 46 | path = str(file.dist.locate_file(file)) 47 | assert '.whl/' in path, path 48 | 49 | def test_one_distribution(self): 50 | dists = list(distributions(path=sys.path[:1])) 51 | assert len(dists) == 1 52 | 53 | @unittest.skipUnless( 54 | hasattr(os, 'register_at_fork') 55 | and 'fork' in multiprocessing.get_all_start_methods(), 56 | 'requires fork-based multiprocessing support', 57 | ) 58 | def test_fastpath_cache_cleared_in_forked_child(self): 59 | zip_path = sys.path[0] 60 | 61 | FastPath(zip_path) 62 | assert FastPath.__new__.cache_info().currsize >= 1 63 | 64 | ctx = multiprocessing.get_context('fork') 65 | parent_conn, child_conn = ctx.Pipe() 66 | 67 | def child(conn, root): 68 | try: 69 | before = FastPath.__new__.cache_info().currsize 70 | FastPath(root) 71 | after = FastPath.__new__.cache_info().currsize 72 | conn.send((before, after)) 73 | finally: 74 | conn.close() 75 | 76 | proc = ctx.Process(target=child, args=(child_conn, zip_path)) 77 | proc.start() 78 | child_conn.close() 79 | cache_sizes = parent_conn.recv() 80 | proc.join() 81 | 82 | self.assertEqual(cache_sizes, (0, 1)) 83 | 84 | 85 | class TestEgg(TestZip): 86 | def setUp(self): 87 | super().setUp() 88 | self._fixture_on_path('example-21.12-py3.6.egg') 89 | 90 | def test_files(self): 91 | for file in files('example'): 92 | path = str(file.dist.locate_file(file)) 93 | assert '.egg/' in path, path 94 | 95 | def test_normalized_name(self): 96 | dist = distribution('example') 97 | assert dist._normalized_name == 'example' 98 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/importlib_metadata.svg 2 | :target: https://pypi.org/project/importlib_metadata 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/importlib_metadata.svg 5 | 6 | .. image:: https://github.com/python/importlib_metadata/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/python/importlib_metadata/actions?query=workflow%3A%22tests%22 8 | :alt: tests 9 | 10 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 11 | :target: https://github.com/astral-sh/ruff 12 | :alt: Ruff 13 | 14 | .. image:: https://readthedocs.org/projects/importlib-metadata/badge/?version=latest 15 | :target: https://importlib-metadata.readthedocs.io/en/latest/?badge=latest 16 | 17 | .. image:: https://img.shields.io/badge/skeleton-2025-informational 18 | :target: https://blog.jaraco.com/skeleton 19 | 20 | .. image:: https://tidelift.com/badges/package/pypi/importlib-metadata 21 | :target: https://tidelift.com/subscription/pkg/pypi-importlib-metadata?utm_source=pypi-importlib-metadata&utm_medium=readme 22 | 23 | Library to access the metadata for a Python package. 24 | 25 | This package supplies third-party access to the functionality of 26 | `importlib.metadata `_ 27 | including improvements added to subsequent Python versions. 28 | 29 | 30 | Compatibility 31 | ============= 32 | 33 | New features are introduced in this third-party library and later merged 34 | into CPython. The following table indicates which versions of this library 35 | were contributed to different versions in the standard library: 36 | 37 | .. list-table:: 38 | :header-rows: 1 39 | 40 | * - importlib_metadata 41 | - stdlib 42 | * - 7.0 43 | - 3.13 44 | * - 6.5 45 | - 3.12 46 | * - 4.13 47 | - 3.11 48 | * - 4.6 49 | - 3.10 50 | * - 1.4 51 | - 3.8 52 | 53 | 54 | Usage 55 | ===== 56 | 57 | See the `online documentation `_ 58 | for usage details. 59 | 60 | `Finder authors 61 | `_ can 62 | also add support for custom package installers. See the above documentation 63 | for details. 64 | 65 | 66 | Caveats 67 | ======= 68 | 69 | This project primarily supports third-party packages installed by PyPA 70 | tools (or other conforming packages). It does not support: 71 | 72 | - Packages in the stdlib. 73 | - Packages installed without metadata. 74 | 75 | Project details 76 | =============== 77 | 78 | * Project home: https://github.com/python/importlib_metadata 79 | * Report bugs at: https://github.com/python/importlib_metadata/issues 80 | * Code hosting: https://github.com/python/importlib_metadata 81 | * Documentation: https://importlib-metadata.readthedocs.io/ 82 | 83 | For Enterprise 84 | ============== 85 | 86 | Available as part of the Tidelift Subscription. 87 | 88 | This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. 89 | 90 | `Learn more `_. 91 | -------------------------------------------------------------------------------- /docs/migration.rst: -------------------------------------------------------------------------------- 1 | .. _migration: 2 | 3 | ================= 4 | Migration guide 5 | ================= 6 | 7 | The following guide will help you migrate common ``pkg_resources`` 8 | APIs to ``importlib_metadata``. ``importlib_metadata`` aims to 9 | replace the following ``pkg_resources`` APIs: 10 | 11 | * ``pkg_resources.iter_entry_points()`` 12 | * ``pkg_resources.require()`` 13 | * convenience functions 14 | * ``pkg_resources.find_distributions()`` 15 | * ``pkg_resources.get_distribution()`` 16 | 17 | Other functionality from ``pkg_resources`` is replaced by other 18 | packages such as 19 | `importlib_resources `_ 20 | and `packaging `_. 21 | 22 | 23 | pkg_resources.iter_entry_points() 24 | ================================= 25 | 26 | ``importlib_metadata`` provides :ref:`entry-points`. 27 | 28 | Compatibility note: entry points provided by importlib_metadata 29 | do not have the following implicit behaviors found in those 30 | from ``pkg_resources``: 31 | 32 | - Each EntryPoint is not automatically validated to match. To 33 | ensure each one is validated, invoke any property on the 34 | object (e.g. ``ep.name``). 35 | 36 | - When invoking ``EntryPoint.load()``, no checks are performed 37 | to ensure the declared extras are installed. If this behavior 38 | is desired/required, it is left to the user to perform the 39 | check and install any dependencies. See 40 | `importlib_metadata#368 `_ 41 | for more details. 42 | 43 | pkg_resources.require() 44 | ======================= 45 | 46 | ``importlib_metadata`` does not provide support for dynamically 47 | discovering or requiring distributions nor does it provide any 48 | support for managing the "working set". Furthermore, 49 | ``importlib_metadata`` assumes that only one version of a given 50 | distribution is discoverable at any time (no support for multi-version 51 | installs). Any projects that require the above behavior needs to 52 | provide that behavior independently. 53 | 54 | ``importlib_metadata`` does aim to resolve metadata concerns late 55 | such that any dynamic changes to package availability should be 56 | reflected immediately. 57 | 58 | Convenience functions 59 | ===================== 60 | 61 | In addition to the support for direct access to ``Distribution`` 62 | objects (below), ``importlib_metadata`` presents some top-level 63 | functions for easy access to the most common metadata: 64 | 65 | - :ref:`metadata` queries the metadata fields from the distribution. 66 | - :ref:`version` provides quick access to the distribution version. 67 | - :ref:`requirements` presents the requirements of the distribution. 68 | - :ref:`files` provides file-like access to the data blobs backing 69 | the metadata. 70 | 71 | pkg_resources.find_distributions() 72 | ================================== 73 | 74 | ``importlib_metadata`` provides functionality 75 | similar to ``find_distributions()``. Both ``distributions(...)`` and 76 | ``Distribution.discover(...)`` return an iterable of :ref:`distributions` 77 | matching the indicated parameters. 78 | 79 | pkg_resources.get_distribution() 80 | ================================= 81 | 82 | Similar to ``distributions``, the ``distribution()`` function provides 83 | access to a single distribution by name. 84 | 85 | -------------------------------------------------------------------------------- /tests/_path.py: -------------------------------------------------------------------------------- 1 | # from jaraco.path 3.7.2 2 | 3 | from __future__ import annotations 4 | 5 | import functools 6 | import pathlib 7 | from collections.abc import Mapping 8 | from typing import TYPE_CHECKING, Protocol, Union, runtime_checkable 9 | 10 | if TYPE_CHECKING: 11 | from typing_extensions import Self 12 | 13 | 14 | class Symlink(str): 15 | """ 16 | A string indicating the target of a symlink. 17 | """ 18 | 19 | 20 | FilesSpec = Mapping[str, Union[str, bytes, Symlink, 'FilesSpec']] 21 | 22 | 23 | @runtime_checkable 24 | class TreeMaker(Protocol): 25 | def __truediv__(self, other, /) -> Self: ... 26 | def mkdir(self, *, exist_ok) -> object: ... 27 | def write_text(self, content, /, *, encoding) -> object: ... 28 | def write_bytes(self, content, /) -> object: ... 29 | def symlink_to(self, target, /) -> object: ... 30 | 31 | 32 | def _ensure_tree_maker(obj: str | TreeMaker) -> TreeMaker: 33 | return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) 34 | 35 | 36 | def build( 37 | spec: FilesSpec, 38 | prefix: str | TreeMaker = pathlib.Path(), 39 | ): 40 | """ 41 | Build a set of files/directories, as described by the spec. 42 | 43 | Each key represents a pathname, and the value represents 44 | the content. Content may be a nested directory. 45 | 46 | >>> spec = { 47 | ... 'README.txt': "A README file", 48 | ... "foo": { 49 | ... "__init__.py": "", 50 | ... "bar": { 51 | ... "__init__.py": "", 52 | ... }, 53 | ... "baz.py": "# Some code", 54 | ... "bar.py": Symlink("baz.py"), 55 | ... }, 56 | ... "bing": Symlink("foo"), 57 | ... } 58 | >>> target = getfixture('tmp_path') 59 | >>> build(spec, target) 60 | >>> target.joinpath('foo/baz.py').read_text(encoding='utf-8') 61 | '# Some code' 62 | >>> target.joinpath('bing/bar.py').read_text(encoding='utf-8') 63 | '# Some code' 64 | """ 65 | for name, contents in spec.items(): 66 | create(contents, _ensure_tree_maker(prefix) / name) 67 | 68 | 69 | @functools.singledispatch 70 | def create(content: str | bytes | FilesSpec, path: TreeMaker) -> None: 71 | path.mkdir(exist_ok=True) 72 | # Mypy only looks at the signature of the main singledispatch method. So it must contain the complete Union 73 | build(content, prefix=path) # type: ignore[arg-type] # python/mypy#11727 74 | 75 | 76 | @create.register 77 | def _(content: bytes, path: TreeMaker) -> None: 78 | path.write_bytes(content) 79 | 80 | 81 | @create.register 82 | def _(content: str, path: TreeMaker) -> None: 83 | path.write_text(content, encoding='utf-8') 84 | 85 | 86 | @create.register 87 | def _(content: Symlink, path: TreeMaker) -> None: 88 | path.symlink_to(content) 89 | 90 | 91 | class Recording: 92 | """ 93 | A TreeMaker object that records everything that would be written. 94 | 95 | >>> r = Recording() 96 | >>> build({'foo': {'foo1.txt': 'yes'}, 'bar.txt': 'abc'}, r) 97 | >>> r.record 98 | ['foo/foo1.txt', 'bar.txt'] 99 | """ 100 | 101 | def __init__(self, loc=pathlib.PurePosixPath(), record=None): 102 | self.loc = loc 103 | self.record = record if record is not None else [] 104 | 105 | def __truediv__(self, other): 106 | return Recording(self.loc / other, self.record) 107 | 108 | def write_text(self, content, **kwargs): 109 | self.record.append(str(self.loc)) 110 | 111 | write_bytes = write_text 112 | 113 | def mkdir(self, **kwargs): 114 | return 115 | 116 | def symlink_to(self, target): 117 | pass 118 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches-ignore: 7 | # temporary GH branches relating to merge queues (jaraco/skeleton#93) 8 | - gh-readonly-queue/** 9 | tags: 10 | # required if branches-ignore is supplied (jaraco/skeleton#103) 11 | - '**' 12 | pull_request: 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Environment variable to support color support (jaraco/skeleton#66) 20 | FORCE_COLOR: 1 21 | 22 | # Suppress noisy pip warnings 23 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 24 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 25 | 26 | # Ensure tests can sense settings about the environment 27 | TOX_OVERRIDE: >- 28 | testenv.pass_env+=GITHUB_*,FORCE_COLOR 29 | 30 | 31 | jobs: 32 | test: 33 | strategy: 34 | # https://blog.jaraco.com/efficient-use-of-ci-resources/ 35 | matrix: 36 | python: 37 | - "3.9" 38 | - "3.13" 39 | platform: 40 | - ubuntu-latest 41 | - macos-latest 42 | - windows-latest 43 | include: 44 | - python: "3.10" 45 | platform: ubuntu-latest 46 | - python: "3.11" 47 | platform: ubuntu-latest 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | - python: "3.14" 51 | platform: ubuntu-latest 52 | - python: pypy3.10 53 | platform: ubuntu-latest 54 | runs-on: ${{ matrix.platform }} 55 | continue-on-error: ${{ matrix.python == '3.14' }} 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Install build dependencies 59 | # Install dependencies for building packages on pre-release Pythons 60 | # jaraco/skeleton#161 61 | if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' 62 | run: | 63 | sudo apt update 64 | sudo apt install -y libxml2-dev libxslt-dev 65 | - name: Setup Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: ${{ matrix.python }} 69 | allow-prereleases: true 70 | - name: Install tox 71 | run: python -m pip install tox 72 | - name: Run 73 | run: tox 74 | 75 | collateral: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | job: 80 | - diffcov 81 | - docs 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | - name: Setup Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: 3.x 91 | - name: Install tox 92 | run: python -m pip install tox 93 | - name: Eval ${{ matrix.job }} 94 | run: tox -e ${{ matrix.job }} 95 | 96 | check: # This job does nothing and is only used for the branch protection 97 | if: always() 98 | 99 | needs: 100 | - test 101 | - collateral 102 | 103 | runs-on: ubuntu-latest 104 | 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@release/v1 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | release: 112 | permissions: 113 | contents: write 114 | needs: 115 | - check 116 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Setup Python 122 | uses: actions/setup-python@v5 123 | with: 124 | python-version: 3.x 125 | - name: Install tox 126 | run: python -m pip install tox 127 | - name: Run 128 | run: tox -e release 129 | env: 130 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | -------------------------------------------------------------------------------- /importlib_metadata/_functools.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import types 3 | from typing import Callable, TypeVar 4 | 5 | 6 | # from jaraco.functools 3.3 7 | def method_cache(method, cache_wrapper=None): 8 | """ 9 | Wrap lru_cache to support storing the cache data in the object instances. 10 | 11 | Abstracts the common paradigm where the method explicitly saves an 12 | underscore-prefixed protected property on first call and returns that 13 | subsequently. 14 | 15 | >>> class MyClass: 16 | ... calls = 0 17 | ... 18 | ... @method_cache 19 | ... def method(self, value): 20 | ... self.calls += 1 21 | ... return value 22 | 23 | >>> a = MyClass() 24 | >>> a.method(3) 25 | 3 26 | >>> for x in range(75): 27 | ... res = a.method(x) 28 | >>> a.calls 29 | 75 30 | 31 | Note that the apparent behavior will be exactly like that of lru_cache 32 | except that the cache is stored on each instance, so values in one 33 | instance will not flush values from another, and when an instance is 34 | deleted, so are the cached values for that instance. 35 | 36 | >>> b = MyClass() 37 | >>> for x in range(35): 38 | ... res = b.method(x) 39 | >>> b.calls 40 | 35 41 | >>> a.method(0) 42 | 0 43 | >>> a.calls 44 | 75 45 | 46 | Note that if method had been decorated with ``functools.lru_cache()``, 47 | a.calls would have been 76 (due to the cached value of 0 having been 48 | flushed by the 'b' instance). 49 | 50 | Clear the cache with ``.cache_clear()`` 51 | 52 | >>> a.method.cache_clear() 53 | 54 | Same for a method that hasn't yet been called. 55 | 56 | >>> c = MyClass() 57 | >>> c.method.cache_clear() 58 | 59 | Another cache wrapper may be supplied: 60 | 61 | >>> cache = functools.lru_cache(maxsize=2) 62 | >>> MyClass.method2 = method_cache(lambda self: 3, cache_wrapper=cache) 63 | >>> a = MyClass() 64 | >>> a.method2() 65 | 3 66 | 67 | Caution - do not subsequently wrap the method with another decorator, such 68 | as ``@property``, which changes the semantics of the function. 69 | 70 | See also 71 | http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/ 72 | for another implementation and additional justification. 73 | """ 74 | cache_wrapper = cache_wrapper or functools.lru_cache() 75 | 76 | def wrapper(self, *args, **kwargs): 77 | # it's the first call, replace the method with a cached, bound method 78 | bound_method = types.MethodType(method, self) 79 | cached_method = cache_wrapper(bound_method) 80 | setattr(self, method.__name__, cached_method) 81 | return cached_method(*args, **kwargs) 82 | 83 | # Support cache clear even before cache has been created. 84 | wrapper.cache_clear = lambda: None 85 | 86 | return wrapper 87 | 88 | 89 | # From jaraco.functools 3.3 90 | def pass_none(func): 91 | """ 92 | Wrap func so it's not called if its first param is None 93 | 94 | >>> print_text = pass_none(print) 95 | >>> print_text('text') 96 | text 97 | >>> print_text(None) 98 | """ 99 | 100 | @functools.wraps(func) 101 | def wrapper(param, *args, **kwargs): 102 | if param is not None: 103 | return func(param, *args, **kwargs) 104 | 105 | return wrapper 106 | 107 | 108 | # From jaraco.functools 4.4 109 | def noop(*args, **kwargs): 110 | """ 111 | A no-operation function that does nothing. 112 | 113 | >>> noop(1, 2, three=3) 114 | """ 115 | 116 | 117 | _T = TypeVar('_T') 118 | 119 | 120 | # From jaraco.functools 4.4 121 | def passthrough(func: Callable[..., object]) -> Callable[[_T], _T]: 122 | """ 123 | Wrap the function to always return the first parameter. 124 | 125 | >>> passthrough(print)('3') 126 | 3 127 | '3' 128 | """ 129 | 130 | @functools.wraps(func) 131 | def wrapper(first: _T, *args, **kwargs) -> _T: 132 | func(first, *args, **kwargs) 133 | return first 134 | 135 | return wrapper # type: ignore[return-value] 136 | -------------------------------------------------------------------------------- /importlib_metadata/_adapters.py: -------------------------------------------------------------------------------- 1 | import email.message 2 | import email.policy 3 | import re 4 | import textwrap 5 | 6 | from ._text import FoldedCase 7 | 8 | 9 | class RawPolicy(email.policy.EmailPolicy): 10 | def fold(self, name, value): 11 | folded = self.linesep.join( 12 | textwrap 13 | .indent(value, prefix=' ' * 8, predicate=lambda line: True) 14 | .lstrip() 15 | .splitlines() 16 | ) 17 | return f'{name}: {folded}{self.linesep}' 18 | 19 | 20 | class Message(email.message.Message): 21 | r""" 22 | Specialized Message subclass to handle metadata naturally. 23 | 24 | Reads values that may have newlines in them and converts the 25 | payload to the Description. 26 | 27 | >>> msg_text = textwrap.dedent(''' 28 | ... Name: Foo 29 | ... Version: 3.0 30 | ... License: blah 31 | ... de-blah 32 | ... 33 | ... First line of description. 34 | ... Second line of description. 35 | ... 36 | ... Fourth line! 37 | ... ''').lstrip().replace('', '') 38 | >>> msg = Message(email.message_from_string(msg_text)) 39 | >>> msg['Description'] 40 | 'First line of description.\nSecond line of description.\n\nFourth line!\n' 41 | 42 | Message should render even if values contain newlines. 43 | 44 | >>> print(msg) 45 | Name: Foo 46 | Version: 3.0 47 | License: blah 48 | de-blah 49 | Description: First line of description. 50 | Second line of description. 51 | 52 | Fourth line! 53 | 54 | 55 | """ 56 | 57 | multiple_use_keys = set( 58 | map( 59 | FoldedCase, 60 | [ 61 | 'Classifier', 62 | 'Obsoletes-Dist', 63 | 'Platform', 64 | 'Project-URL', 65 | 'Provides-Dist', 66 | 'Provides-Extra', 67 | 'Requires-Dist', 68 | 'Requires-External', 69 | 'Supported-Platform', 70 | 'Dynamic', 71 | ], 72 | ) 73 | ) 74 | """ 75 | Keys that may be indicated multiple times per PEP 566. 76 | """ 77 | 78 | def __new__(cls, orig: email.message.Message): 79 | res = super().__new__(cls) 80 | vars(res).update(vars(orig)) 81 | return res 82 | 83 | def __init__(self, *args, **kwargs): 84 | self._headers = self._repair_headers() 85 | 86 | # suppress spurious error from mypy 87 | def __iter__(self): 88 | return super().__iter__() 89 | 90 | def __getitem__(self, item): 91 | """ 92 | Override parent behavior to typical dict behavior. 93 | 94 | ``email.message.Message`` will emit None values for missing 95 | keys. Typical mappings, including this ``Message``, will raise 96 | a key error for missing keys. 97 | 98 | Ref python/importlib_metadata#371. 99 | """ 100 | res = super().__getitem__(item) 101 | if res is None: 102 | raise KeyError(item) 103 | return res 104 | 105 | def _repair_headers(self): 106 | def redent(value): 107 | "Correct for RFC822 indentation" 108 | indent = ' ' * 8 109 | if not value or '\n' + indent not in value: 110 | return value 111 | return textwrap.dedent(indent + value) 112 | 113 | headers = [(key, redent(value)) for key, value in vars(self)['_headers']] 114 | if self._payload: 115 | headers.append(('Description', self.get_payload())) 116 | self.set_payload('') 117 | return headers 118 | 119 | def as_string(self): 120 | return super().as_string(policy=RawPolicy()) 121 | 122 | @property 123 | def json(self): 124 | """ 125 | Convert PackageMetadata to a JSON-compatible format 126 | per PEP 0566. 127 | """ 128 | 129 | def transform(key): 130 | value = self.get_all(key) if key in self.multiple_use_keys else self[key] 131 | if key == 'Keywords': 132 | value = re.split(r'\s+', value) 133 | tk = key.lower().replace('-', '_') 134 | return tk, value 135 | 136 | return dict(map(transform, map(FoldedCase, self))) 137 | -------------------------------------------------------------------------------- /importlib_metadata/_itertools.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, deque 2 | from itertools import filterfalse 3 | 4 | 5 | def unique_everseen(iterable, key=None): 6 | "List unique elements, preserving order. Remember all elements ever seen." 7 | # unique_everseen('AAAABBBCCDAABBB') --> A B C D 8 | # unique_everseen('ABBCcAD', str.lower) --> A B C D 9 | seen = set() 10 | seen_add = seen.add 11 | if key is None: 12 | for element in filterfalse(seen.__contains__, iterable): 13 | seen_add(element) 14 | yield element 15 | else: 16 | for element in iterable: 17 | k = key(element) 18 | if k not in seen: 19 | seen_add(k) 20 | yield element 21 | 22 | 23 | # copied from more_itertools 8.8 24 | def always_iterable(obj, base_type=(str, bytes)): 25 | """If *obj* is iterable, return an iterator over its items:: 26 | 27 | >>> obj = (1, 2, 3) 28 | >>> list(always_iterable(obj)) 29 | [1, 2, 3] 30 | 31 | If *obj* is not iterable, return a one-item iterable containing *obj*:: 32 | 33 | >>> obj = 1 34 | >>> list(always_iterable(obj)) 35 | [1] 36 | 37 | If *obj* is ``None``, return an empty iterable: 38 | 39 | >>> obj = None 40 | >>> list(always_iterable(None)) 41 | [] 42 | 43 | By default, binary and text strings are not considered iterable:: 44 | 45 | >>> obj = 'foo' 46 | >>> list(always_iterable(obj)) 47 | ['foo'] 48 | 49 | If *base_type* is set, objects for which ``isinstance(obj, base_type)`` 50 | returns ``True`` won't be considered iterable. 51 | 52 | >>> obj = {'a': 1} 53 | >>> list(always_iterable(obj)) # Iterate over the dict's keys 54 | ['a'] 55 | >>> list(always_iterable(obj, base_type=dict)) # Treat dicts as a unit 56 | [{'a': 1}] 57 | 58 | Set *base_type* to ``None`` to avoid any special handling and treat objects 59 | Python considers iterable as iterable: 60 | 61 | >>> obj = 'foo' 62 | >>> list(always_iterable(obj, base_type=None)) 63 | ['f', 'o', 'o'] 64 | """ 65 | if obj is None: 66 | return iter(()) 67 | 68 | if (base_type is not None) and isinstance(obj, base_type): 69 | return iter((obj,)) 70 | 71 | try: 72 | return iter(obj) 73 | except TypeError: 74 | return iter((obj,)) 75 | 76 | 77 | # Copied from more_itertools 10.3 78 | class bucket: 79 | """Wrap *iterable* and return an object that buckets the iterable into 80 | child iterables based on a *key* function. 81 | 82 | >>> iterable = ['a1', 'b1', 'c1', 'a2', 'b2', 'c2', 'b3'] 83 | >>> s = bucket(iterable, key=lambda x: x[0]) # Bucket by 1st character 84 | >>> sorted(list(s)) # Get the keys 85 | ['a', 'b', 'c'] 86 | >>> a_iterable = s['a'] 87 | >>> next(a_iterable) 88 | 'a1' 89 | >>> next(a_iterable) 90 | 'a2' 91 | >>> list(s['b']) 92 | ['b1', 'b2', 'b3'] 93 | 94 | The original iterable will be advanced and its items will be cached until 95 | they are used by the child iterables. This may require significant storage. 96 | 97 | By default, attempting to select a bucket to which no items belong will 98 | exhaust the iterable and cache all values. 99 | If you specify a *validator* function, selected buckets will instead be 100 | checked against it. 101 | 102 | >>> from itertools import count 103 | >>> it = count(1, 2) # Infinite sequence of odd numbers 104 | >>> key = lambda x: x % 10 # Bucket by last digit 105 | >>> validator = lambda x: x in {1, 3, 5, 7, 9} # Odd digits only 106 | >>> s = bucket(it, key=key, validator=validator) 107 | >>> 2 in s 108 | False 109 | >>> list(s[2]) 110 | [] 111 | 112 | """ 113 | 114 | def __init__(self, iterable, key, validator=None): 115 | self._it = iter(iterable) 116 | self._key = key 117 | self._cache = defaultdict(deque) 118 | self._validator = validator or (lambda x: True) 119 | 120 | def __contains__(self, value): 121 | if not self._validator(value): 122 | return False 123 | 124 | try: 125 | item = next(self[value]) 126 | except StopIteration: 127 | return False 128 | else: 129 | self._cache[value].appendleft(item) 130 | 131 | return True 132 | 133 | def _get_values(self, value): 134 | """ 135 | Helper to yield items from the parent iterator that match *value*. 136 | Items that don't match are stored in the local cache as they 137 | are encountered. 138 | """ 139 | while True: 140 | # If we've cached some items that match the target value, emit 141 | # the first one and evict it from the cache. 142 | if self._cache[value]: 143 | yield self._cache[value].popleft() 144 | # Otherwise we need to advance the parent iterator to search for 145 | # a matching item, caching the rest. 146 | else: 147 | while True: 148 | try: 149 | item = next(self._it) 150 | except StopIteration: 151 | return 152 | item_value = self._key(item) 153 | if item_value == value: 154 | yield item 155 | break 156 | elif self._validator(item_value): 157 | self._cache[item_value].append(item) 158 | 159 | def __iter__(self): 160 | for item in self._it: 161 | item_value = self._key(item) 162 | if self._validator(item_value): 163 | self._cache[item_value].append(item) 164 | 165 | yield from self._cache.keys() 166 | 167 | def __getitem__(self, value): 168 | if not self._validator(value): 169 | return iter(()) 170 | 171 | return self._get_values(value) 172 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import re 3 | import textwrap 4 | import unittest 5 | 6 | from importlib_metadata import ( 7 | Distribution, 8 | PackageNotFoundError, 9 | distribution, 10 | entry_points, 11 | files, 12 | metadata, 13 | requires, 14 | version, 15 | ) 16 | 17 | from . import fixtures 18 | 19 | 20 | class APITests( 21 | fixtures.EggInfoPkg, 22 | fixtures.EggInfoPkgPipInstalledNoToplevel, 23 | fixtures.EggInfoPkgPipInstalledNoModules, 24 | fixtures.EggInfoPkgPipInstalledExternalDataFiles, 25 | fixtures.EggInfoPkgSourcesFallback, 26 | fixtures.DistInfoPkg, 27 | fixtures.DistInfoPkgWithDot, 28 | fixtures.EggInfoFile, 29 | unittest.TestCase, 30 | ): 31 | version_pattern = r'\d+\.\d+(\.\d)?' 32 | 33 | def test_retrieves_version_of_self(self): 34 | pkg_version = version('egginfo-pkg') 35 | assert isinstance(pkg_version, str) 36 | assert re.match(self.version_pattern, pkg_version) 37 | 38 | def test_retrieves_version_of_distinfo_pkg(self): 39 | pkg_version = version('distinfo-pkg') 40 | assert isinstance(pkg_version, str) 41 | assert re.match(self.version_pattern, pkg_version) 42 | 43 | def test_for_name_does_not_exist(self): 44 | with self.assertRaises(PackageNotFoundError): 45 | distribution('does-not-exist') 46 | 47 | def test_name_normalization(self): 48 | names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot' 49 | for name in names: 50 | with self.subTest(name): 51 | assert distribution(name).metadata['Name'] == 'pkg.dot' 52 | 53 | def test_prefix_not_matched(self): 54 | prefixes = 'p', 'pkg', 'pkg.' 55 | for prefix in prefixes: 56 | with self.subTest(prefix): 57 | with self.assertRaises(PackageNotFoundError): 58 | distribution(prefix) 59 | 60 | def test_for_top_level(self): 61 | tests = [ 62 | ('egginfo-pkg', 'mod'), 63 | ('egg_with_no_modules-pkg', ''), 64 | ] 65 | for pkg_name, expect_content in tests: 66 | with self.subTest(pkg_name): 67 | self.assertEqual( 68 | distribution(pkg_name).read_text('top_level.txt').strip(), 69 | expect_content, 70 | ) 71 | 72 | def test_read_text(self): 73 | tests = [ 74 | ('egginfo-pkg', 'mod\n'), 75 | ('egg_with_no_modules-pkg', '\n'), 76 | ] 77 | for pkg_name, expect_content in tests: 78 | with self.subTest(pkg_name): 79 | top_level = [ 80 | path for path in files(pkg_name) if path.name == 'top_level.txt' 81 | ][0] 82 | self.assertEqual(top_level.read_text(), expect_content) 83 | 84 | def test_entry_points(self): 85 | eps = entry_points() 86 | assert 'entries' in eps.groups 87 | entries = eps.select(group='entries') 88 | assert 'main' in entries.names 89 | ep = entries['main'] 90 | self.assertEqual(ep.value, 'mod:main') 91 | self.assertEqual(ep.extras, []) 92 | 93 | def test_entry_points_distribution(self): 94 | entries = entry_points(group='entries') 95 | for entry in ("main", "ns:sub"): 96 | ep = entries[entry] 97 | self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg')) 98 | self.assertEqual(ep.dist.version, "1.0.0") 99 | 100 | def test_entry_points_unique_packages_normalized(self): 101 | """ 102 | Entry points should only be exposed for the first package 103 | on sys.path with a given name (even when normalized). 104 | """ 105 | alt_site_dir = self.fixtures.enter_context(fixtures.tmp_path()) 106 | self.fixtures.enter_context(self.add_sys_path(alt_site_dir)) 107 | alt_pkg = { 108 | "DistInfo_pkg-1.1.0.dist-info": { 109 | "METADATA": """ 110 | Name: distinfo-pkg 111 | Version: 1.1.0 112 | """, 113 | "entry_points.txt": """ 114 | [entries] 115 | main = mod:altmain 116 | """, 117 | }, 118 | } 119 | fixtures.build_files(alt_pkg, alt_site_dir) 120 | entries = entry_points(group='entries') 121 | assert not any( 122 | ep.dist.name == 'distinfo-pkg' and ep.dist.version == '1.0.0' 123 | for ep in entries 124 | ) 125 | # ns:sub doesn't exist in alt_pkg 126 | assert 'ns:sub' not in entries.names 127 | 128 | def test_entry_points_missing_name(self): 129 | with self.assertRaises(KeyError): 130 | entry_points(group='entries')['missing'] 131 | 132 | def test_entry_points_missing_group(self): 133 | assert entry_points(group='missing') == () 134 | 135 | def test_entry_points_allows_no_attributes(self): 136 | ep = entry_points().select(group='entries', name='main') 137 | with self.assertRaises(AttributeError): 138 | ep.foo = 4 139 | 140 | def test_metadata_for_this_package(self): 141 | md = metadata('egginfo-pkg') 142 | assert md['author'] == 'Steven Ma' 143 | assert md['LICENSE'] == 'Unknown' 144 | assert md['Name'] == 'egginfo-pkg' 145 | classifiers = md.get_all('Classifier') 146 | assert 'Topic :: Software Development :: Libraries' in classifiers 147 | 148 | def test_importlib_metadata_version(self): 149 | resolved = version('importlib-metadata') 150 | assert re.match(self.version_pattern, resolved) 151 | 152 | def test_missing_key(self): 153 | """ 154 | Requesting a missing key raises KeyError. 155 | """ 156 | md = metadata('distinfo-pkg') 157 | with self.assertRaises(KeyError): 158 | md['does-not-exist'] 159 | 160 | def test_get_key(self): 161 | """ 162 | Getting a key gets the key. 163 | """ 164 | md = metadata('egginfo-pkg') 165 | assert md.get('Name') == 'egginfo-pkg' 166 | 167 | def test_get_missing_key(self): 168 | """ 169 | Requesting a missing key will return None. 170 | """ 171 | md = metadata('distinfo-pkg') 172 | assert md.get('does-not-exist') is None 173 | 174 | @staticmethod 175 | def _test_files(files): 176 | root = files[0].root 177 | for file in files: 178 | assert file.root == root 179 | assert not file.hash or file.hash.value 180 | assert not file.hash or file.hash.mode == 'sha256' 181 | assert not file.size or file.size >= 0 182 | assert file.locate().exists() 183 | assert isinstance(file.read_binary(), bytes) 184 | if file.name.endswith('.py'): 185 | file.read_text() 186 | 187 | def test_file_hash_repr(self): 188 | util = [p for p in files('distinfo-pkg') if p.name == 'mod.py'][0] 189 | self.assertRegex(repr(util.hash), '') 190 | 191 | def test_files_dist_info(self): 192 | self._test_files(files('distinfo-pkg')) 193 | 194 | def test_files_egg_info(self): 195 | self._test_files(files('egginfo-pkg')) 196 | self._test_files(files('egg_with_module-pkg')) 197 | self._test_files(files('egg_with_no_modules-pkg')) 198 | self._test_files(files('sources_fallback-pkg')) 199 | 200 | def test_version_egg_info_file(self): 201 | self.assertEqual(version('egginfo-file'), '0.1') 202 | 203 | def test_requires_egg_info_file(self): 204 | requirements = requires('egginfo-file') 205 | self.assertIsNone(requirements) 206 | 207 | def test_requires_egg_info(self): 208 | deps = requires('egginfo-pkg') 209 | assert len(deps) == 2 210 | assert any(dep == 'wheel >= 1.0; python_version >= "2.7"' for dep in deps) 211 | 212 | def test_requires_egg_info_empty(self): 213 | fixtures.build_files( 214 | { 215 | 'requires.txt': '', 216 | }, 217 | self.site_dir.joinpath('egginfo_pkg.egg-info'), 218 | ) 219 | deps = requires('egginfo-pkg') 220 | assert deps == [] 221 | 222 | def test_requires_dist_info(self): 223 | deps = requires('distinfo-pkg') 224 | assert len(deps) == 2 225 | assert all(deps) 226 | assert 'wheel >= 1.0' in deps 227 | assert "pytest; extra == 'test'" in deps 228 | 229 | def test_more_complex_deps_requires_text(self): 230 | requires = textwrap.dedent( 231 | """ 232 | dep1 233 | dep2 234 | 235 | [:python_version < "3"] 236 | dep3 237 | 238 | [extra1] 239 | dep4 240 | dep6@ git+https://example.com/python/dep.git@v1.0.0 241 | 242 | [extra2:python_version < "3"] 243 | dep5 244 | """ 245 | ) 246 | deps = sorted(Distribution._deps_from_requires_text(requires)) 247 | expected = [ 248 | 'dep1', 249 | 'dep2', 250 | 'dep3; python_version < "3"', 251 | 'dep4; extra == "extra1"', 252 | 'dep5; (python_version < "3") and extra == "extra2"', 253 | 'dep6@ git+https://example.com/python/dep.git@v1.0.0 ; extra == "extra1"', 254 | ] 255 | # It's important that the environment marker expression be 256 | # wrapped in parentheses to avoid the following 'and' binding more 257 | # tightly than some other part of the environment expression. 258 | 259 | assert deps == expected 260 | 261 | def test_as_json(self): 262 | md = metadata('distinfo-pkg').json 263 | assert 'name' in md 264 | assert md['keywords'] == ['sample', 'package'] 265 | desc = md['description'] 266 | assert desc.startswith('Once upon a time\nThere was') 267 | assert len(md['requires_dist']) == 2 268 | 269 | def test_as_json_egg_info(self): 270 | md = metadata('egginfo-pkg').json 271 | assert 'name' in md 272 | assert md['keywords'] == ['sample', 'package'] 273 | desc = md['description'] 274 | assert desc.startswith('Once upon a time\nThere was') 275 | assert len(md['classifier']) == 2 276 | 277 | def test_as_json_odd_case(self): 278 | self.make_uppercase() 279 | md = metadata('distinfo-pkg').json 280 | assert 'name' in md 281 | assert len(md['requires_dist']) == 2 282 | assert md['keywords'] == ['SAMPLE', 'PACKAGE'] 283 | 284 | 285 | class LegacyDots(fixtures.DistInfoPkgWithDotLegacy, unittest.TestCase): 286 | def test_name_normalization(self): 287 | names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot' 288 | for name in names: 289 | with self.subTest(name): 290 | assert distribution(name).metadata['Name'] == 'pkg.dot' 291 | 292 | def test_name_normalization_versionless_egg_info(self): 293 | names = 'pkg.lot', 'pkg_lot', 'pkg-lot', 'pkg..lot', 'Pkg.Lot' 294 | for name in names: 295 | with self.subTest(name): 296 | assert distribution(name).metadata['Name'] == 'pkg.lot' 297 | 298 | 299 | class OffSysPathTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): 300 | def test_find_distributions_specified_path(self): 301 | dists = Distribution.discover(path=[str(self.site_dir)]) 302 | assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists) 303 | 304 | def test_distribution_at_pathlib(self): 305 | """Demonstrate how to load metadata direct from a directory.""" 306 | dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' 307 | dist = Distribution.at(dist_info_path) 308 | assert dist.version == '1.0.0' 309 | 310 | def test_distribution_at_str(self): 311 | dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' 312 | dist = Distribution.at(str(dist_info_path)) 313 | assert dist.version == '1.0.0' 314 | 315 | 316 | class InvalidateCache(unittest.TestCase): 317 | def test_invalidate_cache(self): 318 | # No externally observable behavior, but ensures test coverage... 319 | importlib.invalidate_caches() 320 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import copy 3 | import functools 4 | import json 5 | import pathlib 6 | import shutil 7 | import sys 8 | import textwrap 9 | from importlib import resources 10 | 11 | from . import _path 12 | from ._path import FilesSpec 13 | from .compat.py39 import os_helper 14 | from .compat.py312 import import_helper 15 | 16 | 17 | @contextlib.contextmanager 18 | def tmp_path(): 19 | """ 20 | Like os_helper.temp_dir, but yields a pathlib.Path. 21 | """ 22 | with os_helper.temp_dir() as path: 23 | yield pathlib.Path(path) 24 | 25 | 26 | @contextlib.contextmanager 27 | def install_finder(finder): 28 | sys.meta_path.append(finder) 29 | try: 30 | yield 31 | finally: 32 | sys.meta_path.remove(finder) 33 | 34 | 35 | class Fixtures: 36 | def setUp(self): 37 | self.fixtures = contextlib.ExitStack() 38 | self.addCleanup(self.fixtures.close) 39 | 40 | 41 | class SiteDir(Fixtures): 42 | def setUp(self): 43 | super().setUp() 44 | self.site_dir = self.fixtures.enter_context(tmp_path()) 45 | 46 | 47 | class OnSysPath(Fixtures): 48 | @staticmethod 49 | @contextlib.contextmanager 50 | def add_sys_path(dir): 51 | sys.path[:0] = [str(dir)] 52 | try: 53 | yield 54 | finally: 55 | sys.path.remove(str(dir)) 56 | 57 | def setUp(self): 58 | super().setUp() 59 | self.fixtures.enter_context(self.add_sys_path(self.site_dir)) 60 | self.fixtures.enter_context(import_helper.isolated_modules()) 61 | 62 | 63 | class SiteBuilder(SiteDir): 64 | def setUp(self): 65 | super().setUp() 66 | for cls in self.__class__.mro(): 67 | with contextlib.suppress(AttributeError): 68 | build_files(cls.files, prefix=self.site_dir) 69 | 70 | 71 | class DistInfoPkg(OnSysPath, SiteBuilder): 72 | files: FilesSpec = { 73 | "distinfo_pkg-1.0.0.dist-info": { 74 | "METADATA": """ 75 | Name: distinfo-pkg 76 | Author: Steven Ma 77 | Version: 1.0.0 78 | Requires-Dist: wheel >= 1.0 79 | Requires-Dist: pytest; extra == 'test' 80 | Keywords: sample package 81 | 82 | Once upon a time 83 | There was a distinfo pkg 84 | """, 85 | "RECORD": "mod.py,sha256=abc,20\n", 86 | "entry_points.txt": """ 87 | [entries] 88 | main = mod:main 89 | ns:sub = mod:main 90 | """, 91 | }, 92 | "mod.py": """ 93 | def main(): 94 | print("hello world") 95 | """, 96 | } 97 | 98 | def make_uppercase(self): 99 | """ 100 | Rewrite metadata with everything uppercase. 101 | """ 102 | shutil.rmtree(self.site_dir / "distinfo_pkg-1.0.0.dist-info") 103 | files = copy.deepcopy(DistInfoPkg.files) 104 | info = files["distinfo_pkg-1.0.0.dist-info"] 105 | info["METADATA"] = info["METADATA"].upper() 106 | build_files(files, self.site_dir) 107 | 108 | 109 | class DistInfoPkgEditable(DistInfoPkg): 110 | """ 111 | Package with a PEP 660 direct_url.json. 112 | """ 113 | 114 | some_hash = '524127ce937f7cb65665130c695abd18ca386f60bb29687efb976faa1596fdcc' 115 | files: FilesSpec = { 116 | 'distinfo_pkg-1.0.0.dist-info': { 117 | 'direct_url.json': json.dumps({ 118 | "archive_info": { 119 | "hash": f"sha256={some_hash}", 120 | "hashes": {"sha256": f"{some_hash}"}, 121 | }, 122 | "url": "file:///path/to/distinfo_pkg-1.0.0.editable-py3-none-any.whl", 123 | }) 124 | }, 125 | } 126 | 127 | 128 | class DistInfoPkgWithDot(OnSysPath, SiteBuilder): 129 | files: FilesSpec = { 130 | "pkg_dot-1.0.0.dist-info": { 131 | "METADATA": """ 132 | Name: pkg.dot 133 | Version: 1.0.0 134 | """, 135 | }, 136 | } 137 | 138 | 139 | class DistInfoPkgWithDotLegacy(OnSysPath, SiteBuilder): 140 | files: FilesSpec = { 141 | "pkg.dot-1.0.0.dist-info": { 142 | "METADATA": """ 143 | Name: pkg.dot 144 | Version: 1.0.0 145 | """, 146 | }, 147 | "pkg.lot.egg-info": { 148 | "METADATA": """ 149 | Name: pkg.lot 150 | Version: 1.0.0 151 | """, 152 | }, 153 | } 154 | 155 | 156 | class DistInfoPkgOffPath(SiteBuilder): 157 | files = DistInfoPkg.files 158 | 159 | 160 | class EggInfoPkg(OnSysPath, SiteBuilder): 161 | files: FilesSpec = { 162 | "egginfo_pkg.egg-info": { 163 | "PKG-INFO": """ 164 | Name: egginfo-pkg 165 | Author: Steven Ma 166 | License: Unknown 167 | Version: 1.0.0 168 | Classifier: Intended Audience :: Developers 169 | Classifier: Topic :: Software Development :: Libraries 170 | Keywords: sample package 171 | Description: Once upon a time 172 | There was an egginfo package 173 | """, 174 | "SOURCES.txt": """ 175 | mod.py 176 | egginfo_pkg.egg-info/top_level.txt 177 | """, 178 | "entry_points.txt": """ 179 | [entries] 180 | main = mod:main 181 | """, 182 | "requires.txt": """ 183 | wheel >= 1.0; python_version >= "2.7" 184 | [test] 185 | pytest 186 | """, 187 | "top_level.txt": "mod\n", 188 | }, 189 | "mod.py": """ 190 | def main(): 191 | print("hello world") 192 | """, 193 | } 194 | 195 | 196 | class EggInfoPkgPipInstalledNoToplevel(OnSysPath, SiteBuilder): 197 | files: FilesSpec = { 198 | "egg_with_module_pkg.egg-info": { 199 | "PKG-INFO": "Name: egg_with_module-pkg", 200 | # SOURCES.txt is made from the source archive, and contains files 201 | # (setup.py) that are not present after installation. 202 | "SOURCES.txt": """ 203 | egg_with_module.py 204 | setup.py 205 | egg_with_module_pkg.egg-info/PKG-INFO 206 | egg_with_module_pkg.egg-info/SOURCES.txt 207 | egg_with_module_pkg.egg-info/top_level.txt 208 | """, 209 | # installed-files.txt is written by pip, and is a strictly more 210 | # accurate source than SOURCES.txt as to the installed contents of 211 | # the package. 212 | "installed-files.txt": """ 213 | ../egg_with_module.py 214 | PKG-INFO 215 | SOURCES.txt 216 | top_level.txt 217 | """, 218 | # missing top_level.txt (to trigger fallback to installed-files.txt) 219 | }, 220 | "egg_with_module.py": """ 221 | def main(): 222 | print("hello world") 223 | """, 224 | } 225 | 226 | 227 | class EggInfoPkgPipInstalledExternalDataFiles(OnSysPath, SiteBuilder): 228 | files: FilesSpec = { 229 | "egg_with_module_pkg.egg-info": { 230 | "PKG-INFO": "Name: egg_with_module-pkg", 231 | # SOURCES.txt is made from the source archive, and contains files 232 | # (setup.py) that are not present after installation. 233 | "SOURCES.txt": """ 234 | egg_with_module.py 235 | setup.py 236 | egg_with_module.json 237 | egg_with_module_pkg.egg-info/PKG-INFO 238 | egg_with_module_pkg.egg-info/SOURCES.txt 239 | egg_with_module_pkg.egg-info/top_level.txt 240 | """, 241 | # installed-files.txt is written by pip, and is a strictly more 242 | # accurate source than SOURCES.txt as to the installed contents of 243 | # the package. 244 | "installed-files.txt": """ 245 | ../../../etc/jupyter/jupyter_notebook_config.d/relative.json 246 | /etc/jupyter/jupyter_notebook_config.d/absolute.json 247 | ../egg_with_module.py 248 | PKG-INFO 249 | SOURCES.txt 250 | top_level.txt 251 | """, 252 | # missing top_level.txt (to trigger fallback to installed-files.txt) 253 | }, 254 | "egg_with_module.py": """ 255 | def main(): 256 | print("hello world") 257 | """, 258 | } 259 | 260 | 261 | class EggInfoPkgPipInstalledNoModules(OnSysPath, SiteBuilder): 262 | files: FilesSpec = { 263 | "egg_with_no_modules_pkg.egg-info": { 264 | "PKG-INFO": "Name: egg_with_no_modules-pkg", 265 | # SOURCES.txt is made from the source archive, and contains files 266 | # (setup.py) that are not present after installation. 267 | "SOURCES.txt": """ 268 | setup.py 269 | egg_with_no_modules_pkg.egg-info/PKG-INFO 270 | egg_with_no_modules_pkg.egg-info/SOURCES.txt 271 | egg_with_no_modules_pkg.egg-info/top_level.txt 272 | """, 273 | # installed-files.txt is written by pip, and is a strictly more 274 | # accurate source than SOURCES.txt as to the installed contents of 275 | # the package. 276 | "installed-files.txt": """ 277 | PKG-INFO 278 | SOURCES.txt 279 | top_level.txt 280 | """, 281 | # top_level.txt correctly reflects that no modules are installed 282 | "top_level.txt": b"\n", 283 | }, 284 | } 285 | 286 | 287 | class EggInfoPkgSourcesFallback(OnSysPath, SiteBuilder): 288 | files: FilesSpec = { 289 | "sources_fallback_pkg.egg-info": { 290 | "PKG-INFO": "Name: sources_fallback-pkg", 291 | # SOURCES.txt is made from the source archive, and contains files 292 | # (setup.py) that are not present after installation. 293 | "SOURCES.txt": """ 294 | sources_fallback.py 295 | setup.py 296 | sources_fallback_pkg.egg-info/PKG-INFO 297 | sources_fallback_pkg.egg-info/SOURCES.txt 298 | """, 299 | # missing installed-files.txt (i.e. not installed by pip) and 300 | # missing top_level.txt (to trigger fallback to SOURCES.txt) 301 | }, 302 | "sources_fallback.py": """ 303 | def main(): 304 | print("hello world") 305 | """, 306 | } 307 | 308 | 309 | class EggInfoFile(OnSysPath, SiteBuilder): 310 | files: FilesSpec = { 311 | "egginfo_file.egg-info": """ 312 | Metadata-Version: 1.0 313 | Name: egginfo_file 314 | Version: 0.1 315 | Summary: An example package 316 | Home-page: www.example.com 317 | Author: Eric Haffa-Vee 318 | Author-email: eric@example.coms 319 | License: UNKNOWN 320 | Description: UNKNOWN 321 | Platform: UNKNOWN 322 | """, 323 | } 324 | 325 | 326 | # dedent all text strings before writing 327 | orig = _path.create.registry[str] 328 | _path.create.register(str, lambda content, path: orig(DALS(content), path)) 329 | 330 | 331 | build_files = _path.build 332 | 333 | 334 | def build_record(file_defs): 335 | return ''.join(f'{name},,\n' for name in record_names(file_defs)) 336 | 337 | 338 | def record_names(file_defs): 339 | recording = _path.Recording() 340 | _path.build(file_defs, recording) 341 | return recording.record 342 | 343 | 344 | class FileBuilder: 345 | def unicode_filename(self): 346 | return os_helper.FS_NONASCII or self.skip( 347 | "File system does not support non-ascii." 348 | ) 349 | 350 | 351 | def DALS(str): 352 | "Dedent and left-strip" 353 | return textwrap.dedent(str).lstrip() 354 | 355 | 356 | class ZipFixtures: 357 | root = 'tests.data' 358 | 359 | def _fixture_on_path(self, filename): 360 | pkg_file = resources.files(self.root).joinpath(filename) 361 | file = self.resources.enter_context(resources.as_file(pkg_file)) 362 | assert file.name.startswith('example'), file.name 363 | sys.path.insert(0, str(file)) 364 | self.resources.callback(sys.path.pop, 0) 365 | 366 | def setUp(self): 367 | # Add self.zip_name to the front of sys.path. 368 | self.resources = contextlib.ExitStack() 369 | self.addCleanup(self.resources.close) 370 | 371 | 372 | def parameterize(*args_set): 373 | """Run test method with a series of parameters.""" 374 | 375 | def wrapper(func): 376 | @functools.wraps(func) 377 | def _inner(self): 378 | for args in args_set: 379 | with self.subTest(**args): 380 | func(self, **args) 381 | 382 | return _inner 383 | 384 | return wrapper 385 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import pickle 3 | import re 4 | import unittest 5 | 6 | import pyfakefs.fake_filesystem_unittest as ffs 7 | 8 | import importlib_metadata 9 | from importlib_metadata import ( 10 | Distribution, 11 | EntryPoint, 12 | PackageNotFoundError, 13 | _unique, 14 | distributions, 15 | entry_points, 16 | metadata, 17 | packages_distributions, 18 | version, 19 | ) 20 | 21 | from . import fixtures 22 | from ._path import Symlink 23 | from .compat.py39 import os_helper 24 | 25 | 26 | class BasicTests(fixtures.DistInfoPkg, unittest.TestCase): 27 | version_pattern = r'\d+\.\d+(\.\d)?' 28 | 29 | def test_retrieves_version_of_self(self): 30 | dist = Distribution.from_name('distinfo-pkg') 31 | assert isinstance(dist.version, str) 32 | assert re.match(self.version_pattern, dist.version) 33 | 34 | def test_for_name_does_not_exist(self): 35 | with self.assertRaises(PackageNotFoundError): 36 | Distribution.from_name('does-not-exist') 37 | 38 | def test_package_not_found_mentions_metadata(self): 39 | """ 40 | When a package is not found, that could indicate that the 41 | package is not installed or that it is installed without 42 | metadata. Ensure the exception mentions metadata to help 43 | guide users toward the cause. See #124. 44 | """ 45 | with self.assertRaises(PackageNotFoundError) as ctx: 46 | Distribution.from_name('does-not-exist') 47 | 48 | assert "metadata" in str(ctx.exception) 49 | 50 | def test_abc_enforced(self): 51 | with self.assertRaises(TypeError): 52 | type('DistributionSubclass', (Distribution,), {})() 53 | 54 | @fixtures.parameterize( 55 | dict(name=None), 56 | dict(name=''), 57 | ) 58 | def test_invalid_inputs_to_from_name(self, name): 59 | with self.assertRaises(Exception): 60 | Distribution.from_name(name) 61 | 62 | 63 | class ImportTests(fixtures.DistInfoPkg, unittest.TestCase): 64 | def test_import_nonexistent_module(self): 65 | # Ensure that the MetadataPathFinder does not crash an import of a 66 | # non-existent module. 67 | with self.assertRaises(ImportError): 68 | importlib.import_module('does_not_exist') 69 | 70 | def test_resolve(self): 71 | ep = entry_points(group='entries')['main'] 72 | self.assertEqual(ep.load().__name__, "main") 73 | 74 | def test_entrypoint_with_colon_in_name(self): 75 | ep = entry_points(group='entries')['ns:sub'] 76 | self.assertEqual(ep.value, 'mod:main') 77 | 78 | def test_resolve_without_attr(self): 79 | ep = EntryPoint( 80 | name='ep', 81 | value='importlib_metadata', 82 | group='grp', 83 | ) 84 | assert ep.load() is importlib_metadata 85 | 86 | 87 | class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 88 | @staticmethod 89 | def make_pkg(name): 90 | """ 91 | Create minimal metadata for a dist-info package with 92 | the indicated name on the file system. 93 | """ 94 | return { 95 | f'{name}.dist-info': { 96 | 'METADATA': 'VERSION: 1.0\n', 97 | }, 98 | } 99 | 100 | def test_dashes_in_dist_name_found_as_underscores(self): 101 | """ 102 | For a package with a dash in the name, the dist-info metadata 103 | uses underscores in the name. Ensure the metadata loads. 104 | """ 105 | fixtures.build_files(self.make_pkg('my_pkg'), self.site_dir) 106 | assert version('my-pkg') == '1.0' 107 | 108 | def test_dist_name_found_as_any_case(self): 109 | """ 110 | Ensure the metadata loads when queried with any case. 111 | """ 112 | pkg_name = 'CherryPy' 113 | fixtures.build_files(self.make_pkg(pkg_name), self.site_dir) 114 | assert version(pkg_name) == '1.0' 115 | assert version(pkg_name.lower()) == '1.0' 116 | assert version(pkg_name.upper()) == '1.0' 117 | 118 | def test_unique_distributions(self): 119 | """ 120 | Two distributions varying only by non-normalized name on 121 | the file system should resolve as the same. 122 | """ 123 | fixtures.build_files(self.make_pkg('abc'), self.site_dir) 124 | before = list(_unique(distributions())) 125 | 126 | alt_site_dir = self.fixtures.enter_context(fixtures.tmp_path()) 127 | self.fixtures.enter_context(self.add_sys_path(alt_site_dir)) 128 | fixtures.build_files(self.make_pkg('ABC'), alt_site_dir) 129 | after = list(_unique(distributions())) 130 | 131 | assert len(after) == len(before) 132 | 133 | 134 | class InvalidMetadataTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 135 | @staticmethod 136 | def make_pkg(name, files=dict(METADATA="VERSION: 1.0")): 137 | """ 138 | Create metadata for a dist-info package with name and files. 139 | """ 140 | return { 141 | f'{name}.dist-info': files, 142 | } 143 | 144 | def test_valid_dists_preferred(self): 145 | """ 146 | Dists with metadata should be preferred when discovered by name. 147 | 148 | Ref python/importlib_metadata#489. 149 | """ 150 | # create three dists with the valid one in the middle (lexicographically) 151 | # such that on most file systems, the valid one is never naturally first. 152 | fixtures.build_files(self.make_pkg('foo-4.0', files={}), self.site_dir) 153 | fixtures.build_files(self.make_pkg('foo-4.1'), self.site_dir) 154 | fixtures.build_files(self.make_pkg('foo-4.2', files={}), self.site_dir) 155 | dist = Distribution.from_name('foo') 156 | assert dist.version == "1.0" 157 | 158 | def test_missing_metadata(self): 159 | """ 160 | Dists with a missing metadata file should return None. 161 | 162 | Ref python/importlib_metadata#493. 163 | """ 164 | fixtures.build_files(self.make_pkg('foo-4.3', files={}), self.site_dir) 165 | assert Distribution.from_name('foo').metadata is None 166 | assert metadata('foo') is None 167 | 168 | 169 | class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 170 | @staticmethod 171 | def pkg_with_non_ascii_description(site_dir): 172 | """ 173 | Create minimal metadata for a package with non-ASCII in 174 | the description. 175 | """ 176 | contents = { 177 | 'portend.dist-info': { 178 | 'METADATA': 'Description: pôrˈtend', 179 | }, 180 | } 181 | fixtures.build_files(contents, site_dir) 182 | return 'portend' 183 | 184 | @staticmethod 185 | def pkg_with_non_ascii_description_egg_info(site_dir): 186 | """ 187 | Create minimal metadata for an egg-info package with 188 | non-ASCII in the description. 189 | """ 190 | contents = { 191 | 'portend.dist-info': { 192 | 'METADATA': """ 193 | Name: portend 194 | 195 | pôrˈtend""", 196 | }, 197 | } 198 | fixtures.build_files(contents, site_dir) 199 | return 'portend' 200 | 201 | def test_metadata_loads(self): 202 | pkg_name = self.pkg_with_non_ascii_description(self.site_dir) 203 | meta = metadata(pkg_name) 204 | assert meta['Description'] == 'pôrˈtend' 205 | 206 | def test_metadata_loads_egg_info(self): 207 | pkg_name = self.pkg_with_non_ascii_description_egg_info(self.site_dir) 208 | meta = metadata(pkg_name) 209 | assert meta['Description'] == 'pôrˈtend' 210 | 211 | 212 | class DiscoveryTests( 213 | fixtures.EggInfoPkg, 214 | fixtures.EggInfoPkgPipInstalledNoToplevel, 215 | fixtures.EggInfoPkgPipInstalledNoModules, 216 | fixtures.EggInfoPkgSourcesFallback, 217 | fixtures.DistInfoPkg, 218 | unittest.TestCase, 219 | ): 220 | def test_package_discovery(self): 221 | dists = list(distributions()) 222 | assert all(isinstance(dist, Distribution) for dist in dists) 223 | assert any(dist.metadata['Name'] == 'egginfo-pkg' for dist in dists) 224 | assert any(dist.metadata['Name'] == 'egg_with_module-pkg' for dist in dists) 225 | assert any(dist.metadata['Name'] == 'egg_with_no_modules-pkg' for dist in dists) 226 | assert any(dist.metadata['Name'] == 'sources_fallback-pkg' for dist in dists) 227 | assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists) 228 | 229 | def test_invalid_usage(self): 230 | with self.assertRaises(ValueError): 231 | list(distributions(context='something', name='else')) 232 | 233 | def test_interleaved_discovery(self): 234 | """ 235 | Ensure interleaved searches are safe. 236 | 237 | When the search is cached, it is possible for searches to be 238 | interleaved, so make sure those use-cases are safe. 239 | 240 | Ref #293 241 | """ 242 | dists = distributions() 243 | next(dists) 244 | version('egginfo-pkg') 245 | next(dists) 246 | 247 | 248 | class DirectoryTest(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 249 | def test_egg_info(self): 250 | # make an `EGG-INFO` directory that's unrelated 251 | self.site_dir.joinpath('EGG-INFO').mkdir() 252 | # used to crash with `IsADirectoryError` 253 | with self.assertRaises(PackageNotFoundError): 254 | version('unknown-package') 255 | 256 | def test_egg(self): 257 | egg = self.site_dir.joinpath('foo-3.6.egg') 258 | egg.mkdir() 259 | with self.add_sys_path(egg): 260 | with self.assertRaises(PackageNotFoundError): 261 | version('foo') 262 | 263 | 264 | class MissingSysPath(fixtures.OnSysPath, unittest.TestCase): 265 | site_dir = '/does-not-exist' 266 | 267 | def test_discovery(self): 268 | """ 269 | Discovering distributions should succeed even if 270 | there is an invalid path on sys.path. 271 | """ 272 | importlib_metadata.distributions() 273 | 274 | 275 | class InaccessibleSysPath(fixtures.OnSysPath, ffs.TestCase): 276 | site_dir = '/access-denied' 277 | 278 | def setUp(self): 279 | super().setUp() 280 | self.setUpPyfakefs() 281 | self.fs.create_dir(self.site_dir, perm_bits=000) 282 | 283 | def test_discovery(self): 284 | """ 285 | Discovering distributions should succeed even if 286 | there is an invalid path on sys.path. 287 | """ 288 | list(importlib_metadata.distributions()) 289 | 290 | 291 | class TestEntryPoints(unittest.TestCase): 292 | def __init__(self, *args): 293 | super().__init__(*args) 294 | self.ep = importlib_metadata.EntryPoint( 295 | name='name', value='value', group='group' 296 | ) 297 | 298 | def test_entry_point_pickleable(self): 299 | revived = pickle.loads(pickle.dumps(self.ep)) 300 | assert revived == self.ep 301 | 302 | def test_positional_args(self): 303 | """ 304 | Capture legacy (namedtuple) construction, discouraged. 305 | """ 306 | EntryPoint('name', 'value', 'group') 307 | 308 | def test_immutable(self): 309 | """EntryPoints should be immutable""" 310 | with self.assertRaises(AttributeError): 311 | self.ep.name = 'badactor' 312 | 313 | def test_repr(self): 314 | assert 'EntryPoint' in repr(self.ep) 315 | assert 'name=' in repr(self.ep) 316 | assert "'name'" in repr(self.ep) 317 | 318 | def test_hashable(self): 319 | """EntryPoints should be hashable""" 320 | hash(self.ep) 321 | 322 | def test_module(self): 323 | assert self.ep.module == 'value' 324 | 325 | def test_attr(self): 326 | assert self.ep.attr is None 327 | 328 | def test_sortable(self): 329 | """ 330 | EntryPoint objects are sortable, but result is undefined. 331 | """ 332 | sorted([ 333 | EntryPoint(name='b', value='val', group='group'), 334 | EntryPoint(name='a', value='val', group='group'), 335 | ]) 336 | 337 | 338 | class FileSystem( 339 | fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, unittest.TestCase 340 | ): 341 | def test_unicode_dir_on_sys_path(self): 342 | """ 343 | Ensure a Unicode subdirectory of a directory on sys.path 344 | does not crash. 345 | """ 346 | fixtures.build_files( 347 | {self.unicode_filename(): {}}, 348 | prefix=self.site_dir, 349 | ) 350 | list(distributions()) 351 | 352 | 353 | class PackagesDistributionsPrebuiltTest(fixtures.ZipFixtures, unittest.TestCase): 354 | def test_packages_distributions_example(self): 355 | self._fixture_on_path('example-21.12-py3-none-any.whl') 356 | assert packages_distributions()['example'] == ['example'] 357 | 358 | def test_packages_distributions_example2(self): 359 | """ 360 | Test packages_distributions on a wheel built 361 | by trampolim. 362 | """ 363 | self._fixture_on_path('example2-1.0.0-py3-none-any.whl') 364 | assert packages_distributions()['example2'] == ['example2'] 365 | 366 | 367 | class PackagesDistributionsTest( 368 | fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase 369 | ): 370 | def test_packages_distributions_neither_toplevel_nor_files(self): 371 | """ 372 | Test a package built without 'top-level.txt' or a file list. 373 | """ 374 | fixtures.build_files( 375 | { 376 | 'trim_example-1.0.0.dist-info': { 377 | 'METADATA': """ 378 | Name: trim_example 379 | Version: 1.0.0 380 | """, 381 | } 382 | }, 383 | prefix=self.site_dir, 384 | ) 385 | packages_distributions() 386 | 387 | def test_packages_distributions_all_module_types(self): 388 | """ 389 | Test top-level modules detected on a package without 'top-level.txt'. 390 | """ 391 | suffixes = importlib.machinery.all_suffixes() 392 | metadata = dict( 393 | METADATA=""" 394 | Name: all_distributions 395 | Version: 1.0.0 396 | """, 397 | ) 398 | files = { 399 | 'all_distributions-1.0.0.dist-info': metadata, 400 | } 401 | for i, suffix in enumerate(suffixes): 402 | files.update({ 403 | f'importable-name {i}{suffix}': '', 404 | f'in_namespace_{i}': { 405 | f'mod{suffix}': '', 406 | }, 407 | f'in_package_{i}': { 408 | '__init__.py': '', 409 | f'mod{suffix}': '', 410 | }, 411 | }) 412 | metadata.update(RECORD=fixtures.build_record(files)) 413 | fixtures.build_files(files, prefix=self.site_dir) 414 | 415 | distributions = packages_distributions() 416 | 417 | for i in range(len(suffixes)): 418 | assert distributions[f'importable-name {i}'] == ['all_distributions'] 419 | assert distributions[f'in_namespace_{i}'] == ['all_distributions'] 420 | assert distributions[f'in_package_{i}'] == ['all_distributions'] 421 | 422 | assert not any(name.endswith('.dist-info') for name in distributions) 423 | 424 | @os_helper.skip_unless_symlink 425 | def test_packages_distributions_symlinked_top_level(self) -> None: 426 | """ 427 | Distribution is resolvable from a simple top-level symlink in RECORD. 428 | See #452. 429 | """ 430 | 431 | files: fixtures.FilesSpec = { 432 | "symlinked_pkg-1.0.0.dist-info": { 433 | "METADATA": """ 434 | Name: symlinked-pkg 435 | Version: 1.0.0 436 | """, 437 | "RECORD": "symlinked,,\n", 438 | }, 439 | ".symlink.target": {}, 440 | "symlinked": Symlink(".symlink.target"), 441 | } 442 | 443 | fixtures.build_files(files, self.site_dir) 444 | assert packages_distributions()['symlinked'] == ['symlinked-pkg'] 445 | 446 | 447 | class PackagesDistributionsEggTest( 448 | fixtures.EggInfoPkg, 449 | fixtures.EggInfoPkgPipInstalledNoToplevel, 450 | fixtures.EggInfoPkgPipInstalledNoModules, 451 | fixtures.EggInfoPkgSourcesFallback, 452 | unittest.TestCase, 453 | ): 454 | def test_packages_distributions_on_eggs(self): 455 | """ 456 | Test old-style egg packages with a variation of 'top_level.txt', 457 | 'SOURCES.txt', and 'installed-files.txt', available. 458 | """ 459 | distributions = packages_distributions() 460 | 461 | def import_names_from_package(package_name): 462 | return { 463 | import_name 464 | for import_name, package_names in distributions.items() 465 | if package_name in package_names 466 | } 467 | 468 | # egginfo-pkg declares one import ('mod') via top_level.txt 469 | assert import_names_from_package('egginfo-pkg') == {'mod'} 470 | 471 | # egg_with_module-pkg has one import ('egg_with_module') inferred from 472 | # installed-files.txt (top_level.txt is missing) 473 | assert import_names_from_package('egg_with_module-pkg') == {'egg_with_module'} 474 | 475 | # egg_with_no_modules-pkg should not be associated with any import names 476 | # (top_level.txt is empty, and installed-files.txt has no .py files) 477 | assert import_names_from_package('egg_with_no_modules-pkg') == set() 478 | 479 | # sources_fallback-pkg has one import ('sources_fallback') inferred from 480 | # SOURCES.txt (top_level.txt and installed-files.txt is missing) 481 | assert import_names_from_package('sources_fallback-pkg') == {'sources_fallback'} 482 | 483 | 484 | class EditableDistributionTest(fixtures.DistInfoPkgEditable, unittest.TestCase): 485 | def test_origin(self): 486 | dist = Distribution.from_name('distinfo-pkg') 487 | assert dist.origin.url.endswith('.whl') 488 | assert dist.origin.archive_info.hashes.sha256 489 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v8.7.1 2 | ====== 3 | 4 | Bugfixes 5 | -------- 6 | 7 | - Fixed errors in FastPath under fork-multiprocessing. (#520) 8 | - Removed cruft from Python 3.8. (#524) 9 | 10 | 11 | v8.7.0 12 | ====== 13 | 14 | Features 15 | -------- 16 | 17 | - ``.metadata()`` (and ``Distribution.metadata``) can now return ``None`` if the metadata directory exists but not metadata file is present. (#493) 18 | 19 | 20 | Bugfixes 21 | -------- 22 | 23 | - Raise consistent ValueError for invalid EntryPoint.value (#518) 24 | 25 | 26 | v8.6.1 27 | ====== 28 | 29 | Bugfixes 30 | -------- 31 | 32 | - Fixed indentation logic to also honor blank lines. 33 | 34 | 35 | v8.6.0 36 | ====== 37 | 38 | Features 39 | -------- 40 | 41 | - Add support for rendering metadata where some fields have newlines (python/cpython#119650). 42 | 43 | 44 | v8.5.0 45 | ====== 46 | 47 | Features 48 | -------- 49 | 50 | - Deferred import of zipfile.Path (#502) 51 | - Deferred import of json (#503) 52 | - Rely on zipp overlay for zipfile.Path. 53 | 54 | 55 | v8.4.0 56 | ====== 57 | 58 | Features 59 | -------- 60 | 61 | - Deferred import of inspect for import performance. (#499) 62 | 63 | 64 | v8.3.0 65 | ====== 66 | 67 | Features 68 | -------- 69 | 70 | - Disallow passing of 'dist' to EntryPoints.select. 71 | 72 | 73 | v8.2.0 74 | ====== 75 | 76 | Features 77 | -------- 78 | 79 | - Add SimplePath to importlib_metadata.__all__. (#494) 80 | 81 | 82 | v8.1.0 83 | ====== 84 | 85 | Features 86 | -------- 87 | 88 | - Prioritize valid dists to invalid dists when retrieving by name. (#489) 89 | 90 | 91 | v8.0.0 92 | ====== 93 | 94 | Deprecations and Removals 95 | ------------------------- 96 | 97 | - Message.__getitem__ now raises a KeyError on missing keys. (#371) 98 | - Removed deprecated support for Distribution subclasses not implementing abstract methods. 99 | 100 | 101 | v7.2.1 102 | ====== 103 | 104 | Bugfixes 105 | -------- 106 | 107 | - When reading installed files from an egg, use ``relative_to(walk_up=True)`` to honor files installed outside of the installation root. (#455) 108 | 109 | 110 | v7.2.0 111 | ====== 112 | 113 | Features 114 | -------- 115 | 116 | - Deferred select imports in for speedup (python/cpython#109829). 117 | - Updated fixtures for python/cpython#120801. 118 | 119 | 120 | v7.1.0 121 | ====== 122 | 123 | Features 124 | -------- 125 | 126 | - Improve import time (python/cpython#114664). 127 | 128 | 129 | Bugfixes 130 | -------- 131 | 132 | - Make MetadataPathFinder.find_distributions a classmethod for consistency with CPython. Closes #484. (#484) 133 | - Allow ``MetadataPathFinder.invalidate_caches`` to be called as a classmethod. 134 | 135 | 136 | v7.0.2 137 | ====== 138 | 139 | No significant changes. 140 | 141 | 142 | v7.0.1 143 | ====== 144 | 145 | Bugfixes 146 | -------- 147 | 148 | - Corrected the interface for SimplePath to encompass the expectations of locate_file and PackagePath. 149 | - Fixed type annotations to allow strings. 150 | 151 | 152 | v7.0.0 153 | ====== 154 | 155 | Deprecations and Removals 156 | ------------------------- 157 | 158 | - Removed EntryPoint access by numeric index (tuple behavior). 159 | 160 | 161 | v6.11.0 162 | ======= 163 | 164 | Features 165 | -------- 166 | 167 | - Added ``Distribution.origin`` supplying the ``direct_url.json`` in a ``SimpleNamespace``. (#404) 168 | 169 | 170 | v6.10.0 171 | ======= 172 | 173 | Features 174 | -------- 175 | 176 | - Added diagnose script. (#461) 177 | 178 | 179 | v6.9.0 180 | ====== 181 | 182 | Features 183 | -------- 184 | 185 | - Added EntryPoints.__repr__ (#473) 186 | 187 | 188 | v6.8.0 189 | ====== 190 | 191 | Features 192 | -------- 193 | 194 | - Require Python 3.8 or later. 195 | 196 | 197 | v6.7.0 198 | ====== 199 | 200 | * #453: When inferring top-level names that are importable for 201 | distributions in ``package_distributions``, now symlinks to 202 | other directories are honored. 203 | 204 | v6.6.0 205 | ====== 206 | 207 | * #449: Expanded type annotations. 208 | 209 | v6.5.1 210 | ====== 211 | 212 | * python/cpython#103661: Removed excess error suppression in 213 | ``_read_files_egginfo_installed`` and fixed path handling 214 | on Windows. 215 | 216 | v6.5.0 217 | ====== 218 | 219 | * #422: Removed ABC metaclass from ``Distribution`` and instead 220 | deprecated construction of ``Distribution`` objects without 221 | concrete methods. 222 | 223 | v6.4.1 224 | ====== 225 | 226 | * Updated docs with tweaks from upstream CPython. 227 | 228 | v6.4.0 229 | ====== 230 | 231 | * Consolidated some behaviors in tests around ``_path``. 232 | * Added type annotation for ``Distribution.read_text``. 233 | 234 | v6.3.0 235 | ====== 236 | 237 | * #115: Support ``installed-files.txt`` for ``Distribution.files`` 238 | when present. 239 | 240 | v6.2.1 241 | ====== 242 | 243 | * #442: Fixed issue introduced in v6.1.0 where non-importable 244 | names (metadata dirs) began appearing in 245 | ``packages_distributions``. 246 | 247 | v6.2.0 248 | ====== 249 | 250 | * #384: ``PackageMetadata`` now stipulates an additional ``get`` 251 | method allowing for easy querying of metadata keys that may not 252 | be present. 253 | 254 | v6.1.0 255 | ====== 256 | 257 | * #428: ``packages_distributions`` now honors packages and modules 258 | with Python modules that not ``.py`` sources (e.g. ``.pyc``, 259 | ``.so``). 260 | 261 | v6.0.1 262 | ====== 263 | 264 | * #434: Expand protocol for ``PackageMetadata.get_all`` to match 265 | the upstream implementation of ``email.message.Message.get_all`` 266 | in python/typeshed#9620. 267 | 268 | v6.0.0 269 | ====== 270 | 271 | * #419: Declared ``Distribution`` as an abstract class, enforcing 272 | definition of abstract methods in instantiated subclasses. It's no 273 | longer possible to instantiate a ``Distribution`` or any subclasses 274 | unless they define the abstract methods. 275 | 276 | Please comment in the issue if this change breaks any projects. 277 | This change will likely be rolled back if it causes significant 278 | disruption. 279 | 280 | v5.2.0 281 | ====== 282 | 283 | * #371: Deprecated expectation that ``PackageMetadata.__getitem__`` 284 | will return ``None`` for missing keys. In the future, it will raise a 285 | ``KeyError``. 286 | 287 | v5.1.0 288 | ====== 289 | 290 | * #415: Instrument ``SimplePath`` with generic support. 291 | 292 | v5.0.0 293 | ====== 294 | 295 | * #97, #284, #300: Removed compatibility shims for deprecated entry 296 | point interfaces. 297 | 298 | v4.13.0 299 | ======= 300 | 301 | * #396: Added compatibility for ``PathDistributions`` originating 302 | from Python 3.8 and 3.9. 303 | 304 | v4.12.0 305 | ======= 306 | 307 | * py-93259: Now raise ``ValueError`` when ``None`` or an empty 308 | string are passed to ``Distribution.from_name`` (and other 309 | callers). 310 | 311 | v4.11.4 312 | ======= 313 | 314 | * #379: In ``PathDistribution._name_from_stem``, avoid including 315 | parts of the extension in the result. 316 | * #381: In ``PathDistribution._normalized_name``, ensure names 317 | loaded from the stem of the filename are also normalized, ensuring 318 | duplicate entry points by packages varying only by non-normalized 319 | name are hidden. 320 | 321 | Note (#459): This change had a backward-incompatible effect for 322 | any installers that created metadata in the filesystem with dashes 323 | in the package names (not replaced by underscores). 324 | 325 | v4.11.3 326 | ======= 327 | 328 | * #372: Removed cast of path items in FastPath, not needed. 329 | 330 | v4.11.2 331 | ======= 332 | 333 | * #369: Fixed bug where ``EntryPoint.extras`` was returning 334 | match objects and not the extras strings. 335 | 336 | v4.11.1 337 | ======= 338 | 339 | * #367: In ``Distribution.requires`` for egg-info, if ``requires.txt`` 340 | is empty, return an empty list. 341 | 342 | v4.11.0 343 | ======= 344 | 345 | * bpo-46246: Added ``__slots__`` to ``EntryPoints``. 346 | 347 | v4.10.2 348 | ======= 349 | 350 | * #365 and bpo-46546: Avoid leaking ``method_name`` in 351 | ``DeprecatedList``. 352 | 353 | v4.10.1 354 | ======= 355 | 356 | v2.1.3 357 | ======= 358 | 359 | * #361: Avoid potential REDoS in ``EntryPoint.pattern``. 360 | 361 | v4.10.0 362 | ======= 363 | 364 | * #354: Removed ``Distribution._local`` factory. This 365 | functionality was created as a demonstration of the 366 | possible implementation. Now, the 367 | `pep517 `_ package 368 | provides this functionality directly through 369 | `pep517.meta.load `_. 370 | 371 | v4.9.0 372 | ====== 373 | 374 | * Require Python 3.7 or later. 375 | 376 | v4.8.3 377 | ====== 378 | 379 | * #357: Fixed requirement generation from egg-info when a 380 | URL requirement is given. 381 | 382 | v4.8.2 383 | ====== 384 | 385 | v2.1.2 386 | ====== 387 | 388 | * #353: Fixed discovery of distributions when path is empty. 389 | 390 | v4.8.1 391 | ====== 392 | 393 | * #348: Restored support for ``EntryPoint`` access by item, 394 | deprecating support in the process. Users are advised 395 | to use direct member access instead of item-based access:: 396 | 397 | - ep[0] -> ep.name 398 | - ep[1] -> ep.value 399 | - ep[2] -> ep.group 400 | - ep[:] -> ep.name, ep.value, ep.group 401 | 402 | v4.8.0 403 | ====== 404 | 405 | * #337: Rewrote ``EntryPoint`` as a simple class, still 406 | immutable and still with the attributes, but without any 407 | expectation for ``namedtuple`` functionality such as 408 | ``_asdict``. 409 | 410 | v4.7.1 411 | ====== 412 | 413 | * #344: Fixed regression in ``packages_distributions`` when 414 | neither top-level.txt nor a files manifest is present. 415 | 416 | v4.7.0 417 | ====== 418 | 419 | * #330: In ``packages_distributions``, now infer top-level 420 | names from ``.files()`` when a ``top-level.txt`` 421 | (Setuptools-specific metadata) is not present. 422 | 423 | v4.6.4 424 | ====== 425 | 426 | * #334: Correct ``SimplePath`` protocol to match ``pathlib`` 427 | protocol for ``__truediv__``. 428 | 429 | v4.6.3 430 | ====== 431 | 432 | * Moved workaround for #327 to ``_compat`` module. 433 | 434 | v4.6.2 435 | ====== 436 | 437 | * bpo-44784: Avoid errors in test suite when 438 | DeprecationWarnings are treated as errors. 439 | 440 | v4.6.1 441 | ====== 442 | 443 | * #327: Deprecation warnings now honor call stack variance 444 | on PyPy. 445 | 446 | v4.6.0 447 | ====== 448 | 449 | * #326: Performance tests now rely on 450 | `pytest-perf `_. 451 | To disable these tests, which require network access 452 | and a git checkout, pass ``-p no:perf`` to pytest. 453 | 454 | v4.5.0 455 | ====== 456 | 457 | * #319: Remove ``SelectableGroups`` deprecation exception 458 | for flake8. 459 | 460 | v4.4.0 461 | ====== 462 | 463 | * #300: Restore compatibility in the result from 464 | ``Distribution.entry_points`` (``EntryPoints``) to honor 465 | expectations in older implementations and issuing 466 | deprecation warnings for these cases: 467 | 468 | - ``EntryPoints`` objects are once again mutable, allowing 469 | for ``sort()`` and other list-based mutation operations. 470 | Avoid deprecation warnings by casting to a 471 | mutable sequence (e.g. 472 | ``list(dist.entry_points).sort()``). 473 | 474 | - ``EntryPoints`` results once again allow 475 | for access by index. To avoid deprecation warnings, 476 | cast the result to a Sequence first 477 | (e.g. ``tuple(dist.entry_points)[0]``). 478 | 479 | v4.3.1 480 | ====== 481 | 482 | * #320: Fix issue where normalized name for eggs was 483 | incorrectly solicited, leading to metadata being 484 | unavailable for eggs. 485 | 486 | v4.3.0 487 | ====== 488 | 489 | * #317: De-duplication of distributions no longer requires 490 | loading the full metadata for ``PathDistribution`` objects, 491 | entry point loading performance by ~10x. 492 | 493 | v4.2.0 494 | ====== 495 | 496 | * Prefer f-strings to ``.format`` calls. 497 | 498 | v4.1.0 499 | ====== 500 | 501 | * #312: Add support for metadata 2.2 (``Dynamic`` field). 502 | 503 | * #315: Add ``SimplePath`` protocol for interface clarity 504 | in ``PathDistribution``. 505 | 506 | v4.0.1 507 | ====== 508 | 509 | * #306: Clearer guidance about compatibility in readme. 510 | 511 | v4.0.0 512 | ====== 513 | 514 | * #304: ``PackageMetadata`` as returned by ``metadata()`` 515 | and ``Distribution.metadata()`` now provides normalized 516 | metadata honoring PEP 566: 517 | 518 | - If a long description is provided in the payload of the 519 | RFC 822 value, it can be retrieved as the ``Description`` 520 | field. 521 | - Any multi-line values in the metadata will be returned as 522 | such. 523 | - For any multi-line values, line continuation characters 524 | are removed. This backward-incompatible change means 525 | that any projects relying on the RFC 822 line continuation 526 | characters being present must be tolerant to them having 527 | been removed. 528 | - Add a ``json`` property that provides the metadata 529 | converted to a JSON-compatible form per PEP 566. 530 | 531 | 532 | v3.10.1 533 | ======= 534 | 535 | * Minor tweaks from CPython. 536 | 537 | v3.10.0 538 | ======= 539 | 540 | * #295: Internal refactoring to unify section parsing logic. 541 | 542 | v3.9.1 543 | ====== 544 | 545 | * #296: Exclude 'prepare' package. 546 | * #297: Fix ValueError when entry points contains comments. 547 | 548 | v3.9.0 549 | ====== 550 | 551 | * Use of Mapping (dict) interfaces on ``SelectableGroups`` 552 | is now flagged as deprecated. Instead, users are advised 553 | to use the select interface for future compatibility. 554 | 555 | Suppress the warning with this filter: 556 | ``ignore:SelectableGroups dict interface``. 557 | 558 | Or with this invocation in the Python environment: 559 | ``warnings.filterwarnings('ignore', 'SelectableGroups dict interface')``. 560 | 561 | Preferably, switch to the ``select`` interface introduced 562 | in 3.7.0. See the 563 | `entry points documentation `_ and changelog for the 3.6 564 | release below for more detail. 565 | 566 | For some use-cases, especially those that rely on 567 | ``importlib.metadata`` in Python 3.8 and 3.9 or 568 | those relying on older ``importlib_metadata`` (especially 569 | on Python 3.5 and earlier), 570 | `backports.entry_points_selectable `_ 571 | was created to ease the transition. Please have a look 572 | at that project if simply relying on importlib_metadata 3.6+ 573 | is not straightforward. Background in #298. 574 | 575 | * #283: Entry point parsing no longer relies on ConfigParser 576 | and instead uses a custom, one-pass parser to load the 577 | config, resulting in a ~20% performance improvement when 578 | loading entry points. 579 | 580 | v3.8.2 581 | ====== 582 | 583 | * #293: Re-enabled lazy evaluation of path lookup through 584 | a FreezableDefaultDict. 585 | 586 | v3.8.1 587 | ====== 588 | 589 | * #293: Workaround for error in distribution search. 590 | 591 | v3.8.0 592 | ====== 593 | 594 | * #290: Add mtime-based caching for ``FastPath`` and its 595 | lookups, dramatically increasing performance for repeated 596 | distribution lookups. 597 | 598 | v3.7.3 599 | ====== 600 | 601 | * Docs enhancements and cleanup following review in 602 | `GH-24782 `_. 603 | 604 | v3.7.2 605 | ====== 606 | 607 | * Cleaned up cruft in entry_points docstring. 608 | 609 | v3.7.1 610 | ====== 611 | 612 | * Internal refactoring to facilitate ``entry_points() -> dict`` 613 | deprecation. 614 | 615 | v3.7.0 616 | ====== 617 | 618 | * #131: Added ``packages_distributions`` to conveniently 619 | resolve a top-level package or module to its distribution(s). 620 | 621 | v3.6.0 622 | ====== 623 | 624 | * #284: Introduces new ``EntryPoints`` object, a tuple of 625 | ``EntryPoint`` objects but with convenience properties for 626 | selecting and inspecting the results: 627 | 628 | - ``.select()`` accepts ``group`` or ``name`` keyword 629 | parameters and returns a new ``EntryPoints`` tuple 630 | with only those that match the selection. 631 | - ``.groups`` property presents all of the group names. 632 | - ``.names`` property presents the names of the entry points. 633 | - Item access (e.g. ``eps[name]``) retrieves a single 634 | entry point by name. 635 | 636 | ``entry_points`` now accepts "selection parameters", 637 | same as ``EntryPoint.select()``. 638 | 639 | ``entry_points()`` now provides a future-compatible 640 | ``SelectableGroups`` object that supplies the above interface 641 | (except item access) but remains a dict for compatibility. 642 | 643 | In the future, ``entry_points()`` will return an 644 | ``EntryPoints`` object for all entry points. 645 | 646 | If passing selection parameters to ``entry_points``, the 647 | future behavior is invoked and an ``EntryPoints`` is the 648 | result. 649 | 650 | * #284: Construction of entry points using 651 | ``dict([EntryPoint, ...])`` is now deprecated and raises 652 | an appropriate DeprecationWarning and will be removed in 653 | a future version. 654 | 655 | * #300: ``Distribution.entry_points`` now presents as an 656 | ``EntryPoints`` object and access by index is no longer 657 | allowed. If access by index is required, cast to a sequence 658 | first. 659 | 660 | v3.5.0 661 | ====== 662 | 663 | * #280: ``entry_points`` now only returns entry points for 664 | unique distributions (by name). 665 | 666 | v3.4.0 667 | ====== 668 | 669 | * #10: Project now declares itself as being typed. 670 | * #272: Additional performance enhancements to distribution 671 | discovery. 672 | * #111: For PyPA projects, add test ensuring that 673 | ``MetadataPathFinder._search_paths`` honors the needed 674 | interface. Method is still private. 675 | 676 | v3.3.0 677 | ====== 678 | 679 | * #265: ``EntryPoint`` objects now expose a ``.dist`` object 680 | referencing the ``Distribution`` when constructed from a 681 | Distribution. 682 | 683 | v3.2.0 684 | ====== 685 | 686 | * The object returned by ``metadata()`` now has a 687 | formally-defined protocol called ``PackageMetadata`` 688 | with declared support for the ``.get_all()`` method. 689 | Fixes #126. 690 | 691 | v3.1.1 692 | ====== 693 | 694 | v2.1.1 695 | ====== 696 | 697 | * #261: Restored compatibility for package discovery for 698 | metadata without version in the name and for legacy 699 | eggs. 700 | 701 | v3.1.0 702 | ====== 703 | 704 | * Merge with 2.1.0. 705 | 706 | v2.1.0 707 | ====== 708 | 709 | * #253: When querying for package metadata, the lookup 710 | now honors 711 | `package normalization rules `_. 712 | 713 | v3.0.0 714 | ====== 715 | 716 | * Require Python 3.6 or later. 717 | 718 | v2.0.0 719 | ====== 720 | 721 | * ``importlib_metadata`` no longer presents a 722 | ``__version__`` attribute. Consumers wishing to 723 | resolve the version of the package should query it 724 | directly with 725 | ``importlib_metadata.version('importlib-metadata')``. 726 | Closes #71. 727 | 728 | v1.7.0 729 | ====== 730 | 731 | * ``PathNotFoundError`` now has a custom ``__str__`` 732 | mentioning "package metadata" being missing to help 733 | guide users to the cause when the package is installed 734 | but no metadata is present. Closes #124. 735 | 736 | v1.6.1 737 | ====== 738 | 739 | * Added ``Distribution._local()`` as a provisional 740 | demonstration of how to load metadata for a local 741 | package. Implicitly requires that 742 | `pep517 `_ is 743 | installed. Ref #42. 744 | * Ensure inputs to FastPath are Unicode. Closes #121. 745 | * Tests now rely on ``importlib.resources.files`` (and 746 | backport) instead of the older ``path`` function. 747 | * Support any iterable from ``find_distributions``. 748 | Closes #122. 749 | 750 | v1.6.0 751 | ====== 752 | 753 | * Added ``module`` and ``attr`` attributes to ``EntryPoint`` 754 | 755 | v1.5.2 756 | ====== 757 | 758 | * Fix redundant entries from ``FastPath.zip_children``. 759 | Closes #117. 760 | 761 | v1.5.1 762 | ====== 763 | 764 | * Improve reliability and consistency of compatibility 765 | imports for contextlib and pathlib when running tests. 766 | Closes #116. 767 | 768 | v1.5.0 769 | ====== 770 | 771 | * Additional performance optimizations in FastPath now 772 | saves an additional 20% on a typical call. 773 | * Correct for issue where PyOxidizer finder has no 774 | ``__module__`` attribute. Closes #110. 775 | 776 | v1.4.0 777 | ====== 778 | 779 | * Through careful optimization, ``distribution()`` is 780 | 3-4x faster. Thanks to Antony Lee for the 781 | contribution. Closes #95. 782 | 783 | * When searching through ``sys.path``, if any error 784 | occurs attempting to list a path entry, that entry 785 | is skipped, making the system much more lenient 786 | to errors. Closes #94. 787 | 788 | v1.3.0 789 | ====== 790 | 791 | * Improve custom finders documentation. Closes #105. 792 | 793 | v1.2.0 794 | ====== 795 | 796 | * Once again, drop support for Python 3.4. Ref #104. 797 | 798 | v1.1.3 799 | ====== 800 | 801 | * Restored support for Python 3.4 due to improper version 802 | compatibility declarations in the v1.1.0 and v1.1.1 803 | releases. Closes #104. 804 | 805 | v1.1.2 806 | ====== 807 | 808 | * Repaired project metadata to correctly declare the 809 | ``python_requires`` directive. Closes #103. 810 | 811 | v1.1.1 812 | ====== 813 | 814 | * Fixed ``repr(EntryPoint)`` on PyPy 3 also. Closes #102. 815 | 816 | v1.1.0 817 | ====== 818 | 819 | * Dropped support for Python 3.4. 820 | * EntryPoints are now pickleable. Closes #96. 821 | * Fixed ``repr(EntryPoint)`` on PyPy 2. Closes #97. 822 | 823 | v1.0.0 824 | ====== 825 | 826 | * Project adopts semver for versioning. 827 | 828 | * Removed compatibility shim introduced in 0.23. 829 | 830 | * For better compatibility with the stdlib implementation and to 831 | avoid the same distributions being discovered by the stdlib and 832 | backport implementations, the backport now disables the 833 | stdlib DistributionFinder during initialization (import time). 834 | Closes #91 and closes #100. 835 | 836 | 0.23 837 | ==== 838 | 839 | * Added a compatibility shim to prevent failures on beta releases 840 | of Python before the signature changed to accept the 841 | "context" parameter on find_distributions. This workaround 842 | will have a limited lifespan, not to extend beyond release of 843 | Python 3.8 final. 844 | 845 | 0.22 846 | ==== 847 | 848 | * Renamed ``package`` parameter to ``distribution_name`` 849 | as `recommended `_ 850 | in the following functions: ``distribution``, ``metadata``, 851 | ``version``, ``files``, and ``requires``. This 852 | backward-incompatible change is expected to have little impact 853 | as these functions are assumed to be primarily used with 854 | positional parameters. 855 | 856 | 0.21 857 | ==== 858 | 859 | * ``importlib.metadata`` now exposes the ``DistributionFinder`` 860 | metaclass and references it in the docs for extending the 861 | search algorithm. 862 | * Add ``Distribution.at`` for constructing a Distribution object 863 | from a known metadata directory on the file system. Closes #80. 864 | * Distribution finders now receive a context object that 865 | supplies ``.path`` and ``.name`` properties. This change 866 | introduces a fundamental backward incompatibility for 867 | any projects implementing a ``find_distributions`` method 868 | on a ``MetaPathFinder``. This new layer of abstraction 869 | allows this context to be supplied directly or constructed 870 | on demand and opens the opportunity for a 871 | ``find_distributions`` method to solicit additional 872 | context from the caller. Closes #85. 873 | 874 | 0.20 875 | ==== 876 | 877 | * Clarify in the docs that calls to ``.files`` could return 878 | ``None`` when the metadata is not present. Closes #69. 879 | * Return all requirements and not just the first for dist-info 880 | packages. Closes #67. 881 | 882 | 0.19 883 | ==== 884 | 885 | * Restrain over-eager egg metadata resolution. 886 | * Add support for entry points with colons in the name. Closes #75. 887 | 888 | 0.18 889 | ==== 890 | 891 | * Parse entry points case sensitively. Closes #68 892 | * Add a version constraint on the backport configparser package. Closes #66 893 | 894 | 0.17 895 | ==== 896 | 897 | * Fix a permission problem in the tests on Windows. 898 | 899 | 0.16 900 | ==== 901 | 902 | * Don't crash if there exists an EGG-INFO directory on sys.path. 903 | 904 | 0.15 905 | ==== 906 | 907 | * Fix documentation. 908 | 909 | 0.14 910 | ==== 911 | 912 | * Removed ``local_distribution`` function from the API. 913 | **This backward-incompatible change removes this 914 | behavior summarily**. Projects should remove their 915 | reliance on this behavior. A replacement behavior is 916 | under review in the `pep517 project 917 | `_. Closes #42. 918 | 919 | 0.13 920 | ==== 921 | 922 | * Update docstrings to match PEP 8. Closes #63. 923 | * Merged modules into one module. Closes #62. 924 | 925 | 0.12 926 | ==== 927 | 928 | * Add support for eggs. !65; Closes #19. 929 | 930 | 0.11 931 | ==== 932 | 933 | * Support generic zip files (not just wheels). Closes #59 934 | * Support zip files with multiple distributions in them. Closes #60 935 | * Fully expose the public API in ``importlib_metadata.__all__``. 936 | 937 | 0.10 938 | ==== 939 | 940 | * The ``Distribution`` ABC is now officially part of the public API. 941 | Closes #37. 942 | * Fixed support for older single file egg-info formats. Closes #43. 943 | * Fixed a testing bug when ``$CWD`` has spaces in the path. Closes #50. 944 | * Add Python 3.8 to the ``tox`` testing matrix. 945 | 946 | 0.9 947 | === 948 | 949 | * Fixed issue where entry points without an attribute would raise an 950 | Exception. Closes #40. 951 | * Removed unused ``name`` parameter from ``entry_points()``. Closes #44. 952 | * ``DistributionFinder`` classes must now be instantiated before 953 | being placed on ``sys.meta_path``. 954 | 955 | 0.8 956 | === 957 | 958 | * This library can now discover/enumerate all installed packages. **This 959 | backward-incompatible change alters the protocol finders must 960 | implement to support distribution package discovery.** Closes #24. 961 | * The signature of ``find_distributions()`` on custom installer finders 962 | should now accept two parameters, ``name`` and ``path`` and 963 | these parameters must supply defaults. 964 | * The ``entry_points()`` method no longer accepts a package name 965 | but instead returns all entry points in a dictionary keyed by the 966 | ``EntryPoint.group``. The ``resolve`` method has been removed. Instead, 967 | call ``EntryPoint.load()``, which has the same semantics as 968 | ``pkg_resources`` and ``entrypoints``. **This is a backward incompatible 969 | change.** 970 | * Metadata is now always returned as Unicode text regardless of 971 | Python version. Closes #29. 972 | * This library can now discover metadata for a 'local' package (found 973 | in the current-working directory). Closes #27. 974 | * Added ``files()`` function for resolving files from a distribution. 975 | * Added a new ``requires()`` function, which returns the requirements 976 | for a package suitable for parsing by 977 | ``packaging.requirements.Requirement``. Closes #18. 978 | * The top-level ``read_text()`` function has been removed. Use 979 | ``PackagePath.read_text()`` on instances returned by the ``files()`` 980 | function. **This is a backward incompatible change.** 981 | * Release dates are now automatically injected into the changelog 982 | based on SCM tags. 983 | 984 | 0.7 985 | === 986 | 987 | * Fixed issue where packages with dashes in their names would 988 | not be discovered. Closes #21. 989 | * Distribution lookup is now case-insensitive. Closes #20. 990 | * Wheel distributions can no longer be discovered by their module 991 | name. Like Path distributions, they must be indicated by their 992 | distribution package name. 993 | 994 | 0.6 995 | === 996 | 997 | * Removed ``importlib_metadata.distribution`` function. Now 998 | the public interface is primarily the utility functions exposed 999 | in ``importlib_metadata.__all__``. Closes #14. 1000 | * Added two new utility functions ``read_text`` and 1001 | ``metadata``. 1002 | 1003 | 0.5 1004 | === 1005 | 1006 | * Updated README and removed details about Distribution 1007 | class, now considered private. Closes #15. 1008 | * Added test suite support for Python 3.4+. 1009 | * Fixed SyntaxErrors on Python 3.4 and 3.5. !12 1010 | * Fixed errors on Windows joining Path elements. !15 1011 | 1012 | 0.4 1013 | === 1014 | 1015 | * Housekeeping. 1016 | 1017 | 0.3 1018 | === 1019 | 1020 | * Added usage documentation. Closes #8 1021 | * Add support for getting metadata from wheels on ``sys.path``. Closes #9 1022 | 1023 | 0.2 1024 | === 1025 | 1026 | * Added ``importlib_metadata.entry_points()``. Closes #1 1027 | * Added ``importlib_metadata.resolve()``. Closes #12 1028 | * Add support for Python 2.7. Closes #4 1029 | 1030 | 0.1 1031 | === 1032 | 1033 | * Initial release. 1034 | 1035 | 1036 | .. 1037 | Local Variables: 1038 | mode: change-log-mode 1039 | indent-tabs-mode: nil 1040 | sentence-end-double-space: t 1041 | fill-column: 78 1042 | coding: utf-8 1043 | End: 1044 | -------------------------------------------------------------------------------- /importlib_metadata/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | APIs exposing metadata from third-party Python packages. 3 | 4 | This codebase is shared between importlib.metadata in the stdlib 5 | and importlib_metadata in PyPI. See 6 | https://github.com/python/importlib_metadata/wiki/Development-Methodology 7 | for more detail. 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import abc 13 | import collections 14 | import email 15 | import functools 16 | import itertools 17 | import operator 18 | import os 19 | import pathlib 20 | import posixpath 21 | import re 22 | import sys 23 | import textwrap 24 | import types 25 | from collections.abc import Iterable, Mapping 26 | from contextlib import suppress 27 | from importlib import import_module 28 | from importlib.abc import MetaPathFinder 29 | from itertools import starmap 30 | from typing import Any 31 | 32 | from . import _meta 33 | from ._collections import FreezableDefaultDict, Pair 34 | from ._compat import ( 35 | NullFinder, 36 | install, 37 | ) 38 | from ._functools import method_cache, noop, pass_none, passthrough 39 | from ._itertools import always_iterable, bucket, unique_everseen 40 | from ._meta import PackageMetadata, SimplePath 41 | from ._typing import md_none 42 | from .compat import py39, py311 43 | 44 | __all__ = [ 45 | 'Distribution', 46 | 'DistributionFinder', 47 | 'PackageMetadata', 48 | 'PackageNotFoundError', 49 | 'SimplePath', 50 | 'distribution', 51 | 'distributions', 52 | 'entry_points', 53 | 'files', 54 | 'metadata', 55 | 'packages_distributions', 56 | 'requires', 57 | 'version', 58 | ] 59 | 60 | 61 | class PackageNotFoundError(ModuleNotFoundError): 62 | """The package was not found.""" 63 | 64 | def __str__(self) -> str: 65 | return f"No package metadata was found for {self.name}" 66 | 67 | @property 68 | def name(self) -> str: # type: ignore[override] # make readonly 69 | (name,) = self.args 70 | return name 71 | 72 | 73 | class Sectioned: 74 | """ 75 | A simple entry point config parser for performance 76 | 77 | >>> for item in Sectioned.read(Sectioned._sample): 78 | ... print(item) 79 | Pair(name='sec1', value='# comments ignored') 80 | Pair(name='sec1', value='a = 1') 81 | Pair(name='sec1', value='b = 2') 82 | Pair(name='sec2', value='a = 2') 83 | 84 | >>> res = Sectioned.section_pairs(Sectioned._sample) 85 | >>> item = next(res) 86 | >>> item.name 87 | 'sec1' 88 | >>> item.value 89 | Pair(name='a', value='1') 90 | >>> item = next(res) 91 | >>> item.value 92 | Pair(name='b', value='2') 93 | >>> item = next(res) 94 | >>> item.name 95 | 'sec2' 96 | >>> item.value 97 | Pair(name='a', value='2') 98 | >>> list(res) 99 | [] 100 | """ 101 | 102 | _sample = textwrap.dedent( 103 | """ 104 | [sec1] 105 | # comments ignored 106 | a = 1 107 | b = 2 108 | 109 | [sec2] 110 | a = 2 111 | """ 112 | ).lstrip() 113 | 114 | @classmethod 115 | def section_pairs(cls, text): 116 | return ( 117 | section._replace(value=Pair.parse(section.value)) 118 | for section in cls.read(text, filter_=cls.valid) 119 | if section.name is not None 120 | ) 121 | 122 | @staticmethod 123 | def read(text, filter_=None): 124 | lines = filter(filter_, map(str.strip, text.splitlines())) 125 | name = None 126 | for value in lines: 127 | section_match = value.startswith('[') and value.endswith(']') 128 | if section_match: 129 | name = value.strip('[]') 130 | continue 131 | yield Pair(name, value) 132 | 133 | @staticmethod 134 | def valid(line: str): 135 | return line and not line.startswith('#') 136 | 137 | 138 | class _EntryPointMatch(types.SimpleNamespace): 139 | module: str 140 | attr: str 141 | extras: str 142 | 143 | 144 | class EntryPoint: 145 | """An entry point as defined by Python packaging conventions. 146 | 147 | See `the packaging docs on entry points 148 | `_ 149 | for more information. 150 | 151 | >>> ep = EntryPoint( 152 | ... name=None, group=None, value='package.module:attr [extra1, extra2]') 153 | >>> ep.module 154 | 'package.module' 155 | >>> ep.attr 156 | 'attr' 157 | >>> ep.extras 158 | ['extra1', 'extra2'] 159 | 160 | If the value package or module are not valid identifiers, a 161 | ValueError is raised on access. 162 | 163 | >>> EntryPoint(name=None, group=None, value='invalid-name').module 164 | Traceback (most recent call last): 165 | ... 166 | ValueError: ('Invalid object reference...invalid-name... 167 | >>> EntryPoint(name=None, group=None, value='invalid-name').attr 168 | Traceback (most recent call last): 169 | ... 170 | ValueError: ('Invalid object reference...invalid-name... 171 | >>> EntryPoint(name=None, group=None, value='invalid-name').extras 172 | Traceback (most recent call last): 173 | ... 174 | ValueError: ('Invalid object reference...invalid-name... 175 | 176 | The same thing happens on construction. 177 | 178 | >>> EntryPoint(name=None, group=None, value='invalid-name') 179 | Traceback (most recent call last): 180 | ... 181 | ValueError: ('Invalid object reference...invalid-name... 182 | 183 | """ 184 | 185 | pattern = re.compile( 186 | r'(?P[\w.]+)\s*' 187 | r'(:\s*(?P[\w.]+)\s*)?' 188 | r'((?P\[.*\])\s*)?$' 189 | ) 190 | """ 191 | A regular expression describing the syntax for an entry point, 192 | which might look like: 193 | 194 | - module 195 | - package.module 196 | - package.module:attribute 197 | - package.module:object.attribute 198 | - package.module:attr [extra1, extra2] 199 | 200 | Other combinations are possible as well. 201 | 202 | The expression is lenient about whitespace around the ':', 203 | following the attr, and following any extras. 204 | """ 205 | 206 | name: str 207 | value: str 208 | group: str 209 | 210 | dist: Distribution | None = None 211 | 212 | def __init__(self, name: str, value: str, group: str) -> None: 213 | vars(self).update(name=name, value=value, group=group) 214 | self.module 215 | 216 | def load(self) -> Any: 217 | """Load the entry point from its definition. If only a module 218 | is indicated by the value, return that module. Otherwise, 219 | return the named object. 220 | """ 221 | module = import_module(self.module) 222 | attrs = filter(None, (self.attr or '').split('.')) 223 | return functools.reduce(getattr, attrs, module) 224 | 225 | @property 226 | def module(self) -> str: 227 | return self._match.module 228 | 229 | @property 230 | def attr(self) -> str: 231 | return self._match.attr 232 | 233 | @property 234 | def extras(self) -> list[str]: 235 | return re.findall(r'\w+', self._match.extras or '') 236 | 237 | @functools.cached_property 238 | def _match(self) -> _EntryPointMatch: 239 | match = self.pattern.match(self.value) 240 | if not match: 241 | raise ValueError( 242 | 'Invalid object reference. ' 243 | 'See https://packaging.python.org' 244 | '/en/latest/specifications/entry-points/#data-model', 245 | self.value, 246 | ) 247 | return _EntryPointMatch(**match.groupdict()) 248 | 249 | def _for(self, dist): 250 | vars(self).update(dist=dist) 251 | return self 252 | 253 | def matches(self, **params): 254 | """ 255 | EntryPoint matches the given parameters. 256 | 257 | >>> ep = EntryPoint(group='foo', name='bar', value='bing:bong [extra1, extra2]') 258 | >>> ep.matches(group='foo') 259 | True 260 | >>> ep.matches(name='bar', value='bing:bong [extra1, extra2]') 261 | True 262 | >>> ep.matches(group='foo', name='other') 263 | False 264 | >>> ep.matches() 265 | True 266 | >>> ep.matches(extras=['extra1', 'extra2']) 267 | True 268 | >>> ep.matches(module='bing') 269 | True 270 | >>> ep.matches(attr='bong') 271 | True 272 | """ 273 | self._disallow_dist(params) 274 | attrs = (getattr(self, param) for param in params) 275 | return all(map(operator.eq, params.values(), attrs)) 276 | 277 | @staticmethod 278 | def _disallow_dist(params): 279 | """ 280 | Querying by dist is not allowed (dist objects are not comparable). 281 | >>> EntryPoint(name='fan', value='fav', group='fag').matches(dist='foo') 282 | Traceback (most recent call last): 283 | ... 284 | ValueError: "dist" is not suitable for matching... 285 | """ 286 | if "dist" in params: 287 | raise ValueError( 288 | '"dist" is not suitable for matching. ' 289 | "Instead, use Distribution.entry_points.select() on a " 290 | "located distribution." 291 | ) 292 | 293 | def _key(self): 294 | return self.name, self.value, self.group 295 | 296 | def __lt__(self, other): 297 | return self._key() < other._key() 298 | 299 | def __eq__(self, other): 300 | return self._key() == other._key() 301 | 302 | def __setattr__(self, name, value): 303 | raise AttributeError("EntryPoint objects are immutable.") 304 | 305 | def __repr__(self): 306 | return ( 307 | f'EntryPoint(name={self.name!r}, value={self.value!r}, ' 308 | f'group={self.group!r})' 309 | ) 310 | 311 | def __hash__(self) -> int: 312 | return hash(self._key()) 313 | 314 | 315 | class EntryPoints(tuple): 316 | """ 317 | An immutable collection of selectable EntryPoint objects. 318 | """ 319 | 320 | __slots__ = () 321 | 322 | def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override] # Work with str instead of int 323 | """ 324 | Get the EntryPoint in self matching name. 325 | """ 326 | try: 327 | return next(iter(self.select(name=name))) 328 | except StopIteration: 329 | raise KeyError(name) 330 | 331 | def __repr__(self): 332 | """ 333 | Repr with classname and tuple constructor to 334 | signal that we deviate from regular tuple behavior. 335 | """ 336 | return '%s(%r)' % (self.__class__.__name__, tuple(self)) 337 | 338 | def select(self, **params) -> EntryPoints: 339 | """ 340 | Select entry points from self that match the 341 | given parameters (typically group and/or name). 342 | """ 343 | return EntryPoints(ep for ep in self if py39.ep_matches(ep, **params)) 344 | 345 | @property 346 | def names(self) -> set[str]: 347 | """ 348 | Return the set of all names of all entry points. 349 | """ 350 | return {ep.name for ep in self} 351 | 352 | @property 353 | def groups(self) -> set[str]: 354 | """ 355 | Return the set of all groups of all entry points. 356 | """ 357 | return {ep.group for ep in self} 358 | 359 | @classmethod 360 | def _from_text_for(cls, text, dist): 361 | return cls(ep._for(dist) for ep in cls._from_text(text)) 362 | 363 | @staticmethod 364 | def _from_text(text): 365 | return ( 366 | EntryPoint(name=item.value.name, value=item.value.value, group=item.name) 367 | for item in Sectioned.section_pairs(text or '') 368 | ) 369 | 370 | 371 | class PackagePath(pathlib.PurePosixPath): 372 | """A reference to a path in a package""" 373 | 374 | hash: FileHash | None 375 | size: int 376 | dist: Distribution 377 | 378 | def read_text(self, encoding: str = 'utf-8') -> str: 379 | return self.locate().read_text(encoding=encoding) 380 | 381 | def read_binary(self) -> bytes: 382 | return self.locate().read_bytes() 383 | 384 | def locate(self) -> SimplePath: 385 | """Return a path-like object for this path""" 386 | return self.dist.locate_file(self) 387 | 388 | 389 | class FileHash: 390 | def __init__(self, spec: str) -> None: 391 | self.mode, _, self.value = spec.partition('=') 392 | 393 | def __repr__(self) -> str: 394 | return f'' 395 | 396 | 397 | class Distribution(metaclass=abc.ABCMeta): 398 | """ 399 | An abstract Python distribution package. 400 | 401 | Custom providers may derive from this class and define 402 | the abstract methods to provide a concrete implementation 403 | for their environment. Some providers may opt to override 404 | the default implementation of some properties to bypass 405 | the file-reading mechanism. 406 | """ 407 | 408 | @abc.abstractmethod 409 | def read_text(self, filename) -> str | None: 410 | """Attempt to load metadata file given by the name. 411 | 412 | Python distribution metadata is organized by blobs of text 413 | typically represented as "files" in the metadata directory 414 | (e.g. package-1.0.dist-info). These files include things 415 | like: 416 | 417 | - METADATA: The distribution metadata including fields 418 | like Name and Version and Description. 419 | - entry_points.txt: A series of entry points as defined in 420 | `the entry points spec `_. 421 | - RECORD: A record of files according to 422 | `this recording spec `_. 423 | 424 | A package may provide any set of files, including those 425 | not listed here or none at all. 426 | 427 | :param filename: The name of the file in the distribution info. 428 | :return: The text if found, otherwise None. 429 | """ 430 | 431 | @abc.abstractmethod 432 | def locate_file(self, path: str | os.PathLike[str]) -> SimplePath: 433 | """ 434 | Given a path to a file in this distribution, return a SimplePath 435 | to it. 436 | 437 | This method is used by callers of ``Distribution.files()`` to 438 | locate files within the distribution. If it's possible for a 439 | Distribution to represent files in the distribution as 440 | ``SimplePath`` objects, it should implement this method 441 | to resolve such objects. 442 | 443 | Some Distribution providers may elect not to resolve SimplePath 444 | objects within the distribution by raising a 445 | NotImplementedError, but consumers of such a Distribution would 446 | be unable to invoke ``Distribution.files()``. 447 | """ 448 | 449 | @classmethod 450 | def from_name(cls, name: str) -> Distribution: 451 | """Return the Distribution for the given package name. 452 | 453 | :param name: The name of the distribution package to search for. 454 | :return: The Distribution instance (or subclass thereof) for the named 455 | package, if found. 456 | :raises PackageNotFoundError: When the named package's distribution 457 | metadata cannot be found. 458 | :raises ValueError: When an invalid value is supplied for name. 459 | """ 460 | if not name: 461 | raise ValueError("A distribution name is required.") 462 | try: 463 | return next(iter(cls._prefer_valid(cls.discover(name=name)))) 464 | except StopIteration: 465 | raise PackageNotFoundError(name) 466 | 467 | @classmethod 468 | def discover( 469 | cls, *, context: DistributionFinder.Context | None = None, **kwargs 470 | ) -> Iterable[Distribution]: 471 | """Return an iterable of Distribution objects for all packages. 472 | 473 | Pass a ``context`` or pass keyword arguments for constructing 474 | a context. 475 | 476 | :context: A ``DistributionFinder.Context`` object. 477 | :return: Iterable of Distribution objects for packages matching 478 | the context. 479 | """ 480 | if context and kwargs: 481 | raise ValueError("cannot accept context and kwargs") 482 | context = context or DistributionFinder.Context(**kwargs) 483 | return itertools.chain.from_iterable( 484 | resolver(context) for resolver in cls._discover_resolvers() 485 | ) 486 | 487 | @staticmethod 488 | def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]: 489 | """ 490 | Prefer (move to the front) distributions that have metadata. 491 | 492 | Ref python/importlib_resources#489. 493 | """ 494 | buckets = bucket(dists, lambda dist: bool(dist.metadata)) 495 | return itertools.chain(buckets[True], buckets[False]) 496 | 497 | @staticmethod 498 | def at(path: str | os.PathLike[str]) -> Distribution: 499 | """Return a Distribution for the indicated metadata path. 500 | 501 | :param path: a string or path-like object 502 | :return: a concrete Distribution instance for the path 503 | """ 504 | return PathDistribution(pathlib.Path(path)) 505 | 506 | @staticmethod 507 | def _discover_resolvers(): 508 | """Search the meta_path for resolvers (MetadataPathFinders).""" 509 | declared = ( 510 | getattr(finder, 'find_distributions', None) for finder in sys.meta_path 511 | ) 512 | return filter(None, declared) 513 | 514 | @property 515 | def metadata(self) -> _meta.PackageMetadata | None: 516 | """Return the parsed metadata for this Distribution. 517 | 518 | The returned object will have keys that name the various bits of 519 | metadata per the 520 | `Core metadata specifications `_. 521 | 522 | Custom providers may provide the METADATA file or override this 523 | property. 524 | """ 525 | 526 | text = ( 527 | self.read_text('METADATA') 528 | or self.read_text('PKG-INFO') 529 | # This last clause is here to support old egg-info files. Its 530 | # effect is to just end up using the PathDistribution's self._path 531 | # (which points to the egg-info file) attribute unchanged. 532 | or self.read_text('') 533 | ) 534 | return self._assemble_message(text) 535 | 536 | @staticmethod 537 | @pass_none 538 | def _assemble_message(text: str) -> _meta.PackageMetadata: 539 | # deferred for performance (python/cpython#109829) 540 | from . import _adapters 541 | 542 | return _adapters.Message(email.message_from_string(text)) 543 | 544 | @property 545 | def name(self) -> str: 546 | """Return the 'Name' metadata for the distribution package.""" 547 | return md_none(self.metadata)['Name'] 548 | 549 | @property 550 | def _normalized_name(self): 551 | """Return a normalized version of the name.""" 552 | return Prepared.normalize(self.name) 553 | 554 | @property 555 | def version(self) -> str: 556 | """Return the 'Version' metadata for the distribution package.""" 557 | return md_none(self.metadata)['Version'] 558 | 559 | @property 560 | def entry_points(self) -> EntryPoints: 561 | """ 562 | Return EntryPoints for this distribution. 563 | 564 | Custom providers may provide the ``entry_points.txt`` file 565 | or override this property. 566 | """ 567 | return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) 568 | 569 | @property 570 | def files(self) -> list[PackagePath] | None: 571 | """Files in this distribution. 572 | 573 | :return: List of PackagePath for this distribution or None 574 | 575 | Result is `None` if the metadata file that enumerates files 576 | (i.e. RECORD for dist-info, or installed-files.txt or 577 | SOURCES.txt for egg-info) is missing. 578 | Result may be empty if the metadata exists but is empty. 579 | 580 | Custom providers are recommended to provide a "RECORD" file (in 581 | ``read_text``) or override this property to allow for callers to be 582 | able to resolve filenames provided by the package. 583 | """ 584 | 585 | def make_file(name, hash=None, size_str=None): 586 | result = PackagePath(name) 587 | result.hash = FileHash(hash) if hash else None 588 | result.size = int(size_str) if size_str else None 589 | result.dist = self 590 | return result 591 | 592 | @pass_none 593 | def make_files(lines): 594 | # Delay csv import, since Distribution.files is not as widely used 595 | # as other parts of importlib.metadata 596 | import csv 597 | 598 | return starmap(make_file, csv.reader(lines)) 599 | 600 | @pass_none 601 | def skip_missing_files(package_paths): 602 | return list(filter(lambda path: path.locate().exists(), package_paths)) 603 | 604 | return skip_missing_files( 605 | make_files( 606 | self._read_files_distinfo() 607 | or self._read_files_egginfo_installed() 608 | or self._read_files_egginfo_sources() 609 | ) 610 | ) 611 | 612 | def _read_files_distinfo(self): 613 | """ 614 | Read the lines of RECORD. 615 | """ 616 | text = self.read_text('RECORD') 617 | return text and text.splitlines() 618 | 619 | def _read_files_egginfo_installed(self): 620 | """ 621 | Read installed-files.txt and return lines in a similar 622 | CSV-parsable format as RECORD: each file must be placed 623 | relative to the site-packages directory and must also be 624 | quoted (since file names can contain literal commas). 625 | 626 | This file is written when the package is installed by pip, 627 | but it might not be written for other installation methods. 628 | Assume the file is accurate if it exists. 629 | """ 630 | text = self.read_text('installed-files.txt') 631 | # Prepend the .egg-info/ subdir to the lines in this file. 632 | # But this subdir is only available from PathDistribution's 633 | # self._path. 634 | subdir = getattr(self, '_path', None) 635 | if not text or not subdir: 636 | return 637 | 638 | paths = ( 639 | py311 640 | .relative_fix((subdir / name).resolve()) 641 | .relative_to(self.locate_file('').resolve(), walk_up=True) 642 | .as_posix() 643 | for name in text.splitlines() 644 | ) 645 | return map('"{}"'.format, paths) 646 | 647 | def _read_files_egginfo_sources(self): 648 | """ 649 | Read SOURCES.txt and return lines in a similar CSV-parsable 650 | format as RECORD: each file name must be quoted (since it 651 | might contain literal commas). 652 | 653 | Note that SOURCES.txt is not a reliable source for what 654 | files are installed by a package. This file is generated 655 | for a source archive, and the files that are present 656 | there (e.g. setup.py) may not correctly reflect the files 657 | that are present after the package has been installed. 658 | """ 659 | text = self.read_text('SOURCES.txt') 660 | return text and map('"{}"'.format, text.splitlines()) 661 | 662 | @property 663 | def requires(self) -> list[str] | None: 664 | """Generated requirements specified for this Distribution""" 665 | reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs() 666 | return reqs and list(reqs) 667 | 668 | def _read_dist_info_reqs(self): 669 | return self.metadata.get_all('Requires-Dist') 670 | 671 | def _read_egg_info_reqs(self): 672 | source = self.read_text('requires.txt') 673 | return pass_none(self._deps_from_requires_text)(source) 674 | 675 | @classmethod 676 | def _deps_from_requires_text(cls, source): 677 | return cls._convert_egg_info_reqs_to_simple_reqs(Sectioned.read(source)) 678 | 679 | @staticmethod 680 | def _convert_egg_info_reqs_to_simple_reqs(sections): 681 | """ 682 | Historically, setuptools would solicit and store 'extra' 683 | requirements, including those with environment markers, 684 | in separate sections. More modern tools expect each 685 | dependency to be defined separately, with any relevant 686 | extras and environment markers attached directly to that 687 | requirement. This method converts the former to the 688 | latter. See _test_deps_from_requires_text for an example. 689 | """ 690 | 691 | def make_condition(name): 692 | return name and f'extra == "{name}"' 693 | 694 | def quoted_marker(section): 695 | section = section or '' 696 | extra, sep, markers = section.partition(':') 697 | if extra and markers: 698 | markers = f'({markers})' 699 | conditions = list(filter(None, [markers, make_condition(extra)])) 700 | return '; ' + ' and '.join(conditions) if conditions else '' 701 | 702 | def url_req_space(req): 703 | """ 704 | PEP 508 requires a space between the url_spec and the quoted_marker. 705 | Ref python/importlib_metadata#357. 706 | """ 707 | # '@' is uniquely indicative of a url_req. 708 | return ' ' * ('@' in req) 709 | 710 | for section in sections: 711 | space = url_req_space(section.value) 712 | yield section.value + space + quoted_marker(section.name) 713 | 714 | @property 715 | def origin(self): 716 | return self._load_json('direct_url.json') 717 | 718 | def _load_json(self, filename): 719 | # Deferred for performance (python/importlib_metadata#503) 720 | import json 721 | 722 | return pass_none(json.loads)( 723 | self.read_text(filename), 724 | object_hook=lambda data: types.SimpleNamespace(**data), 725 | ) 726 | 727 | 728 | class DistributionFinder(MetaPathFinder): 729 | """ 730 | A MetaPathFinder capable of discovering installed distributions. 731 | 732 | Custom providers should implement this interface in order to 733 | supply metadata. 734 | """ 735 | 736 | class Context: 737 | """ 738 | Keyword arguments presented by the caller to 739 | ``distributions()`` or ``Distribution.discover()`` 740 | to narrow the scope of a search for distributions 741 | in all DistributionFinders. 742 | 743 | Each DistributionFinder may expect any parameters 744 | and should attempt to honor the canonical 745 | parameters defined below when appropriate. 746 | 747 | This mechanism gives a custom provider a means to 748 | solicit additional details from the caller beyond 749 | "name" and "path" when searching distributions. 750 | For example, imagine a provider that exposes suites 751 | of packages in either a "public" or "private" ``realm``. 752 | A caller may wish to query only for distributions in 753 | a particular realm and could call 754 | ``distributions(realm="private")`` to signal to the 755 | custom provider to only include distributions from that 756 | realm. 757 | """ 758 | 759 | name = None 760 | """ 761 | Specific name for which a distribution finder should match. 762 | A name of ``None`` matches all distributions. 763 | """ 764 | 765 | def __init__(self, **kwargs): 766 | vars(self).update(kwargs) 767 | 768 | @property 769 | def path(self) -> list[str]: 770 | """ 771 | The sequence of directory path that a distribution finder 772 | should search. 773 | 774 | Typically refers to Python installed package paths such as 775 | "site-packages" directories and defaults to ``sys.path``. 776 | """ 777 | return vars(self).get('path', sys.path) 778 | 779 | @abc.abstractmethod 780 | def find_distributions(self, context=Context()) -> Iterable[Distribution]: 781 | """ 782 | Find distributions. 783 | 784 | Return an iterable of all Distribution instances capable of 785 | loading the metadata for packages matching the ``context``, 786 | a DistributionFinder.Context instance. 787 | """ 788 | 789 | 790 | @passthrough 791 | def _clear_after_fork(cached): 792 | """Ensure ``func`` clears cached state after ``fork`` when supported. 793 | 794 | ``FastPath`` caches zip-backed ``pathlib.Path`` objects that retain a 795 | reference to the parent's open ``ZipFile`` handle. Re-using a cached 796 | instance in a forked child can therefore resurrect invalid file pointers 797 | and trigger ``BadZipFile``/``OSError`` failures (python/importlib_metadata#520). 798 | Registering ``cache_clear`` with ``os.register_at_fork`` keeps each process 799 | on its own cache. 800 | """ 801 | getattr(os, 'register_at_fork', noop)(after_in_child=cached.cache_clear) 802 | 803 | 804 | class FastPath: 805 | """ 806 | Micro-optimized class for searching a root for children. 807 | 808 | Root is a path on the file system that may contain metadata 809 | directories either as natural directories or within a zip file. 810 | 811 | >>> FastPath('').children() 812 | ['...'] 813 | 814 | FastPath objects are cached and recycled for any given root. 815 | 816 | >>> FastPath('foobar') is FastPath('foobar') 817 | True 818 | """ 819 | 820 | @_clear_after_fork # type: ignore[misc] 821 | @functools.lru_cache() 822 | def __new__(cls, root): 823 | return super().__new__(cls) 824 | 825 | def __init__(self, root): 826 | self.root = root 827 | 828 | def joinpath(self, child): 829 | return pathlib.Path(self.root, child) 830 | 831 | def children(self): 832 | with suppress(Exception): 833 | return os.listdir(self.root or '.') 834 | with suppress(Exception): 835 | return self.zip_children() 836 | return [] 837 | 838 | def zip_children(self): 839 | # deferred for performance (python/importlib_metadata#502) 840 | from zipp.compat.overlay import zipfile 841 | 842 | zip_path = zipfile.Path(self.root) 843 | names = zip_path.root.namelist() 844 | self.joinpath = zip_path.joinpath 845 | 846 | return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names) 847 | 848 | def search(self, name): 849 | return self.lookup(self.mtime).search(name) 850 | 851 | @property 852 | def mtime(self): 853 | with suppress(OSError): 854 | return os.stat(self.root).st_mtime 855 | self.lookup.cache_clear() 856 | 857 | @method_cache 858 | def lookup(self, mtime): 859 | return Lookup(self) 860 | 861 | 862 | class Lookup: 863 | """ 864 | A micro-optimized class for searching a (fast) path for metadata. 865 | """ 866 | 867 | def __init__(self, path: FastPath): 868 | """ 869 | Calculate all of the children representing metadata. 870 | 871 | From the children in the path, calculate early all of the 872 | children that appear to represent metadata (infos) or legacy 873 | metadata (eggs). 874 | """ 875 | 876 | base = os.path.basename(path.root).lower() 877 | base_is_egg = base.endswith(".egg") 878 | self.infos = FreezableDefaultDict(list) 879 | self.eggs = FreezableDefaultDict(list) 880 | 881 | for child in path.children(): 882 | low = child.lower() 883 | if low.endswith((".dist-info", ".egg-info")): 884 | # rpartition is faster than splitext and suitable for this purpose. 885 | name = low.rpartition(".")[0].partition("-")[0] 886 | normalized = Prepared.normalize(name) 887 | self.infos[normalized].append(path.joinpath(child)) 888 | elif base_is_egg and low == "egg-info": 889 | name = base.rpartition(".")[0].partition("-")[0] 890 | legacy_normalized = Prepared.legacy_normalize(name) 891 | self.eggs[legacy_normalized].append(path.joinpath(child)) 892 | 893 | self.infos.freeze() 894 | self.eggs.freeze() 895 | 896 | def search(self, prepared: Prepared): 897 | """ 898 | Yield all infos and eggs matching the Prepared query. 899 | """ 900 | infos = ( 901 | self.infos[prepared.normalized] 902 | if prepared 903 | else itertools.chain.from_iterable(self.infos.values()) 904 | ) 905 | eggs = ( 906 | self.eggs[prepared.legacy_normalized] 907 | if prepared 908 | else itertools.chain.from_iterable(self.eggs.values()) 909 | ) 910 | return itertools.chain(infos, eggs) 911 | 912 | 913 | class Prepared: 914 | """ 915 | A prepared search query for metadata on a possibly-named package. 916 | 917 | Pre-calculates the normalization to prevent repeated operations. 918 | 919 | >>> none = Prepared(None) 920 | >>> none.normalized 921 | >>> none.legacy_normalized 922 | >>> bool(none) 923 | False 924 | >>> sample = Prepared('Sample__Pkg-name.foo') 925 | >>> sample.normalized 926 | 'sample_pkg_name_foo' 927 | >>> sample.legacy_normalized 928 | 'sample__pkg_name.foo' 929 | >>> bool(sample) 930 | True 931 | """ 932 | 933 | normalized = None 934 | legacy_normalized = None 935 | 936 | def __init__(self, name: str | None): 937 | self.name = name 938 | if name is None: 939 | return 940 | self.normalized = self.normalize(name) 941 | self.legacy_normalized = self.legacy_normalize(name) 942 | 943 | @staticmethod 944 | def normalize(name): 945 | """ 946 | PEP 503 normalization plus dashes as underscores. 947 | """ 948 | return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_') 949 | 950 | @staticmethod 951 | def legacy_normalize(name): 952 | """ 953 | Normalize the package name as found in the convention in 954 | older packaging tools versions and specs. 955 | """ 956 | return name.lower().replace('-', '_') 957 | 958 | def __bool__(self): 959 | return bool(self.name) 960 | 961 | 962 | @install 963 | class MetadataPathFinder(NullFinder, DistributionFinder): 964 | """A degenerate finder for distribution packages on the file system. 965 | 966 | This finder supplies only a find_distributions() method for versions 967 | of Python that do not have a PathFinder find_distributions(). 968 | """ 969 | 970 | @classmethod 971 | def find_distributions( 972 | cls, context=DistributionFinder.Context() 973 | ) -> Iterable[PathDistribution]: 974 | """ 975 | Find distributions. 976 | 977 | Return an iterable of all Distribution instances capable of 978 | loading the metadata for packages matching ``context.name`` 979 | (or all names if ``None`` indicated) along the paths in the list 980 | of directories ``context.path``. 981 | """ 982 | found = cls._search_paths(context.name, context.path) 983 | return map(PathDistribution, found) 984 | 985 | @classmethod 986 | def _search_paths(cls, name, paths): 987 | """Find metadata directories in paths heuristically.""" 988 | prepared = Prepared(name) 989 | return itertools.chain.from_iterable( 990 | path.search(prepared) for path in map(FastPath, paths) 991 | ) 992 | 993 | @classmethod 994 | def invalidate_caches(cls) -> None: 995 | FastPath.__new__.cache_clear() 996 | 997 | 998 | class PathDistribution(Distribution): 999 | def __init__(self, path: SimplePath) -> None: 1000 | """Construct a distribution. 1001 | 1002 | :param path: SimplePath indicating the metadata directory. 1003 | """ 1004 | self._path = path 1005 | 1006 | def read_text(self, filename: str | os.PathLike[str]) -> str | None: 1007 | with suppress( 1008 | FileNotFoundError, 1009 | IsADirectoryError, 1010 | KeyError, 1011 | NotADirectoryError, 1012 | PermissionError, 1013 | ): 1014 | return self._path.joinpath(filename).read_text(encoding='utf-8') 1015 | 1016 | return None 1017 | 1018 | read_text.__doc__ = Distribution.read_text.__doc__ 1019 | 1020 | def locate_file(self, path: str | os.PathLike[str]) -> SimplePath: 1021 | return self._path.parent / path 1022 | 1023 | @property 1024 | def _normalized_name(self): 1025 | """ 1026 | Performance optimization: where possible, resolve the 1027 | normalized name from the file system path. 1028 | """ 1029 | stem = os.path.basename(str(self._path)) 1030 | return ( 1031 | pass_none(Prepared.normalize)(self._name_from_stem(stem)) 1032 | or super()._normalized_name 1033 | ) 1034 | 1035 | @staticmethod 1036 | def _name_from_stem(stem): 1037 | """ 1038 | >>> PathDistribution._name_from_stem('foo-3.0.egg-info') 1039 | 'foo' 1040 | >>> PathDistribution._name_from_stem('CherryPy-3.0.dist-info') 1041 | 'CherryPy' 1042 | >>> PathDistribution._name_from_stem('face.egg-info') 1043 | 'face' 1044 | >>> PathDistribution._name_from_stem('foo.bar') 1045 | """ 1046 | filename, ext = os.path.splitext(stem) 1047 | if ext not in ('.dist-info', '.egg-info'): 1048 | return 1049 | name, sep, rest = filename.partition('-') 1050 | return name 1051 | 1052 | 1053 | def distribution(distribution_name: str) -> Distribution: 1054 | """Get the ``Distribution`` instance for the named package. 1055 | 1056 | :param distribution_name: The name of the distribution package as a string. 1057 | :return: A ``Distribution`` instance (or subclass thereof). 1058 | """ 1059 | return Distribution.from_name(distribution_name) 1060 | 1061 | 1062 | def distributions(**kwargs) -> Iterable[Distribution]: 1063 | """Get all ``Distribution`` instances in the current environment. 1064 | 1065 | :return: An iterable of ``Distribution`` instances. 1066 | """ 1067 | return Distribution.discover(**kwargs) 1068 | 1069 | 1070 | def metadata(distribution_name: str) -> _meta.PackageMetadata | None: 1071 | """Get the metadata for the named package. 1072 | 1073 | :param distribution_name: The name of the distribution package to query. 1074 | :return: A PackageMetadata containing the parsed metadata. 1075 | """ 1076 | return Distribution.from_name(distribution_name).metadata 1077 | 1078 | 1079 | def version(distribution_name: str) -> str: 1080 | """Get the version string for the named package. 1081 | 1082 | :param distribution_name: The name of the distribution package to query. 1083 | :return: The version string for the package as defined in the package's 1084 | "Version" metadata key. 1085 | """ 1086 | return distribution(distribution_name).version 1087 | 1088 | 1089 | _unique = functools.partial( 1090 | unique_everseen, 1091 | key=py39.normalized_name, 1092 | ) 1093 | """ 1094 | Wrapper for ``distributions`` to return unique distributions by name. 1095 | """ 1096 | 1097 | 1098 | def entry_points(**params) -> EntryPoints: 1099 | """Return EntryPoint objects for all installed packages. 1100 | 1101 | Pass selection parameters (group or name) to filter the 1102 | result to entry points matching those properties (see 1103 | EntryPoints.select()). 1104 | 1105 | :return: EntryPoints for all installed packages. 1106 | """ 1107 | eps = itertools.chain.from_iterable( 1108 | dist.entry_points for dist in _unique(distributions()) 1109 | ) 1110 | return EntryPoints(eps).select(**params) 1111 | 1112 | 1113 | def files(distribution_name: str) -> list[PackagePath] | None: 1114 | """Return a list of files for the named package. 1115 | 1116 | :param distribution_name: The name of the distribution package to query. 1117 | :return: List of files composing the distribution. 1118 | """ 1119 | return distribution(distribution_name).files 1120 | 1121 | 1122 | def requires(distribution_name: str) -> list[str] | None: 1123 | """ 1124 | Return a list of requirements for the named package. 1125 | 1126 | :return: An iterable of requirements, suitable for 1127 | packaging.requirement.Requirement. 1128 | """ 1129 | return distribution(distribution_name).requires 1130 | 1131 | 1132 | def packages_distributions() -> Mapping[str, list[str]]: 1133 | """ 1134 | Return a mapping of top-level packages to their 1135 | distributions. 1136 | 1137 | >>> import collections.abc 1138 | >>> pkgs = packages_distributions() 1139 | >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values()) 1140 | True 1141 | """ 1142 | pkg_to_dist = collections.defaultdict(list) 1143 | for dist in distributions(): 1144 | for pkg in _top_level_declared(dist) or _top_level_inferred(dist): 1145 | pkg_to_dist[pkg].append(md_none(dist.metadata)['Name']) 1146 | return dict(pkg_to_dist) 1147 | 1148 | 1149 | def _top_level_declared(dist): 1150 | return (dist.read_text('top_level.txt') or '').split() 1151 | 1152 | 1153 | def _topmost(name: PackagePath) -> str | None: 1154 | """ 1155 | Return the top-most parent as long as there is a parent. 1156 | """ 1157 | top, *rest = name.parts 1158 | return top if rest else None 1159 | 1160 | 1161 | def _get_toplevel_name(name: PackagePath) -> str: 1162 | """ 1163 | Infer a possibly importable module name from a name presumed on 1164 | sys.path. 1165 | 1166 | >>> _get_toplevel_name(PackagePath('foo.py')) 1167 | 'foo' 1168 | >>> _get_toplevel_name(PackagePath('foo')) 1169 | 'foo' 1170 | >>> _get_toplevel_name(PackagePath('foo.pyc')) 1171 | 'foo' 1172 | >>> _get_toplevel_name(PackagePath('foo/__init__.py')) 1173 | 'foo' 1174 | >>> _get_toplevel_name(PackagePath('foo.pth')) 1175 | 'foo.pth' 1176 | >>> _get_toplevel_name(PackagePath('foo.dist-info')) 1177 | 'foo.dist-info' 1178 | """ 1179 | # Defer import of inspect for performance (python/cpython#118761) 1180 | import inspect 1181 | 1182 | return _topmost(name) or inspect.getmodulename(name) or str(name) 1183 | 1184 | 1185 | def _top_level_inferred(dist): 1186 | opt_names = set(map(_get_toplevel_name, always_iterable(dist.files))) 1187 | 1188 | def importable_name(name): 1189 | return '.' not in name 1190 | 1191 | return filter(importable_name, opt_names) 1192 | --------------------------------------------------------------------------------