diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..49fe2faf --- /dev/null +++ b/.coveragerc @@ -0,0 +1,11 @@ +[run] +omit = + # leading `*/` for pytest-dev/pytest-cov#456 + */.tox/* + tests/* + prepare/* + */_itertools.py + exercises.py + +[report] +show_missing = True diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b8aeea17 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 4 +insert_final_newline = true +end_of_line = lf + +[*.py] +indent_style = space +max_line_length = 88 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..542d2986 --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max-line-length = 88 + +# jaraco/skeleton#34 +max-complexity = 10 + +extend-ignore = + # Black creates whitespace before colon + E203 +enable-extensions = U4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..89ff3396 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + allow: + - dependency-type: "all" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..2ff3ea6d --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,71 @@ +name: tests + +on: [push, pull_request] + +jobs: + test: + strategy: + matrix: + python: + - 3.6 + - 3.9 + - "3.10" + platform: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v2 + with: + # fetch all branches and tags (to get tags for versioning) + # ref actions/checkout#448 + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: Install tox + run: | + python -m pip install tox + - name: Run tests + run: tox + + diffcov: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install tox + run: | + python -m pip install tox + - name: Evaluate coverage + run: tox + env: + TOXENV: diffcov + + release: + needs: test + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: "3.10" + - name: Install tox + run: | + python -m pip install tox + - name: Release + run: tox -e release + env: + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index a8d1bd28..00000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,50 +0,0 @@ -image: quay.io/python-devs/ci-image - -stages: - - test - - qa - - docs - - codecov - - deploy - -qa: - script: - - tox -e qa - -tests: - script: - - tox -e py27,py35,py36,py37,py38 - -coverage: - script: - - tox -e py27-cov,py35-cov,py36-cov,py37-cov,py38-cov - artifacts: - paths: - - coverage.xml - -benchmark: - script: - - tox -e perf - -diffcov: - script: - - tox -e py27-diffcov,py35-diffcov,py36-diffcov,py37-diffcov,py38-diffcov - -docs: - script: - - tox -e docs - -codecov: - stage: codecov - dependencies: - - coverage - script: - - codecov - when: on_success - -release: - stage: deploy - only: - - /^v\d+\.\d+(\.\d+)?([abc]\d*)?$/ - script: - - tox -e release diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..f66bf563 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: +- repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black diff --git a/.readthedocs.yml b/.readthedocs.yml index 8ae44684..cc698548 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,5 +1,6 @@ +version: 2 python: - version: 3 - extra_requirements: - - docs - pip_install: true + install: + - path: . + extra_requirements: + - docs diff --git a/importlib_metadata/docs/changelog.rst b/CHANGES.rst similarity index 51% rename from importlib_metadata/docs/changelog.rst rename to CHANGES.rst index b7e93b5d..0d319d58 100644 --- a/importlib_metadata/docs/changelog.rst +++ b/CHANGES.rst @@ -1,6 +1,344 @@ -========================= - importlib_metadata NEWS -========================= +v4.8.3 +====== + +* #357: Fixed requirement generation from egg-info when a + URL requirement is given. + +v4.8.2 +====== + +v2.1.2 +====== + +* #353: Fixed discovery of distributions when path is empty. + +v4.8.1 +====== + +* #348: Restored support for ``EntryPoint`` access by item, + deprecating support in the process. Users are advised + to use direct member access instead of item-based access:: + + - ep[0] -> ep.name + - ep[1] -> ep.value + - ep[2] -> ep.group + - ep[:] -> ep.name, ep.value, ep.group + +v4.8.0 +====== + +* #337: Rewrote ``EntryPoint`` as a simple class, still + immutable and still with the attributes, but without any + expectation for ``namedtuple`` functionality such as + ``_asdict``. + +v4.7.1 +====== + +* #344: Fixed regression in ``packages_distributions`` when + neither top-level.txt nor a files manifest is present. + +v4.7.0 +====== + +* #330: In ``packages_distributions``, now infer top-level + names from ``.files()`` when a ``top-level.txt`` + (Setuptools-specific metadata) is not present. + +v4.6.4 +====== + +* #334: Correct ``SimplePath`` protocol to match ``pathlib`` + protocol for ``__truediv__``. + +v4.6.3 +====== + +* Moved workaround for #327 to ``_compat`` module. + +v4.6.2 +====== + +* bpo-44784: Avoid errors in test suite when + DeprecationWarnings are treated as errors. + +v4.6.1 +====== + +* #327: Deprecation warnings now honor call stack variance + on PyPy. + +v4.6.0 +====== + +* #326: Performance tests now rely on + `pytest-perf `_. + To disable these tests, which require network access + and a git checkout, pass ``-p no:perf`` to pytest. + +v4.5.0 +====== + +* #319: Remove ``SelectableGroups`` deprecation exception + for flake8. + +v4.4.0 +====== + +* #300: Restore compatibility in the result from + ``Distribution.entry_points`` (``EntryPoints``) to honor + expectations in older implementations and issuing + deprecation warnings for these cases: + + - ``EntryPoints`` objects are once again mutable, allowing + for ``sort()`` and other list-based mutation operations. + Avoid deprecation warnings by casting to a + mutable sequence (e.g. + ``list(dist.entry_points).sort()``). + + - ``EntryPoints`` results once again allow + for access by index. To avoid deprecation warnings, + cast the result to a Sequence first + (e.g. ``tuple(dist.entry_points)[0]``). + +v4.3.1 +====== + +* #320: Fix issue where normalized name for eggs was + incorrectly solicited, leading to metadata being + unavailable for eggs. + +v4.3.0 +====== + +* #317: De-duplication of distributions no longer requires + loading the full metadata for ``PathDistribution`` objects, + entry point loading performance by ~10x. + +v4.2.0 +====== + +* Prefer f-strings to ``.format`` calls. + +v4.1.0 +====== + +* #312: Add support for metadata 2.2 (``Dynamic`` field). + +* #315: Add ``SimplePath`` protocol for interface clarity + in ``PathDistribution``. + +v4.0.1 +====== + +* #306: Clearer guidance about compatibility in readme. + +v4.0.0 +====== + +* #304: ``PackageMetadata`` as returned by ``metadata()`` + and ``Distribution.metadata()`` now provides normalized + metadata honoring PEP 566: + + - If a long description is provided in the payload of the + RFC 822 value, it can be retrieved as the ``Description`` + field. + - Any multi-line values in the metadata will be returned as + such. + - For any multi-line values, line continuation characters + are removed. This backward-incompatible change means + that any projects relying on the RFC 822 line continuation + characters being present must be tolerant to them having + been removed. + - Add a ``json`` property that provides the metadata + converted to a JSON-compatible form per PEP 566. + + +v3.10.1 +======= + +* Minor tweaks from CPython. + +v3.10.0 +======= + +* #295: Internal refactoring to unify section parsing logic. + +v3.9.1 +====== + +* #296: Exclude 'prepare' package. +* #297: Fix ValueError when entry points contains comments. + +v3.9.0 +====== + +* Use of Mapping (dict) interfaces on ``SelectableGroups`` + is now flagged as deprecated. Instead, users are advised + to use the select interface for future compatibility. + + Suppress the warning with this filter: + ``ignore:SelectableGroups dict interface``. + + Or with this invocation in the Python environment: + ``warnings.filterwarnings('ignore', 'SelectableGroups dict interface')``. + + Preferably, switch to the ``select`` interface introduced + in 3.7.0. See the + `entry points documentation `_ and changelog for the 3.6 + release below for more detail. + + For some use-cases, especially those that rely on + ``importlib.metadata`` in Python 3.8 and 3.9 or + those relying on older ``importlib_metadata`` (especially + on Python 3.5 and earlier), + `backports.entry_points_selectable `_ + was created to ease the transition. Please have a look + at that project if simply relying on importlib_metadata 3.6+ + is not straightforward. Background in #298. + +* #283: Entry point parsing no longer relies on ConfigParser + and instead uses a custom, one-pass parser to load the + config, resulting in a ~20% performance improvement when + loading entry points. + +v3.8.2 +====== + +* #293: Re-enabled lazy evaluation of path lookup through + a FreezableDefaultDict. + +v3.8.1 +====== + +* #293: Workaround for error in distribution search. + +v3.8.0 +====== + +* #290: Add mtime-based caching for ``FastPath`` and its + lookups, dramatically increasing performance for repeated + distribution lookups. + +v3.7.3 +====== + +* Docs enhancements and cleanup following review in + `GH-24782 `_. + +v3.7.2 +====== + +* Cleaned up cruft in entry_points docstring. + +v3.7.1 +====== + +* Internal refactoring to facilitate ``entry_points() -> dict`` + deprecation. + +v3.7.0 +====== + +* #131: Added ``packages_distributions`` to conveniently + resolve a top-level package or module to its distribution(s). + +v3.6.0 +====== + +* #284: Introduces new ``EntryPoints`` object, a tuple of + ``EntryPoint`` objects but with convenience properties for + selecting and inspecting the results: + + - ``.select()`` accepts ``group`` or ``name`` keyword + parameters and returns a new ``EntryPoints`` tuple + with only those that match the selection. + - ``.groups`` property presents all of the group names. + - ``.names`` property presents the names of the entry points. + - Item access (e.g. ``eps[name]``) retrieves a single + entry point by name. + + ``entry_points`` now accepts "selection parameters", + same as ``EntryPoint.select()``. + + ``entry_points()`` now provides a future-compatible + ``SelectableGroups`` object that supplies the above interface + (except item access) but remains a dict for compatibility. + + In the future, ``entry_points()`` will return an + ``EntryPoints`` object for all entry points. + + If passing selection parameters to ``entry_points``, the + future behavior is invoked and an ``EntryPoints`` is the + result. + +* #284: Construction of entry points using + ``dict([EntryPoint, ...])`` is now deprecated and raises + an appropriate DeprecationWarning and will be removed in + a future version. + +* #300: ``Distribution.entry_points`` now presents as an + ``EntryPoints`` object and access by index is no longer + allowed. If access by index is required, cast to a sequence + first. + +v3.5.0 +====== + +* #280: ``entry_points`` now only returns entry points for + unique distributions (by name). + +v3.4.0 +====== + +* #10: Project now declares itself as being typed. +* #272: Additional performance enhancements to distribution + discovery. +* #111: For PyPA projects, add test ensuring that + ``MetadataPathFinder._search_paths`` honors the needed + interface. Method is still private. + +v3.3.0 +====== + +* #265: ``EntryPoint`` objects now expose a ``.dist`` object + referencing the ``Distribution`` when constructed from a + Distribution. + +v3.2.0 +====== + +* The object returned by ``metadata()`` now has a + formally-defined protocol called ``PackageMetadata`` + with declared support for the ``.get_all()`` method. + Fixes #126. + +v3.1.1 +====== + +v2.1.1 +====== + +* #261: Restored compatibility for package discovery for + metadata without version in the name and for legacy + eggs. + +v3.1.0 +====== + +* Merge with 2.1.0. + +v2.1.0 +====== + +* #253: When querying for package metadata, the lookup + now honors + `package normalization rules `_. + +v3.0.0 +====== + +* Require Python 3.6 or later. v2.0.0 ====== @@ -122,6 +460,7 @@ v1.0.0 0.23 ==== + * Added a compatibility shim to prevent failures on beta releases of Python before the signature changed to accept the "context" parameter on find_distributions. This workaround @@ -130,6 +469,7 @@ v1.0.0 0.22 ==== + * Renamed ``package`` parameter to ``distribution_name`` as `recommended `_ in the following functions: ``distribution``, ``metadata``, @@ -140,6 +480,7 @@ v1.0.0 0.21 ==== + * ``importlib.metadata`` now exposes the ``DistributionFinder`` metaclass and references it in the docs for extending the search algorithm. @@ -157,6 +498,7 @@ v1.0.0 0.20 ==== + * Clarify in the docs that calls to ``.files`` could return ``None`` when the metadata is not present. Closes #69. * Return all requirements and not just the first for dist-info @@ -164,28 +506,34 @@ v1.0.0 0.19 ==== + * Restrain over-eager egg metadata resolution. * Add support for entry points with colons in the name. Closes #75. 0.18 ==== + * Parse entry points case sensitively. Closes #68 * Add a version constraint on the backport configparser package. Closes #66 0.17 ==== + * Fix a permission problem in the tests on Windows. 0.16 ==== + * Don't crash if there exists an EGG-INFO directory on sys.path. 0.15 ==== + * Fix documentation. 0.14 ==== + * Removed ``local_distribution`` function from the API. **This backward-incompatible change removes this behavior summarily**. Projects should remove their @@ -195,21 +543,25 @@ v1.0.0 0.13 ==== + * Update docstrings to match PEP 8. Closes #63. * Merged modules into one module. Closes #62. 0.12 ==== + * Add support for eggs. !65; Closes #19. 0.11 ==== + * Support generic zip files (not just wheels). Closes #59 * Support zip files with multiple distributions in them. Closes #60 * Fully expose the public API in ``importlib_metadata.__all__``. 0.10 ==== + * The ``Distribution`` ABC is now officially part of the public API. Closes #37. * Fixed support for older single file egg-info formats. Closes #43. @@ -218,6 +570,7 @@ v1.0.0 0.9 === + * Fixed issue where entry points without an attribute would raise an Exception. Closes #40. * Removed unused ``name`` parameter from ``entry_points()``. Closes #44. @@ -226,6 +579,7 @@ v1.0.0 0.8 === + * This library can now discover/enumerate all installed packages. **This backward-incompatible change alters the protocol finders must implement to support distribution package discovery.** Closes #24. @@ -254,6 +608,7 @@ v1.0.0 0.7 === + * Fixed issue where packages with dashes in their names would not be discovered. Closes #21. * Distribution lookup is now case-insensitive. Closes #20. @@ -263,6 +618,7 @@ v1.0.0 0.6 === + * Removed ``importlib_metadata.distribution`` function. Now the public interface is primarily the utility functions exposed in ``importlib_metadata.__all__``. Closes #14. @@ -271,6 +627,7 @@ v1.0.0 0.5 === + * Updated README and removed details about Distribution class, now considered private. Closes #15. * Added test suite support for Python 3.4+. @@ -279,21 +636,25 @@ v1.0.0 0.4 === + * Housekeeping. 0.3 === + * Added usage documentation. Closes #8 * Add support for getting metadata from wheels on ``sys.path``. Closes #9 0.2 === + * Added ``importlib_metadata.entry_points()``. Closes #1 * Added ``importlib_metadata.resolve()``. Closes #12 * Add support for Python 2.7. Closes #4 0.1 === + * Initial release. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 3fcf6d63..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include *.py MANIFEST.in LICENSE README.rst -global-include *.txt *.rst *.ini *.cfg *.toml *.whl *.egg -exclude .gitignore -prune build -prune .tox diff --git a/README.rst b/README.rst index 2bdd4b8a..9f8c8670 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,49 @@ -========================= - ``importlib_metadata`` -========================= +.. image:: https://img.shields.io/pypi/v/importlib_metadata.svg + :target: `PyPI link`_ -``importlib_metadata`` is a library to access the metadata for a Python -package. It is intended to be ported to Python 3.8. +.. image:: https://img.shields.io/pypi/pyversions/importlib_metadata.svg + :target: `PyPI link`_ + +.. _PyPI link: https://pypi.org/project/importlib_metadata + +.. image:: https://github.com/python/importlib_metadata/workflows/tests/badge.svg + :target: https://github.com/python/importlib_metadata/actions?query=workflow%3A%22tests%22 + :alt: tests + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/psf/black + :alt: Code style: Black + +.. image:: https://readthedocs.org/projects/importlib-metadata/badge/?version=latest + :target: https://importlib-metadata.readthedocs.io/en/latest/?badge=latest + +.. image:: https://img.shields.io/badge/skeleton-2021-informational + :target: https://blog.jaraco.com/skeleton + + +Library to access the metadata for a Python package. + +This package supplies third-party access to the functionality of +`importlib.metadata `_ +including improvements added to subsequent Python versions. + + +Compatibility +============= + +New features are introduced in this third-party library and later merged +into CPython. The following table indicates which versions of this library +were contributed to different versions in the standard library: + +.. list-table:: + :header-rows: 1 + + * - importlib_metadata + - stdlib + * - 4.4 + - 3.10 + * - 1.4 + - 3.8 Usage @@ -30,7 +70,7 @@ tools (or other conforming packages). It does not support: Project details =============== - * Project home: https://gitlab.com/python-devs/importlib_metadata - * Report bugs at: https://gitlab.com/python-devs/importlib_metadata/issues - * Code hosting: https://gitlab.com/python-devs/importlib_metadata.git - * Documentation: http://importlib_metadata.readthedocs.io/ + * Project home: https://github.com/python/importlib_metadata + * Report bugs at: https://github.com/python/importlib_metadata/issues + * Code hosting: https://github.com/python/importlib_metadata + * Documentation: https://importlib_metadata.readthedocs.io/ diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 66c7f4bd..00000000 --- a/codecov.yml +++ /dev/null @@ -1,2 +0,0 @@ -codecov: - token: 5eb1bc45-1b7f-43e6-8bc1-f2b02833dba9 diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..ab6c8cae --- /dev/null +++ b/conftest.py @@ -0,0 +1,25 @@ +import sys + + +collect_ignore = [ + # this module fails mypy tests because 'setup.py' matches './setup.py' + 'prepare/example/setup.py', +] + + +def pytest_configure(): + remove_importlib_metadata() + + +def remove_importlib_metadata(): + """ + Because pytest imports importlib_metadata, the coverage + reports are broken (#322). So work around the issue by + undoing the changes made by pytest's import of + importlib_metadata (if any). + """ + if sys.meta_path[-1].__class__.__name__ == 'MetadataPathFinder': + del sys.meta_path[-1] + for mod in list(sys.modules): + if mod.startswith('importlib_metadata'): + del sys.modules[mod] diff --git a/coverage.ini b/coverage.ini deleted file mode 100644 index b4d3102f..00000000 --- a/coverage.ini +++ /dev/null @@ -1,24 +0,0 @@ -[run] -branch = true -parallel = true -omit = - setup* - .tox/*/lib/python* - */tests/*.py - */testing/*.py - /usr/local/* - */mod.py -plugins = - coverplug - -[report] -exclude_lines = - pragma: nocover - raise NotImplementedError - raise AssertionError - assert\s - nocoverpy${PYV} - -[paths] -source = - importlib_metadata diff --git a/coverplug.py b/coverplug.py deleted file mode 100644 index 0b0c7cb5..00000000 --- a/coverplug.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Coverage plugin to add exclude lines based on the Python version.""" - -import sys - -from coverage import CoveragePlugin - - -class MyConfigPlugin(CoveragePlugin): - def configure(self, config): - opt_name = 'report:exclude_lines' - exclude_lines = config.get_option(opt_name) - # Python >= 3.6 has os.PathLike. - if sys.version_info >= (3, 6): - exclude_lines.append('pragma: >=36') - else: - exclude_lines.append('pragma: <=35') - config.set_option(opt_name, exclude_lines) - - -def coverage_init(reg, options): - reg.add_configurer(MyConfigPlugin()) diff --git a/importlib_metadata/docs/__init__.py b/docs/__init__.py similarity index 100% rename from importlib_metadata/docs/__init__.py rename to docs/__init__.py diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..02b389ba --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,11 @@ +============= +API Reference +============= + +``importlib_metadata`` module +----------------------------- + +.. automodule:: importlib_metadata + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..96dc2030 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +extensions = ['sphinx.ext.autodoc', 'jaraco.packaging.sphinx', 'rst.linker'] + +master_doc = "index" + +link_files = { + '../CHANGES.rst': dict( + using=dict(GH='https://github.com'), + replace=[ + dict( + pattern=r'(Issue #|\B#)(?P\d+)', + url='{package_url}/issues/{issue}', + ), + dict( + pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', + with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', + ), + dict( + pattern=r'PEP[- ](?P\d+)', + url='https://www.python.org/dev/peps/pep-{pep_number:0>4}/', + ), + ], + ) +} + +# Be strict about any broken references: +nitpicky = True + +# Include Python intersphinx mapping to prevent failures +# jaraco/skeleton#51 +extensions += ['sphinx.ext.intersphinx'] +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), +} + +intersphinx_mapping.update( + importlib_resources=( + 'https://importlib-resources.readthedocs.io/en/latest/', + None, + ), +) + +# Workaround for #316 +nitpick_ignore = [ + ('py:class', 'importlib_metadata.EntryPoints'), + ('py:class', 'importlib_metadata.SelectableGroups'), + ('py:class', 'importlib_metadata._meta._T'), +] diff --git a/docs/history.rst b/docs/history.rst new file mode 100644 index 00000000..8e217503 --- /dev/null +++ b/docs/history.rst @@ -0,0 +1,8 @@ +:tocdepth: 2 + +.. _changes: + +History +******* + +.. include:: ../CHANGES (links).rst diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..ef47c49c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,49 @@ +Welcome to |project| documentation! +=================================== + +``importlib_metadata`` is a library which provides an API for accessing an +installed package's metadata (see :pep:`566`), such as its entry points or its top-level +name. This functionality intends to replace most uses of ``pkg_resources`` +`entry point API`_ and `metadata API`_. Along with :mod:`importlib.resources` +and newer (backported as :doc:`importlib_resources `), +this package can eliminate the need to use the older and less +efficient ``pkg_resources`` package. + +``importlib_metadata`` supplies a backport of +:doc:`importlib.metadata `, +enabling early access to features of future Python versions and making +functionality available for older Python versions. Users are encouraged to +use the Python standard library where suitable and fall back to +this library for future compatibility. Developers looking for detailed API +descriptions should refer to the standard library documentation. + +The documentation here includes a general :ref:`usage ` guide. + + +.. toctree:: + :maxdepth: 1 + + using + api + history + + +Project details +=============== + + * Project home: https://github.com/python/importlib_metadata + * Report bugs at: https://github.com/python/importlib_metadata/issues + * Code hosting: https://github.com/python/importlib_metadata + * Documentation: https://importlib_metadata.readthedocs.io/ + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + + +.. _`entry point API`: https://setuptools.readthedocs.io/en/latest/pkg_resources.html#entry-points +.. _`metadata API`: https://setuptools.readthedocs.io/en/latest/pkg_resources.html#metadata-api diff --git a/importlib_metadata/docs/using.rst b/docs/using.rst similarity index 75% rename from importlib_metadata/docs/using.rst rename to docs/using.rst index 11965147..d60cfaa6 100644 --- a/importlib_metadata/docs/using.rst +++ b/docs/using.rst @@ -67,18 +67,48 @@ This package provides the following functionality via its public API. Entry points ------------ -The ``entry_points()`` function returns a dictionary of all entry points, -keyed by group. Entry points are represented by ``EntryPoint`` instances; +The ``entry_points()`` function returns a collection of entry points. +Entry points are represented by ``EntryPoint`` instances; each ``EntryPoint`` has a ``.name``, ``.group``, and ``.value`` attributes and a ``.load()`` method to resolve the value. There are also ``.module``, ``.attr``, and ``.extras`` attributes for getting the components of the -``.value`` attribute:: +``.value`` attribute. + +Query all entry points:: >>> eps = entry_points() - >>> list(eps) + +The ``entry_points()`` function returns an ``EntryPoints`` object, +a sequence of all ``EntryPoint`` objects with ``names`` and ``groups`` +attributes for convenience:: + + >>> sorted(eps.groups) ['console_scripts', 'distutils.commands', 'distutils.setup_keywords', 'egg_info.writers', 'setuptools.installation'] - >>> scripts = eps['console_scripts'] - >>> wheel = [ep for ep in scripts if ep.name == 'wheel'][0] + +``EntryPoints`` has a ``select`` method to select entry points +matching specific properties. Select entry points in the +``console_scripts`` group:: + + >>> scripts = eps.select(group='console_scripts') + +Equivalently, since ``entry_points`` passes keyword arguments +through to select:: + + >>> scripts = entry_points(group='console_scripts') + +Pick out a specific script named "wheel" (found in the wheel project):: + + >>> 'wheel' in scripts.names + True + >>> wheel = scripts['wheel'] + +Equivalently, query for that entry point during selection:: + + >>> (wheel,) = entry_points(group='console_scripts', name='wheel') + >>> (wheel,) = entry_points().select(group='console_scripts', name='wheel') + +Inspect the resolved entry point:: + >>> wheel EntryPoint(name='wheel', value='wheel.cli:main', group='console_scripts') >>> wheel.module @@ -97,6 +127,17 @@ group. Read `the setuptools docs `_ for more information on entry points, their definition, and usage. +*Compatibility Note* + +The "selectable" entry points were introduced in ``importlib_metadata`` +3.6 and Python 3.10. Prior to those changes, ``entry_points`` accepted +no parameters and always returned a dictionary of entry points, keyed +by group. For compatibility, if no parameters are passed to entry_points, +a ``SelectableGroups`` object is returned, implementing that dict +interface. In the future, calling ``entry_points`` with no parameters +will return an ``EntryPoints`` object. Users should rely on the selection +interface to retrieve entry points by group. + .. _metadata: @@ -108,12 +149,19 @@ Every distribution includes some metadata, which you can extract using the >>> wheel_metadata = metadata('wheel') -The keys of the returned data structure [#f1]_ name the metadata keywords, and -their values are returned unparsed from the distribution metadata:: +The keys of the returned data structure, a ``PackageMetadata``, +name the metadata keywords, and +the values are returned unparsed from the distribution metadata:: >>> wheel_metadata['Requires-Python'] '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*' +``PackageMetadata`` also presents a ``json`` attribute that returns +all the metadata in a JSON-compatible form per PEP 566:: + + >>> wheel_metadata.json['requires_python'] + '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*' + .. _version: @@ -135,7 +183,7 @@ Distribution files You can also get the full set of files contained within a distribution. The ``files()`` function takes a distribution package name and returns all of the files installed by this distribution. Each file object returned is a -``PackagePath``, a :class:`pathlib.Path` derived object with additional ``dist``, +``PackagePath``, a :class:`pathlib.PurePath` derived object with additional ``dist``, ``size``, and ``hash`` properties as indicated by the metadata. For example:: >>> util = [p for p in files('wheel') if 'util.py' in str(p)][0] @@ -159,6 +207,12 @@ Once you have the file, you can also read its contents:: return s.encode('utf-8') return s +You can also use the ``locate`` method to get a the absolute path to the +file:: + + >>> util.locate() # doctest: +SKIP + PosixPath('/home/gustav/example/lib/site-packages/wheel/util.py') + In the case where the metadata file listing files (RECORD or SOURCES.txt) is missing, ``files()`` will return ``None``. The caller may wish to wrap calls to @@ -179,6 +233,17 @@ function:: ["pytest (>=3.0.0) ; extra == 'test'", "pytest-cov ; extra == 'test'"] +Package distributions +--------------------- + +A convenience method to resolve the distribution or +distributions (in the case of a namespace package) for top-level +Python packages or modules:: + + >>> packages_distributions() + {'importlib_metadata': ['importlib-metadata'], 'yaml': ['PyYAML'], 'jaraco': ['jaraco.classes', 'jaraco.functools'], ...} + + Distributions ============= @@ -199,9 +264,9 @@ Thus, an alternative way to get the version number is through the There are all kinds of additional metadata available on the ``Distribution`` instance:: - >>> d.metadata['Requires-Python'] + >>> dist.metadata['Requires-Python'] '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*' - >>> d.metadata['License'] + >>> dist.metadata['License'] 'MIT' The full set of available metadata is not described here. See :pep:`566` @@ -252,9 +317,3 @@ a custom finder, return instances of this derived ``Distribution`` in the .. rubric:: Footnotes - -.. [#f1] Technically, the returned distribution metadata object is an - :class:`email.message.EmailMessage` - instance, but this is an implementation detail, and not part of the - stable API. You should only use dictionary-like methods and syntax - to access the metadata contents. diff --git a/exercises.py b/exercises.py new file mode 100644 index 00000000..bc8a44e9 --- /dev/null +++ b/exercises.py @@ -0,0 +1,36 @@ +from pytest_perf.deco import extras + + +@extras('perf') +def discovery_perf(): + "discovery" + import importlib_metadata # end warmup + + importlib_metadata.distribution('ipython') + + +def entry_points_perf(): + "entry_points()" + import importlib_metadata # end warmup + + importlib_metadata.entry_points() + + +@extras('perf') +def cached_distribution_perf(): + "cached distribution" + import importlib_metadata + + importlib_metadata.distribution('ipython') # end warmup + importlib_metadata.distribution('ipython') + + +@extras('perf') +def uncached_distribution_perf(): + "uncached distribution" + import importlib + import importlib_metadata + + # end warmup + importlib.invalidate_caches() + importlib_metadata.distribution('ipython') diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 7031323d..d08e9c14 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -1,73 +1,157 @@ -from __future__ import unicode_literals, absolute_import - -import io import os import re import abc import csv import sys import zipp +import email +import pathlib import operator +import textwrap +import warnings import functools import itertools import posixpath import collections +from . import _adapters, _meta +from ._collections import FreezableDefaultDict, Pair from ._compat import ( - install, NullFinder, - ConfigParser, - suppress, - map, - FileNotFoundError, - IsADirectoryError, - NotADirectoryError, - PermissionError, - pathlib, - ModuleNotFoundError, - MetaPathFinder, - email_message_from_string, - PyPy_repr, - unique_ordered, - str, - ) + install, + pypy_partial, +) +from ._functools import method_cache, pass_none +from ._itertools import always_iterable, unique_everseen +from ._meta import PackageMetadata, SimplePath + +from contextlib import suppress from importlib import import_module +from importlib.abc import MetaPathFinder from itertools import starmap - - -__metaclass__ = type +from typing import List, Mapping, Optional, Union __all__ = [ 'Distribution', 'DistributionFinder', + 'PackageMetadata', 'PackageNotFoundError', 'distribution', 'distributions', 'entry_points', 'files', 'metadata', + 'packages_distributions', 'requires', 'version', - ] +] class PackageNotFoundError(ModuleNotFoundError): """The package was not found.""" def __str__(self): - tmpl = "No package metadata was found for {self.name}" - return tmpl.format(**locals()) + return f"No package metadata was found for {self.name}" @property def name(self): - name, = self.args + (name,) = self.args return name -class EntryPoint( - PyPy_repr, - collections.namedtuple('EntryPointBase', 'name value group')): +class Sectioned: + """ + A simple entry point config parser for performance + + >>> for item in Sectioned.read(Sectioned._sample): + ... print(item) + Pair(name='sec1', value='# comments ignored') + Pair(name='sec1', value='a = 1') + Pair(name='sec1', value='b = 2') + Pair(name='sec2', value='a = 2') + + >>> res = Sectioned.section_pairs(Sectioned._sample) + >>> item = next(res) + >>> item.name + 'sec1' + >>> item.value + Pair(name='a', value='1') + >>> item = next(res) + >>> item.value + Pair(name='b', value='2') + >>> item = next(res) + >>> item.name + 'sec2' + >>> item.value + Pair(name='a', value='2') + >>> list(res) + [] + """ + + _sample = textwrap.dedent( + """ + [sec1] + # comments ignored + a = 1 + b = 2 + + [sec2] + a = 2 + """ + ).lstrip() + + @classmethod + def section_pairs(cls, text): + return ( + section._replace(value=Pair.parse(section.value)) + for section in cls.read(text, filter_=cls.valid) + if section.name is not None + ) + + @staticmethod + def read(text, filter_=None): + lines = filter(filter_, map(str.strip, text.splitlines())) + name = None + for value in lines: + section_match = value.startswith('[') and value.endswith(']') + if section_match: + name = value.strip('[]') + continue + yield Pair(name, value) + + @staticmethod + def valid(line): + return line and not line.startswith('#') + + +class DeprecatedTuple: + """ + Provide subscript item access for backward compatibility. + + >>> recwarn = getfixture('recwarn') + >>> ep = EntryPoint(name='name', value='value', group='group') + >>> ep[:] + ('name', 'value', 'group') + >>> ep[0] + 'name' + >>> len(recwarn) + 1 + """ + + _warn = functools.partial( + warnings.warn, + "EntryPoint tuple interface is deprecated. Access members by name.", + DeprecationWarning, + stacklevel=pypy_partial(2), + ) + + def __getitem__(self, item): + self._warn() + return self._key()[item] + + +class EntryPoint(DeprecatedTuple): """An entry point as defined by Python packaging conventions. See `the packaging docs on entry points @@ -79,7 +163,7 @@ class EntryPoint( r'(?P[\w.]+)\s*' r'(:\s*(?P[\w.]+))?\s*' r'(?P\[.*\])?\s*$' - ) + ) """ A regular expression describing the syntax for an entry point, which might look like: @@ -96,6 +180,11 @@ class EntryPoint( following the attr, and following any extras. """ + dist: Optional['Distribution'] = None + + def __init__(self, name, value, group): + vars(self).update(name=name, value=value, group=group) + def load(self): """Load the entry point from its definition. If only a module is indicated by the value, return that module. Otherwise, @@ -121,37 +210,275 @@ def extras(self): match = self.pattern.match(self.value) return list(re.finditer(r'\w+', match.group('extras') or '')) - @classmethod - def _from_config(cls, config): - return [ - cls(name, value, group) - for group in config.sections() - for name, value in config.items(group) - ] - - @classmethod - def _from_text(cls, text): - config = ConfigParser(delimiters='=') - # case sensitive: https://stackoverflow.com/q/1611799/812183 - config.optionxform = str - try: - config.read_string(text) - except AttributeError: # pragma: nocover - # Python 2 has no read_string - config.readfp(io.StringIO(text)) - return EntryPoint._from_config(config) + def _for(self, dist): + vars(self).update(dist=dist) + return self def __iter__(self): """ - Supply iter so one may construct dicts of EntryPoints easily. + Supply iter so one may construct dicts of EntryPoints by name. """ + msg = ( + "Construction of dict of EntryPoints is deprecated in " + "favor of EntryPoints." + ) + warnings.warn(msg, DeprecationWarning) return iter((self.name, self)) - def __reduce__(self): + def matches(self, **params): + attrs = (getattr(self, param) for param in params) + return all(map(operator.eq, params.values(), attrs)) + + def _key(self): + return self.name, self.value, self.group + + def __lt__(self, other): + return self._key() < other._key() + + def __eq__(self, other): + return self._key() == other._key() + + def __setattr__(self, name, value): + raise AttributeError("EntryPoint objects are immutable.") + + def __repr__(self): return ( - self.__class__, - (self.name, self.value, self.group), + f'EntryPoint(name={self.name!r}, value={self.value!r}, ' + f'group={self.group!r})' + ) + + def __hash__(self): + return hash(self._key()) + + +class DeprecatedList(list): + """ + Allow an otherwise immutable object to implement mutability + for compatibility. + + >>> recwarn = getfixture('recwarn') + >>> dl = DeprecatedList(range(3)) + >>> dl[0] = 1 + >>> dl.append(3) + >>> del dl[3] + >>> dl.reverse() + >>> dl.sort() + >>> dl.extend([4]) + >>> dl.pop(-1) + 4 + >>> dl.remove(1) + >>> dl += [5] + >>> dl + [6] + [1, 2, 5, 6] + >>> dl + (6,) + [1, 2, 5, 6] + >>> dl.insert(0, 0) + >>> dl + [0, 1, 2, 5] + >>> dl == [0, 1, 2, 5] + True + >>> dl == (0, 1, 2, 5) + True + >>> len(recwarn) + 1 + """ + + _warn = functools.partial( + warnings.warn, + "EntryPoints list interface is deprecated. Cast to list if needed.", + DeprecationWarning, + stacklevel=pypy_partial(2), + ) + + def _wrap_deprecated_method(method_name: str): # type: ignore + def wrapped(self, *args, **kwargs): + self._warn() + return getattr(super(), method_name)(*args, **kwargs) + + return wrapped + + for method_name in [ + '__setitem__', + '__delitem__', + 'append', + 'reverse', + 'extend', + 'pop', + 'remove', + '__iadd__', + 'insert', + 'sort', + ]: + locals()[method_name] = _wrap_deprecated_method(method_name) + + def __add__(self, other): + if not isinstance(other, tuple): + self._warn() + other = tuple(other) + return self.__class__(tuple(self) + other) + + def __eq__(self, other): + if not isinstance(other, tuple): + self._warn() + other = tuple(other) + + return tuple(self).__eq__(other) + + +class EntryPoints(DeprecatedList): + """ + An immutable collection of selectable EntryPoint objects. + """ + + __slots__ = () + + def __getitem__(self, name): # -> EntryPoint: + """ + Get the EntryPoint in self matching name. + """ + if isinstance(name, int): + warnings.warn( + "Accessing entry points by index is deprecated. " + "Cast to tuple if needed.", + DeprecationWarning, + stacklevel=2, ) + return super().__getitem__(name) + try: + return next(iter(self.select(name=name))) + except StopIteration: + raise KeyError(name) + + def select(self, **params): + """ + Select entry points from self that match the + given parameters (typically group and/or name). + """ + return EntryPoints(ep for ep in self if ep.matches(**params)) + + @property + def names(self): + """ + Return the set of all names of all entry points. + """ + return {ep.name for ep in self} + + @property + def groups(self): + """ + Return the set of all groups of all entry points. + + For coverage while SelectableGroups is present. + >>> EntryPoints().groups + set() + """ + return {ep.group for ep in self} + + @classmethod + def _from_text_for(cls, text, dist): + return cls(ep._for(dist) for ep in cls._from_text(text)) + + @staticmethod + def _from_text(text): + return ( + EntryPoint(name=item.value.name, value=item.value.value, group=item.name) + for item in Sectioned.section_pairs(text or '') + ) + + +class Deprecated: + """ + Compatibility add-in for mapping to indicate that + mapping behavior is deprecated. + + >>> recwarn = getfixture('recwarn') + >>> class DeprecatedDict(Deprecated, dict): pass + >>> dd = DeprecatedDict(foo='bar') + >>> dd.get('baz', None) + >>> dd['foo'] + 'bar' + >>> list(dd) + ['foo'] + >>> list(dd.keys()) + ['foo'] + >>> 'foo' in dd + True + >>> list(dd.values()) + ['bar'] + >>> len(recwarn) + 1 + """ + + _warn = functools.partial( + warnings.warn, + "SelectableGroups dict interface is deprecated. Use select.", + DeprecationWarning, + stacklevel=pypy_partial(2), + ) + + def __getitem__(self, name): + self._warn() + return super().__getitem__(name) + + def get(self, name, default=None): + self._warn() + return super().get(name, default) + + def __iter__(self): + self._warn() + return super().__iter__() + + def __contains__(self, *args): + self._warn() + return super().__contains__(*args) + + def keys(self): + self._warn() + return super().keys() + + def values(self): + self._warn() + return super().values() + + +class SelectableGroups(Deprecated, dict): + """ + A backward- and forward-compatible result from + entry_points that fully implements the dict interface. + """ + + @classmethod + def load(cls, eps): + by_group = operator.attrgetter('group') + ordered = sorted(eps, key=by_group) + grouped = itertools.groupby(ordered, by_group) + return cls((group, EntryPoints(eps)) for group, eps in grouped) + + @property + def _all(self): + """ + Reconstruct a list of all entrypoints from the groups. + """ + groups = super(Deprecated, self).values() + return EntryPoints(itertools.chain.from_iterable(groups)) + + @property + def groups(self): + return self._all.groups + + @property + def names(self): + """ + for coverage: + >>> SelectableGroups().names + set() + """ + return self._all.names + + def select(self, **params): + if not params: + return self + return self._all.select(**params) class PackagePath(pathlib.PurePosixPath): @@ -175,7 +502,7 @@ def __init__(self, spec): self.mode, _, self.value = spec.partition('=') def __repr__(self): - return ''.format(self.mode, self.value) + return f'' class Distribution: @@ -229,9 +556,8 @@ def discover(cls, **kwargs): raise ValueError("cannot accept context and kwargs") context = context or DistributionFinder.Context(**kwargs) return itertools.chain.from_iterable( - resolver(context) - for resolver in cls._discover_resolvers() - ) + resolver(context) for resolver in cls._discover_resolvers() + ) @staticmethod def at(path): @@ -246,24 +572,24 @@ def at(path): def _discover_resolvers(): """Search the meta_path for resolvers.""" declared = ( - getattr(finder, 'find_distributions', None) - for finder in sys.meta_path - ) + getattr(finder, 'find_distributions', None) for finder in sys.meta_path + ) return filter(None, declared) @classmethod def _local(cls, root='.'): from pep517 import build, meta + system = build.compat_system(root) builder = functools.partial( meta.build, source_dir=root, system=system, - ) + ) return PathDistribution(zipp.Path(meta.build_as_zip(builder))) @property - def metadata(self): + def metadata(self) -> _meta.PackageMetadata: """Return the parsed metadata for this Distribution. The returned object will have keys that name the various bits of @@ -276,8 +602,18 @@ def metadata(self): # effect is to just end up using the PathDistribution's self._path # (which points to the egg-info file) attribute unchanged. or self.read_text('') - ) - return email_message_from_string(text) + ) + return _adapters.Message(email.message_from_string(text)) + + @property + def name(self): + """Return the 'Name' metadata for the distribution package.""" + return self.metadata['Name'] + + @property + def _normalized_name(self): + """Return a normalized version of the name.""" + return Prepared.normalize(self.name) @property def version(self): @@ -286,7 +622,7 @@ def version(self): @property def entry_points(self): - return EntryPoint._from_text(self.read_text('entry_points.txt')) + return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property def files(self): @@ -299,7 +635,6 @@ def files(self): missing. Result may be empty if the metadata exists but is empty. """ - file_lines = self._read_files_distinfo() or self._read_files_egginfo() def make_file(name, hash=None, size_str=None): result = PackagePath(name) @@ -308,7 +643,11 @@ def make_file(name, hash=None, size_str=None): result.dist = self return result - return file_lines and list(starmap(make_file, csv.reader(file_lines))) + @pass_none + def make_files(lines): + return list(starmap(make_file, csv.reader(lines))) + + return make_files(self._read_files_distinfo() or self._read_files_egginfo()) def _read_files_distinfo(self): """ @@ -340,23 +679,7 @@ def _read_egg_info_reqs(self): @classmethod def _deps_from_requires_text(cls, source): - section_pairs = cls._read_sections(source.splitlines()) - sections = { - section: list(map(operator.itemgetter('line'), results)) - for section, results in - itertools.groupby(section_pairs, operator.itemgetter('section')) - } - return cls._convert_egg_info_reqs_to_simple_reqs(sections) - - @staticmethod - def _read_sections(lines): - section = None - for line in filter(None, lines): - section_match = re.match(r'\[(.*)\]$', line) - if section_match: - section = section_match.group(1) - continue - yield locals() + return cls._convert_egg_info_reqs_to_simple_reqs(Sectioned.read(source)) @staticmethod def _convert_egg_info_reqs_to_simple_reqs(sections): @@ -369,20 +692,29 @@ def _convert_egg_info_reqs_to_simple_reqs(sections): requirement. This method converts the former to the latter. See _test_deps_from_requires_text for an example. """ + def make_condition(name): - return name and 'extra == "{name}"'.format(name=name) + return name and f'extra == "{name}"' - def parse_condition(section): + def quoted_marker(section): section = section or '' extra, sep, markers = section.partition(':') if extra and markers: - markers = '({markers})'.format(markers=markers) + markers = f'({markers})' conditions = list(filter(None, [markers, make_condition(extra)])) return '; ' + ' and '.join(conditions) if conditions else '' - for section, deps in sections.items(): - for dep in deps: - yield dep + parse_condition(section) + def url_req_space(req): + """ + PEP 508 requires a space between the url_spec and the quoted_marker. + Ref python/importlib_metadata#357. + """ + # '@' is uniquely indicative of a url_req. + return ' ' * ('@' in req) + + for section in sections: + space = url_req_space(section.value) + yield section.value + space + quoted_marker(section.name) class DistributionFinder(MetaPathFinder): @@ -414,10 +746,11 @@ def __init__(self, **kwargs): @property def path(self): """ - The path that a distribution finder should search. + The sequence of directory path that a distribution finder + should search. - Typically refers to Python package paths and defaults - to ``sys.path``. + Typically refers to Python installed package paths such as + "site-packages" directories and defaults to ``sys.path``. """ return vars(self).get('path', sys.path) @@ -436,18 +769,24 @@ class FastPath: """ Micro-optimized class for searching a path for children. + + >>> FastPath('').children() + ['...'] """ + @functools.lru_cache() # type: ignore + def __new__(cls, root): + return super().__new__(cls) + def __init__(self, root): self.root = str(root) - self.base = os.path.basename(self.root).lower() def joinpath(self, child): return pathlib.Path(self.root, child) def children(self): with suppress(Exception): - return os.listdir(self.root or '') + return os.listdir(self.root or '.') with suppress(Exception): return self.zip_children() return [] @@ -457,48 +796,90 @@ def zip_children(self): names = zip_path.root.namelist() self.joinpath = zip_path.joinpath - return unique_ordered( - child.split(posixpath.sep, 1)[0] - for child in names - ) - - def is_egg(self, search): - base = self.base - return ( - base == search.versionless_egg_name - or base.startswith(search.prefix) - and base.endswith('.egg')) + return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names) def search(self, name): - for child in self.children(): - n_low = child.lower() - if (n_low in name.exact_matches - or n_low.startswith(name.prefix) - and n_low.endswith(name.suffixes) - # legacy case: - or self.is_egg(name) and n_low == 'egg-info'): - yield self.joinpath(child) + return self.lookup(self.mtime).search(name) + + @property + def mtime(self): + with suppress(OSError): + return os.stat(self.root).st_mtime + self.lookup.cache_clear() + + @method_cache + def lookup(self, mtime): + return Lookup(self) + + +class Lookup: + def __init__(self, path: FastPath): + base = os.path.basename(path.root).lower() + base_is_egg = base.endswith(".egg") + self.infos = FreezableDefaultDict(list) + self.eggs = FreezableDefaultDict(list) + + for child in path.children(): + low = child.lower() + if low.endswith((".dist-info", ".egg-info")): + # rpartition is faster than splitext and suitable for this purpose. + name = low.rpartition(".")[0].partition("-")[0] + normalized = Prepared.normalize(name) + self.infos[normalized].append(path.joinpath(child)) + elif base_is_egg and low == "egg-info": + name = base.rpartition(".")[0].partition("-")[0] + legacy_normalized = Prepared.legacy_normalize(name) + self.eggs[legacy_normalized].append(path.joinpath(child)) + + self.infos.freeze() + self.eggs.freeze() + + def search(self, prepared): + infos = ( + self.infos[prepared.normalized] + if prepared + else itertools.chain.from_iterable(self.infos.values()) + ) + eggs = ( + self.eggs[prepared.legacy_normalized] + if prepared + else itertools.chain.from_iterable(self.eggs.values()) + ) + return itertools.chain(infos, eggs) class Prepared: """ A prepared search for metadata on a possibly-named package. """ - normalized = '' - prefix = '' - suffixes = '.dist-info', '.egg-info' - exact_matches = [''][:0] - versionless_egg_name = '' + + normalized = None + legacy_normalized = None def __init__(self, name): self.name = name if name is None: return - self.normalized = name.lower().replace('-', '_') - self.prefix = self.normalized + '-' - self.exact_matches = [ - self.normalized + suffix for suffix in self.suffixes] - self.versionless_egg_name = self.normalized + '.egg' + self.normalized = self.normalize(name) + self.legacy_normalized = self.legacy_normalize(name) + + @staticmethod + def normalize(name): + """ + PEP 503 normalization plus dashes as underscores. + """ + return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_') + + @staticmethod + def legacy_normalize(name): + """ + Normalize the package name as found in the convention in + older packaging tools versions and specs. + """ + return name.lower().replace('-', '_') + + def __bool__(self): + return bool(self.name) @install @@ -524,30 +905,54 @@ def find_distributions(self, context=DistributionFinder.Context()): @classmethod def _search_paths(cls, name, paths): """Find metadata directories in paths heuristically.""" + prepared = Prepared(name) return itertools.chain.from_iterable( - path.search(Prepared(name)) - for path in map(FastPath, paths) - ) + path.search(prepared) for path in map(FastPath, paths) + ) + + def invalidate_caches(cls): + FastPath.__new__.cache_clear() class PathDistribution(Distribution): - def __init__(self, path): - """Construct a distribution from a path to the metadata directory. + def __init__(self, path: SimplePath): + """Construct a distribution. - :param path: A pathlib.Path or similar object supporting - .joinpath(), __div__, .parent, and .read_text(). + :param path: SimplePath indicating the metadata directory. """ self._path = path def read_text(self, filename): - with suppress(FileNotFoundError, IsADirectoryError, KeyError, - NotADirectoryError, PermissionError): + with suppress( + FileNotFoundError, + IsADirectoryError, + KeyError, + NotADirectoryError, + PermissionError, + ): return self._path.joinpath(filename).read_text(encoding='utf-8') + read_text.__doc__ = Distribution.read_text.__doc__ def locate_file(self, path): return self._path.parent / path + @property + def _normalized_name(self): + """ + Performance optimization: where possible, resolve the + normalized name from the file system path. + """ + stem = os.path.basename(str(self._path)) + return self._name_from_stem(stem) or super()._normalized_name + + def _name_from_stem(self, stem): + name, ext = os.path.splitext(stem) + if ext not in ('.dist-info', '.egg-info'): + return + name, sep, rest = stem.partition('-') + return name + def distribution(distribution_name): """Get the ``Distribution`` instance for the named package. @@ -566,11 +971,11 @@ def distributions(**kwargs): return Distribution.discover(**kwargs) -def metadata(distribution_name): +def metadata(distribution_name) -> _meta.PackageMetadata: """Get the metadata for the named package. :param distribution_name: The name of the distribution package to query. - :return: An email.Message containing the parsed metadata. + :return: A PackageMetadata containing the parsed metadata. """ return Distribution.from_name(distribution_name).metadata @@ -585,20 +990,29 @@ def version(distribution_name): return distribution(distribution_name).version -def entry_points(): +def entry_points(**params) -> Union[EntryPoints, SelectableGroups]: """Return EntryPoint objects for all installed packages. - :return: EntryPoint objects for all installed packages. + Pass selection parameters (group or name) to filter the + result to entry points matching those properties (see + EntryPoints.select()). + + For compatibility, returns ``SelectableGroups`` object unless + selection parameters are supplied. In the future, this function + will return ``EntryPoints`` instead of ``SelectableGroups`` + even when no selection parameters are supplied. + + For maximum future compatibility, pass selection parameters + or invoke ``.select`` with parameters on the result. + + :return: EntryPoints or SelectableGroups for all installed packages. """ + norm_name = operator.attrgetter('_normalized_name') + unique = functools.partial(unique_everseen, key=norm_name) eps = itertools.chain.from_iterable( - dist.entry_points for dist in distributions()) - by_group = operator.attrgetter('group') - ordered = sorted(eps, key=by_group) - grouped = itertools.groupby(ordered, by_group) - return { - group: tuple(eps) - for group, eps in grouped - } + dist.entry_points for dist in unique(distributions()) + ) + return SelectableGroups.load(eps).select(**params) def files(distribution_name): @@ -615,6 +1029,35 @@ def requires(distribution_name): Return a list of requirements for the named package. :return: An iterator of requirements, suitable for - packaging.requirement.Requirement. + packaging.requirement.Requirement. """ return distribution(distribution_name).requires + + +def packages_distributions() -> Mapping[str, List[str]]: + """ + Return a mapping of top-level packages to their + distributions. + + >>> import collections.abc + >>> pkgs = packages_distributions() + >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values()) + True + """ + pkg_to_dist = collections.defaultdict(list) + for dist in distributions(): + for pkg in _top_level_declared(dist) or _top_level_inferred(dist): + pkg_to_dist[pkg].append(dist.metadata['Name']) + return dict(pkg_to_dist) + + +def _top_level_declared(dist): + return (dist.read_text('top_level.txt') or '').split() + + +def _top_level_inferred(dist): + return { + f.parts[0] if len(f.parts) > 1 else f.with_suffix('').name + for f in always_iterable(dist.files) + if f.suffix == ".py" + } diff --git a/importlib_metadata/_adapters.py b/importlib_metadata/_adapters.py new file mode 100644 index 00000000..aa460d3e --- /dev/null +++ b/importlib_metadata/_adapters.py @@ -0,0 +1,68 @@ +import re +import textwrap +import email.message + +from ._text import FoldedCase + + +class Message(email.message.Message): + multiple_use_keys = set( + map( + FoldedCase, + [ + 'Classifier', + 'Obsoletes-Dist', + 'Platform', + 'Project-URL', + 'Provides-Dist', + 'Provides-Extra', + 'Requires-Dist', + 'Requires-External', + 'Supported-Platform', + 'Dynamic', + ], + ) + ) + """ + Keys that may be indicated multiple times per PEP 566. + """ + + def __new__(cls, orig: email.message.Message): + res = super().__new__(cls) + vars(res).update(vars(orig)) + return res + + def __init__(self, *args, **kwargs): + self._headers = self._repair_headers() + + # suppress spurious error from mypy + def __iter__(self): + return super().__iter__() + + def _repair_headers(self): + def redent(value): + "Correct for RFC822 indentation" + if not value or '\n' not in value: + return value + return textwrap.dedent(' ' * 8 + value) + + headers = [(key, redent(value)) for key, value in vars(self)['_headers']] + if self._payload: + headers.append(('Description', self.get_payload())) + return headers + + @property + def json(self): + """ + Convert PackageMetadata to a JSON-compatible format + per PEP 0566. + """ + + def transform(key): + value = self.get_all(key) if key in self.multiple_use_keys else self[key] + if key == 'Keywords': + value = re.split(r'\s+', value) + tk = key.lower().replace('-', '_') + return tk, value + + return dict(map(transform, map(FoldedCase, self))) diff --git a/importlib_metadata/_collections.py b/importlib_metadata/_collections.py new file mode 100644 index 00000000..cf0954e1 --- /dev/null +++ b/importlib_metadata/_collections.py @@ -0,0 +1,30 @@ +import collections + + +# from jaraco.collections 3.3 +class FreezableDefaultDict(collections.defaultdict): + """ + Often it is desirable to prevent the mutation of + a default dict after its initial construction, such + as to prevent mutation during iteration. + + >>> dd = FreezableDefaultDict(list) + >>> dd[0].append('1') + >>> dd.freeze() + >>> dd[1] + [] + >>> len(dd) + 1 + """ + + def __missing__(self, key): + return getattr(self, '_frozen', super().__missing__)(key) + + def freeze(self): + self._frozen = lambda key: self.default_factory() + + +class Pair(collections.namedtuple('Pair', 'name value')): + @classmethod + def parse(cls, text): + return cls(*map(str.strip, text.split("=", 1))) diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index 303d4a22..8fe4e4e3 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -1,59 +1,14 @@ -from __future__ import absolute_import, unicode_literals - -import io -import abc import sys -import email - - -if sys.version_info > (3,): # pragma: nocover - import builtins - from configparser import ConfigParser - import contextlib - FileNotFoundError = builtins.FileNotFoundError - IsADirectoryError = builtins.IsADirectoryError - NotADirectoryError = builtins.NotADirectoryError - PermissionError = builtins.PermissionError - map = builtins.map - from itertools import filterfalse -else: # pragma: nocover - from backports.configparser import ConfigParser - from itertools import imap as map # type: ignore - from itertools import ifilterfalse as filterfalse - import contextlib2 as contextlib - FileNotFoundError = IOError, OSError - IsADirectoryError = IOError, OSError - NotADirectoryError = IOError, OSError - PermissionError = IOError, OSError - -str = type('') - -suppress = contextlib.suppress - -if sys.version_info > (3, 5): # pragma: nocover - import pathlib -else: # pragma: nocover - import pathlib2 as pathlib - -try: - ModuleNotFoundError = builtins.FileNotFoundError -except (NameError, AttributeError): # pragma: nocover - ModuleNotFoundError = ImportError # type: ignore +import platform -if sys.version_info >= (3,): # pragma: nocover - from importlib.abc import MetaPathFinder -else: # pragma: nocover - class MetaPathFinder(object): - __metaclass__ = abc.ABCMeta +__all__ = ['install', 'NullFinder', 'Protocol'] -__metaclass__ = type -__all__ = [ - 'install', 'NullFinder', 'MetaPathFinder', 'ModuleNotFoundError', - 'pathlib', 'ConfigParser', 'map', 'suppress', 'FileNotFoundError', - 'NotADirectoryError', 'email_message_from_string', - ] +try: + from typing import Protocol +except ImportError: # pragma: no cover + from typing_extensions import Protocol # type: ignore def install(cls): @@ -77,11 +32,12 @@ def disable_stdlib_finder(): See #91 for more background for rationale on this sketchy behavior. """ + def matches(finder): - return ( - getattr(finder, '__module__', None) == '_frozen_importlib_external' - and hasattr(finder, 'find_distributions') - ) + return getattr( + finder, '__module__', None + ) == '_frozen_importlib_external' and hasattr(finder, 'find_distributions') + for finder in filter(matches, sys.meta_path): # pragma: nocover del finder.find_distributions @@ -91,6 +47,7 @@ class NullFinder: A "Finder" (aka "MetaClassFinder") that never finds any modules, but may find distributions. """ + @staticmethod def find_spec(*args, **kwargs): return None @@ -104,49 +61,11 @@ def find_spec(*args, **kwargs): find_module = find_spec -def py2_message_from_string(text): # nocoverpy3 - # Work around https://bugs.python.org/issue25545 where - # email.message_from_string cannot handle Unicode on Python 2. - io_buffer = io.StringIO(text) - return email.message_from_file(io_buffer) - - -email_message_from_string = ( - py2_message_from_string - if sys.version_info < (3,) else - email.message_from_string - ) - - -class PyPy_repr: - """ - Override repr for EntryPoint objects on PyPy to avoid __iter__ access. - Ref #97, #102. +def pypy_partial(val): """ - affected = hasattr(sys, 'pypy_version_info') - - def __compat_repr__(self): # pragma: nocover - def make_param(name): - value = getattr(self, name) - return '{name}={value!r}'.format(**locals()) - params = ', '.join(map(make_param, self._fields)) - return 'EntryPoint({params})'.format(**locals()) - - if affected: # pragma: nocover - __repr__ = __compat_repr__ - del affected - + Adjust for variable stacklevel on partial under PyPy. -# from itertools recipes -def unique_everseen(iterable): # pragma: nocover - "List unique elements, preserving order. Remember all elements ever seen." - seen = set() - seen_add = seen.add - - for element in filterfalse(seen.__contains__, iterable): - seen_add(element) - yield element - - -unique_ordered = ( - unique_everseen if sys.version_info < (3, 7) else dict.fromkeys) + Workaround for #327. + """ + is_pypy = platform.python_implementation() == 'PyPy' + return val + is_pypy diff --git a/importlib_metadata/_functools.py b/importlib_metadata/_functools.py new file mode 100644 index 00000000..71f66bd0 --- /dev/null +++ b/importlib_metadata/_functools.py @@ -0,0 +1,104 @@ +import types +import functools + + +# from jaraco.functools 3.3 +def method_cache(method, cache_wrapper=None): + """ + Wrap lru_cache to support storing the cache data in the object instances. + + Abstracts the common paradigm where the method explicitly saves an + underscore-prefixed protected property on first call and returns that + subsequently. + + >>> class MyClass: + ... calls = 0 + ... + ... @method_cache + ... def method(self, value): + ... self.calls += 1 + ... return value + + >>> a = MyClass() + >>> a.method(3) + 3 + >>> for x in range(75): + ... res = a.method(x) + >>> a.calls + 75 + + Note that the apparent behavior will be exactly like that of lru_cache + except that the cache is stored on each instance, so values in one + instance will not flush values from another, and when an instance is + deleted, so are the cached values for that instance. + + >>> b = MyClass() + >>> for x in range(35): + ... res = b.method(x) + >>> b.calls + 35 + >>> a.method(0) + 0 + >>> a.calls + 75 + + Note that if method had been decorated with ``functools.lru_cache()``, + a.calls would have been 76 (due to the cached value of 0 having been + flushed by the 'b' instance). + + Clear the cache with ``.cache_clear()`` + + >>> a.method.cache_clear() + + Same for a method that hasn't yet been called. + + >>> c = MyClass() + >>> c.method.cache_clear() + + Another cache wrapper may be supplied: + + >>> cache = functools.lru_cache(maxsize=2) + >>> MyClass.method2 = method_cache(lambda self: 3, cache_wrapper=cache) + >>> a = MyClass() + >>> a.method2() + 3 + + Caution - do not subsequently wrap the method with another decorator, such + as ``@property``, which changes the semantics of the function. + + See also + http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/ + for another implementation and additional justification. + """ + cache_wrapper = cache_wrapper or functools.lru_cache() + + def wrapper(self, *args, **kwargs): + # it's the first call, replace the method with a cached, bound method + bound_method = types.MethodType(method, self) + cached_method = cache_wrapper(bound_method) + setattr(self, method.__name__, cached_method) + return cached_method(*args, **kwargs) + + # Support cache clear even before cache has been created. + wrapper.cache_clear = lambda: None + + return wrapper + + +# From jaraco.functools 3.3 +def pass_none(func): + """ + Wrap func so it's not called if its first param is None + + >>> print_text = pass_none(print) + >>> print_text('text') + text + >>> print_text(None) + """ + + @functools.wraps(func) + def wrapper(param, *args, **kwargs): + if param is not None: + return func(param, *args, **kwargs) + + return wrapper diff --git a/importlib_metadata/_itertools.py b/importlib_metadata/_itertools.py new file mode 100644 index 00000000..d4ca9b91 --- /dev/null +++ b/importlib_metadata/_itertools.py @@ -0,0 +1,73 @@ +from itertools import filterfalse + + +def unique_everseen(iterable, key=None): + "List unique elements, preserving order. Remember all elements ever seen." + # unique_everseen('AAAABBBCCDAABBB') --> A B C D + # unique_everseen('ABBCcAD', str.lower) --> A B C D + seen = set() + seen_add = seen.add + if key is None: + for element in filterfalse(seen.__contains__, iterable): + seen_add(element) + yield element + else: + for element in iterable: + k = key(element) + if k not in seen: + seen_add(k) + yield element + + +# copied from more_itertools 8.8 +def always_iterable(obj, base_type=(str, bytes)): + """If *obj* is iterable, return an iterator over its items:: + + >>> obj = (1, 2, 3) + >>> list(always_iterable(obj)) + [1, 2, 3] + + If *obj* is not iterable, return a one-item iterable containing *obj*:: + + >>> obj = 1 + >>> list(always_iterable(obj)) + [1] + + If *obj* is ``None``, return an empty iterable: + + >>> obj = None + >>> list(always_iterable(None)) + [] + + By default, binary and text strings are not considered iterable:: + + >>> obj = 'foo' + >>> list(always_iterable(obj)) + ['foo'] + + If *base_type* is set, objects for which ``isinstance(obj, base_type)`` + returns ``True`` won't be considered iterable. + + >>> obj = {'a': 1} + >>> list(always_iterable(obj)) # Iterate over the dict's keys + ['a'] + >>> list(always_iterable(obj, base_type=dict)) # Treat dicts as a unit + [{'a': 1}] + + Set *base_type* to ``None`` to avoid any special handling and treat objects + Python considers iterable as iterable: + + >>> obj = 'foo' + >>> list(always_iterable(obj, base_type=None)) + ['f', 'o', 'o'] + """ + if obj is None: + return iter(()) + + if (base_type is not None) and isinstance(obj, base_type): + return iter((obj,)) + + try: + return iter(obj) + except TypeError: + return iter((obj,)) diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py new file mode 100644 index 00000000..37ee43e6 --- /dev/null +++ b/importlib_metadata/_meta.py @@ -0,0 +1,48 @@ +from ._compat import Protocol +from typing import Any, Dict, Iterator, List, TypeVar, Union + + +_T = TypeVar("_T") + + +class PackageMetadata(Protocol): + def __len__(self) -> int: + ... # pragma: no cover + + def __contains__(self, item: str) -> bool: + ... # pragma: no cover + + def __getitem__(self, key: str) -> str: + ... # pragma: no cover + + def __iter__(self) -> Iterator[str]: + ... # pragma: no cover + + def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]: + """ + Return all values associated with a possibly multi-valued key. + """ + + @property + def json(self) -> Dict[str, Union[str, List[str]]]: + """ + A JSON-compatible form of the metadata. + """ + + +class SimplePath(Protocol): + """ + A minimal subset of pathlib.Path required by PathDistribution. + """ + + def joinpath(self) -> 'SimplePath': + ... # pragma: no cover + + def __truediv__(self) -> 'SimplePath': + ... # pragma: no cover + + def parent(self) -> 'SimplePath': + ... # pragma: no cover + + def read_text(self) -> str: + ... # pragma: no cover diff --git a/importlib_metadata/_text.py b/importlib_metadata/_text.py new file mode 100644 index 00000000..c88cfbb2 --- /dev/null +++ b/importlib_metadata/_text.py @@ -0,0 +1,99 @@ +import re + +from ._functools import method_cache + + +# from jaraco.text 3.5 +class FoldedCase(str): + """ + A case insensitive string class; behaves just like str + except compares equal when the only variation is case. + + >>> s = FoldedCase('hello world') + + >>> s == 'Hello World' + True + + >>> 'Hello World' == s + True + + >>> s != 'Hello World' + False + + >>> s.index('O') + 4 + + >>> s.split('O') + ['hell', ' w', 'rld'] + + >>> sorted(map(FoldedCase, ['GAMMA', 'alpha', 'Beta'])) + ['alpha', 'Beta', 'GAMMA'] + + Sequence membership is straightforward. + + >>> "Hello World" in [s] + True + >>> s in ["Hello World"] + True + + You may test for set inclusion, but candidate and elements + must both be folded. + + >>> FoldedCase("Hello World") in {s} + True + >>> s in {FoldedCase("Hello World")} + True + + String inclusion works as long as the FoldedCase object + is on the right. + + >>> "hello" in FoldedCase("Hello World") + True + + But not if the FoldedCase object is on the left: + + >>> FoldedCase('hello') in 'Hello World' + False + + In that case, use in_: + + >>> FoldedCase('hello').in_('Hello World') + True + + >>> FoldedCase('hello') > FoldedCase('Hello') + False + """ + + def __lt__(self, other): + return self.lower() < other.lower() + + def __gt__(self, other): + return self.lower() > other.lower() + + def __eq__(self, other): + return self.lower() == other.lower() + + def __ne__(self, other): + return self.lower() != other.lower() + + def __hash__(self): + return hash(self.lower()) + + def __contains__(self, other): + return super().lower().__contains__(other.lower()) + + def in_(self, other): + "Does self appear in other?" + return self in FoldedCase(other) + + # cache lower since it's likely to be called frequently. + @method_cache + def lower(self): + return super().lower() + + def index(self, sub): + return self.lower().index(sub.lower()) + + def split(self, splitter=' ', maxsplit=0): + pattern = re.compile(re.escape(splitter), re.I) + return pattern.split(self, maxsplit) diff --git a/importlib_metadata/docs/conf.py b/importlib_metadata/docs/conf.py deleted file mode 100644 index 129a7a4e..00000000 --- a/importlib_metadata/docs/conf.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# importlib_metadata documentation build configuration file, created by -# sphinx-quickstart on Thu Nov 30 10:21:00 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'rst.linker', - 'sphinx.ext.autodoc', - 'sphinx.ext.coverage', - 'sphinx.ext.doctest', - 'sphinx.ext.intersphinx', - 'sphinx.ext.viewcode', - ] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = 'importlib_metadata' -copyright = '2017-2019, Jason R. Coombs, Barry Warsaw' -author = 'Jason R. Coombs, Barry Warsaw' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.1' -# The full version, including alpha/beta/rc tags. -release = '0.1' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'default' - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# This is required for the alabaster theme -# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -html_sidebars = { - '**': [ - 'relations.html', # needs 'show_related': True theme option to display - 'searchbox.html', - ] - } - - -# -- Options for HTMLHelp output ------------------------------------------ - -# Output file base name for HTML help builder. -htmlhelp_basename = 'importlib_metadatadoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', - } - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'importlib_metadata.tex', - 'importlib\\_metadata Documentation', - 'Brett Cannon, Barry Warsaw', 'manual'), - ] - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'importlib_metadata', 'importlib_metadata Documentation', - [author], 1) - ] - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'importlib_metadata', 'importlib_metadata Documentation', - author, 'importlib_metadata', 'One line description of project.', - 'Miscellaneous'), - ] - - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = { - 'python': ('https://docs.python.org/3', None), - 'importlib_resources': ( - 'https://importlib-resources.readthedocs.io/en/latest/', None - ), - } - - -# For rst.linker, inject release dates into changelog.rst -link_files = { - 'changelog.rst': dict( - replace=[ - dict( - pattern=r'^(?m)((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n', - with_scm='{text}\n{rev[timestamp]:%Y-%m-%d}\n\n', - ), - ], - ), - } diff --git a/importlib_metadata/docs/index.rst b/importlib_metadata/docs/index.rst deleted file mode 100644 index 530197cf..00000000 --- a/importlib_metadata/docs/index.rst +++ /dev/null @@ -1,50 +0,0 @@ -=============================== - Welcome to importlib_metadata -=============================== - -``importlib_metadata`` is a library which provides an API for accessing an -installed package's metadata (see :pep:`566`), such as its entry points or its top-level -name. This functionality intends to replace most uses of ``pkg_resources`` -`entry point API`_ and `metadata API`_. Along with :mod:`importlib.resources` in -Python 3.7 and newer (backported as :doc:`importlib_resources ` for older -versions of Python), this can eliminate the need to use the older and less -efficient ``pkg_resources`` package. - -``importlib_metadata`` is a backport of Python 3.8's standard library -:doc:`importlib.metadata ` module for Python 2.7, and 3.4 through 3.7. Users of -Python 3.8 and beyond are encouraged to use the standard library module. -When imported on Python 3.8 and later, ``importlib_metadata`` replaces the -DistributionFinder behavior from the stdlib, but leaves the API in tact. -Developers looking for detailed API descriptions should refer to the Python -3.8 standard library documentation. - -The documentation here includes a general :ref:`usage ` guide. - - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - using.rst - changelog (links).rst - - -Project details -=============== - - * Project home: https://gitlab.com/python-devs/importlib_metadata - * Report bugs at: https://gitlab.com/python-devs/importlib_metadata/issues - * Code hosting: https://gitlab.com/python-devs/importlib_metadata.git - * Documentation: http://importlib_metadata.readthedocs.io/ - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - - -.. _`entry point API`: https://setuptools.readthedocs.io/en/latest/pkg_resources.html#entry-points -.. _`metadata API`: https://setuptools.readthedocs.io/en/latest/pkg_resources.html#metadata-api diff --git a/importlib_metadata/tests/__init__.py b/importlib_metadata/py.typed similarity index 100% rename from importlib_metadata/tests/__init__.py rename to importlib_metadata/py.typed diff --git a/importlib_metadata/tests/test_api.py b/importlib_metadata/tests/test_api.py deleted file mode 100644 index eb0ff53b..00000000 --- a/importlib_metadata/tests/test_api.py +++ /dev/null @@ -1,177 +0,0 @@ -import re -import textwrap -import unittest - -from . import fixtures -from .. import ( - Distribution, PackageNotFoundError, distribution, - entry_points, files, metadata, requires, version, - ) - -try: - from collections.abc import Iterator -except ImportError: - from collections import Iterator # noqa: F401 - -try: - from builtins import str as text -except ImportError: - from __builtin__ import unicode as text - - -class APITests( - fixtures.EggInfoPkg, - fixtures.DistInfoPkg, - fixtures.EggInfoFile, - unittest.TestCase): - - version_pattern = r'\d+\.\d+(\.\d)?' - - def test_retrieves_version_of_self(self): - pkg_version = version('egginfo-pkg') - assert isinstance(pkg_version, text) - assert re.match(self.version_pattern, pkg_version) - - def test_retrieves_version_of_distinfo_pkg(self): - pkg_version = version('distinfo-pkg') - assert isinstance(pkg_version, text) - assert re.match(self.version_pattern, pkg_version) - - def test_for_name_does_not_exist(self): - with self.assertRaises(PackageNotFoundError): - distribution('does-not-exist') - - def test_for_top_level(self): - self.assertEqual( - distribution('egginfo-pkg').read_text('top_level.txt').strip(), - 'mod') - - def test_read_text(self): - top_level = [ - path for path in files('egginfo-pkg') - if path.name == 'top_level.txt' - ][0] - self.assertEqual(top_level.read_text(), 'mod\n') - - def test_entry_points(self): - entries = dict(entry_points()['entries']) - ep = entries['main'] - self.assertEqual(ep.value, 'mod:main') - self.assertEqual(ep.extras, []) - - def test_metadata_for_this_package(self): - md = metadata('egginfo-pkg') - assert md['author'] == 'Steven Ma' - assert md['LICENSE'] == 'Unknown' - assert md['Name'] == 'egginfo-pkg' - classifiers = md.get_all('Classifier') - assert 'Topic :: Software Development :: Libraries' in classifiers - - def test_importlib_metadata_version(self): - resolved = version('importlib-metadata') - assert re.match(self.version_pattern, resolved) - - @staticmethod - def _test_files(files): - root = files[0].root - for file in files: - assert file.root == root - assert not file.hash or file.hash.value - assert not file.hash or file.hash.mode == 'sha256' - assert not file.size or file.size >= 0 - assert file.locate().exists() - assert isinstance(file.read_binary(), bytes) - if file.name.endswith('.py'): - file.read_text() - - def test_file_hash_repr(self): - try: - assertRegex = self.assertRegex - except AttributeError: - # Python 2 - assertRegex = self.assertRegexpMatches - - util = [ - p for p in files('distinfo-pkg') - if p.name == 'mod.py' - ][0] - assertRegex( - repr(util.hash), - '') - - def test_files_dist_info(self): - self._test_files(files('distinfo-pkg')) - - def test_files_egg_info(self): - self._test_files(files('egginfo-pkg')) - - def test_version_egg_info_file(self): - self.assertEqual(version('egginfo-file'), '0.1') - - def test_requires_egg_info_file(self): - requirements = requires('egginfo-file') - self.assertIsNone(requirements) - - def test_requires_egg_info(self): - deps = requires('egginfo-pkg') - assert len(deps) == 2 - assert any( - dep == 'wheel >= 1.0; python_version >= "2.7"' - for dep in deps - ) - - def test_requires_dist_info(self): - deps = requires('distinfo-pkg') - assert len(deps) == 2 - assert all(deps) - assert 'wheel >= 1.0' in deps - assert "pytest; extra == 'test'" in deps - - def test_more_complex_deps_requires_text(self): - requires = textwrap.dedent(""" - dep1 - dep2 - - [:python_version < "3"] - dep3 - - [extra1] - dep4 - - [extra2:python_version < "3"] - dep5 - """) - deps = sorted(Distribution._deps_from_requires_text(requires)) - expected = [ - 'dep1', - 'dep2', - 'dep3; python_version < "3"', - 'dep4; extra == "extra1"', - 'dep5; (python_version < "3") and extra == "extra2"', - ] - # It's important that the environment marker expression be - # wrapped in parentheses to avoid the following 'and' binding more - # tightly than some other part of the environment expression. - - assert deps == expected - - -class OffSysPathTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): - def test_find_distributions_specified_path(self): - dists = Distribution.discover(path=[str(self.site_dir)]) - assert any( - dist.metadata['Name'] == 'distinfo-pkg' - for dist in dists - ) - - def test_distribution_at_pathlib(self): - """Demonstrate how to load metadata direct from a directory. - """ - dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' - dist = Distribution.at(dist_info_path) - assert dist.version == '1.0.0' - - def test_distribution_at_str(self): - dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' - dist = Distribution.at(str(dist_info_path)) - assert dist.version == '1.0.0' diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..976ba029 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +ignore_missing_imports = True diff --git a/prepare/example/setup.py b/prepare/example/setup.py index 8663ad38..479488a0 100644 --- a/prepare/example/setup.py +++ b/prepare/example/setup.py @@ -1,4 +1,5 @@ from setuptools import setup + setup( name='example', version='21.12', @@ -6,5 +7,5 @@ packages=['example'], entry_points={ 'console_scripts': ['example = example:main', 'Example=example:main'], - }, - ) + }, +) diff --git a/prepare/example2/example2/__init__.py b/prepare/example2/example2/__init__.py new file mode 100644 index 00000000..de645c2e --- /dev/null +++ b/prepare/example2/example2/__init__.py @@ -0,0 +1,2 @@ +def main(): + return "example" diff --git a/prepare/example2/pyproject.toml b/prepare/example2/pyproject.toml new file mode 100644 index 00000000..011f4751 --- /dev/null +++ b/prepare/example2/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +build-backend = 'trampolim' +requires = ['trampolim'] + +[project] +name = 'example2' +version = '1.0.0' + +[project.scripts] +example = 'example2:main' diff --git a/pyproject.toml b/pyproject.toml index e5c3a6a4..190b3551 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,20 @@ [build-system] -requires = ["setuptools>=30.3", "wheel", "setuptools_scm"] +requires = ["setuptools>=56", "setuptools_scm[toml]>=3.4.1"] +build-backend = "setuptools.build_meta" + +[tool.black] +skip-string-normalization = true + +[tool.setuptools_scm] + +[pytest.enabler.black] +addopts = "--black" + +[pytest.enabler.mypy] +addopts = "--mypy" + +[pytest.enabler.flake8] +addopts = "--flake8" + +[pytest.enabler.cov] +addopts = "--cov" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..9ecdba49 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +norecursedirs=dist build .tox .eggs +addopts=--doctest-modules +doctest_optionflags=ALLOW_UNICODE ELLIPSIS +filterwarnings= + # Suppress deprecation warning in flake8 + ignore:SelectableGroups dict interface is deprecated::flake8 + # Suppress deprecation warning in pypa/packaging#433 + ignore:The distutils package is deprecated::packaging.tags diff --git a/setup.cfg b/setup.cfg index eee6caf2..28a47076 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,60 +2,64 @@ name = importlib_metadata author = Jason R. Coombs author_email = jaraco@jaraco.com -url = http://importlib-metadata.readthedocs.io/ description = Read metadata from Python packages -long_description = file: README.rst -license = Apache Software License +long_description = file:README.rst +url = https://github.com/python/importlib_metadata classifiers = - Development Status :: 3 - Alpha - Intended Audience :: Developers - License :: OSI Approved :: Apache Software License - Topic :: Software Development :: Libraries - Programming Language :: Python :: 3 - Programming Language :: Python :: 2 + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only [options] -python_requires = >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.* -setup_requires = setuptools-scm +packages = find_namespace: +include_package_data = true +python_requires = >=3.6 install_requires = - zipp>=0.5 - pathlib2; python_version < '3' - contextlib2; python_version < '3' - configparser>=3.5; python_version < '3' -packages = find: - -[options.package_data] -* = *.zip, *.file, *.txt, *.toml -importlib_metadata = - docs/* - docs/_static/* -importlib_metadata.tests.data = - *.egg - *.whl - -[mypy] -ignore_missing_imports = True -# XXX We really should use the default `True` value here, but it causes too -# many warnings, so for now just disable it. E.g. a package's __spec__ is -# defined as Optional[ModuleSpec] so we can't just blindly pull attributes off -# of that attribute. The real fix is to add conditionals or asserts proving -# that package.__spec__ is not None. -strict_optional = False - -[mypy-importlib_metadata.docs.*] -ignore_errors: True - -[mypy-importlib_metadata.tests.*] -ignore_errors: True - -[wheel] -universal=1 + zipp>=0.5 + typing-extensions>=3.6.4; python_version < "3.8" + +[options.packages.find] +exclude = + build* + dist* + docs* + tests* + prepare* [options.extras_require] testing = - importlib_resources>=1.3; python_version < "3.9" - packaging - pep517 + # upstream + pytest >= 6 + pytest-checkdocs >= 2.4 + pytest-flake8 + pytest-black >= 0.3.7; \ + # workaround for jaraco/skeleton#22 + python_implementation != "PyPy" + pytest-cov + pytest-mypy; \ + # workaround for jaraco/skeleton#22 + python_implementation != "PyPy" + pytest-enabler >= 1.0.1 + + # local + importlib_resources>=1.3; python_version < "3.9" + packaging + pep517 + pyfakefs + flufl.flake8 + pytest-perf >= 0.9.2 + docs = - sphinx - rst.linker + # upstream + sphinx + jaraco.packaging >= 8.2 + rst.linker >= 1.9 + + # local + +perf = + ipython + +[options.entry_points] diff --git a/setup.py b/setup.py index d5d43d7c..bac24a43 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,6 @@ -from setuptools import setup +#!/usr/bin/env python -setup(use_scm_version=True) +import setuptools + +if __name__ == "__main__": + setuptools.setup() diff --git a/importlib_metadata/tests/data/__init__.py b/tests/__init__.py similarity index 100% rename from importlib_metadata/tests/data/__init__.py rename to tests/__init__.py diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/importlib_metadata/tests/data/example-21.12-py3-none-any.whl b/tests/data/example-21.12-py3-none-any.whl similarity index 100% rename from importlib_metadata/tests/data/example-21.12-py3-none-any.whl rename to tests/data/example-21.12-py3-none-any.whl diff --git a/importlib_metadata/tests/data/example-21.12-py3.6.egg b/tests/data/example-21.12-py3.6.egg similarity index 100% rename from importlib_metadata/tests/data/example-21.12-py3.6.egg rename to tests/data/example-21.12-py3.6.egg diff --git a/tests/data/example2-1.0.0-py3-none-any.whl b/tests/data/example2-1.0.0-py3-none-any.whl new file mode 100644 index 00000000..5ca93657 Binary files /dev/null and b/tests/data/example2-1.0.0-py3-none-any.whl differ diff --git a/importlib_metadata/tests/fixtures.py b/tests/fixtures.py similarity index 62% rename from importlib_metadata/tests/fixtures.py rename to tests/fixtures.py index 20982fa1..68584abf 100644 --- a/importlib_metadata/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,16 +1,22 @@ -from __future__ import unicode_literals - import os import sys +import copy import shutil +import pathlib import tempfile import textwrap -import test.support +import contextlib -from .._compat import pathlib, contextlib +from .py39compat import FS_NONASCII +from typing import Dict, Union +try: + from importlib import resources # type: ignore -__metaclass__ = type + getattr(resources, 'files') + getattr(resources, 'as_file') +except (ImportError, AttributeError): + import importlib_resources as resources # type: ignore @contextlib.contextmanager @@ -56,7 +62,7 @@ def setUp(self): class SiteDir(Fixtures): def setUp(self): - super(SiteDir, self).setUp() + super().setUp() self.site_dir = self.fixtures.enter_context(tempdir()) @@ -71,12 +77,17 @@ def add_sys_path(dir): sys.path.remove(str(dir)) def setUp(self): - super(OnSysPath, self).setUp() + super().setUp() self.fixtures.enter_context(self.add_sys_path(self.site_dir)) +# Except for python/mypy#731, prefer to define +# FilesDef = Dict[str, Union['FilesDef', str]] +FilesDef = Dict[str, Union[Dict[str, Union[Dict[str, str], str]], str]] + + class DistInfoPkg(OnSysPath, SiteDir): - files = { + files: FilesDef = { "distinfo_pkg-1.0.0.dist-info": { "METADATA": """ Name: distinfo-pkg @@ -84,33 +95,83 @@ class DistInfoPkg(OnSysPath, SiteDir): Version: 1.0.0 Requires-Dist: wheel >= 1.0 Requires-Dist: pytest; extra == 'test' + Keywords: sample package + + Once upon a time + There was a distinfo pkg """, "RECORD": "mod.py,sha256=abc,20\n", "entry_points.txt": """ [entries] main = mod:main ns:sub = mod:main - """ - }, + """, + }, "mod.py": """ def main(): print("hello world") """, - } + } def setUp(self): - super(DistInfoPkg, self).setUp() + super().setUp() build_files(DistInfoPkg.files, self.site_dir) + def make_uppercase(self): + """ + Rewrite metadata with everything uppercase. + """ + shutil.rmtree(self.site_dir / "distinfo_pkg-1.0.0.dist-info") + files = copy.deepcopy(DistInfoPkg.files) + info = files["distinfo_pkg-1.0.0.dist-info"] + info["METADATA"] = info["METADATA"].upper() + build_files(files, self.site_dir) + + +class DistInfoPkgWithDot(OnSysPath, SiteDir): + files: FilesDef = { + "pkg_dot-1.0.0.dist-info": { + "METADATA": """ + Name: pkg.dot + Version: 1.0.0 + """, + }, + } + + def setUp(self): + super().setUp() + build_files(DistInfoPkgWithDot.files, self.site_dir) + + +class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir): + files: FilesDef = { + "pkg.dot-1.0.0.dist-info": { + "METADATA": """ + Name: pkg.dot + Version: 1.0.0 + """, + }, + "pkg.lot.egg-info": { + "METADATA": """ + Name: pkg.lot + Version: 1.0.0 + """, + }, + } + + def setUp(self): + super().setUp() + build_files(DistInfoPkgWithDotLegacy.files, self.site_dir) + class DistInfoPkgOffPath(SiteDir): def setUp(self): - super(DistInfoPkgOffPath, self).setUp() + super().setUp() build_files(DistInfoPkg.files, self.site_dir) class EggInfoPkg(OnSysPath, SiteDir): - files = { + files: FilesDef = { "egginfo_pkg.egg-info": { "PKG-INFO": """ Name: egginfo-pkg @@ -119,6 +180,9 @@ class EggInfoPkg(OnSysPath, SiteDir): Version: 1.0.0 Classifier: Intended Audience :: Developers Classifier: Topic :: Software Development :: Libraries + Keywords: sample package + Description: Once upon a time + There was an egginfo package """, "SOURCES.txt": """ mod.py @@ -133,21 +197,21 @@ class EggInfoPkg(OnSysPath, SiteDir): [test] pytest """, - "top_level.txt": "mod\n" - }, + "top_level.txt": "mod\n", + }, "mod.py": """ def main(): print("hello world") """, - } + } def setUp(self): - super(EggInfoPkg, self).setUp() + super().setUp() build_files(EggInfoPkg.files, prefix=self.site_dir) class EggInfoFile(OnSysPath, SiteDir): - files = { + files: FilesDef = { "egginfo_file.egg-info": """ Metadata-Version: 1.0 Name: egginfo_file @@ -160,20 +224,20 @@ class EggInfoFile(OnSysPath, SiteDir): Description: UNKNOWN Platform: UNKNOWN """, - } + } def setUp(self): - super(EggInfoFile, self).setUp() + super().setUp() build_files(EggInfoFile.files, prefix=self.site_dir) class LocalPackage: - files = { + files: FilesDef = { "setup.py": """ import setuptools setuptools.setup(name="local-pkg", version="2.0.1") """, - } + } def setUp(self): self.fixtures = contextlib.ExitStack() @@ -212,14 +276,13 @@ def build_files(file_defs, prefix=pathlib.Path()): with full_name.open('wb') as f: f.write(contents) else: - with full_name.open('w') as f: + with full_name.open('w', encoding='utf-8') as f: f.write(DALS(contents)) class FileBuilder: def unicode_filename(self): - return test.support.FS_NONASCII or \ - self.skip("File system does not support non-ascii.") + return FS_NONASCII or self.skip("File system does not support non-ascii.") def DALS(str): @@ -230,3 +293,19 @@ def DALS(str): class NullFinder: def find_module(self, name): pass + + +class ZipFixtures: + root = 'tests.data' + + def _fixture_on_path(self, filename): + pkg_file = resources.files(self.root).joinpath(filename) + file = self.resources.enter_context(resources.as_file(pkg_file)) + assert file.name.startswith('example'), file.name + sys.path.insert(0, str(file)) + self.resources.callback(sys.path.pop, 0) + + def setUp(self): + # Add self.zip_name to the front of sys.path. + self.resources = contextlib.ExitStack() + self.addCleanup(self.resources.close) diff --git a/tests/py39compat.py b/tests/py39compat.py new file mode 100644 index 00000000..926dcad9 --- /dev/null +++ b/tests/py39compat.py @@ -0,0 +1,4 @@ +try: + from test.support.os_helper import FS_NONASCII +except ImportError: + from test.support import FS_NONASCII # noqa diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..7e340142 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,327 @@ +import re +import textwrap +import unittest +import warnings +import importlib +import contextlib + +from . import fixtures +from importlib_metadata import ( + Distribution, + PackageNotFoundError, + distribution, + entry_points, + files, + metadata, + requires, + version, +) + + +@contextlib.contextmanager +def suppress_known_deprecation(): + with warnings.catch_warnings(record=True) as ctx: + warnings.simplefilter('default', category=DeprecationWarning) + yield ctx + + +class APITests( + fixtures.EggInfoPkg, + fixtures.DistInfoPkg, + fixtures.DistInfoPkgWithDot, + fixtures.EggInfoFile, + unittest.TestCase, +): + + version_pattern = r'\d+\.\d+(\.\d)?' + + def test_retrieves_version_of_self(self): + pkg_version = version('egginfo-pkg') + assert isinstance(pkg_version, str) + assert re.match(self.version_pattern, pkg_version) + + def test_retrieves_version_of_distinfo_pkg(self): + pkg_version = version('distinfo-pkg') + assert isinstance(pkg_version, str) + assert re.match(self.version_pattern, pkg_version) + + def test_for_name_does_not_exist(self): + with self.assertRaises(PackageNotFoundError): + distribution('does-not-exist') + + def test_name_normalization(self): + names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot' + for name in names: + with self.subTest(name): + assert distribution(name).metadata['Name'] == 'pkg.dot' + + def test_prefix_not_matched(self): + prefixes = 'p', 'pkg', 'pkg.' + for prefix in prefixes: + with self.subTest(prefix): + with self.assertRaises(PackageNotFoundError): + distribution(prefix) + + def test_for_top_level(self): + self.assertEqual( + distribution('egginfo-pkg').read_text('top_level.txt').strip(), 'mod' + ) + + def test_read_text(self): + top_level = [ + path for path in files('egginfo-pkg') if path.name == 'top_level.txt' + ][0] + self.assertEqual(top_level.read_text(), 'mod\n') + + def test_entry_points(self): + eps = entry_points() + assert 'entries' in eps.groups + entries = eps.select(group='entries') + assert 'main' in entries.names + ep = entries['main'] + self.assertEqual(ep.value, 'mod:main') + self.assertEqual(ep.extras, []) + + def test_entry_points_distribution(self): + entries = entry_points(group='entries') + for entry in ("main", "ns:sub"): + ep = entries[entry] + self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg')) + self.assertEqual(ep.dist.version, "1.0.0") + + def test_entry_points_unique_packages(self): + """ + Entry points should only be exposed for the first package + on sys.path with a given name. + """ + alt_site_dir = self.fixtures.enter_context(fixtures.tempdir()) + self.fixtures.enter_context(self.add_sys_path(alt_site_dir)) + alt_pkg = { + "distinfo_pkg-1.1.0.dist-info": { + "METADATA": """ + Name: distinfo-pkg + Version: 1.1.0 + """, + "entry_points.txt": """ + [entries] + main = mod:altmain + """, + }, + } + fixtures.build_files(alt_pkg, alt_site_dir) + entries = entry_points(group='entries') + assert not any( + ep.dist.name == 'distinfo-pkg' and ep.dist.version == '1.0.0' + for ep in entries + ) + # ns:sub doesn't exist in alt_pkg + assert 'ns:sub' not in entries.names + + def test_entry_points_missing_name(self): + with self.assertRaises(KeyError): + entry_points(group='entries')['missing'] + + def test_entry_points_missing_group(self): + assert entry_points(group='missing') == () + + def test_entry_points_dict_construction(self): + """ + Prior versions of entry_points() returned simple lists and + allowed casting those lists into maps by name using ``dict()``. + Capture this now deprecated use-case. + """ + with suppress_known_deprecation() as caught: + eps = dict(entry_points(group='entries')) + + assert 'main' in eps + assert eps['main'] == entry_points(group='entries')['main'] + + # check warning + expected = next(iter(caught)) + assert expected.category is DeprecationWarning + assert "Construction of dict of EntryPoints is deprecated" in str(expected) + + def test_entry_points_by_index(self): + """ + Prior versions of Distribution.entry_points would return a + tuple that allowed access by index. + Capture this now deprecated use-case + See python/importlib_metadata#300 and bpo-44246. + """ + eps = distribution('distinfo-pkg').entry_points + with suppress_known_deprecation() as caught: + eps[0] + + # check warning + expected = next(iter(caught)) + assert expected.category is DeprecationWarning + assert "Accessing entry points by index is deprecated" in str(expected) + + def test_entry_points_groups_getitem(self): + """ + Prior versions of entry_points() returned a dict. Ensure + that callers using '.__getitem__()' are supported but warned to + migrate. + """ + with suppress_known_deprecation(): + entry_points()['entries'] == entry_points(group='entries') + + with self.assertRaises(KeyError): + entry_points()['missing'] + + def test_entry_points_groups_get(self): + """ + Prior versions of entry_points() returned a dict. Ensure + that callers using '.get()' are supported but warned to + migrate. + """ + with suppress_known_deprecation(): + entry_points().get('missing', 'default') == 'default' + entry_points().get('entries', 'default') == entry_points()['entries'] + entry_points().get('missing', ()) == () + + def test_metadata_for_this_package(self): + md = metadata('egginfo-pkg') + assert md['author'] == 'Steven Ma' + assert md['LICENSE'] == 'Unknown' + assert md['Name'] == 'egginfo-pkg' + classifiers = md.get_all('Classifier') + assert 'Topic :: Software Development :: Libraries' in classifiers + + def test_importlib_metadata_version(self): + resolved = version('importlib-metadata') + assert re.match(self.version_pattern, resolved) + + @staticmethod + def _test_files(files): + root = files[0].root + for file in files: + assert file.root == root + assert not file.hash or file.hash.value + assert not file.hash or file.hash.mode == 'sha256' + assert not file.size or file.size >= 0 + assert file.locate().exists() + assert isinstance(file.read_binary(), bytes) + if file.name.endswith('.py'): + file.read_text() + + def test_file_hash_repr(self): + util = [p for p in files('distinfo-pkg') if p.name == 'mod.py'][0] + self.assertRegex(repr(util.hash), '') + + def test_files_dist_info(self): + self._test_files(files('distinfo-pkg')) + + def test_files_egg_info(self): + self._test_files(files('egginfo-pkg')) + + def test_version_egg_info_file(self): + self.assertEqual(version('egginfo-file'), '0.1') + + def test_requires_egg_info_file(self): + requirements = requires('egginfo-file') + self.assertIsNone(requirements) + + def test_requires_egg_info(self): + deps = requires('egginfo-pkg') + assert len(deps) == 2 + assert any(dep == 'wheel >= 1.0; python_version >= "2.7"' for dep in deps) + + def test_requires_dist_info(self): + deps = requires('distinfo-pkg') + assert len(deps) == 2 + assert all(deps) + assert 'wheel >= 1.0' in deps + assert "pytest; extra == 'test'" in deps + + def test_more_complex_deps_requires_text(self): + requires = textwrap.dedent( + """ + dep1 + dep2 + + [:python_version < "3"] + dep3 + + [extra1] + dep4 + dep6@ git+https://example.com/python/dep.git@v1.0.0 + + [extra2:python_version < "3"] + dep5 + """ + ) + deps = sorted(Distribution._deps_from_requires_text(requires)) + expected = [ + 'dep1', + 'dep2', + 'dep3; python_version < "3"', + 'dep4; extra == "extra1"', + 'dep5; (python_version < "3") and extra == "extra2"', + 'dep6@ git+https://example.com/python/dep.git@v1.0.0 ; extra == "extra1"', + ] + # It's important that the environment marker expression be + # wrapped in parentheses to avoid the following 'and' binding more + # tightly than some other part of the environment expression. + + assert deps == expected + + def test_as_json(self): + md = metadata('distinfo-pkg').json + assert 'name' in md + assert md['keywords'] == ['sample', 'package'] + desc = md['description'] + assert desc.startswith('Once upon a time\nThere was') + assert len(md['requires_dist']) == 2 + + def test_as_json_egg_info(self): + md = metadata('egginfo-pkg').json + assert 'name' in md + assert md['keywords'] == ['sample', 'package'] + desc = md['description'] + assert desc.startswith('Once upon a time\nThere was') + assert len(md['classifier']) == 2 + + def test_as_json_odd_case(self): + self.make_uppercase() + md = metadata('distinfo-pkg').json + assert 'name' in md + assert len(md['requires_dist']) == 2 + assert md['keywords'] == ['SAMPLE', 'PACKAGE'] + + +class LegacyDots(fixtures.DistInfoPkgWithDotLegacy, unittest.TestCase): + def test_name_normalization(self): + names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot' + for name in names: + with self.subTest(name): + assert distribution(name).metadata['Name'] == 'pkg.dot' + + def test_name_normalization_versionless_egg_info(self): + names = 'pkg.lot', 'pkg_lot', 'pkg-lot', 'pkg..lot', 'Pkg.Lot' + for name in names: + with self.subTest(name): + assert distribution(name).metadata['Name'] == 'pkg.lot' + + +class OffSysPathTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): + def test_find_distributions_specified_path(self): + dists = Distribution.discover(path=[str(self.site_dir)]) + assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists) + + def test_distribution_at_pathlib(self): + """Demonstrate how to load metadata direct from a directory.""" + dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' + dist = Distribution.at(dist_info_path) + assert dist.version == '1.0.0' + + def test_distribution_at_str(self): + dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' + dist = Distribution.at(str(dist_info_path)) + assert dist.version == '1.0.0' + + +class InvalidateCache(unittest.TestCase): + def test_invalidate_cache(self): + # No externally observable behavior, but ensures test coverage... + importlib.invalidate_caches() diff --git a/importlib_metadata/tests/test_integration.py b/tests/test_integration.py similarity index 62% rename from importlib_metadata/tests/test_integration.py rename to tests/test_integration.py index cbb940bd..00e9021a 100644 --- a/importlib_metadata/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,26 +1,24 @@ -# coding: utf-8 - -from __future__ import unicode_literals - import unittest import packaging.requirements import packaging.version from . import fixtures -from .. import ( +from importlib_metadata import ( Distribution, + MetadataPathFinder, _compat, + distributions, version, - ) +) class IntegrationTests(fixtures.DistInfoPkg, unittest.TestCase): - def test_package_spec_installed(self): """ Illustrate the recommended procedure to determine if a specified version of a package is installed. """ + def is_installed(package_spec): req = packaging.requirements.Requirement(package_spec) return version(req.name) in req.specifier @@ -31,19 +29,18 @@ def is_installed(package_spec): class FinderTests(fixtures.Fixtures, unittest.TestCase): - def test_finder_without_module(self): class ModuleFreeFinder(fixtures.NullFinder): """ A finder without an __module__ attribute """ + def __getattribute__(self, name): if name == '__module__': raise AttributeError(name) return super().__getattribute__(name) - self.fixtures.enter_context( - fixtures.install_finder(ModuleFreeFinder())) + self.fixtures.enter_context(fixtures.install_finder(ModuleFreeFinder())) _compat.disable_stdlib_finder() @@ -52,3 +49,27 @@ def test_find_local(self): dist = Distribution._local() assert dist.metadata['Name'] == 'local-pkg' assert dist.version == '2.0.1' + + +class DistSearch(unittest.TestCase): + def test_search_dist_dirs(self): + """ + Pip needs the _search_paths interface to locate + distribution metadata dirs. Protect it for PyPA + use-cases (only). Ref python/importlib_metadata#111. + """ + res = MetadataPathFinder._search_paths('any-name', []) + assert list(res) == [] + + def test_interleaved_discovery(self): + """ + When the search is cached, it is + possible for searches to be interleaved, so make sure + those use-cases are safe. + + Ref #293 + """ + dists = distributions() + next(dists) + version('importlib_metadata') + next(dists) diff --git a/importlib_metadata/tests/test_main.py b/tests/test_main.py similarity index 70% rename from importlib_metadata/tests/test_main.py rename to tests/test_main.py index 4ffdd5d6..1a64af56 100644 --- a/importlib_metadata/tests/test_main.py +++ b/tests/test_main.py @@ -1,26 +1,25 @@ -# coding: utf-8 -from __future__ import unicode_literals - import re import json import pickle import textwrap import unittest +import warnings import importlib import importlib_metadata import pyfakefs.fake_filesystem_unittest as ffs from . import fixtures -from .. import ( - Distribution, EntryPoint, MetadataPathFinder, - PackageNotFoundError, distributions, - entry_points, metadata, version, - ) - -try: - from builtins import str as text -except ImportError: - from __builtin__ import unicode as text +from importlib_metadata import ( + Distribution, + EntryPoint, + MetadataPathFinder, + PackageNotFoundError, + distributions, + entry_points, + metadata, + packages_distributions, + version, +) class BasicTests(fixtures.DistInfoPkg, unittest.TestCase): @@ -28,7 +27,7 @@ class BasicTests(fixtures.DistInfoPkg, unittest.TestCase): def test_retrieves_version_of_self(self): dist = Distribution.from_name('distinfo-pkg') - assert isinstance(dist.version, text) + assert isinstance(dist.version, str) assert re.match(self.version_pattern, dist.version) def test_for_name_does_not_exist(self): @@ -60,13 +59,11 @@ def test_import_nonexistent_module(self): importlib.import_module('does_not_exist') def test_resolve(self): - entries = dict(entry_points()['entries']) - ep = entries['main'] + ep = entry_points(group='entries')['main'] self.assertEqual(ep.load().__name__, "main") def test_entrypoint_with_colon_in_name(self): - entries = dict(entry_points()['entries']) - ep = entries['ns:sub'] + ep = entry_points(group='entries')['ns:sub'] self.assertEqual(ep.value, 'mod:main') def test_resolve_without_attr(self): @@ -74,12 +71,11 @@ def test_resolve_without_attr(self): name='ep', value='importlib_metadata', group='grp', - ) + ) assert ep.load() is importlib_metadata -class NameNormalizationTests( - fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): +class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod def pkg_with_dashes(site_dir): """ @@ -89,7 +85,7 @@ def pkg_with_dashes(site_dir): metadata_dir = site_dir / 'my_pkg.dist-info' metadata_dir.mkdir() metadata = metadata_dir / 'METADATA' - with metadata.open('w') as strm: + with metadata.open('w', encoding='utf-8') as strm: strm.write('Version: 1.0\n') return 'my-pkg' @@ -110,7 +106,7 @@ def pkg_with_mixed_case(site_dir): metadata_dir = site_dir / 'CherryPy.dist-info' metadata_dir.mkdir() metadata = metadata_dir / 'METADATA' - with metadata.open('w') as strm: + with metadata.open('w', encoding='utf-8') as strm: strm.write('Version: 1.0\n') return 'CherryPy' @@ -135,7 +131,7 @@ def pkg_with_non_ascii_description(site_dir): metadata_dir.mkdir() metadata = metadata_dir / 'METADATA' with metadata.open('w', encoding='utf-8') as fp: - fp.write('Description: pôrˈtend\n') + fp.write('Description: pôrˈtend') return 'portend' @staticmethod @@ -148,11 +144,15 @@ def pkg_with_non_ascii_description_egg_info(site_dir): metadata_dir.mkdir() metadata = metadata_dir / 'METADATA' with metadata.open('w', encoding='utf-8') as fp: - fp.write(textwrap.dedent(""" + fp.write( + textwrap.dedent( + """ Name: portend pôrˈtend - """).lstrip()) + """ + ).strip() + ) return 'portend' def test_metadata_loads(self): @@ -163,27 +163,15 @@ def test_metadata_loads(self): def test_metadata_loads_egg_info(self): pkg_name = self.pkg_with_non_ascii_description_egg_info(self.site_dir) meta = metadata(pkg_name) - assert meta.get_payload() == 'pôrˈtend\n' - + assert meta['Description'] == 'pôrˈtend' -class DiscoveryTests(fixtures.EggInfoPkg, - fixtures.DistInfoPkg, - unittest.TestCase): +class DiscoveryTests(fixtures.EggInfoPkg, fixtures.DistInfoPkg, unittest.TestCase): def test_package_discovery(self): dists = list(distributions()) - assert all( - isinstance(dist, Distribution) - for dist in dists - ) - assert any( - dist.metadata['Name'] == 'egginfo-pkg' - for dist in dists - ) - assert any( - dist.metadata['Name'] == 'distinfo-pkg' - for dist in dists - ) + assert all(isinstance(dist, Distribution) for dist in dists) + assert any(dist.metadata['Name'] == 'egginfo-pkg' for dist in dists) + assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists) def test_invalid_usage(self): with self.assertRaises(ValueError): @@ -221,7 +209,7 @@ class InaccessibleSysPath(fixtures.OnSysPath, ffs.TestCase): site_dir = '/access-denied' def setUp(self): - super(InaccessibleSysPath, self).setUp() + super().setUp() self.setUpPyfakefs() self.fs.create_dir(self.site_dir, perm_bits=000) @@ -235,13 +223,21 @@ def test_discovery(self): class TestEntryPoints(unittest.TestCase): def __init__(self, *args): - super(TestEntryPoints, self).__init__(*args) - self.ep = importlib_metadata.EntryPoint('name', 'value', 'group') + super().__init__(*args) + self.ep = importlib_metadata.EntryPoint( + name='name', value='value', group='group' + ) def test_entry_point_pickleable(self): revived = pickle.loads(pickle.dumps(self.ep)) assert revived == self.ep + def test_positional_args(self): + """ + Capture legacy (namedtuple) construction, discouraged. + """ + EntryPoint('name', 'value', 'group') + def test_immutable(self): """EntryPoints should be immutable""" with self.assertRaises(AttributeError): @@ -261,7 +257,8 @@ def test_json_dump(self): json should not expect to be able to dump an EntryPoint """ with self.assertRaises(Exception): - json.dumps(self.ep) + with warnings.catch_warnings(record=True): + json.dumps(self.ep) def test_module(self): assert self.ep.module == 'value' @@ -269,10 +266,21 @@ def test_module(self): def test_attr(self): assert self.ep.attr is None + def test_sortable(self): + """ + EntryPoint objects are sortable, but result is undefined. + """ + sorted( + [ + EntryPoint(name='b', value='val', group='group'), + EntryPoint(name='a', value='val', group='group'), + ] + ) + class FileSystem( - fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, - unittest.TestCase): + fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, unittest.TestCase +): def test_unicode_dir_on_sys_path(self): """ Ensure a Unicode subdirectory of a directory on sys.path @@ -281,5 +289,40 @@ def test_unicode_dir_on_sys_path(self): fixtures.build_files( {self.unicode_filename(): {}}, prefix=self.site_dir, - ) + ) list(distributions()) + + +class PackagesDistributionsPrebuiltTest(fixtures.ZipFixtures, unittest.TestCase): + def test_packages_distributions_example(self): + self._fixture_on_path('example-21.12-py3-none-any.whl') + assert packages_distributions()['example'] == ['example'] + + def test_packages_distributions_example2(self): + """ + Test packages_distributions on a wheel built + by trampolim. + """ + self._fixture_on_path('example2-1.0.0-py3-none-any.whl') + assert packages_distributions()['example2'] == ['example2'] + + +class PackagesDistributionsTest( + fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase +): + def test_packages_distributions_neither_toplevel_nor_files(self): + """ + Test a package built without 'top-level.txt' or a file list. + """ + fixtures.build_files( + { + 'trim_example-1.0.0.dist-info': { + 'METADATA': """ + Name: trim_example + Version: 1.0.0 + """, + } + }, + prefix=self.site_dir, + ) + packages_distributions() diff --git a/importlib_metadata/tests/test_zip.py b/tests/test_zip.py similarity index 50% rename from importlib_metadata/tests/test_zip.py rename to tests/test_zip.py index 4aae933d..01aba6df 100644 --- a/importlib_metadata/tests/test_zip.py +++ b/tests/test_zip.py @@ -1,39 +1,20 @@ import sys import unittest -from .. import ( - distribution, entry_points, files, PackageNotFoundError, - version, distributions, - ) - -try: - from importlib import resources - getattr(resources, 'files') - getattr(resources, 'as_file') -except (ImportError, AttributeError): - import importlib_resources as resources - -try: - from contextlib import ExitStack -except ImportError: - from contextlib2 import ExitStack - - -class TestZip(unittest.TestCase): - root = 'importlib_metadata.tests.data' - - def _fixture_on_path(self, filename): - pkg_file = resources.files(self.root).joinpath(filename) - file = self.resources.enter_context(resources.as_file(pkg_file)) - assert file.name.startswith('example-'), file.name - sys.path.insert(0, str(file)) - self.resources.callback(sys.path.pop, 0) - +from . import fixtures +from importlib_metadata import ( + PackageNotFoundError, + distribution, + distributions, + entry_points, + files, + version, +) + + +class TestZip(fixtures.ZipFixtures, unittest.TestCase): def setUp(self): - # Find the path to the example-*.whl so we can add it to the front of - # sys.path, where we'll then try to find the metadata thereof. - self.resources = ExitStack() - self.addCleanup(self.resources.close) + super().setUp() self._fixture_on_path('example-21.12-py3-none-any.whl') def test_zip_version(self): @@ -44,7 +25,7 @@ def test_zip_version_does_not_match(self): version('definitely-not-installed') def test_zip_entry_points(self): - scripts = dict(entry_points()['console_scripts']) + scripts = entry_points(group='console_scripts') entry_point = scripts['example'] self.assertEqual(entry_point.value, 'example:main') entry_point = scripts['Example'] @@ -68,13 +49,14 @@ def test_one_distribution(self): class TestEgg(TestZip): def setUp(self): - # Find the path to the example-*.egg so we can add it to the front of - # sys.path, where we'll then try to find the metadata thereof. - self.resources = ExitStack() - self.addCleanup(self.resources.close) + super().setUp() self._fixture_on_path('example-21.12-py3.6.egg') def test_files(self): for file in files('example'): path = str(file.dist.locate_file(file)) assert '.egg/' in path, path + + def test_normalized_name(self): + dist = distribution('example') + assert dist._normalized_name == 'example' diff --git a/tox.ini b/tox.ini index b2775cd1..a0ce7c61 100644 --- a/tox.ini +++ b/tox.ini @@ -1,97 +1,50 @@ [tox] -envlist = {py27,py35,py36,py37,py38}{,-cov,-diffcov},qa,docs,perf -skip_missing_interpreters = True +envlist = python minversion = 3.2 -# Ensure that a late version of pip is used even on tox-venv. -requires = - tox-pip-version>=0.0.6 +# https://github.com/jaraco/skeleton/issues/6 +tox_pip_extensions_ext_venv_update = true +toxworkdir={env:TOX_WORK_DIR:.tox} + [testenv] -pip_version = pip -commands = - !cov,!diffcov: python -m unittest discover {posargs} - cov,diffcov: python -m coverage run {[coverage]rc} -m unittest discover {posargs} - cov,diffcov: python -m coverage combine {[coverage]rc} - cov: python -m coverage html {[coverage]rc} - cov: python -m coverage xml {[coverage]rc} - cov: python -m coverage report -m {[coverage]rc} --fail-under=100 - diffcov: python -m coverage xml {[coverage]rc} - diffcov: diff-cover coverage.xml --html-report diffcov.html - diffcov: diff-cover coverage.xml --fail-under=100 -usedevelop = True -passenv = - PYTHON* - LANG* - LC_* - PYV deps = - cov,diffcov: coverage>=4.5 - diffcov: diff_cover - pyfakefs -setenv = - cov: COVERAGE_PROCESS_START={[coverage]rcfile} - cov: COVERAGE_OPTIONS="-p" - cov: COVERAGE_FILE={toxinidir}/.coverage - py27: PYV=2 - py35,py36,py37,py38: PYV=3 - # workaround deprecation warnings in pip's vendored packages - PYTHONWARNINGS=ignore:Using or importing the ABCs:DeprecationWarning:pip._vendor -extras = - testing - - -[testenv:qa] -basepython = python3.7 commands = - python -m flake8 importlib_metadata - mypy importlib_metadata -deps = - mypy - flake8 - flufl.flake8 -extras = + pytest {posargs} +passenv = + HOME +usedevelop = True +extras = testing [testenv:docs] -basepython = python3 -commands = - sphinx-build importlib_metadata/docs build/sphinx/html extras = - docs - + docs + testing +changedir = docs +commands = + python -m sphinx -W --keep-going . {toxinidir}/build/html -[testenv:perf] -use_develop = False +[testenv:diffcov] deps = - ipython + diff-cover commands = - python -m timeit -s 'import importlib_metadata' -- 'importlib_metadata.distribution("ipython")' - + pytest {posargs} --cov-report xml + diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html + diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 [testenv:release] -basepython = python3 +skip_install = True deps = - twine - wheel - setuptools - keyring - setuptools_scm + build + twine>=3 + jaraco.develop>=7.1 passenv = - TWINE_PASSWORD + TWINE_PASSWORD + GITHUB_TOKEN setenv = - TWINE_USERNAME = {env:TWINE_USERNAME:__token__} + TWINE_USERNAME = {env:TWINE_USERNAME:__token__} commands = - python setup.py sdist bdist_wheel - python -m twine {posargs} upload dist/* - - -[coverage] -rcfile = {toxinidir}/coverage.ini -rc = --rcfile="{[coverage]rcfile}" - - -[flake8] -hang-closing = True -jobs = 1 -max-line-length = 79 -enable-extensions = U4 + python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" + python -m build + python -m twine upload dist/* + python -m jaraco.develop.create-github-release