diff --git a/.coveragerc b/.coveragerc index 833a3217..ab44bcad 100644 --- a/.coveragerc +++ b/.coveragerc @@ -14,6 +14,8 @@ disable_warnings = [report] show_missing = True exclude_also = - # jaraco/skeleton#97 - @overload + # Exclude common false positives per + # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion + # Ref jaraco/skeleton#97 and jaraco/skeleton#135 + class .*\bProtocol\): if TYPE_CHECKING: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a4a7e91..8ec58e22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.8 + rev: v0.5.6 hooks: - id: ruff + args: [--fix, --unsafe-fixes] - id: ruff-format diff --git a/NEWS.rst b/NEWS.rst index f4a37fdf..4e75f3b0 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,14 @@ +v8.5.0 +====== + +Features +-------- + +- Deferred import of zipfile.Path (#502) +- Deferred import of json (#503) +- Rely on zipp overlay for zipfile.Path. + + v8.4.0 ====== diff --git a/conftest.py b/conftest.py index 762e66f3..6d3402d6 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,5 @@ import sys - collect_ignore = [ # this module fails mypy tests because 'setup.py' matches './setup.py' 'tests/data/sources/example/setup.py', diff --git a/docs/conf.py b/docs/conf.py index 2cd8fb0c..bb19889b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + extensions = [ 'sphinx.ext.autodoc', 'jaraco.packaging.sphinx', @@ -34,6 +37,7 @@ # Be strict about any broken references nitpicky = True +nitpick_ignore: list[tuple[str, str]] = [] # Include Python intersphinx mapping to prevent failures # jaraco/skeleton#51 @@ -45,6 +49,17 @@ # Preserve authored syntax for defaults autodoc_preserve_defaults = True +# Add support for linking usernames, PyPI projects, Wikipedia pages +github_url = 'https://github.com/' +extlinks = { + 'user': (f'{github_url}%s', '@%s'), + 'pypi': ('https://pypi.org/project/%s', '%s'), + 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), +} +extensions += ['sphinx.ext.extlinks'] + +# local + extensions += ['jaraco.tidelift'] intersphinx_mapping.update( @@ -61,7 +76,7 @@ ), ) -nitpick_ignore = [ +nitpick_ignore += [ # Workaround for #316 ('py:class', 'importlib_metadata.EntryPoints'), ('py:class', 'importlib_metadata.PackagePath'), diff --git a/exercises.py b/exercises.py index c88fa983..adccf03c 100644 --- a/exercises.py +++ b/exercises.py @@ -29,6 +29,7 @@ def cached_distribution_perf(): def uncached_distribution_perf(): "uncached distribution" import importlib + import importlib_metadata # end warmup @@ -37,9 +38,10 @@ def uncached_distribution_perf(): def entrypoint_regexp_perf(): - import importlib_metadata import re + import importlib_metadata + input = '0' + ' ' * 2**10 + '0' # end warmup re.match(importlib_metadata.EntryPoint.pattern, input) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 24587e68..46a14e64 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -1,23 +1,34 @@ +""" +APIs exposing metadata from third-party Python packages. + +This codebase is shared between importlib.metadata in the stdlib +and importlib_metadata in PyPI. See +https://github.com/python/importlib_metadata/wiki/Development-Methodology +for more detail. +""" + from __future__ import annotations -import os -import re import abc -import sys -import json -import zipp +import collections import email -import types -import pathlib -import operator -import textwrap import functools import itertools +import operator +import os +import pathlib import posixpath -import collections +import re +import sys +import textwrap +import types +from contextlib import suppress +from importlib import import_module +from importlib.abc import MetaPathFinder +from itertools import starmap +from typing import Any, Iterable, List, Mapping, Match, Optional, Set, cast from . import _meta -from .compat import py39, py311 from ._collections import FreezableDefaultDict, Pair from ._compat import ( NullFinder, @@ -26,12 +37,7 @@ from ._functools import method_cache, pass_none from ._itertools import always_iterable, bucket, unique_everseen from ._meta import PackageMetadata, SimplePath - -from contextlib import suppress -from importlib import import_module -from importlib.abc import MetaPathFinder -from itertools import starmap -from typing import Any, Iterable, List, Mapping, Match, Optional, Set, cast +from .compat import py39, py311 __all__ = [ 'Distribution', @@ -57,7 +63,7 @@ def __str__(self) -> str: return f"No package metadata was found for {self.name}" @property - def name(self) -> str: # type: ignore[override] + def name(self) -> str: # type: ignore[override] # make readonly (name,) = self.args return name @@ -275,7 +281,7 @@ class EntryPoints(tuple): __slots__ = () - def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override] + def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override] # Work with str instead of int """ Get the EntryPoint in self matching name. """ @@ -331,7 +337,7 @@ class PackagePath(pathlib.PurePosixPath): size: int dist: Distribution - def read_text(self, encoding: str = 'utf-8') -> str: # type: ignore[override] + def read_text(self, encoding: str = 'utf-8') -> str: return self.locate().read_text(encoding=encoding) def read_binary(self) -> bytes: @@ -666,6 +672,9 @@ def origin(self): return self._load_json('direct_url.json') def _load_json(self, filename): + # Deferred for performance (python/importlib_metadata#503) + import json + return pass_none(json.loads)( self.read_text(filename), object_hook=lambda data: types.SimpleNamespace(**data), @@ -750,7 +759,7 @@ class FastPath: True """ - @functools.lru_cache() # type: ignore + @functools.lru_cache() # type: ignore[misc] def __new__(cls, root): return super().__new__(cls) @@ -768,7 +777,10 @@ def children(self): return [] def zip_children(self): - zip_path = zipp.Path(self.root) + # deferred for performance (python/importlib_metadata#502) + from zipp.compat.overlay import zipfile + + zip_path = zipfile.Path(self.root) names = zip_path.root.namelist() self.joinpath = zip_path.joinpath @@ -1108,7 +1120,7 @@ def _get_toplevel_name(name: PackagePath) -> str: # Defer import of inspect for performance (python/cpython#118761) import inspect - return _topmost(name) or (inspect.getmodulename(name) or str(name)) + return _topmost(name) or inspect.getmodulename(name) or str(name) def _top_level_inferred(dist): diff --git a/importlib_metadata/_adapters.py b/importlib_metadata/_adapters.py index 6223263e..3b516a2d 100644 --- a/importlib_metadata/_adapters.py +++ b/importlib_metadata/_adapters.py @@ -1,6 +1,6 @@ +import email.message import re import textwrap -import email.message from ._text import FoldedCase diff --git a/importlib_metadata/_compat.py b/importlib_metadata/_compat.py index df312b1c..01356d69 100644 --- a/importlib_metadata/_compat.py +++ b/importlib_metadata/_compat.py @@ -1,6 +1,5 @@ -import sys import platform - +import sys __all__ = ['install', 'NullFinder'] diff --git a/importlib_metadata/_functools.py b/importlib_metadata/_functools.py index 71f66bd0..5dda6a21 100644 --- a/importlib_metadata/_functools.py +++ b/importlib_metadata/_functools.py @@ -1,5 +1,5 @@ -import types import functools +import types # from jaraco.functools 3.3 diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 1927d0f6..0942bbd9 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -1,9 +1,17 @@ from __future__ import annotations import os -from typing import Protocol -from typing import Any, Dict, Iterator, List, Optional, TypeVar, Union, overload - +from typing import ( + Any, + Dict, + Iterator, + List, + Optional, + Protocol, + TypeVar, + Union, + overload, +) _T = TypeVar("_T") diff --git a/mypy.ini b/mypy.ini index b6f97276..feac94cc 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,27 @@ [mypy] -ignore_missing_imports = True -# required to support namespace packages -# https://github.com/python/mypy/issues/14057 +# Is the project well-typed? +strict = False + +# Early opt-in even when strict = False +warn_unused_ignores = True +warn_redundant_casts = True +enable_error_code = ignore-without-code + +# Support namespace packages per https://github.com/python/mypy/issues/14057 explicit_package_bases = True + +disable_error_code = + # Disable due to many false positives + overload-overlap, + +# jaraco/pytest-perf#16 +[mypy-pytest_perf.*] +ignore_missing_imports = True + +# jaraco/zipp#123 +[mypy-zipp.*] +ignore_missing_imports = True + +# jaraco/jaraco.test#7 +[mypy-jaraco.test.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index 24ce25e3..2e5e40c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ ] requires-python = ">=3.8" dependencies = [ - "zipp>=0.5", + "zipp>=3.20", 'typing-extensions>=3.6.4; python_version < "3.8"', ] dynamic = ["version"] @@ -30,11 +30,6 @@ Source = "https://github.com/python/importlib_metadata" test = [ # upstream "pytest >= 6, != 8.1.*", - "pytest-checkdocs >= 2.4", - "pytest-cov", - "pytest-mypy", - "pytest-enabler >= 2.2", - "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", # local 'importlib_resources>=1.3; python_version < "3.9"', @@ -44,6 +39,7 @@ test = [ "pytest-perf >= 0.9.2", "jaraco.test >= 5.4", ] + doc = [ # upstream "sphinx >= 3.5", @@ -59,4 +55,25 @@ doc = [ ] perf = ["ipython"] +check = [ + "pytest-checkdocs >= 2.4", + "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", +] + +cover = [ + "pytest-cov", +] + +enabler = [ + "pytest-enabler >= 2.2", +] + +type = [ + # upstream + "pytest-mypy", + + # local +] + + [tool.setuptools_scm] diff --git a/tests/_path.py b/tests/_path.py index b3cfb9cd..c66cf5f8 100644 --- a/tests/_path.py +++ b/tests/_path.py @@ -1,9 +1,13 @@ -# from jaraco.path 3.7 +# from jaraco.path 3.7.2 + +from __future__ import annotations import functools import pathlib -from typing import Dict, Protocol, Union -from typing import runtime_checkable +from typing import TYPE_CHECKING, Mapping, Protocol, Union, runtime_checkable + +if TYPE_CHECKING: + from typing_extensions import Self class Symlink(str): @@ -12,29 +16,25 @@ class Symlink(str): """ -FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']] # type: ignore +FilesSpec = Mapping[str, Union[str, bytes, Symlink, 'FilesSpec']] @runtime_checkable class TreeMaker(Protocol): - def __truediv__(self, *args, **kwargs): ... # pragma: no cover - - def mkdir(self, **kwargs): ... # pragma: no cover - - def write_text(self, content, **kwargs): ... # pragma: no cover - - def write_bytes(self, content): ... # pragma: no cover - - def symlink_to(self, target): ... # pragma: no cover + def __truediv__(self, other, /) -> Self: ... + def mkdir(self, *, exist_ok) -> object: ... + def write_text(self, content, /, *, encoding) -> object: ... + def write_bytes(self, content, /) -> object: ... + def symlink_to(self, target, /) -> object: ... -def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker: - return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) # type: ignore +def _ensure_tree_maker(obj: str | TreeMaker) -> TreeMaker: + return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) def build( spec: FilesSpec, - prefix: Union[str, TreeMaker] = pathlib.Path(), # type: ignore + prefix: str | TreeMaker = pathlib.Path(), ): """ Build a set of files/directories, as described by the spec. @@ -66,23 +66,24 @@ def build( @functools.singledispatch -def create(content: Union[str, bytes, FilesSpec], path): +def create(content: str | bytes | FilesSpec, path: TreeMaker) -> None: path.mkdir(exist_ok=True) - build(content, prefix=path) # type: ignore + # Mypy only looks at the signature of the main singledispatch method. So it must contain the complete Union + build(content, prefix=path) # type: ignore[arg-type] # python/mypy#11727 @create.register -def _(content: bytes, path): +def _(content: bytes, path: TreeMaker) -> None: path.write_bytes(content) @create.register -def _(content: str, path): +def _(content: str, path: TreeMaker) -> None: path.write_text(content, encoding='utf-8') @create.register -def _(content: Symlink, path): +def _(content: Symlink, path: TreeMaker) -> None: path.symlink_to(content) diff --git a/tests/compat/py39.py b/tests/compat/py39.py index 9476eb35..4e45d7cc 100644 --- a/tests/compat/py39.py +++ b/tests/compat/py39.py @@ -1,6 +1,5 @@ from jaraco.test.cpython import from_test_support, try_import - os_helper = try_import('os_helper') or from_test_support( 'FS_NONASCII', 'skip_unless_symlink', 'temp_dir' ) diff --git a/tests/compat/test_py39_compat.py b/tests/compat/test_py39_compat.py index 549e518a..db9fb1b7 100644 --- a/tests/compat/test_py39_compat.py +++ b/tests/compat/test_py39_compat.py @@ -1,8 +1,7 @@ -import sys import pathlib +import sys import unittest -from .. import fixtures from importlib_metadata import ( distribution, distributions, @@ -11,6 +10,8 @@ version, ) +from .. import fixtures + class OldStdlibFinderTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): def setUp(self): diff --git a/tests/fixtures.py b/tests/fixtures.py index 187f1705..8e692f86 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,26 +1,21 @@ -import sys +import contextlib import copy +import functools import json -import shutil import pathlib +import shutil +import sys import textwrap -import functools -import contextlib - -from .compat.py312 import import_helper -from .compat.py39 import os_helper from . import _path from ._path import FilesSpec +from .compat.py39 import os_helper +from .compat.py312 import import_helper - -try: - from importlib import resources # type: ignore - - getattr(resources, 'files') - getattr(resources, 'as_file') -except (ImportError, AttributeError): - import importlib_resources as resources # type: ignore +if sys.version_info >= (3, 9): + from importlib import resources +else: + import importlib_resources as resources @contextlib.contextmanager diff --git a/tests/test_api.py b/tests/test_api.py index 7ce0cd64..c36f93e0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,9 +1,8 @@ +import importlib import re import textwrap import unittest -import importlib -from . import fixtures from importlib_metadata import ( Distribution, PackageNotFoundError, @@ -15,6 +14,8 @@ version, ) +from . import fixtures + class APITests( fixtures.EggInfoPkg, diff --git a/tests/test_integration.py b/tests/test_integration.py index f7af67f3..9bb3e793 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -8,15 +8,17 @@ """ import unittest + import packaging.requirements import packaging.version -from . import fixtures from importlib_metadata import ( _compat, version, ) +from . import fixtures + class IntegrationTests(fixtures.DistInfoPkg, unittest.TestCase): def test_package_spec_installed(self): diff --git a/tests/test_main.py b/tests/test_main.py index dc248492..7c9851fc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,14 +1,11 @@ -import re +import importlib import pickle +import re import unittest -import importlib -import importlib_metadata -from .compat.py39 import os_helper import pyfakefs.fake_filesystem_unittest as ffs -from . import fixtures -from ._path import Symlink +import importlib_metadata from importlib_metadata import ( Distribution, EntryPoint, @@ -21,6 +18,10 @@ version, ) +from . import fixtures +from ._path import Symlink +from .compat.py39 import os_helper + class BasicTests(fixtures.DistInfoPkg, unittest.TestCase): version_pattern = r'\d+\.\d+(\.\d)?' diff --git a/tests/test_zip.py b/tests/test_zip.py index 01aba6df..d4f8e2f0 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -1,7 +1,6 @@ import sys import unittest -from . import fixtures from importlib_metadata import ( PackageNotFoundError, distribution, @@ -11,6 +10,8 @@ version, ) +from . import fixtures + class TestZip(fixtures.ZipFixtures, unittest.TestCase): def setUp(self): diff --git a/tox.ini b/tox.ini index 71fd05f6..b404332c 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,10 @@ passenv = usedevelop = True extras = test + check + cover + enabler + type [testenv:diffcov] description = run tests and check that diff from main is covered