From 01d21aa80e298d189f582598bae42a08e6737975 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Fri, 5 Feb 2021 10:36:08 +0100 Subject: [PATCH] Add @nox.session drop-in replacement (#259) * chore: Configure flake8-rst-docstrings to recognize :meth: roles * feat: Add sessions module with @session and Session * docs: Remove obsolete advice from docstring for `install` * feat: Export @session and Session from package * refactor: Add type stubs for @session and Session * tests: Add functional tests for @session * tests: Add unit tests for @session * docs: Update API reference for @session and Session * docs: Update README for @session and Session * refactor(sessions): Remove dependency on core module * refactor(sessions): Eliminate redundant instantiations of Poetry * refactor(core): Depend on sessions module * test: Restore coverage for core.build_package * chore: Use nox_poetry.session in our own noxfile --- .flake8 | 2 +- README.rst | 85 ++++------ docs/reference.rst | 26 ++- noxfile.py | 27 +-- src/nox_poetry/__init__.py | 28 ++- src/nox_poetry/core.py | 189 +++------------------ src/nox_poetry/sessions.py | 255 ++++++++++++++++++++++++++++ src/nox_poetry/sessions.pyi | 81 +++++++++ tests/functional/test_functional.py | 88 ++++++++++ tests/unit/test_nox_poetry.py | 8 + tests/unit/test_sessions.py | 61 +++++++ 11 files changed, 594 insertions(+), 256 deletions(-) create mode 100644 src/nox_poetry/sessions.py create mode 100644 src/nox_poetry/sessions.pyi create mode 100644 tests/unit/test_sessions.py diff --git a/.flake8 b/.flake8 index 90b8026d..eaeb36ee 100644 --- a/.flake8 +++ b/.flake8 @@ -5,4 +5,4 @@ max-line-length = 80 max-complexity = 10 docstring-convention = google per-file-ignores = tests/*:S101 -rst-roles = const,class,func,mod +rst-roles = const,class,func,meth,mod diff --git a/README.rst b/README.rst index a7409bb2..a15ddd29 100644 --- a/README.rst +++ b/README.rst @@ -38,11 +38,18 @@ nox-poetry Use Poetry_ inside Nox_ sessions -This package provides a drop-in replacement for ``session.install`` in Nox sessions. -It modifies its behavior in two ways: +This package provides a drop-in replacement for the ``nox.session`` decorator, +and for the ``nox.Session`` object passed to user-defined session functions. +This enables ``session.install`` to install packages at the versions specified in the Poetry lock file. -- Packages are pinned to the versions specified in Poetry's lock file. -- The argument ``"."`` is replaced by a wheel built from the package. +.. code:: python + + from nox_poetry import session + + @session(python=["3.8", "3.9"]) + def tests(session): + session.install("pytest", ".") + session.run("pytest") Installation @@ -68,64 +75,38 @@ use the following command to install this package into the same environment: Usage ----- -Import ``nox_poetry.patch`` at the top of your ``noxfile.py``. +Import the ``@session`` decorator from ``nox_poetry`` instead of ``nox``. +There is nothing else you need to do. +The ``session.install`` method automatically honors the Poetry lock file when installing dependencies. +This allows you to manage packages used in Nox sessions as development dependencies in Poetry. -``nox-poetry`` intercepts calls to ``session.install`` -and uses Poetry to export a `constraints file`_ and build the package behind the scenes. -All packages installed in Nox sessions must be managed as dependencies in Poetry. +This works because session functions are passed instances of ``nox_poetry.Session``, +a proxy for ``nox.Session`` adding Poetry-related functionality. +Behind the scenes, nox-poetry uses Poetry to export a `constraints file`_ and build the package. -For example, the following Nox session runs your test suite: +For more fine-grained control, additional utilities are available under the ``session.poetry`` attribute: -.. code:: python - - # noxfile.py - import nox - import nox_poetry.patch - from nox.sessions import Session - - @nox.session - def tests(session: Session) -> None: - """Run the test suite.""" - session.install(".") - session.install("pytest") - session.run("pytest") - -More precisely, the session builds a wheel from the local package, -installs the wheel as well as the ``pytest`` package, and -invokes ``pytest`` to run the test suite against the installation. - -If you prefer a more explicit approach, -invoke ``nox_poetry.install`` and ``nox_poetry.installroot`` instead of ``session.install``. -Use the ``nox_poetry.WHEEL`` or ``nox_poetry.SDIST`` constants to specify the distribution format for the local package. - -Here is the example above using the more explicit approach: - -.. code:: python - - # noxfile.py - import nox - import nox_poetry - from nox.sessions import Session - - @nox.session - def tests(session: Session) -> None: - """Run the test suite.""" - nox_poetry.installroot(session, distribution_format=nox_poetry.WHEEL) - #nox_poetry.install(session, ".") # this is equivalent to the statement above - nox_poetry.install(session, "pytest") - session.run("pytest") +- ``session.poetry.installroot(distribution_format=[WHEEL|SDIST])`` +- ``session.poetry.build_package(distribution_format=[WHEEL|SDIST])`` +- ``session.poetry.export_requirements()`` Why? ---- -Consider what would happen in the first version without the line importing ``nox-poetry.patch``: +The example session above performs the following steps: + +- Build a wheel from the local package. +- Install the wheel as well as the ``pytest`` package. +- Invoke ``pytest`` to run the test suite against the installation. + +Consider what would happen in this session +if we had imported ``@session`` from ``nox`` instead of ``nox_poetry``: - Package dependencies would only be constrained by the wheel metadata, not by the lock file. In other words, their versions would not be *pinned*. - The ``pytest`` dependency would not be constrained at all. -- Poetry would be installed as a build backend every time - (although this could be avoided by passing the option ``--no-build-isolation``). +- Poetry would be installed as a build backend every time. Unpinned dependencies mean that your checks are not reproducible and deterministic, which can lead to surprises in Continuous Integration and when collaborating with others. @@ -168,10 +149,6 @@ In summary, this approach brings the following advantages: - Every tool can run in an isolated environment with minimal dependencies. - No need to install your package with all its dependencies if all you need is some linter. -For more details, take a look at `this article`__. - -__ https://cjolowicz.github.io/posts/hypermodern-python-03-linting/#managing-dependencies-in-nox-sessions-with-poetry - Contributing ------------ diff --git a/docs/reference.rst b/docs/reference.rst index abecbcc0..27df359e 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -3,24 +3,22 @@ API Reference .. automodule:: nox_poetry -Constants -......... - -.. autodata:: WHEEL -.. autodata:: SDIST - Functions ......... -.. autofunction:: install -.. autofunction:: installroot -.. autofunction:: build_package -.. autofunction:: export_requirements -.. autofunction:: nox_poetry.core.patch +.. autofunction:: session -Modules +Classes ....... -**nox_poetry.patch** +.. autoclass:: Session +.. automethod:: nox_poetry.sessions._PoetrySession.install +.. automethod:: nox_poetry.sessions._PoetrySession.installroot +.. automethod:: nox_poetry.sessions._PoetrySession.export_requirements +.. automethod:: nox_poetry.sessions._PoetrySession.build_package -.. automodule:: nox_poetry.patch +Constants +......... + +.. autodata:: WHEEL +.. autodata:: SDIST diff --git a/noxfile.py b/noxfile.py index 35f54bd5..c8c96bfa 100644 --- a/noxfile.py +++ b/noxfile.py @@ -5,9 +5,9 @@ from textwrap import dedent import nox -from nox.sessions import Session -import nox_poetry.patch +from nox_poetry import Session +from nox_poetry import session package = "nox_poetry" @@ -73,7 +73,7 @@ def activate_virtualenv_in_precommit_hooks(session: Session) -> None: hook.write_text("\n".join(lines)) -@nox.session(name="pre-commit", python="3.9") +@session(name="pre-commit", python="3.9") def precommit(session: Session) -> None: """Lint using pre-commit.""" args = session.posargs or ["run", "--all-files", "--show-diff-on-failure"] @@ -95,17 +95,17 @@ def precommit(session: Session) -> None: activate_virtualenv_in_precommit_hooks(session) -@nox.session(python="3.9") +@session(python="3.9") def safety(session: Session) -> None: """Scan dependencies for insecure packages.""" - requirements = nox_poetry.export_requirements(session) + requirements = session.poetry.export_requirements() session.install("safety") # Ignore CVE-2020-28476 affecting all versions of tornado # https://github.com/tornadoweb/tornado/issues/2981 session.run("safety", "check", f"--file={requirements}", "--bare", "--ignore=39462") -@nox.session(python=python_versions) +@session(python=python_versions) def mypy(session: Session) -> None: """Type-check using mypy.""" args = session.posargs or ["src", "tests", "docs/conf.py"] @@ -116,7 +116,7 @@ def mypy(session: Session) -> None: session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py") -@nox.session(python=python_versions) +@session(python=python_versions) def tests(session: Session) -> None: """Run the test suite.""" session.install(".") @@ -138,11 +138,12 @@ def tests(session: Session) -> None: session.notify("coverage") -@nox.session +@session def coverage(session: Session) -> None: """Produce the coverage report.""" # Do not use session.posargs unless this is the only session. - has_args = session.posargs and len(session._runner.manifest) == 1 + nsessions = len(session._runner.manifest) # type: ignore[attr-defined] + has_args = session.posargs and nsessions == 1 args = session.posargs if has_args else ["report"] session.install("coverage[toml]") @@ -153,7 +154,7 @@ def coverage(session: Session) -> None: session.run("coverage", *args) -@nox.session(python=python_versions) +@session(python=python_versions) def typeguard(session: Session) -> None: """Runtime type checking using Typeguard.""" session.install(".") @@ -161,7 +162,7 @@ def typeguard(session: Session) -> None: session.run("pytest", f"--typeguard-packages={package}", *session.posargs) -@nox.session(python=python_versions) +@session(python=python_versions) def xdoctest(session: Session) -> None: """Run examples with xdoctest.""" args = session.posargs or ["all"] @@ -170,7 +171,7 @@ def xdoctest(session: Session) -> None: session.run("python", "-m", "xdoctest", package, *args) -@nox.session(name="docs-build", python="3.8") +@session(name="docs-build", python="3.8") def docs_build(session: Session) -> None: """Build the documentation.""" args = session.posargs or ["docs", "docs/_build"] @@ -184,7 +185,7 @@ def docs_build(session: Session) -> None: session.run("sphinx-build", *args) -@nox.session(python="3.8") +@session(python="3.8") def docs(session: Session) -> None: """Build and serve the documentation with live reloading on file changes.""" args = session.posargs or ["--open-browser", "docs", "docs/_build"] diff --git a/src/nox_poetry/__init__.py b/src/nox_poetry/__init__.py index a529a2d8..3ac1ca6f 100644 --- a/src/nox_poetry/__init__.py +++ b/src/nox_poetry/__init__.py @@ -1,15 +1,25 @@ """Using Poetry in Nox sessions. -This package provides a facility to monkey-patch Nox so ``session.install`` -installs packages at the versions specified in the Poetry lock file, and -``"."`` is replaced by a wheel built from the local package. -See :mod:`nox_poetry.patch`. +This package provides a drop-in replacement for the :func:`session` decorator, +and for the :class:`Session` object passed to user-defined session functions. +This enables :meth:`session.install +` to install packages at the +versions specified in the Poetry lock file. + +Example: + >>> @session(python=["3.8", "3.9"]) + ... def tests(session: Session) -> None: + ... session.install("pytest", ".") + ... session.run("pytest") It also provides helper functions that allow more fine-grained control: -- :func:`install` -- :func:`build_package` -- :func:`export_requirements` +- :meth:`session.poetry.installroot + ` +- :meth:`session.poetry.build_package + ` +- :meth:`session.poetry.export_requirements + ` Two constants are defined to specify the format for distribution archives: @@ -21,6 +31,8 @@ from nox_poetry.core import install from nox_poetry.core import installroot from nox_poetry.poetry import DistributionFormat +from nox_poetry.sessions import Session +from nox_poetry.sessions import session #: A wheel archive. @@ -34,6 +46,8 @@ "export_requirements", "install", "installroot", + "Session", + "session", "SDIST", "WHEEL", ] diff --git a/src/nox_poetry/core.py b/src/nox_poetry/core.py index eb9dbce2..815bea73 100644 --- a/src/nox_poetry/core.py +++ b/src/nox_poetry/core.py @@ -1,191 +1,46 @@ """Core functions.""" -import hashlib -import re from pathlib import Path from typing import Any from typing import Iterable -from typing import Optional -from typing import Tuple -from nox.sessions import Session +import nox.sessions from nox_poetry.poetry import DistributionFormat -from nox_poetry.poetry import Poetry +from nox_poetry.sessions import Session -Session_install = Session.install +Session_install = nox.sessions.Session.install -def export_requirements(session: Session) -> Path: - """Export a requirements file from Poetry. +def export_requirements(session: nox.sessions.Session) -> Path: + """Export a requirements file from Poetry.""" + return Session(session).poetry.export_requirements() - This function uses `poetry export`_ to generate a `requirements file`_ - containing the project dependencies at the versions specified in - ``poetry.lock``. The requirements file includes both core and development - dependencies. - The requirements file is stored in a per-session temporary directory, - together with a hash digest over ``poetry.lock`` to avoid generating the - file when the dependencies have not changed since the last run. +def build_package( + session: nox.sessions.Session, *, distribution_format: DistributionFormat +) -> str: + """Build a distribution archive for the package.""" + return Session(session).poetry.build_package( + distribution_format=distribution_format + ) - .. _poetry export: https://python-poetry.org/docs/cli/#export - .. _requirements file: - https://pip.pypa.io/en/stable/user_guide/#requirements-files - Args: - session: The ``Session`` object. - - Returns: - The path to the requirements file. - """ - tmpdir = Path(session.create_tmp()) - path = tmpdir / "requirements.txt" - hashfile = tmpdir / f"{path.name}.hash" - - lockdata = Path("poetry.lock").read_bytes() - digest = hashlib.blake2b(lockdata).hexdigest() - - if not hashfile.is_file() or hashfile.read_text() != digest: - Poetry(session).export(path) - hashfile.write_text(digest) - - return path - - -def build_package(session: Session, *, distribution_format: DistributionFormat) -> str: - """Build a distribution archive for the package. - - This function uses `poetry build`_ to build a wheel or sdist archive for - the local package, as specified via the ``distribution_format`` parameter. - It returns a file URL with the absolute path to the built archive, and an - embedded `SHA-256 hash`_ computed for the archive. This makes it suitable - as an argument to `pip install`_ when a constraints file is also being - passed, as in :func:`install`. - - .. _poetry build: https://python-poetry.org/docs/cli/#export - .. _pip install: https://pip.pypa.io/en/stable/reference/pip_install/ - .. _SHA-256 hash: - https://pip.pypa.io/en/stable/reference/pip_install/#hash-checking-mode - - Args: - session: The ``Session`` object. - distribution_format: The distribution format, either wheel or sdist. - - Returns: - The file URL for the distribution package. - """ - poetry = Poetry(session) - wheel = Path("dist") / poetry.build(format=distribution_format) - digest = hashlib.sha256(wheel.read_bytes()).hexdigest() - url = f"file://{wheel.resolve().as_posix()}#sha256={digest}" - - if distribution_format is DistributionFormat.SDIST: - url += f"&egg={poetry.config.name}" - - return url - - -_EXTRAS_PATTERN = re.compile(r"^(.+)(\[[^\]]+\])$") - - -def _split_extras(arg: str) -> Tuple[str, Optional[str]]: - # From ``pip._internal.req.constructors._strip_extras`` - match = _EXTRAS_PATTERN.match(arg) - if match: - return match.group(1), match.group(2) - return arg, None - - -def install(session: Session, *args: str, **kwargs: Any) -> None: - """Install packages into a Nox session using Poetry. - - This function installs packages into the session's virtual environment. It - is a wrapper for `nox.sessions.Session.install`_, whose positional - arguments are command-line arguments for `pip install`_, and whose keyword - arguments are the same as those for `nox.sessions.Session.run`_. - - If a positional argument is ".", a wheel is built using - :func:`build_package`, and the argument is replaced with the file URL - returned by that function. Otherwise, the argument is forwarded unchanged. - - In addition, a `constraints file`_ is generated for the package - dependencies using :func:`export_requirements`, and passed to ``pip - install`` via its ``--constraint`` option. This ensures that any package - installed will be at the version specified in Poetry's lock file. - - Every package passed to this function must be managed as a dependency in - Poetry, to avoid an error due to missing archive hashes. - - .. _pip install: https://pip.pypa.io/en/stable/reference/pip_install/ - .. _nox.sessions.Session.install: - https://nox.thea.codes/en/stable/config.html#nox.sessions.Session.install - .. _nox.sessions.Session.run: - https://nox.thea.codes/en/stable/config.html#nox.sessions.Session.run - .. _constraints file: - https://pip.pypa.io/en/stable/user_guide/#constraints-files - - Args: - session: The Session object. - args: Command-line arguments for ``pip install``. - kwargs: Keyword-arguments for ``session.install``. These are the same - as those for `nox.sessions.Session.run`_. - """ - args_extras = [_split_extras(arg) for arg in args] - - if "." in [arg for arg, _ in args_extras]: - package = build_package(session, distribution_format=DistributionFormat.WHEEL) - - def rewrite(arg: str, extras: Optional[str]) -> str: - if arg != ".": - return arg if extras is None else arg + extras - - if extras is None: - return package - - name = Poetry(session).config.name - return f"{name}{extras} @ {package}" - - args = tuple(rewrite(arg, extras) for arg, extras in args_extras) - - session.run("pip", "uninstall", "--yes", package, silent=True) - - requirements = export_requirements(session) - Session_install(session, f"--constraint={requirements}", *args, **kwargs) +def install(session: nox.sessions.Session, *args: str, **kwargs: Any) -> None: + """Install packages into a Nox session using Poetry.""" + Session(session).install(*args, **kwargs) def installroot( - session: Session, + session: nox.sessions.Session, *, distribution_format: DistributionFormat, extras: Iterable[str] = (), ) -> None: - """Install the root package into a Nox session using Poetry. - - This function installs the package located in the current directory into the - session's virtual environment. - - A constraints file is generated for the package dependencies using - :func:`export_requirements`, and passed to ``pip install`` via its - ``--constraint`` option. This ensures that core dependencies are installed - using the versions specified in Poetry's lock file. - - Args: - session: The Session object. - distribution_format: The distribution format, either wheel or sdist. - extras: Extras to install for the package. - """ - package = build_package(session, distribution_format=distribution_format) - requirements = export_requirements(session) - - session.run("pip", "uninstall", "--yes", package, silent=True) - - suffix = ",".join(extras) - if suffix.strip(): - suffix = suffix.join("[]") - name = Poetry(session).config.name - package = f"{name}{suffix} @ {package}" - - Session_install(session, f"--constraint={requirements}", package) + """Install the root package into a Nox session using Poetry.""" + Session(session).poetry.installroot( + distribution_format=distribution_format, extras=extras + ) def patch( @@ -208,4 +63,4 @@ def patch( distribution_format: The distribution format to use when the ``"."`` argument is encountered in calls to ``session.install``. """ - Session.install = install # type: ignore[assignment] + nox.sessions.Session.install = install # type: ignore[assignment] diff --git a/src/nox_poetry/sessions.py b/src/nox_poetry/sessions.py new file mode 100644 index 00000000..b7c2b73b --- /dev/null +++ b/src/nox_poetry/sessions.py @@ -0,0 +1,255 @@ +"""Replacements for the ``nox.session`` decorator and the ``nox.Session`` class.""" +import functools +import hashlib +import re +from pathlib import Path +from typing import Any +from typing import Iterable +from typing import Optional +from typing import Tuple + +import nox + +from nox_poetry.poetry import DistributionFormat +from nox_poetry.poetry import Poetry + + +def session(*args: Any, **kwargs: Any) -> Any: + """Drop-in replacement for the nox.session_ decorator. + + Use this decorator instead of ``@nox.session``. Session functions are passed + :class:`Session` instead of ``nox.Session``; otherwise, the decorators work + exactly the same. + + .. _nox.session: + https://nox.thea.codes/en/stable/config.html#nox.session + + Args: + args: Positional arguments are forwarded to ``nox.session``. + kwargs: Keyword arguments are forwarded to ``nox.session``. + + Returns: + The decorated session function. + """ + if not args: + return functools.partial(session, **kwargs) + + [function] = args + + @functools.wraps(function) + def wrapper(session: nox.Session) -> None: + proxy = Session(session) + function(proxy) + + return nox.session(wrapper, **kwargs) # type: ignore[call-overload] + + +_EXTRAS_PATTERN = re.compile(r"^(.+)(\[[^\]]+\])$") + + +def _split_extras(arg: str) -> Tuple[str, Optional[str]]: + # From ``pip._internal.req.constructors._strip_extras`` + match = _EXTRAS_PATTERN.match(arg) + if match: + return match.group(1), match.group(2) + return arg, None + + +class _PoetrySession: + """Poetry-related utilities for session functions.""" + + def __init__(self, session: nox.Session) -> None: + """Initialize.""" + self.session = session + self.poetry = Poetry(session) + + def install(self, *args: str, **kwargs: Any) -> None: + """Install packages into a Nox session using Poetry. + + This function installs packages into the session's virtual environment. It + is a wrapper for `nox.sessions.Session.install`_, whose positional + arguments are command-line arguments for `pip install`_, and whose keyword + arguments are the same as those for `nox.sessions.Session.run`_. + + If a positional argument is ".", a wheel is built using + :meth:`build_package`, and the argument is replaced with the file URL + returned by that function. Otherwise, the argument is forwarded unchanged. + + In addition, a `constraints file`_ is generated for the package + dependencies using :meth:`export_requirements`, and passed to ``pip + install`` via its ``--constraint`` option. This ensures that any package + installed will be at the version specified in Poetry's lock file. + + .. _pip install: https://pip.pypa.io/en/stable/reference/pip_install/ + .. _nox.sessions.Session.install: + https://nox.thea.codes/en/stable/config.html#nox.sessions.Session.install + .. _nox.sessions.Session.run: + https://nox.thea.codes/en/stable/config.html#nox.sessions.Session.run + .. _constraints file: + https://pip.pypa.io/en/stable/user_guide/#constraints-files + + Args: + args: Command-line arguments for ``pip install``. + kwargs: Keyword-arguments for ``session.install``. These are the same + as those for `nox.sessions.Session.run`_. + """ + from nox_poetry.core import Session_install + + args_extras = [_split_extras(arg) for arg in args] + + if "." in [arg for arg, _ in args_extras]: + package = self.build_package(distribution_format=DistributionFormat.WHEEL) + + def rewrite(arg: str, extras: Optional[str]) -> str: + if arg != ".": + return arg if extras is None else arg + extras + + if extras is None: + return package + + name = self.poetry.config.name + return f"{name}{extras} @ {package}" + + args = tuple(rewrite(arg, extras) for arg, extras in args_extras) + + self.session.run("pip", "uninstall", "--yes", package, silent=True) + + requirements = self.export_requirements() + Session_install(self.session, f"--constraint={requirements}", *args, **kwargs) + + def installroot( + self, + *, + distribution_format: DistributionFormat, + extras: Iterable[str] = (), + ) -> None: + """Install the root package into a Nox session using Poetry. + + This function installs the package located in the current directory into the + session's virtual environment. + + A constraints file is generated for the package dependencies using + :meth:`export_requirements`, and passed to ``pip install`` via its + ``--constraint`` option. This ensures that core dependencies are installed + using the versions specified in Poetry's lock file. + + Args: + distribution_format: The distribution format, either wheel or sdist. + extras: Extras to install for the package. + """ + from nox_poetry.core import Session_install + + package = self.build_package(distribution_format=distribution_format) + requirements = self.export_requirements() + + self.session.run("pip", "uninstall", "--yes", package, silent=True) + + suffix = ",".join(extras) + if suffix.strip(): + suffix = suffix.join("[]") + name = self.poetry.config.name + package = f"{name}{suffix} @ {package}" + + Session_install(self.session, f"--constraint={requirements}", package) + + def export_requirements(self) -> Path: + """Export a requirements file from Poetry. + + This function uses `poetry export`_ to generate a `requirements file`_ + containing the project dependencies at the versions specified in + ``poetry.lock``. The requirements file includes both core and development + dependencies. + + The requirements file is stored in a per-session temporary directory, + together with a hash digest over ``poetry.lock`` to avoid generating the + file when the dependencies have not changed since the last run. + + .. _poetry export: https://python-poetry.org/docs/cli/#export + .. _requirements file: + https://pip.pypa.io/en/stable/user_guide/#requirements-files + + Returns: + The path to the requirements file. + """ + tmpdir = Path(self.session.create_tmp()) + path = tmpdir / "requirements.txt" + hashfile = tmpdir / f"{path.name}.hash" + + lockdata = Path("poetry.lock").read_bytes() + digest = hashlib.blake2b(lockdata).hexdigest() + + if not hashfile.is_file() or hashfile.read_text() != digest: + self.poetry.export(path) + hashfile.write_text(digest) + + return path + + def build_package(self, *, distribution_format: DistributionFormat) -> str: + """Build a distribution archive for the package. + + This function uses `poetry build`_ to build a wheel or sdist archive for + the local package, as specified via the ``distribution_format`` parameter. + It returns a file URL with the absolute path to the built archive, and an + embedded `SHA-256 hash`_ computed for the archive. This makes it suitable + as an argument to `pip install`_ when a constraints file is also being + passed, as in :meth:`install`. + + .. _poetry build: https://python-poetry.org/docs/cli/#export + .. _pip install: https://pip.pypa.io/en/stable/reference/pip_install/ + .. _SHA-256 hash: + https://pip.pypa.io/en/stable/reference/pip_install/#hash-checking-mode + + Args: + distribution_format: The distribution format, either wheel or sdist. + + Returns: + The file URL for the distribution package. + """ + wheel = Path("dist") / self.poetry.build(format=distribution_format) + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + url = f"file://{wheel.resolve().as_posix()}#sha256={digest}" + + if distribution_format is DistributionFormat.SDIST: + url += f"&egg={self.poetry.config.name}" + + return url + + +class _SessionProxy: + """Proxy for nox.Session.""" + + def __init__(self, session: nox.Session) -> None: + """Initialize.""" + self._session = session + + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to nox.Session.""" + return getattr(self._session, name) + + +class Session(_SessionProxy): + """Proxy for nox.sessions.Session_, passed to user-defined session functions. + + .. _nox.sessions.Session: + https://nox.thea.codes/en/stable/config.html#nox.sessions.Session + + This class overrides :meth:`session.install + `, and provides Poetry-related + utilities: + + - :meth:`Session.poetry.installroot + ` + - :meth:`Session.poetry.build_package + ` + - :meth:`Session.poetry.export_requirements + ` + """ + + def __init__(self, session: nox.Session) -> None: + """Initialize.""" + super().__init__(session) + self.poetry = _PoetrySession(session) + + def install(self, *args: str, **kwargs: Any) -> None: + """Install packages into a Nox session using Poetry.""" + return self.poetry.install(*args, **kwargs) diff --git a/src/nox_poetry/sessions.pyi b/src/nox_poetry/sessions.pyi new file mode 100644 index 00000000..bc3f76a8 --- /dev/null +++ b/src/nox_poetry/sessions.pyi @@ -0,0 +1,81 @@ +"""Type stubs for nox_poetry.sessions.""" +from pathlib import Path +from typing import Any +from typing import Callable +from typing import Dict +from typing import Iterable +from typing import List +from typing import Mapping +from typing import NoReturn +from typing import Optional +from typing import overload +from typing import Sequence +from typing import TypeVar +from typing import Union + +import nox.sessions +import nox.virtualenv + +from nox_poetry.poetry import DistributionFormat + +Python = Optional[Union[str, Sequence[str], bool]] + +class _PoetrySession: + def install(self, *args: str, **kwargs: Any) -> None: ... + def installroot( + self, *, distribution_format: DistributionFormat, extras: Iterable[str] = ... + ) -> None: ... + def export_requirements(self) -> Path: ... + def build_package(self, *, distribution_format: DistributionFormat) -> str: ... + +class Session: + poetry: _PoetrySession + _session: nox.Session + def __init__(self, session: nox.Session) -> None: ... + def install(self, *args: str, **kwargs: Any) -> None: ... + @property + def env(self) -> Dict[str, str]: ... + @property + def posargs(self) -> List[str]: ... + @property + def virtualenv(self) -> nox.virtualenv.ProcessEnv: ... + @property + def python(self) -> Python: ... + @property + def bin_paths(self) -> Optional[List[str]]: ... + @property + def bin(self) -> Optional[str]: ... + def create_tmp(self) -> str: ... + @property + def interactive(self) -> bool: ... + def chdir(self, dir: str) -> None: ... + def cd(self, dir: str) -> None: ... + def run( + self, *args: str, env: Optional[Mapping[str, str]] = ..., **kwargs: Any + ) -> Optional[Any]: ... + def run_always( + self, *args: str, env: Optional[Mapping[str, str]] = ..., **kwargs: Any + ) -> Optional[Any]: ... + def conda_install( + self, *args: str, auto_offline: bool = ..., **kwargs: Any + ) -> None: ... + def notify(self, target: Union[str, nox.sessions.SessionRunner]) -> None: ... + def log(self, *args: Any, **kwargs: Any) -> None: ... + def error(self, *args: Any) -> NoReturn: ... + def skip(self, *args: Any) -> NoReturn: ... + +SessionFunction = Callable[[Session], None] +NoxSessionFunction = Callable[[nox.Session], None] +SessionDecorator = Callable[[SessionFunction], NoxSessionFunction] +@overload +def session(__func: SessionFunction) -> NoxSessionFunction: ... +@overload +def session( + __func: None = ..., + python: Python = ..., + py: Python = ..., + reuse_venv: Optional[bool] = ..., + name: Optional[str] = ..., + venv_backend: Any = ..., + venv_params: Any = ..., +) -> SessionDecorator: ... diff --git a/tests/functional/test_functional.py b/tests/functional/test_functional.py index 92accdda..b0f868db 100644 --- a/tests/functional/test_functional.py +++ b/tests/functional/test_functional.py @@ -273,3 +273,91 @@ def test(session: nox.sessions.Session) -> None: packages = list_packages(test) assert set(expected) == set(packages) + + +def test_session_install_local( + project: Project, + run_nox_with_noxfile: RunNoxWithNoxfile, + list_packages: ListPackages, +) -> None: + """It installs the local package.""" + + @nox_poetry.session + def test(session: nox_poetry.Session) -> None: + """Install the local package.""" + session.install(".") + + run_nox_with_noxfile([test], [nox_poetry]) + + expected = [project.package, *project.dependencies] + packages = list_packages(test) + + assert set(expected) == set(packages) + + +def test_session_install_local_with_extras( + project: Project, + run_nox_with_noxfile: RunNoxWithNoxfile, + list_packages: ListPackages, +) -> None: + """It installs the extra.""" + + @nox_poetry.session + def test(session: nox_poetry.Session) -> None: + """Install the local package.""" + session.install(".[pygments]") + + run_nox_with_noxfile([test], [nox_poetry]) + + expected = [ + project.package, + *project.dependencies, + project.get_dependency("pygments"), + ] + packages = list_packages(test) + + assert set(expected) == set(packages) + + +def test_session_install_dependency( + project: Project, + run_nox_with_noxfile: RunNoxWithNoxfile, + list_packages: ListPackages, +) -> None: + """It installs the pinned dependency.""" + + @nox_poetry.session + def test(session: nox_poetry.Session) -> None: + """Install the dependency.""" + session.install("pyflakes") + + run_nox_with_noxfile([test], [nox_poetry]) + + expected = [project.get_dependency("pyflakes")] + packages = list_packages(test) + + assert set(expected) == set(packages) + + +def test_session_install_local_wheel_and_dependency( + project: Project, + run_nox_with_noxfile: RunNoxWithNoxfile, + list_packages: ListPackages, +) -> None: + """It installs the wheel with pinned dependencies.""" + + @nox_poetry.session + def test(session: nox_poetry.Session) -> None: + """Install the dependency.""" + session.install(".", "pyflakes") + + run_nox_with_noxfile([test], [nox_poetry]) + + expected = [ + project.package, + *project.dependencies, + project.get_dependency("pyflakes"), + ] + packages = list_packages(test) + + assert set(expected) == set(packages) diff --git a/tests/unit/test_nox_poetry.py b/tests/unit/test_nox_poetry.py index d03642eb..2c4b3fbe 100644 --- a/tests/unit/test_nox_poetry.py +++ b/tests/unit/test_nox_poetry.py @@ -42,6 +42,14 @@ def test_installroot_with_extras( ) +@pytest.mark.parametrize("distribution_format", [nox_poetry.WHEEL, nox_poetry.SDIST]) +def test_build_package( + session: Session, distribution_format: DistributionFormat +) -> None: + """It builds the package.""" + nox_poetry.build_package(session, distribution_format=distribution_format) + + def test_export_requirements(session: Session) -> None: """It exports the requirements.""" nox_poetry.export_requirements(session).touch() diff --git a/tests/unit/test_sessions.py b/tests/unit/test_sessions.py new file mode 100644 index 00000000..48b36d69 --- /dev/null +++ b/tests/unit/test_sessions.py @@ -0,0 +1,61 @@ +"""Unit tests for the sessions module.""" +import nox.registry +import pytest + +import nox_poetry + + +def test_kwargs() -> None: + """It registers the session function.""" + + @nox_poetry.session(name="tests-renamed") + def tests(session: nox_poetry.Session) -> None: + pass + + assert "tests-renamed" in nox.registry.get() + + +def test_wrapper(session: nox.Session) -> None: + """It invokes the session function.""" + calls = [] + + @nox_poetry.session + def tests(proxy: nox_poetry.Session) -> None: + calls.append(proxy) + + tests(session) + + [proxy] = calls + + assert proxy._session is session + + +@pytest.fixture +def proxy(session: nox.Session) -> nox_poetry.Session: + """Fixture for session proxy.""" + return nox_poetry.Session(session) + + +def test_session_getattr(proxy: nox_poetry.Session) -> None: + """It delegates to the real session.""" + assert proxy.create_tmp() + + +def test_session_install(proxy: nox_poetry.Session) -> None: + """It installs the package.""" + proxy.install(".") + + +def test_session_installroot(proxy: nox_poetry.Session) -> None: + """It installs the package.""" + proxy.poetry.installroot(distribution_format=nox_poetry.WHEEL) + + +def test_session_export_requirements(proxy: nox_poetry.Session) -> None: + """It exports the requirements.""" + proxy.poetry.export_requirements() + + +def test_session_build_package(proxy: nox_poetry.Session) -> None: + """It exports the requirements.""" + proxy.poetry.build_package(distribution_format=nox_poetry.SDIST)