diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..98113f51 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,10 @@ +[run] +omit = + # leading `*/` for pytest-dev/pytest-cov#456 + */.tox/* + tests/* + prepare/* + */_itertools.py + +[report] +show_missing = True diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6385b573 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 4 +insert_final_newline = true +end_of_line = lf + +[*.py] +indent_style = space + +[*.{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/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 00000000..4f70acfb --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,27 @@ +name: automerge +on: + pull_request: + types: + - labeled + - unlabeled + - synchronize + - opened + - edited + - ready_for_review + - reopened + - unlocked + pull_request_review: + types: + - submitted + check_suite: + types: + - completed + status: {} +jobs: + automerge: + runs-on: ubuntu-latest + steps: + - name: automerge + uses: "pascalgn/automerge-action@v0.12.0" + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..1853ace5 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,76 @@ +name: tests + +on: [push, pull_request] + +jobs: + test: + strategy: + matrix: + python: [3.6, 3.8, 3.9] + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v2 + - 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 + + benchmark: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install tox + run: | + python -m pip install tox + - name: Run benchmarks + run: tox + env: + TOXENV: perf + + 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.9 + - 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..c15ab0c9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: +- repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + +- repo: https://github.com/asottile/blacken-docs + rev: v1.9.1 + hooks: + - id: blacken-docs 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 69% rename from importlib_metadata/docs/changelog.rst rename to CHANGES.rst index b7e93b5d..32565111 100644 --- a/importlib_metadata/docs/changelog.rst +++ b/CHANGES.rst @@ -1,6 +1,156 @@ -========================= - importlib_metadata NEWS -========================= +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. + +* #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. + + Construction of entry points using + ``dict([EntryPoint, ...])`` is now deprecated and raises + an appropriate DeprecationWarning and will be removed in + a future version. + +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 +272,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 +281,7 @@ v1.0.0 0.22 ==== + * Renamed ``package`` parameter to ``distribution_name`` as `recommended `_ in the following functions: ``distribution``, ``metadata``, @@ -140,6 +292,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 +310,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 +318,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 +355,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 +382,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 +391,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 +420,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 +430,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 +439,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 +448,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..512c3cc2 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,30 @@ -========================= - ``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 + + +Library to access the metadata for a Python package. + +As of Python 3.8, this functionality has been added to the +`Python standard library +`_. +This package supplies backports of that functionality including +improvements added to subsequent Python versions. Usage @@ -30,7 +51,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..512c16c8 --- /dev/null +++ b/conftest.py @@ -0,0 +1,4 @@ +collect_ignore = [ + # this module fails mypy tests because 'setup.py' matches './setup.py' + 'prepare/example/setup.py', +] 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/conf.py b/docs/conf.py new file mode 100644 index 00000000..433d185d --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +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}/', + ), + ], + ) +} 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/importlib_metadata/docs/index.rst b/docs/index.rst similarity index 66% rename from importlib_metadata/docs/index.rst rename to docs/index.rst index 530197cf..b6cffcc2 100644 --- a/importlib_metadata/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,5 @@ -=============================== - Welcome to importlib_metadata -=============================== +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 @@ -10,9 +9,11 @@ Python 3.7 and newer (backported as :doc:`importlib_resources ` 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. +``importlib_metadata`` supplies a backport of +:doc:`importlib.metadata ` as found in +Python 3.8 and later for earlier Python releases. Users of +Python 3.8 and beyond are encouraged to use the standard library module +when possible and fall back to ``importlib_metadata`` when necessary. 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 @@ -22,20 +23,19 @@ The documentation here includes a general :ref:`usage ` guide. .. toctree:: - :maxdepth: 2 - :caption: Contents: + :maxdepth: 1 - using.rst - changelog (links).rst + using + history 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/ Indices and tables diff --git a/importlib_metadata/docs/using.rst b/docs/using.rst similarity index 79% rename from importlib_metadata/docs/using.rst rename to docs/using.rst index 11965147..17d6f590 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,8 +149,9 @@ 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.*' @@ -179,6 +221,17 @@ function:: ["pytest (>=3.0.0) ; extra == 'test'", "pytest-cov ; extra == 'test'"] +Package distributions +--------------------- + +A convience 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 +252,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 +305,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/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 7031323d..7440acc4 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -1,41 +1,35 @@ -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 contextlib import collections +from ._collections import FreezableDefaultDict from ._compat import ( - install, NullFinder, - ConfigParser, - suppress, - map, - FileNotFoundError, - IsADirectoryError, - NotADirectoryError, - PermissionError, - pathlib, - ModuleNotFoundError, - MetaPathFinder, - email_message_from_string, + Protocol, PyPy_repr, - unique_ordered, - str, - ) + install, +) +from ._functools import method_cache +from ._itertools import unique_everseen + +from contextlib import suppress from importlib import import_module +from importlib.abc import MetaPathFinder from itertools import starmap - - -__metaclass__ = type +from typing import Any, List, Mapping, Optional, TypeVar, Union __all__ = [ @@ -47,9 +41,10 @@ 'entry_points', 'files', 'metadata', + 'packages_distributions', 'requires', 'version', - ] +] class PackageNotFoundError(ModuleNotFoundError): @@ -61,13 +56,67 @@ def __str__(self): @property def name(self): - name, = self.args + (name,) = self.args return name +class Sectioned: + """ + A simple entry point config parser for performance + + >>> res = Sectioned.get_sections(Sectioned._sample) + >>> sec, values = next(res) + >>> sec + 'sec1' + >>> [(key, value) for key, value in values] + [('a', '1'), ('b', '2')] + >>> sec, values = next(res) + >>> sec + 'sec2' + >>> [(key, value) for key, value in values] + [('a', '2')] + >>> list(res) + [] + """ + + _sample = textwrap.dedent( + """ + [sec1] + a = 1 + b = 2 + + [sec2] + a = 2 + """ + ).lstrip() + + def __init__(self): + self.section = None + + def __call__(self, line): + if line.startswith('[') and line.endswith(']'): + # new section + self.section = line.strip('[]') + return + return self.section + + @classmethod + def get_sections(cls, text): + lines = filter(None, map(str.strip, text.splitlines())) + return ( + (section, map(cls.parse_value, values)) + for section, values in itertools.groupby(lines, cls()) + if section is not None + ) + + @staticmethod + def parse_value(line): + return map(str.strip, line.split("=", 1)) + + class EntryPoint( - PyPy_repr, - collections.namedtuple('EntryPointBase', 'name value group')): + PyPy_repr, collections.namedtuple('EntryPointBase', 'name value group') +): """An entry point as defined by Python packaging conventions. See `the packaging docs on entry points @@ -79,7 +128,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 +145,8 @@ class EntryPoint( following the attr, and following any extras. """ + dist: Optional['Distribution'] = None + 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 +172,191 @@ 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): + self.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): return ( self.__class__, (self.name, self.value, self.group), - ) + ) + + def matches(self, **params): + attrs = (getattr(self, param) for param in params) + return all(map(operator.eq, params.values(), attrs)) + + +class EntryPoints(tuple): + """ + An immutable collection of selectable EntryPoint objects. + """ + + __slots__ = () + + def __getitem__(self, name): # -> EntryPoint: + """ + Get the EntryPoint in self matching 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 set(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 set(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)) + + @classmethod + def _from_text(cls, text): + return itertools.starmap(EntryPoint, cls._parse_groups(text or '')) + + @staticmethod + def _parse_groups(text): + return ( + (name, value, section) + for section, values in Sectioned.get_sections(text) + for name, value in values + ) + + +def flake8_bypass(func): + # defer inspect import as performance optimization. + import inspect + + is_flake8 = any('flake8' in str(frame.filename) for frame in inspect.stack()[:5]) + return func if not is_flake8 else lambda: None + + +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=2, + ) + + def __getitem__(self, name): + self._warn() + return super().__getitem__(name) + + def get(self, name, default=None): + flake8_bypass(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): @@ -178,6 +383,25 @@ def __repr__(self): return ''.format(self.mode, self.value) +_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 get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]: + """ + Return all values associated with a possibly multi-valued key. + """ + + class Distribution: """A Python distribution package.""" @@ -229,9 +453,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 +469,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) -> PackageMetadata: """Return the parsed metadata for this Distribution. The returned object will have keys that name the various bits of @@ -276,8 +499,13 @@ 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 email.message_from_string(text) + + @property + def name(self): + """Return the 'Name' metadata for the distribution package.""" + return self.metadata['Name'] @property def version(self): @@ -286,7 +514,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): @@ -343,9 +571,10 @@ 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')) - } + for section, results in itertools.groupby( + section_pairs, operator.itemgetter('section') + ) + } return cls._convert_egg_info_reqs_to_simple_reqs(sections) @staticmethod @@ -369,6 +598,7 @@ 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) @@ -438,9 +668,12 @@ class 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) @@ -457,48 +690,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 contextlib.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,10 +799,13 @@ 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): @@ -540,9 +818,15 @@ def __init__(self, path): 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): @@ -566,11 +850,11 @@ def distributions(**kwargs): return Distribution.discover(**kwargs) -def metadata(distribution_name): +def metadata(distribution_name) -> 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 +869,28 @@ 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. """ + unique = functools.partial(unique_everseen, key=operator.attrgetter('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): @@ -618,3 +910,20 @@ def requires(distribution_name): 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 (dist.read_text('top_level.txt') or '').split(): + pkg_to_dist[pkg].append(dist.metadata['Name']) + return dict(pkg_to_dist) diff --git a/importlib_metadata/_collections.py b/importlib_metadata/_collections.py new file mode 100644 index 00000000..6aa17c84 --- /dev/null +++ b/importlib_metadata/_collections.py @@ -0,0 +1,24 @@ +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() diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index 303d4a22..01128cce 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -1,59 +1,18 @@ -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 -if sys.version_info >= (3,): # pragma: nocover - from importlib.abc import MetaPathFinder -else: # pragma: nocover - class MetaPathFinder(object): - __metaclass__ = abc.ABCMeta +__all__ = ['install', 'NullFinder', 'PyPy_repr', '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 + """ + pytest-mypy complains here because: + error: Incompatible import of "Protocol" (imported name has type + "typing_extensions._SpecialForm", local name has type "typing._SpecialForm") + """ + from typing_extensions import Protocol # type: ignore def install(cls): @@ -77,11 +36,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 +51,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 +65,22 @@ 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. """ + 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 - - -# 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) diff --git a/importlib_metadata/_functools.py b/importlib_metadata/_functools.py new file mode 100644 index 00000000..73f50d00 --- /dev/null +++ b/importlib_metadata/_functools.py @@ -0,0 +1,85 @@ +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 diff --git a/importlib_metadata/_itertools.py b/importlib_metadata/_itertools.py new file mode 100644 index 00000000..dd45f2f0 --- /dev/null +++ b/importlib_metadata/_itertools.py @@ -0,0 +1,19 @@ +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 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/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/pyproject.toml b/pyproject.toml index e5c3a6a4..b6ebc0be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,20 @@ [build-system] -requires = ["setuptools>=30.3", "wheel", "setuptools_scm"] +requires = ["setuptools>=42", "wheel", "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..6bf69af1 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +norecursedirs=dist build .tox .eggs +addopts=--doctest-modules +doctest_optionflags=ALLOW_UNICODE ELLIPSIS +# workaround for warning pytest-dev/pytest#6178 +junit_family=xunit2 +filterwarnings= diff --git a/setup.cfg b/setup.cfg index eee6caf2..8974f885 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,61 +1,63 @@ [metadata] +license_files = + LICENSE 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: + zipp>=0.5 + typing-extensions>=3.6.4; python_version < "3.8" +setup_requires = setuptools_scm[toml] >= 3.4.1 -[options.package_data] -* = *.zip, *.file, *.txt, *.toml -importlib_metadata = - docs/* - docs/_static/* -importlib_metadata.tests.data = - *.egg - *.whl +[options.packages.find] +exclude = + build* + dist* + docs* + tests* -[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 +[options.extras_require] +testing = + # upstream + pytest >= 4.6 + pytest-checkdocs >= 2.4 + pytest-flake8 + # python_implementation: workaround for jaraco/skeleton#22 + # python_version: workaround for python/typed_ast#156 + pytest-black >= 0.3.7; python_implementation != "PyPy" and python_version < "3.10" + pytest-cov + # python_implementation: workaround for jaraco/skeleton#22 + # python_version: workaround for python/typed_ast#156 + pytest-mypy; python_implementation != "PyPy" and python_version < "3.10" + pytest-enabler >= 1.0.1 -[mypy-importlib_metadata.docs.*] -ignore_errors: True + # local + importlib_resources>=1.3; python_version < "3.9" + packaging + pep517 + pyfakefs + flufl.flake8 -[mypy-importlib_metadata.tests.*] -ignore_errors: True +docs = + # upstream + sphinx + jaraco.packaging >= 8.2 + rst.linker >= 1.9 -[wheel] -universal=1 + # local -[options.extras_require] -testing = - importlib_resources>=1.3; python_version < "3.9" - packaging - pep517 -docs = - sphinx - rst.linker +[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/skeleton.md b/skeleton.md new file mode 100644 index 00000000..0938f892 --- /dev/null +++ b/skeleton.md @@ -0,0 +1,166 @@ +# Overview + +This project is merged with [skeleton](https://github.com/jaraco/skeleton). What is skeleton? It's the scaffolding of a Python project jaraco [introduced in his blog](https://blog.jaraco.com/a-project-skeleton-for-python-projects/). It seeks to provide a means to re-use techniques and inherit advances when managing projects for distribution. + +## An SCM-Managed Approach + +While maintaining dozens of projects in PyPI, jaraco derives best practices for project distribution and publishes them in the [skeleton repo](https://github.com/jaraco/skeleton), a Git repo capturing the evolution and culmination of these best practices. + +It's intended to be used by a new or existing project to adopt these practices and honed and proven techniques. Adopters are encouraged to use the project directly and maintain a small deviation from the technique, make their own fork for more substantial changes unique to their environment or preferences, or simply adopt the skeleton once and abandon it thereafter. + +The primary advantage to using an SCM for maintaining these techniques is that those tools help facilitate the merge between the template and its adopting projects. + +Another advantage to using an SCM-managed approach is that tools like GitHub recognize that a change in the skeleton is the _same change_ across all projects that merge with that skeleton. Without the ancestry, with a traditional copy/paste approach, a [commit like this](https://github.com/jaraco/skeleton/commit/12eed1326e1bc26ce256e7b3f8cd8d3a5beab2d5) would produce notifications in the upstream project issue for each and every application, but because it's centralized, GitHub provides just the one notification when the change is added to the skeleton. + +# Usage + +## new projects + +To use skeleton for a new project, simply pull the skeleton into a new project: + +``` +$ git init my-new-project +$ cd my-new-project +$ git pull gh://jaraco/skeleton +``` + +Now customize the project to suit your individual project needs. + +## existing projects + +If you have an existing project, you can still incorporate the skeleton by merging it into the codebase. + +``` +$ git merge skeleton --allow-unrelated-histories +``` + +The `--allow-unrelated-histories` is necessary because the history from the skeleton was previously unrelated to the existing codebase. Resolve any merge conflicts and commit to the master, and now the project is based on the shared skeleton. + +## Updating + +Whenever a change is needed or desired for the general technique for packaging, it can be made in the skeleton project and then merged into each of the derived projects as needed, recommended before each release. As a result, features and best practices for packaging are centrally maintained and readily trickle into a whole suite of packages. This technique lowers the amount of tedious work necessary to create or maintain a project, and coupled with other techniques like continuous integration and deployment, lowers the cost of creating and maintaining refined Python projects to just a few, familiar Git operations. + +For example, here's a session of the [path project](https://pypi.org/project/path) pulling non-conflicting changes from the skeleton: + + + +Thereafter, the target project can make whatever customizations it deems relevant to the scaffolding. The project may even at some point decide that the divergence is too great to merit renewed merging with the original skeleton. This approach applies maximal guidance while creating minimal constraints. + +## Periodic Collapse + +In late 2020, this project [introduced](https://github.com/jaraco/skeleton/issues/27) the idea of a periodic but infrequent (O(years)) collapse of commits to limit the number of commits a new consumer will need to accept to adopt the skeleton. + +The full history of commits is collapsed into a single commit and that commit becomes the new mainline head. + +When one of these collapse operations happens, any project that previously pulled from the skeleton will no longer have a related history with that new main branch. For those projects, the skeleton provides a "handoff" branch that reconciles the two branches. Any project that has previously merged with the skeleton but now gets an error "fatal: refusing to merge unrelated histories" should instead use the handoff branch once to incorporate the new main branch. + +``` +$ git pull https://github.com/jaraco/skeleton 2020-handoff +``` + +This handoff needs to be pulled just once and thereafter the project can pull from the main head. + +The archive and handoff branches from prior collapses are indicate here: + +| refresh | archive | handoff | +|---------|-----------------|--------------| +| 2020-12 | archive/2020-12 | 2020-handoff | + +# Features + +The features/techniques employed by the skeleton include: + +- PEP 517/518-based build relying on Setuptools as the build tool +- Setuptools declarative configuration using setup.cfg +- tox for running tests +- A README.rst as reStructuredText with some popular badges, but with Read the Docs and AppVeyor badges commented out +- A CHANGES.rst file intended for publishing release notes about the project +- Use of [Black](https://black.readthedocs.io/en/stable/) for code formatting (disabled on unsupported Python 3.5 and earlier) +- Integrated type checking through [mypy](https://github.com/python/mypy/). + +## Packaging Conventions + +A pyproject.toml is included to enable PEP 517 and PEP 518 compatibility and declares the requirements necessary to build the project on Setuptools (a minimum version compatible with setup.cfg declarative config). + +The setup.cfg file implements the following features: + +- Assumes universal wheel for release +- Advertises the project's LICENSE file (MIT by default) +- Reads the README.rst file into the long description +- Some common Trove classifiers +- Includes all packages discovered in the repo +- Data files in the package are also included (not just Python files) +- Declares the required Python versions +- Declares install requirements (empty by default) +- Declares setup requirements for legacy environments +- Supplies two 'extras': + - testing: requirements for running tests + - docs: requirements for building docs + - these extras split the declaration into "upstream" (requirements as declared by the skeleton) and "local" (those specific to the local project); these markers help avoid merge conflicts +- Placeholder for defining entry points + +Additionally, the setup.py file declares `use_scm_version` which relies on [setuptools_scm](https://pypi.org/project/setuptools_scm) to do two things: + +- derive the project version from SCM tags +- ensure that all files committed to the repo are automatically included in releases + +## Running Tests + +The skeleton assumes the developer has [tox](https://pypi.org/project/tox) installed. The developer is expected to run `tox` to run tests on the current Python version using [pytest](https://pypi.org/project/pytest). + +Other environments (invoked with `tox -e {name}`) supplied include: + + - a `docs` environment to build the documentation + - a `release` environment to publish the package to PyPI + +A pytest.ini is included to define common options around running tests. In particular: + +- rely on default test discovery in the current directory +- avoid recursing into common directories not containing tests +- run doctests on modules and invoke Flake8 tests +- in doctests, allow Unicode literals and regular literals to match, allowing for doctests to run on Python 2 and 3. Also enable ELLIPSES, a default that would be undone by supplying the prior option. +- filters out known warnings caused by libraries/functionality included by the skeleton + +Relies on a .flake8 file to correct some default behaviors: + +- disable mutually incompatible rules W503 and W504 +- support for Black format + +## Continuous Integration + +The project is pre-configured to run Continuous Integration tests. + +### Github Actions + +[Github Actions](https://docs.github.com/en/free-pro-team@latest/actions) are the preferred provider as they provide free, fast, multi-platform services with straightforward configuration. Configured in `.github/workflows`. + +Features include: +- test against multiple Python versions +- run on late (and updated) platform versions +- automated releases of tagged commits +- [automatic merging of PRs](https://github.com/marketplace/actions/merge-pull-requests) (requires [protecting branches with required status checks](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/enabling-required-status-checks), [not possible through API](https://github.community/t/set-all-status-checks-to-be-required-as-branch-protection-using-the-github-api/119493)) + + +### Continuous Deployments + +In addition to running tests, an additional publish stage is configured to automatically release tagged commits to PyPI using [API tokens](https://pypi.org/help/#apitoken). The release process expects an authorized token to be configured with each Github project (or org) `PYPI_TOKEN` [secret](https://docs.github.com/en/free-pro-team@latest/actions/reference/encrypted-secrets). Example: + +``` +pip-run -q jaraco.develop -- -m jaraco.develop.add-github-secrets +``` + +## Building Documentation + +Documentation is automatically built by [Read the Docs](https://readthedocs.org) when the project is registered with it, by way of the .readthedocs.yml file. To test the docs build manually, a tox env may be invoked as `tox -e docs`. Both techniques rely on the dependencies declared in `setup.cfg/options.extras_require.docs`. + +In addition to building the Sphinx docs scaffolded in `docs/`, the docs build a `history.html` file that first injects release dates and hyperlinks into the CHANGES.rst before incorporating it as history in the docs. + +## Cutting releases + +By default, tagged commits are released through the continuous integration deploy stage. + +Releases may also be cut manually by invoking the tox environment `release` with the PyPI token set as the TWINE_PASSWORD: + +``` +TWINE_PASSWORD={token} tox -e release +``` 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/importlib_metadata/tests/fixtures.py b/tests/fixtures.py similarity index 78% rename from importlib_metadata/tests/fixtures.py rename to tests/fixtures.py index 20982fa1..779c1a58 100644 --- a/importlib_metadata/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,16 +1,13 @@ -from __future__ import unicode_literals - import os import sys import shutil +import pathlib import tempfile import textwrap -import test.support - -from .._compat import pathlib, contextlib - +import contextlib -__metaclass__ = type +from .py39compat import FS_NONASCII +from typing import Dict, Union @contextlib.contextmanager @@ -75,8 +72,13 @@ def setUp(self): 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 @@ -90,19 +92,55 @@ class DistInfoPkg(OnSysPath, SiteDir): [entries] main = mod:main ns:sub = mod:main - """ - }, + """, + }, "mod.py": """ def main(): print("hello world") """, - } + } def setUp(self): super(DistInfoPkg, self).setUp() build_files(DistInfoPkg.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(DistInfoPkgWithDot, self).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(DistInfoPkgWithDotLegacy, self).setUp() + build_files(DistInfoPkgWithDotLegacy.files, self.site_dir) + + class DistInfoPkgOffPath(SiteDir): def setUp(self): super(DistInfoPkgOffPath, self).setUp() @@ -110,7 +148,7 @@ def setUp(self): class EggInfoPkg(OnSysPath, SiteDir): - files = { + files: FilesDef = { "egginfo_pkg.egg-info": { "PKG-INFO": """ Name: egginfo-pkg @@ -133,13 +171,13 @@ 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() @@ -147,7 +185,7 @@ def setUp(self): class EggInfoFile(OnSysPath, SiteDir): - files = { + files: FilesDef = { "egginfo_file.egg-info": """ Metadata-Version: 1.0 Name: egginfo_file @@ -160,7 +198,7 @@ class EggInfoFile(OnSysPath, SiteDir): Description: UNKNOWN Platform: UNKNOWN """, - } + } def setUp(self): super(EggInfoFile, self).setUp() @@ -168,12 +206,12 @@ def setUp(self): 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() @@ -218,8 +256,7 @@ def build_files(file_defs, prefix=pathlib.Path()): 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): diff --git a/tests/py39compat.py b/tests/py39compat.py new file mode 100644 index 00000000..a175d4c3 --- /dev/null +++ b/tests/py39compat.py @@ -0,0 +1,4 @@ +try: + from test.support.os_helpers 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..fef99033 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,284 @@ +import re +import textwrap +import unittest +import warnings +import importlib + +from . import fixtures +from importlib_metadata import ( + Distribution, + PackageNotFoundError, + distribution, + entry_points, + files, + metadata, + requires, + version, +) + + +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 + + 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 warnings.catch_warnings(record=True) 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_groups_getitem(self): + """ + Prior versions of entry_points() returned a dict. Ensure + that callers using '.__getitem__()' are supported but warned to + migrate. + """ + with warnings.catch_warnings(record=True): + 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 warnings.catch_warnings(record=True): + 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): + 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 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 85% rename from importlib_metadata/tests/test_main.py rename to tests/test_main.py index 4ffdd5d6..e8a66c0d 100644 --- a/importlib_metadata/tests/test_main.py +++ b/tests/test_main.py @@ -1,26 +1,24 @@ -# 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, + version, +) class BasicTests(fixtures.DistInfoPkg, unittest.TestCase): @@ -28,7 +26,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 +58,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 +70,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): """ @@ -148,11 +143,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()) + """ + ).lstrip() + ) return 'portend' def test_metadata_loads(self): @@ -166,24 +165,12 @@ def test_metadata_loads_egg_info(self): assert meta.get_payload() == 'pôrˈtend\n' -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): @@ -261,7 +248,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 +257,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('b', 'val', 'group'), + EntryPoint('a', 'val', '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 +280,5 @@ def test_unicode_dir_on_sys_path(self): fixtures.build_files( {self.unicode_filename(): {}}, prefix=self.site_dir, - ) + ) list(distributions()) diff --git a/importlib_metadata/tests/test_zip.py b/tests/test_zip.py similarity index 86% rename from importlib_metadata/tests/test_zip.py rename to tests/test_zip.py index 4aae933d..4279046d 100644 --- a/importlib_metadata/tests/test_zip.py +++ b/tests/test_zip.py @@ -1,26 +1,27 @@ import sys import unittest -from .. import ( - distribution, entry_points, files, PackageNotFoundError, - version, distributions, - ) +from contextlib import ExitStack +from importlib_metadata import ( + PackageNotFoundError, + distribution, + distributions, + entry_points, + files, + version, +) 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 + import importlib_resources as resources # type: ignore class TestZip(unittest.TestCase): - root = 'importlib_metadata.tests.data' + root = 'tests.data' def _fixture_on_path(self, filename): pkg_file = resources.files(self.root).joinpath(filename) @@ -44,7 +45,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'] diff --git a/tox.ini b/tox.ini index b2775cd1..771232be 100644 --- a/tox.ini +++ b/tox.ini @@ -1,97 +1,66 @@ [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 +deps = 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 + pytest {posargs} usedevelop = True -passenv = - PYTHON* - LANG* - LC_* - PYV -deps = - cov,diffcov: coverage>=4.5 - diffcov: diff_cover - pyfakefs +extras = testing 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 = + # workaround pypa/pip#9143 + PIP_USE_DEPRECATED=legacy-resolver [testenv:docs] -basepython = python3 -commands = - sphinx-build importlib_metadata/docs build/sphinx/html extras = - docs + docs + testing +changedir = docs +commands = + python -m sphinx . {toxinidir}/build/html +[testenv:diffcov] +deps = + diff-cover +commands = + 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:perf] use_develop = False deps = - ipython + ipython commands = - python -m timeit -s 'import importlib_metadata' -- 'importlib_metadata.distribution("ipython")' - + python -c 'print("Simple discovery performance")' + python -m timeit -s 'import importlib_metadata' -- 'importlib_metadata.distribution("ipython")' + python -c 'print("Entry point discovery performance")' + python -m timeit -s 'import importlib_metadata' -- 'importlib_metadata.entry_points()' + python -c 'print("Cached lookup performance")' + python -m timeit -s 'import importlib_metadata; importlib_metadata.distribution("ipython")' -- 'importlib_metadata.distribution("ipython")' + python -c 'print("Uncached lookup performance")' + python -m timeit -s 'import importlib, importlib_metadata' -- 'importlib.invalidate_caches(); importlib_metadata.distribution("ipython")' [testenv:release] -basepython = python3 +skip_install = True deps = - twine - wheel - setuptools - keyring - setuptools_scm + build + twine>=3 + path + 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 path; path.Path('dist').rmtree_p()" + python -m build + python -m twine upload dist/* + python -m jaraco.develop.create-github-release