diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a293b0e..3ba2d5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] os: [Ubuntu, macOS, Windows] steps: diff --git a/.gitignore b/.gitignore index 88cd89d..9f359b8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ __pycache__/ /dist/ .nox .pytest_cache -doc/_build/ +docs/_build/ *.egg-info/ .coverage htmlcov/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce2a162..2873424 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,12 @@ repos: hooks: - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.1.1 + hooks: + - id: mypy + exclude: tests/samples + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: diff --git a/.readthedocs.yml b/.readthedocs.yml index 1881693..ffff8cf 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,6 +4,7 @@ sphinx: configuration: docs/conf.py python: + version: 3.8 install: - requirements: docs/requirements.txt - method: pip diff --git a/docs/changelog.rst b/docs/changelog.rst index 607ac05..7f88cfa 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,11 @@ Changelog ========= +v1.1 +---- + +- Add type annotations to the public API. + v1.0 ---- diff --git a/docs/conf.py b/docs/conf.py index 2865b9c..222b257 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,8 +30,11 @@ # -- Options for autodoc ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration +autodoc_class_signature = "separated" autodoc_member_order = "bysource" +autodoc_preserve_defaults = True autodoc_typehints = "description" +autodoc_typehints_description_target = "documented_params" # -- Options for intersphinx ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration diff --git a/docs/pyproject_hooks.rst b/docs/pyproject_hooks.rst index 3acf7dc..3cf0848 100644 --- a/docs/pyproject_hooks.rst +++ b/docs/pyproject_hooks.rst @@ -22,18 +22,31 @@ Custom Subprocess Runners It is possible to provide a custom subprocess runner, that behaves differently. The expected protocol for subprocess runners is as follows: -.. function:: subprocess_runner_protocol(cmd, cwd, extra_environ) +.. function:: subprocess_runner_protocol(cmd, cwd=None, extra_environ=None) :noindex: :param cmd: The command and arguments to execute, as would be passed to :func:`subprocess.run`. - :type cmd: list[str] + :type cmd: typing.Sequence[str] :param cwd: The working directory that must be used for the subprocess. - :type cwd: str + :type cwd: typing.Optional[str] :param extra_environ: Mapping of environment variables (name to value) which must be set for the subprocess execution. - :type extra_environ: dict[str, str] + :type extra_environ: typing.Optional[typing.Mapping[str, str]] :rtype: None +Since this codebase is currently Python 3.7-compatible, the type annotation for this protocol is only available to type checkers. To annotate a variable as a subprocess runner, you can do something along the lines of: + +.. code-block:: python + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from pyproject_hooks import SubprocessRunner + + # Example usage + def build(awesome_runner: "SubprocessRunner") -> None: + ... + Exceptions ---------- diff --git a/noxfile.py b/noxfile.py index 66ff09f..4efa446 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,7 +6,7 @@ nox.options.reuse_existing_virtualenvs = True -@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "pypy3"]) +@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "pypy3"]) def test(session: nox.Session) -> None: session.install("-r", "dev-requirements.txt") session.install(".") @@ -40,7 +40,7 @@ def lint(session: nox.Session) -> None: session.run("pre-commit", "run", *args) -@nox.session(name="docs-live") +@nox.session def release(session: nox.Session) -> None: session.install("flit") session.run("flit", "publish") diff --git a/pyproject.toml b/pyproject.toml index ad93ea0..8e58eb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,5 +22,8 @@ Source = "https://github.com/pypa/pyproject-hooks" Documentation = "https://pyproject-hooks.readthedocs.io/" Changelog = "https://pyproject-hooks.readthedocs.io/en/latest/changelog.html" -[tool.isort] -profile = "black" +[tool.ruff] +src = ["src", "tests"] + +[tool.ruff.isort] +known-first-party = ["pyproject_hooks", "tests"] diff --git a/src/pyproject_hooks/__init__.py b/src/pyproject_hooks/__init__.py index 38a223e..99f11d6 100644 --- a/src/pyproject_hooks/__init__.py +++ b/src/pyproject_hooks/__init__.py @@ -1,6 +1,8 @@ """Wrappers to call pyproject.toml-based build backend hooks. """ +from typing import TYPE_CHECKING + from ._impl import ( BackendUnavailable, BuildBackendHookCaller, @@ -19,3 +21,6 @@ "quiet_subprocess_runner", "BuildBackendHookCaller", ] + +if TYPE_CHECKING: + from ._impl import SubprocessRunner # noqa: F401 diff --git a/src/pyproject_hooks/_impl.py b/src/pyproject_hooks/_impl.py index c0511a0..d1e9d7b 100644 --- a/src/pyproject_hooks/_impl.py +++ b/src/pyproject_hooks/_impl.py @@ -6,16 +6,31 @@ from os.path import abspath from os.path import join as pjoin from subprocess import STDOUT, check_call, check_output +from typing import TYPE_CHECKING, Any, Iterator, Mapping, Optional, Sequence from ._in_process import _in_proc_script_path +if TYPE_CHECKING: + from typing import Protocol -def write_json(obj, path, **kwargs): + class SubprocessRunner(Protocol): + """A protocol for the subprocess runner.""" + + def __call__( + self, + cmd: Sequence[str], + cwd: Optional[str] = None, + extra_environ: Optional[Mapping[str, str]] = None, + ) -> None: + ... + + +def write_json(obj: Mapping[str, Any], path: str, **kwargs) -> None: with open(path, "w", encoding="utf-8") as f: json.dump(obj, f, **kwargs) -def read_json(path): +def read_json(path: str) -> Mapping[str, Any]: with open(path, encoding="utf-8") as f: return json.load(f) @@ -23,7 +38,13 @@ def read_json(path): class BackendUnavailable(Exception): """Will be raised if the backend cannot be imported in the hook process.""" - def __init__(self, traceback, message=None, backend_name=None, backend_path=None): + def __init__( + self, + traceback: str, + message: Optional[str] = None, + backend_name: Optional[str] = None, + backend_path: Optional[Sequence[str]] = None, + ) -> None: # Preserving arg order for the sake of API backward compatibility. self.backend_name = backend_name self.backend_path = backend_path @@ -34,7 +55,7 @@ def __init__(self, traceback, message=None, backend_name=None, backend_path=None class HookMissing(Exception): """Will be raised on missing hooks (if a fallback can't be used).""" - def __init__(self, hook_name): + def __init__(self, hook_name: str) -> None: super().__init__(hook_name) self.hook_name = hook_name @@ -42,11 +63,15 @@ def __init__(self, hook_name): class UnsupportedOperation(Exception): """May be raised by build_sdist if the backend indicates that it can't.""" - def __init__(self, traceback): + def __init__(self, traceback: str) -> None: self.traceback = traceback -def default_subprocess_runner(cmd, cwd=None, extra_environ=None): +def default_subprocess_runner( + cmd: Sequence[str], + cwd: Optional[str] = None, + extra_environ: Optional[Mapping[str, str]] = None, +) -> None: """The default method of calling the wrapper subprocess. This uses :func:`subprocess.check_call` under the hood. @@ -58,7 +83,11 @@ def default_subprocess_runner(cmd, cwd=None, extra_environ=None): check_call(cmd, cwd=cwd, env=env) -def quiet_subprocess_runner(cmd, cwd=None, extra_environ=None): +def quiet_subprocess_runner( + cmd: Sequence[str], + cwd: Optional[str] = None, + extra_environ: Optional[Mapping[str, str]] = None, +) -> None: """Call the subprocess while suppressing output. This uses :func:`subprocess.check_output` under the hood. @@ -70,7 +99,7 @@ def quiet_subprocess_runner(cmd, cwd=None, extra_environ=None): check_output(cmd, cwd=cwd, env=env, stderr=STDOUT) -def norm_and_check(source_tree, requested): +def norm_and_check(source_tree: str, requested: str) -> str: """Normalise and check a backend path. Ensure that the requested backend path is specified as a relative path, @@ -99,12 +128,12 @@ class BuildBackendHookCaller: def __init__( self, - source_dir, - build_backend, - backend_path=None, - runner=None, - python_executable=None, - ): + source_dir: str, + build_backend: str, + backend_path: Optional[Sequence[str]] = None, + runner: Optional["SubprocessRunner"] = None, + python_executable: Optional[str] = None, + ) -> None: """ :param source_dir: The source directory to invoke the build backend for :param build_backend: The build backend spec @@ -127,10 +156,12 @@ def __init__( self.python_executable = python_executable @contextmanager - def subprocess_runner(self, runner): + def subprocess_runner(self, runner: "SubprocessRunner") -> Iterator[None]: """A context manager for temporarily overriding the default :ref:`subprocess runner `. + :param runner: The new subprocess runner to use within the context. + .. code-block:: python hook_caller = BuildBackendHookCaller(...) @@ -144,15 +175,18 @@ def subprocess_runner(self, runner): finally: self._subprocess_runner = prev - def _supported_features(self): + def _supported_features(self) -> Sequence[str]: """Return the list of optional features supported by the backend.""" return self._call_hook("_supported_features", {}) - def get_requires_for_build_wheel(self, config_settings=None): + def get_requires_for_build_wheel( + self, + config_settings: Optional[Mapping[str, Any]] = None, + ) -> Sequence[str]: """Get additional dependencies required for building a wheel. + :param config_settings: The configuration settings for the build backend :returns: A list of :pep:`dependency specifiers <508>`. - :rtype: list[str] .. admonition:: Fallback @@ -164,13 +198,21 @@ def get_requires_for_build_wheel(self, config_settings=None): ) def prepare_metadata_for_build_wheel( - self, metadata_directory, config_settings=None, _allow_fallback=True - ): + self, + metadata_directory: str, + config_settings: Optional[Mapping[str, Any]] = None, + _allow_fallback: bool = True, + ) -> str: """Prepare a ``*.dist-info`` folder with metadata for this project. + :param metadata_directory: The directory to write the metadata to + :param config_settings: The configuration settings for the build backend + :param _allow_fallback: + Whether to allow the fallback to building a wheel and extracting + the metadata from it. Should be passed as a keyword argument only. + :returns: Name of the newly created subfolder within ``metadata_directory``, containing the metadata. - :rtype: str .. admonition:: Fallback @@ -189,10 +231,16 @@ def prepare_metadata_for_build_wheel( ) def build_wheel( - self, wheel_directory, config_settings=None, metadata_directory=None - ): + self, + wheel_directory: str, + config_settings: Optional[Mapping[str, Any]] = None, + metadata_directory: Optional[str] = None, + ) -> str: """Build a wheel from this project. + :param wheel_directory: The directory to write the wheel to + :param config_settings: The configuration settings for the build backend + :param metadata_directory: The directory to reuse existing metadata from :returns: The name of the newly created wheel within ``wheel_directory``. @@ -214,11 +262,14 @@ def build_wheel( }, ) - def get_requires_for_build_editable(self, config_settings=None): + def get_requires_for_build_editable( + self, + config_settings: Optional[Mapping[str, Any]] = None, + ) -> Sequence[str]: """Get additional dependencies required for building an editable wheel. + :param config_settings: The configuration settings for the build backend :returns: A list of :pep:`dependency specifiers <508>`. - :rtype: list[str] .. admonition:: Fallback @@ -230,13 +281,20 @@ def get_requires_for_build_editable(self, config_settings=None): ) def prepare_metadata_for_build_editable( - self, metadata_directory, config_settings=None, _allow_fallback=True - ): + self, + metadata_directory: str, + config_settings: Optional[Mapping[str, Any]] = None, + _allow_fallback: bool = True, + ) -> Optional[str]: """Prepare a ``*.dist-info`` folder with metadata for this project. + :param metadata_directory: The directory to write the metadata to + :param config_settings: The configuration settings for the build backend + :param _allow_fallback: + Whether to allow the fallback to building a wheel and extracting + the metadata from it. Should be passed as a keyword argument only. :returns: Name of the newly created subfolder within ``metadata_directory``, containing the metadata. - :rtype: str .. admonition:: Fallback @@ -255,10 +313,16 @@ def prepare_metadata_for_build_editable( ) def build_editable( - self, wheel_directory, config_settings=None, metadata_directory=None - ): + self, + wheel_directory: str, + config_settings: Optional[Mapping[str, Any]] = None, + metadata_directory: Optional[str] = None, + ) -> str: """Build an editable wheel from this project. + :param wheel_directory: The directory to write the wheel to + :param config_settings: The configuration settings for the build backend + :param metadata_directory: The directory to reuse existing metadata from :returns: The name of the newly created wheel within ``wheel_directory``. @@ -281,17 +345,23 @@ def build_editable( }, ) - def get_requires_for_build_sdist(self, config_settings=None): + def get_requires_for_build_sdist( + self, + config_settings: Optional[Mapping[str, Any]] = None, + ) -> Sequence[str]: """Get additional dependencies required for building an sdist. :returns: A list of :pep:`dependency specifiers <508>`. - :rtype: list[str] """ return self._call_hook( "get_requires_for_build_sdist", {"config_settings": config_settings} ) - def build_sdist(self, sdist_directory, config_settings=None): + def build_sdist( + self, + sdist_directory: str, + config_settings: Optional[Mapping[str, Any]] = None, + ) -> str: """Build an sdist from this project. :returns: @@ -305,7 +375,7 @@ def build_sdist(self, sdist_directory, config_settings=None): }, ) - def _call_hook(self, hook_name, kwargs): + def _call_hook(self, hook_name: str, kwargs: Mapping[str, Any]) -> Any: extra_environ = {"_PYPROJECT_HOOKS_BUILD_BACKEND": self.build_backend} if self.backend_path: diff --git a/src/pyproject_hooks/py.typed b/src/pyproject_hooks/py.typed new file mode 100644 index 0000000..e69de29