diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6d7ef5ca..df822802 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,11 +19,9 @@ jobs: fail-fast: false matrix: name: [ - "windows-py36", "windows-py310", "windows-pypy3", - "ubuntu-py36", "ubuntu-py37-pytestmain", "ubuntu-py37", "ubuntu-py38", @@ -37,23 +35,14 @@ jobs: ] include: - - name: "windows-py36" - python: "3.6" - os: windows-latest - tox_env: "py36" - name: "windows-py310" python: "3.10" os: windows-latest tox_env: "py310" - - name: "windows-pypy3" - python: "pypy3" + - name: "windows-pypy-3" + python: "pypy3.7" os: windows-latest - tox_env: "pypy3" - - name: "ubuntu-py36" - python: "3.6" - os: ubuntu-latest - tox_env: "py36" - use_coverage: true + tox_env: "py37" - name: "ubuntu-py37-pytestmain" python: "3.7" os: ubuntu-latest @@ -80,7 +69,7 @@ jobs: tox_env: "py310" use_coverage: true - name: "ubuntu-pypy3" - python: "pypy3" + python: "pypy3.7" os: ubuntu-latest tox_env: "pypy3" use_coverage: true @@ -103,7 +92,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} @@ -135,7 +124,7 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 with: python-version: "3.8" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5611783..3b09b5e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,23 +1,24 @@ repos: - repo: https://github.com/ambv/black - rev: 21.7b0 + rev: 22.6.0 hooks: - id: black args: [--safe, --quiet] - repo: https://github.com/asottile/blacken-docs - rev: v1.10.0 + rev: v1.12.1 hooks: - id: blacken-docs - additional_dependencies: [black==21.7b0] + additional_dependencies: [black==22.6.0] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.1.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: flake8 + args: [--min-python-version, "3.7.0"] additional_dependencies: [flake8-typing-imports] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.931 + rev: v0.971 hooks: - id: mypy files: ^(src/|testing/) @@ -36,7 +37,7 @@ repos: hooks: - id: rst-backticks - repo: https://github.com/asottile/pyupgrade - rev: v2.23.3 + rev: v2.37.3 hooks: - id: pyupgrade - args: [--py36-plus] + args: [--py37-plus] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..d30f91a7 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,14 @@ +version: 2 + +build: + os: "ubuntu-20.04" + tools: + python: "3.10" + +sphinx: + configuration: docs/conf.py + +python: + install: + - method: pip + path: . \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index f1ce4c03..a2c41e8d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,7 @@ version = ".".join(release.split(".")[:2]) -language = None +language = "en" pygments_style = "sphinx" # html_logo = "_static/img/plug.png" diff --git a/pyproject.toml b/pyproject.toml index 0b1c9028..a4032caa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,70 @@ [build-system] requires = [ - "setuptools", - "setuptools-scm", - "wheel", + "hatchling>=1.3.1", + "hatch-vcs", +] +build-backend = "hatchling.build" + +[project] +name = "pluggy" +description = "plugin and hook calling mechanisms for python" +readme = "README.rst" +license = "MIT" +requires-python = ">=3.7" +authors = [ + { name = "Holger Krekel", email = "holger@merlinux.eu" }, +] +classifiers = [ + "Development Status :: 6 - Mature", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python :: 3.10", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", +] +dependencies = [ + "importlib-metadata>=0.12;python_version<\"3.8\"", +] +dynamic = [ + "version", +] + +[project.optional-dependencies] +dev = [ + "pre-commit", + "tox", +] +testing = [ + "pytest", + "pytest-benchmark", +] + +[project.urls] +Homepage = "https://github.com/pytest-dev/pluggy" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "src/pluggy/_version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", ] -[tool.setuptools_scm] -write_to = "src/pluggy/_version.py" [tool.towncrier] package = "pluggy" @@ -15,42 +73,19 @@ filename = "CHANGELOG.rst" directory = "changelog/" title_format = "pluggy {version} ({project_date})" template = "changelog/_template.rst" - - [[tool.towncrier.type]] - directory = "removal" - name = "Deprecations and Removals" - showcontent = true - - [[tool.towncrier.type]] - directory = "feature" - name = "Features" - showcontent = true - - [[tool.towncrier.type]] - directory = "bugfix" - name = "Bug Fixes" - showcontent = true - - [[tool.towncrier.type]] - directory = "vendor" - name = "Vendored Libraries" - showcontent = true - - [[tool.towncrier.type]] - directory = "doc" - name = "Improved Documentation" - showcontent = true - - [[tool.towncrier.type]] - directory = "trivial" - name = "Trivial/Internal Changes" - showcontent = true +type = [ + { directory = "removal", name = "Deprecations and Removals", showcontent = true }, + { directory = "feature", name = "Features", showcontent = true }, + { directory = "bugfix", name = "Bug Fixes", showcontent = true }, + { directory = "vendor", name = "Vendored Libraries", showcontent = true }, + { directory = "doc", name = "Improved Documentation", showcontent = true }, + { directory = "trivial", name = "Trivial/Internal Changes", showcontent = true }, +] [tool.mypy] +python_version = "3.7" mypy_path = "src" check_untyped_defs = true -# Hopefully we can set this someday! -# disallow_any_expr = true disallow_any_generics = true disallow_any_unimported = true disallow_subclassing_any = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index adb6d4fc..00000000 --- a/setup.cfg +++ /dev/null @@ -1,50 +0,0 @@ -[metadata] -name = pluggy -description = plugin and hook calling mechanisms for python -long_description = file: README.rst -long_description_content_type = text/x-rst -license = MIT -platforms = unix, linux, osx, win32 -author = Holger Krekel -author_email = holger@merlinux.eu -url = https://github.com/pytest-dev/pluggy -classifiers = - Development Status :: 6 - Mature - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Operating System :: POSIX - Operating System :: Microsoft :: Windows - Operating System :: MacOS :: MacOS X - Topic :: Software Development :: Testing - Topic :: Software Development :: Libraries - Topic :: Utilities - Programming Language :: Python :: Implementation :: CPython - Programming Language :: Python :: Implementation :: PyPy - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - 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 - -[options] -packages = - pluggy -install_requires = - importlib-metadata>=0.12;python_version<"3.8" -python_requires = >=3.6 -package_dir = - =src -setup_requires = - setuptools-scm -[options.extras_require] -dev = - pre-commit - tox -testing = - pytest - pytest-benchmark - -[devpi:upload] -formats=sdist.tgz,bdist_wheel diff --git a/setup.py b/setup.py deleted file mode 100644 index ed442375..00000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -from setuptools import setup - - -if __name__ == "__main__": - setup(use_scm_version={"write_to": "src/pluggy/_version.py"}) diff --git a/src/pluggy/_importlib.py b/src/pluggy/_importlib.py new file mode 100644 index 00000000..c9bca131 --- /dev/null +++ b/src/pluggy/_importlib.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import sys +from typing import Callable, Iterable, Any + +if sys.version_info >= (3, 8): + from importlib.metadata import distributions +else: + from importlib_metadata import distributions + + +class DistFacade: + """Emulate a pkg_resources Distribution""" + + # turn Any to Distribution as soon as the typing details for them fit + def __init__(self, dist: Any) -> None: + self._dist = dist + + @property + def project_name(self) -> str: + name: str = self.metadata["name"] + return name + + def __getattr__(self, attr: str, default: Any | None = None) -> Any: + return getattr(self._dist, attr, default) + + def __dir__(self) -> list[str]: + return sorted(dir(self._dist) + ["_dist", "project_name"]) + + +def iter_entrypoint_loaders( + group: str, name: str | None +) -> Iterable[tuple[DistFacade, str, Callable[[], object]]]: + for dist in list(distributions()): + legacy = DistFacade(dist) + for ep in dist.entry_points: + if ep.group == group and name is None or name == ep.name: + yield legacy, ep.name, ep.load diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 023cac0f..7d435aac 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -1,24 +1,19 @@ +from __future__ import annotations import inspect -import sys import types import warnings from typing import ( Any, Callable, cast, - Dict, Iterable, - List, Mapping, - Optional, Sequence, - Set, - Tuple, TYPE_CHECKING, - Union, ) from . import _tracing +from ._importlib import DistFacade, iter_entrypoint_loaders from ._result import _Result from ._callers import _multicall from ._hooks import ( @@ -33,11 +28,6 @@ _Plugin, ) -if sys.version_info >= (3, 8): - from importlib import metadata as importlib_metadata -else: - import importlib_metadata - if TYPE_CHECKING: from typing_extensions import Final @@ -68,24 +58,6 @@ def __init__(self, plugin: _Plugin, message: str) -> None: self.plugin = plugin -class DistFacade: - """Emulate a pkg_resources Distribution""" - - def __init__(self, dist: importlib_metadata.Distribution) -> None: - self._dist = dist - - @property - def project_name(self) -> str: - name: str = self.metadata["name"] - return name - - def __getattr__(self, attr: str, default=None): - return getattr(self._dist, attr, default) - - def __dir__(self) -> List[str]: - return sorted(dir(self._dist) + ["_dist", "project_name"]) - - class PluginManager: """Core class which manages registration of plugin objects and 1:N hook calling. @@ -111,11 +83,11 @@ class PluginManager: ) def __init__(self, project_name: str) -> None: - self.project_name: "Final" = project_name - self._name2plugin: "Final[Dict[str, _Plugin]]" = {} - self._plugin_distinfo: "Final[List[Tuple[_Plugin, DistFacade]]]" = [] - self.trace: "Final" = _tracing.TagTracer().get("pluginmanage") - self.hook: "Final" = _HookRelay() + self.project_name: Final = project_name + self._name2plugin: Final[dict[str, _Plugin]] = {} + self._plugin_distinfo: Final[list[tuple[_Plugin, DistFacade]]] = [] + self.trace: Final = _tracing.TagTracer().get("pluginmanage") + self.hook: Final = _HookRelay() self._inner_hookexec = _multicall def _hookexec( @@ -124,12 +96,12 @@ def _hookexec( methods: Sequence[HookImpl], kwargs: Mapping[str, object], firstresult: bool, - ) -> Union[object, List[object]]: + ) -> object | list[object]: # called from all hookcaller instances. # enable_tracing will set its own wrapping function at self._inner_hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) - def register(self, plugin: _Plugin, name: Optional[str] = None) -> Optional[str]: + def register(self, plugin: _Plugin, name: str | None = None) -> str | None: """Register a plugin and return its name. If a name is not specified, a name is generated using @@ -167,7 +139,7 @@ def register(self, plugin: _Plugin, name: Optional[str] = None) -> Optional[str] method: _HookImplFunction[object] = getattr(plugin, name) hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_opts) name = hookimpl_opts.get("specname") or name - hook: Optional[_HookCaller] = getattr(self.hook, name, None) + hook: _HookCaller | None = getattr(self.hook, name, None) if hook is None: hook = _HookCaller(name, self._hookexec) setattr(self.hook, name, hook) @@ -177,25 +149,20 @@ def register(self, plugin: _Plugin, name: Optional[str] = None) -> Optional[str] hook._add_hookimpl(hookimpl) return plugin_name - def parse_hookimpl_opts( - self, plugin: _Plugin, name: str - ) -> Optional["_HookImplOpts"]: + def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> _HookImplOpts | None: method: object = getattr(plugin, name) if not inspect.isroutine(method): return None try: - res: Optional["_HookImplOpts"] = getattr( + res: _HookImplOpts | None = getattr( method, self.project_name + "_impl", None ) except Exception: res = {} # type: ignore[assignment] - if res is not None and not isinstance(res, dict): - # false positive - res = None return res def unregister( - self, plugin: Optional[_Plugin] = None, name: Optional[str] = None + self, plugin: _Plugin | None = None, name: str | None = None ) -> _Plugin: """Unregister a plugin and all of its hook implementations. @@ -241,7 +208,7 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: for name in dir(module_or_class): spec_opts = self.parse_hookspec_opts(module_or_class, name) if spec_opts is not None: - hc: Optional[_HookCaller] = getattr(self.hook, name, None) + hc: _HookCaller | None = getattr(self.hook, name, None) if hc is None: hc = _HookCaller(name, self._hookexec, module_or_class, spec_opts) setattr(self.hook, name, hc) @@ -259,14 +226,12 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: def parse_hookspec_opts( self, module_or_class: _Namespace, name: str - ) -> Optional["_HookSpecOpts"]: + ) -> _HookSpecOpts | None: method: HookSpec = getattr(module_or_class, name) - opts: Optional[_HookSpecOpts] = getattr( - method, self.project_name + "_spec", None - ) + opts: _HookSpecOpts | None = getattr(method, self.project_name + "_spec", None) return opts - def get_plugins(self) -> Set[Any]: + def get_plugins(self) -> set[Any]: """Return a set of all registered plugin objects.""" return set(self._name2plugin.values()) @@ -282,10 +247,10 @@ def get_canonical_name(self, plugin: _Plugin) -> str: To obtain the name of n registered plugin use :meth:`get_name(plugin) ` instead. """ - name: Optional[str] = getattr(plugin, "__name__", None) + name: str | None = getattr(plugin, "__name__", None) return name or str(id(plugin)) - def get_plugin(self, name: str) -> Optional[Any]: + def get_plugin(self, name: str) -> Any | None: """Return the plugin registered under the given name, if any.""" return self._name2plugin.get(name) @@ -293,7 +258,7 @@ def has_plugin(self, name: str) -> bool: """Return whether a plugin with the given name is registered.""" return self.get_plugin(name) is not None - def get_name(self, plugin: _Plugin) -> Optional[str]: + def get_name(self, plugin: _Plugin) -> str | None: """Return the name the plugin is registered under, or ``None`` if is isn't.""" for name, val in self._name2plugin.items(): @@ -353,9 +318,7 @@ def check_pending(self) -> None: % (name, hookimpl.plugin), ) - def load_setuptools_entrypoints( - self, group: str, name: Optional[str] = None - ) -> int: + def load_setuptools_entrypoints(self, group: str, name: str | None = None) -> int: """Load modules from querying the specified setuptools ``group``. :param str group: Entry point group to load plugins. @@ -364,32 +327,26 @@ def load_setuptools_entrypoints( :return: The number of plugins loaded by this call. """ count = 0 - for dist in list(importlib_metadata.distributions()): - for ep in dist.entry_points: - if ( - ep.group != group - or (name is not None and ep.name != name) - # already registered - or self.get_plugin(ep.name) - or self.is_blocked(ep.name) - ): - continue - plugin = ep.load() - self.register(plugin, name=ep.name) - self._plugin_distinfo.append((plugin, DistFacade(dist))) - count += 1 + for dist, ep_name, loader in iter_entrypoint_loaders(group, name): + if self.get_plugin(ep_name) or self.is_blocked(ep_name): + continue + # already registered + plugin = loader() + self.register(plugin, name=ep_name) + self._plugin_distinfo.append((plugin, dist)) + count += 1 return count - def list_plugin_distinfo(self) -> List[Tuple[_Plugin, DistFacade]]: + def list_plugin_distinfo(self) -> list[tuple[_Plugin, DistFacade]]: """Return a list of (plugin, distinfo) pairs for all setuptools-registered plugins.""" return list(self._plugin_distinfo) - def list_name_plugin(self) -> List[Tuple[str, _Plugin]]: + def list_name_plugin(self) -> list[tuple[str, _Plugin]]: """Return a list of (name, plugin) pairs for all registered plugins.""" return list(self._name2plugin.items()) - def get_hookcallers(self, plugin: _Plugin) -> Optional[List[_HookCaller]]: + def get_hookcallers(self, plugin: _Plugin) -> list[_HookCaller] | None: """Get all hook callers for the specified plugin.""" if self.get_name(plugin) is None: return None @@ -422,7 +379,7 @@ def traced_hookexec( hook_impls: Sequence[HookImpl], caller_kwargs: Mapping[str, object], firstresult: bool, - ) -> Union[object, List[object]]: + ) -> object | list[object]: before(hook_name, hook_impls, caller_kwargs) outcome = _Result.from_call( lambda: oldcall(hook_name, hook_impls, caller_kwargs, firstresult) diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index b2221920..e4ccd9b0 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -11,7 +11,7 @@ PluginManager, PluginValidationError, ) -from pluggy._manager import importlib_metadata +import pluggy._importlib hookspec = HookspecMarker("example") @@ -553,7 +553,7 @@ class Distribution: def my_distributions(): return (dist,) - monkeypatch.setattr(importlib_metadata, "distributions", my_distributions) + monkeypatch.setattr(pluggy._importlib, "distributions", my_distributions) num = pm.load_setuptools_entrypoints("hello") assert num == 1 plugin = pm.get_plugin("myname") @@ -564,7 +564,7 @@ def my_distributions(): assert len(ret) == 1 assert len(ret[0]) == 2 assert ret[0][0] == plugin - assert ret[0][1]._dist == dist # type: ignore[comparison-overlap] + assert ret[0][1]._dist == dist num = pm.load_setuptools_entrypoints("hello") assert num == 0 # no plugin loaded by this call diff --git a/tox.ini b/tox.ini index b833a9fe..70471b9e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,5 @@ [tox] +isolated_build = True envlist=linting,docs,py{36,37,38,39,310,py3},py{36,37}-pytest{main} [testenv]