From ef4fd81a122ea43ee0ec958fc86ab6e22291f11d Mon Sep 17 00:00:00 2001 From: Adriane Boyd Date: Fri, 7 Oct 2022 13:13:26 +0200 Subject: [PATCH] Updates around importlib_metadata for v1.0 (#48) * Move catalogue.py to module dir * Update setup * Add vendored importlib_metadata v3.2.0 * Move tests into package * Backport changes from #41 * Check for select rather than to-be-deprecated SelectableGroups * Set version to v1.0.1, update classifiers * Update CI --- azure-pipelines.yml | 79 ++- catalogue.py => catalogue/__init__.py | 12 +- catalogue/_importlib_metadata/LICENSE | 13 + catalogue/_importlib_metadata/__init__.py | 662 ++++++++++++++++++ catalogue/_importlib_metadata/_compat.py | 86 +++ catalogue/tests/__init__.py | 0 .../tests/test_catalogue.py | 38 +- requirements.txt | 3 +- setup.cfg | 8 +- setup.py | 4 +- 10 files changed, 864 insertions(+), 41 deletions(-) rename catalogue.py => catalogue/__init__.py (94%) create mode 100644 catalogue/_importlib_metadata/LICENSE create mode 100644 catalogue/_importlib_metadata/__init__.py create mode 100644 catalogue/_importlib_metadata/_compat.py create mode 100644 catalogue/tests/__init__.py rename test_catalogue.py => catalogue/tests/test_catalogue.py (82%) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index b97340a..2430cb6 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,39 +9,48 @@ jobs: - job: 'Test' strategy: matrix: - Python27Linux: - imageName: 'ubuntu-16.04' - python.version: '2.7' - Python27Mac: - imageName: 'macos-10.13' - python.version: '2.7' - Python35Linux: - imageName: 'ubuntu-16.04' - python.version: '3.5' - Python35Windows: - imageName: 'vs2017-win2016' - python.version: '3.5' - Python35Mac: - imageName: 'macos-10.13' - python.version: '3.5' Python36Linux: - imageName: 'ubuntu-16.04' + imageName: 'ubuntu-latest' python.version: '3.6' Python36Windows: - imageName: 'vs2017-win2016' - python.version: '3.6' - Python36Mac: - imageName: 'macos-10.13' + imageName: 'windows-2019' python.version: '3.6' + Python37Linux: + imageName: 'ubuntu-latest' + python.version: '3.7' + Python37Windows: + imageName: 'windows-latest' + python.version: '3.7' + Python37Mac: + imageName: 'macos-latest' + python.version: '3.7' Python38Linux: - imageName: 'ubuntu-16.04' + imageName: 'ubuntu-latest' python.version: '3.8' Python38Windows: - imageName: 'vs2017-win2016' + imageName: 'windows-latest' python.version: '3.8' Python38Mac: - imageName: 'macos-10.13' + imageName: 'macos-latest' python.version: '3.8' + Python39Linux: + imageName: 'ubuntu-latest' + python.version: '3.9' + Python39Windows: + imageName: 'windows-latest' + python.version: '3.9' + Python39Mac: + imageName: 'macos-latest' + python.version: '3.9' + Python310Linux: + imageName: 'ubuntu-latest' + python.version: '3.10' + Python310Windows: + imageName: 'windows-latest' + python.version: '3.10' + Python310Mac: + imageName: 'macos-latest' + python.version: '3.10' maxParallel: 4 pool: vmImage: $(imageName) @@ -53,19 +62,37 @@ jobs: architecture: 'x64' - script: | - pip install -U -r requirements.txt + python -m pip install -U pip setuptools wheel + python -m pip install -U -r requirements.txt python setup.py sdist displayName: 'Build sdist' - task: DeleteFiles@1 inputs: - contents: 'catalogue.py' + contents: 'catalogue' displayName: 'Delete source directory' + - script: | + python -m pip freeze > installed.txt + python -m pip uninstall -y -r installed.txt + displayName: "Uninstall all packages" + - bash: | SDIST=$(python -c "import os;print(os.listdir('./dist')[-1])" 2>&1) pip install dist/$SDIST displayName: 'Install from sdist' - - script: python -m pytest test_catalogue.py + - bash: | + python -c "import catalogue" + displayName: 'Test import' + + - script: python -m pip install -U -r requirements.txt + displayName: "Install test requirements" + + - script: python -m pytest --pyargs catalogue -Werror displayName: 'Run tests' + + - bash: | + pip install hypothesis + python -c "import catalogue; import hypothesis" + displayName: 'Test for conflicts' diff --git a/catalogue.py b/catalogue/__init__.py similarity index 94% rename from catalogue.py rename to catalogue/__init__.py index a9496d8..490a0dd 100644 --- a/catalogue.py +++ b/catalogue/__init__.py @@ -7,7 +7,7 @@ try: # Python 3.8 import importlib.metadata as importlib_metadata except ImportError: - import importlib_metadata + from . import _importlib_metadata as importlib_metadata if sys.version_info[0] == 2: basestring_ = basestring # noqa: F821 @@ -123,7 +123,7 @@ def get_entry_points(self): RETURNS (Dict[str, Any]): Entry points, keyed by name. """ result = {} - for entry_point in AVAILABLE_ENTRY_POINTS.get(self.entry_point_namespace, []): + for entry_point in self._get_entry_points(): result[entry_point.name] = entry_point.load() return result @@ -135,11 +135,17 @@ def get_entry_point(self, name, default=None): default (Any): The default value to return. RETURNS (Any): The loaded entry point or the default value. """ - for entry_point in AVAILABLE_ENTRY_POINTS.get(self.entry_point_namespace, []): + for entry_point in self._get_entry_points(): if entry_point.name == name: return entry_point.load() return default + def _get_entry_points(self): + if hasattr(AVAILABLE_ENTRY_POINTS, "select"): + return AVAILABLE_ENTRY_POINTS.select(group=self.entry_point_namespace) + else: # dict + return AVAILABLE_ENTRY_POINTS.get(self.entry_point_namespace, []) + def check_exists(*namespace): """Check if a namespace exists. diff --git a/catalogue/_importlib_metadata/LICENSE b/catalogue/_importlib_metadata/LICENSE new file mode 100644 index 0000000..be7e092 --- /dev/null +++ b/catalogue/_importlib_metadata/LICENSE @@ -0,0 +1,13 @@ +Copyright 2017-2019 Jason R. Coombs, Barry Warsaw + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/catalogue/_importlib_metadata/__init__.py b/catalogue/_importlib_metadata/__init__.py new file mode 100644 index 0000000..b8ee927 --- /dev/null +++ b/catalogue/_importlib_metadata/__init__.py @@ -0,0 +1,662 @@ +import os +import re +import abc +import csv +import sys +import zipp +import email +import pathlib +import operator +import functools +import itertools +import posixpath +import collections + +from ._compat import ( + NullFinder, + PyPy_repr, + install, + Protocol, +) + +from configparser import ConfigParser +from contextlib import suppress +from importlib import import_module +from importlib.abc import MetaPathFinder +from itertools import starmap +from typing import Any, List, Mapping, TypeVar, Union + + +__all__ = [ + 'Distribution', + 'DistributionFinder', + 'PackageNotFoundError', + 'distribution', + 'distributions', + 'entry_points', + 'files', + 'metadata', + 'requires', + 'version', +] + + +class PackageNotFoundError(ModuleNotFoundError): + """The package was not found.""" + + def __str__(self): + tmpl = "No package metadata was found for {self.name}" + return tmpl.format(**locals()) + + @property + def name(self): + (name,) = self.args + return name + + +class EntryPoint( + PyPy_repr, collections.namedtuple('EntryPointBase', 'name value group') +): + """An entry point as defined by Python packaging conventions. + + See `the packaging docs on entry points + `_ + for more information. + """ + + pattern = re.compile( + 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: + + - module + - package.module + - package.module:attribute + - package.module:object.attribute + - package.module:attr [extra1, extra2] + + Other combinations are possible as well. + + The expression is lenient about whitespace around the ':', + following the attr, and following any extras. + """ + + def load(self): + """Load the entry point from its definition. If only a module + is indicated by the value, return that module. Otherwise, + return the named object. + """ + match = self.pattern.match(self.value) + module = import_module(match.group('module')) + attrs = filter(None, (match.group('attr') or '').split('.')) + return functools.reduce(getattr, attrs, module) + + @property + def module(self): + match = self.pattern.match(self.value) + return match.group('module') + + @property + def attr(self): + match = self.pattern.match(self.value) + return match.group('attr') + + @property + 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 + config.read_string(text) + return EntryPoint._from_config(config) + + def __iter__(self): + """ + Supply iter so one may construct dicts of EntryPoints easily. + """ + return iter((self.name, self)) + + def __reduce__(self): + return ( + self.__class__, + (self.name, self.value, self.group), + ) + + +class PackagePath(pathlib.PurePosixPath): + """A reference to a path in a package""" + + def read_text(self, encoding='utf-8'): + with self.locate().open(encoding=encoding) as stream: + return stream.read() + + def read_binary(self): + with self.locate().open('rb') as stream: + return stream.read() + + def locate(self): + """Return a path-like object for this path""" + return self.dist.locate_file(self) + + +class FileHash: + def __init__(self, spec): + self.mode, _, self.value = spec.partition('=') + + 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.""" + + @abc.abstractmethod + def read_text(self, filename): + """Attempt to load metadata file given by the name. + + :param filename: The name of the file in the distribution info. + :return: The text if found, otherwise None. + """ + + @abc.abstractmethod + def locate_file(self, path): + """ + Given a path to a file in this distribution, return a path + to it. + """ + + @classmethod + def from_name(cls, name): + """Return the Distribution for the given package name. + + :param name: The name of the distribution package to search for. + :return: The Distribution instance (or subclass thereof) for the named + package, if found. + :raises PackageNotFoundError: When the named package's distribution + metadata cannot be found. + """ + for resolver in cls._discover_resolvers(): + dists = resolver(DistributionFinder.Context(name=name)) + dist = next(iter(dists), None) + if dist is not None: + return dist + else: + raise PackageNotFoundError(name) + + @classmethod + def discover(cls, **kwargs): + """Return an iterable of Distribution objects for all packages. + + Pass a ``context`` or pass keyword arguments for constructing + a context. + + :context: A ``DistributionFinder.Context`` object. + :return: Iterable of Distribution objects for all packages. + """ + context = kwargs.pop('context', None) + if context and 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() + ) + + @staticmethod + def at(path): + """Return a Distribution for the indicated metadata path + + :param path: a string or path-like object + :return: a concrete Distribution instance for the path + """ + return PathDistribution(pathlib.Path(path)) + + @staticmethod + def _discover_resolvers(): + """Search the meta_path for resolvers.""" + declared = ( + getattr(finder, '_catalogue_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) -> PackageMetadata: + """Return the parsed metadata for this Distribution. + + The returned object will have keys that name the various bits of + metadata. See PEP 566 for details. + """ + text = ( + self.read_text('METADATA') + or self.read_text('PKG-INFO') + # This last clause is here to support old egg-info files. Its + # 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) + + @property + def version(self): + """Return the 'Version' metadata for the distribution package.""" + return self.metadata['Version'] + + @property + def entry_points(self): + return EntryPoint._from_text(self.read_text('entry_points.txt')) + + @property + def files(self): + """Files in this distribution. + + :return: List of PackagePath for this distribution or None + + Result is `None` if the metadata file that enumerates files + (i.e. RECORD for dist-info or SOURCES.txt for egg-info) is + missing. + Result may be empty if the metadata exists but is empty. + """ + file_lines = self._read_files_distinfo() or self._read_files_egginfo() + + def make_file(name, hash=None, size_str=None): + result = PackagePath(name) + result.hash = FileHash(hash) if hash else None + result.size = int(size_str) if size_str else None + result.dist = self + return result + + return file_lines and list(starmap(make_file, csv.reader(file_lines))) + + def _read_files_distinfo(self): + """ + Read the lines of RECORD + """ + text = self.read_text('RECORD') + return text and text.splitlines() + + def _read_files_egginfo(self): + """ + SOURCES.txt might contain literal commas, so wrap each line + in quotes. + """ + text = self.read_text('SOURCES.txt') + return text and map('"{}"'.format, text.splitlines()) + + @property + def requires(self): + """Generated requirements specified for this Distribution""" + reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs() + return reqs and list(reqs) + + def _read_dist_info_reqs(self): + return self.metadata.get_all('Requires-Dist') + + def _read_egg_info_reqs(self): + source = self.read_text('requires.txt') + return source and self._deps_from_requires_text(source) + + @classmethod + def _deps_from_requires_text(cls, source): + section_pairs = cls._read_sections(source.splitlines()) + sections = { + section: list(map(operator.itemgetter('line'), results)) + for section, results in itertools.groupby( + section_pairs, operator.itemgetter('section') + ) + } + return cls._convert_egg_info_reqs_to_simple_reqs(sections) + + @staticmethod + def _read_sections(lines): + section = None + for line in filter(None, lines): + section_match = re.match(r'\[(.*)\]$', line) + if section_match: + section = section_match.group(1) + continue + yield locals() + + @staticmethod + def _convert_egg_info_reqs_to_simple_reqs(sections): + """ + Historically, setuptools would solicit and store 'extra' + requirements, including those with environment markers, + in separate sections. More modern tools expect each + dependency to be defined separately, with any relevant + extras and environment markers attached directly to that + 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) + + def parse_condition(section): + section = section or '' + extra, sep, markers = section.partition(':') + if extra and markers: + markers = '({markers})'.format(markers=markers) + conditions = list(filter(None, [markers, make_condition(extra)])) + return '; ' + ' and '.join(conditions) if conditions else '' + + for section, deps in sections.items(): + for dep in deps: + yield dep + parse_condition(section) + + +class DistributionFinder(MetaPathFinder): + """ + A MetaPathFinder capable of discovering installed distributions. + """ + + class Context: + """ + Keyword arguments presented by the caller to + ``distributions()`` or ``Distribution.discover()`` + to narrow the scope of a search for distributions + in all DistributionFinders. + + Each DistributionFinder may expect any parameters + and should attempt to honor the canonical + parameters defined below when appropriate. + """ + + name = None + """ + Specific name for which a distribution finder should match. + A name of ``None`` matches all distributions. + """ + + def __init__(self, **kwargs): + vars(self).update(kwargs) + + @property + def path(self): + """ + The path that a distribution finder should search. + + Typically refers to Python package paths and defaults + to ``sys.path``. + """ + return vars(self).get('path', sys.path) + + @abc.abstractmethod + def _catalogue_find_distributions(self, context=Context()): + """ + Find distributions. + + Return an iterable of all Distribution instances capable of + loading the metadata for packages matching the ``context``, + a DistributionFinder.Context instance. + """ + + +class FastPath: + """ + Micro-optimized class for searching a path for + children. + """ + + def __init__(self, root): + self.root = str(root) + self.base = os.path.basename(self.root).lower() + + def joinpath(self, child): + return pathlib.Path(self.root, child) + + def children(self): + with suppress(Exception): + return os.listdir(self.root or '') + with suppress(Exception): + return self.zip_children() + return [] + + def zip_children(self): + zip_path = zipp.Path(self.root) + names = zip_path.root.namelist() + self.joinpath = zip_path.joinpath + + return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names) + + def search(self, name): + return ( + self.joinpath(child) + for child in self.children() + if name.matches(child, self.base) + ) + + +class Prepared: + """ + A prepared search for metadata on a possibly-named package. + """ + + normalized = None + suffixes = '.dist-info', '.egg-info' + exact_matches = [''][:0] + + def __init__(self, name): + self.name = name + if name is None: + return + self.normalized = self.normalize(name) + self.exact_matches = [self.normalized + suffix for suffix in self.suffixes] + + @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 matches(self, cand, base): + low = cand.lower() + pre, ext = os.path.splitext(low) + name, sep, rest = pre.partition('-') + return ( + low in self.exact_matches + or ext in self.suffixes + and (not self.normalized or name.replace('.', '_') == self.normalized) + # legacy case: + or self.is_egg(base) + and low == 'egg-info' + ) + + def is_egg(self, base): + normalized = self.legacy_normalize(self.name or '') + prefix = normalized + '-' if normalized else '' + versionless_egg_name = normalized + '.egg' if self.name else '' + return ( + base == versionless_egg_name + or base.startswith(prefix) + and base.endswith('.egg') + ) + + +@install +class MetadataPathFinder(NullFinder, DistributionFinder): + """A degenerate finder for distribution packages on the file system. + + This finder supplies only a find_distributions() method for versions + of Python that do not have a PathFinder find_distributions(). + """ + + def _catalogue_find_distributions(self, context=DistributionFinder.Context()): + """ + Find distributions. + + Return an iterable of all Distribution instances capable of + loading the metadata for packages matching ``context.name`` + (or all names if ``None`` indicated) along the paths in the list + of directories ``context.path``. + """ + found = self._search_paths(context.name, context.path) + return map(PathDistribution, found) + + @classmethod + def _search_paths(cls, name, paths): + """Find metadata directories in paths heuristically.""" + return itertools.chain.from_iterable( + path.search(Prepared(name)) for path in map(FastPath, paths) + ) + + +class PathDistribution(Distribution): + def __init__(self, path): + """Construct a distribution from a path to the metadata directory. + + :param path: A pathlib.Path or similar object supporting + .joinpath(), __div__, .parent, and .read_text(). + """ + self._path = path + + def read_text(self, filename): + with suppress( + FileNotFoundError, + IsADirectoryError, + KeyError, + NotADirectoryError, + PermissionError, + ): + return self._path.joinpath(filename).read_text(encoding='utf-8') + + read_text.__doc__ = Distribution.read_text.__doc__ + + def locate_file(self, path): + return self._path.parent / path + + +def distribution(distribution_name): + """Get the ``Distribution`` instance for the named package. + + :param distribution_name: The name of the distribution package as a string. + :return: A ``Distribution`` instance (or subclass thereof). + """ + return Distribution.from_name(distribution_name) + + +def distributions(**kwargs): + """Get all ``Distribution`` instances in the current environment. + + :return: An iterable of ``Distribution`` instances. + """ + return Distribution.discover(**kwargs) + + +def metadata(distribution_name) -> PackageMetadata: + """Get the metadata for the named package. + + :param distribution_name: The name of the distribution package to query. + :return: A PackageMetadata containing the parsed metadata. + """ + return Distribution.from_name(distribution_name).metadata + + +def version(distribution_name): + """Get the version string for the named package. + + :param distribution_name: The name of the distribution package to query. + :return: The version string for the package as defined in the package's + "Version" metadata key. + """ + return distribution(distribution_name).version + + +def entry_points(): + """Return EntryPoint objects for all installed packages. + + :return: EntryPoint objects for all installed packages. + """ + 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} + + +def files(distribution_name): + """Return a list of files for the named package. + + :param distribution_name: The name of the distribution package to query. + :return: List of files composing the distribution. + """ + return distribution(distribution_name).files + + +def requires(distribution_name): + """ + Return a list of requirements for the named package. + + :return: An iterator of requirements, suitable for + 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. + >>> 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/catalogue/_importlib_metadata/_compat.py b/catalogue/_importlib_metadata/_compat.py new file mode 100644 index 0000000..f9c4a59 --- /dev/null +++ b/catalogue/_importlib_metadata/_compat.py @@ -0,0 +1,86 @@ +import sys + + +__all__ = ['install', 'NullFinder', 'PyPy_repr', 'Protocol'] + + +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): + """ + Class decorator for installation on sys.meta_path. + + Adds the backport DistributionFinder to sys.meta_path and + attempts to disable the finder functionality of the stdlib + DistributionFinder. + """ + sys.meta_path.append(cls()) + disable_stdlib_finder() + return cls + + +def disable_stdlib_finder(): + """ + Give the backport primacy for discovering path-based distributions + by monkey-patching the stdlib O_O. + + 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, '_catalogue_find_distributions') + + for finder in filter(matches, sys.meta_path): # pragma: nocover + del finder._catalogue_find_distributions + + +class NullFinder: + """ + A "Finder" (aka "MetaClassFinder") that never finds any modules, + but may find distributions. + """ + + @staticmethod + def find_spec(*args, **kwargs): + return None + + # In Python 2, the import system requires finders + # to have a find_module() method, but this usage + # is deprecated in Python 3 in favor of find_spec(). + # For the purposes of this finder (i.e. being present + # on sys.meta_path but having no other import + # system functionality), the two methods are identical. + find_module = find_spec + + +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 diff --git a/catalogue/tests/__init__.py b/catalogue/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_catalogue.py b/catalogue/tests/test_catalogue.py similarity index 82% rename from test_catalogue.py rename to catalogue/tests/test_catalogue.py index 1e5e676..c4c810c 100644 --- a/test_catalogue.py +++ b/catalogue/tests/test_catalogue.py @@ -1,6 +1,7 @@ # coding: utf8 from __future__ import unicode_literals +import sys import pytest import catalogue @@ -103,12 +104,8 @@ def z(): assert catalogue._get(("x", "y", "z")) == z -def test_entry_points(): - # Create a new EntryPoint object by pretending we have a setup.cfg and - # use one of catalogue's util functions as the advertised function - ep_string = "[options.entry_points]test_foo\n bar = catalogue:check_exists" - ep = catalogue.importlib_metadata.EntryPoint._from_text(ep_string) - catalogue.AVAILABLE_ENTRY_POINTS["test_foo"] = ep +def _check_entry_points(): + # Check entry points for test_entry_points_older() and test_entry_points_newer(). assert catalogue.REGISTRY == {} test_registry = catalogue.create("test", "foo", entry_points=True) entry_points = test_registry.get_entry_points() @@ -119,3 +116,32 @@ def test_entry_points(): assert test_registry.get("bar") == catalogue.check_exists assert test_registry.get_all() == {"bar": catalogue.check_exists} assert "bar" in test_registry + + +@pytest.mark.skipif( + sys.version_info >= (3, 10), + reason="Test does not support >=3.10 importlib_metadata API", +) +def test_entry_points_older(): + # Create a new EntryPoint object by pretending we have a setup.cfg and + # use one of catalogue's util functions as the advertised function + ep_string = "[options.entry_points]test_foo\n bar = catalogue:check_exists" + ep = catalogue.importlib_metadata.EntryPoint._from_text(ep_string) + catalogue.AVAILABLE_ENTRY_POINTS["test_foo"] = ep + _check_entry_points() + + +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="Test does not support <3.10 importlib_metadata API", +) +def test_entry_points_newer(): + # Create a new EntryPoint object by pretending we have a setup.cfg and + # use one of catalogue's util functions as the advertised function + ep = catalogue.importlib_metadata.EntryPoint( + "bar", "catalogue:check_exists", "test_foo" + ) + catalogue.AVAILABLE_ENTRY_POINTS[ + "test_foo" + ] = catalogue.importlib_metadata.EntryPoints([ep]) + _check_entry_points() diff --git a/requirements.txt b/requirements.txt index 568e84e..a28a930 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -importlib_metadata>=0.20; python_version < "3.8" +zipp>=0.5; python_version < "3.8" +typing-extensions>=3.6.4; python_version < "3.8" setuptools pytest>=4.6.5 diff --git a/setup.cfg b/setup.cfg index 98e2f25..d734fc2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 1.0.0 +version = 1.0.1 description = Super lightweight function registries for your library url = https://github.com/explosion/catalogue author = Explosion @@ -23,15 +23,17 @@ classifiers = Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Topic :: Scientific/Engineering [options] -py_modules = catalogue zip_safe = true include_package_data = true python_requires = >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.* install_requires = - importlib_metadata>=0.20; python_version < "3.8" + zipp>=0.5; python_version < "3.8" + typing-extensions>=3.6.4; python_version < "3.8" [bdist_wheel] universal = true diff --git a/setup.py b/setup.py index 45fe34a..9e0ebff 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,6 @@ # coding: utf-8 if __name__ == "__main__": - from setuptools import setup + from setuptools import setup, find_packages - setup(name="catalogue") + setup(name="catalogue", packages=find_packages())