Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support installing Poetry dependency groups #2

Closed
wants to merge 12 commits into from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
/docs/_build/
/src/*.egg-info/
__pycache__/
.vscode
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Here is a comparison of the different installation methods:
- Use `session.install(...)` to install specific development dependencies, e.g. `session.install("pytest")`.
- Use `session.install(".")` (or `session.poetry.installroot()`) to install your own package.
- Use `session.run_always("poetry", "install", external=True)` to install your package with _all_ development dependencies.
- Use `session.install_groups(...)` to install all depedencies in given dependency groups (only available for poetry >= 1.2.0).

Please read the next section for the tradeoffs of each method.

Expand Down
7 changes: 6 additions & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

package = "nox_poetry"
python_versions = ["3.11", "3.10", "3.9", "3.8", "3.7"]
poetry_versions = ["1.0.10", "1.2.0"]
nox.needs_version = ">= 2021.6.6"
nox.options.sessions = (
"pre-commit",
Expand Down Expand Up @@ -152,7 +153,11 @@ def mypy(session: Session) -> None:
@session
@nox.parametrize(
"python,poetry",
[(python_versions[0], "1.0.10"), *((python, None) for python in python_versions)],
[
(python_versions[0], poetry_versions[0]),
(python_versions[0], poetry_versions[1]),
*((python, None) for python in python_versions),
],
)
def tests(session: Session, poetry: Optional[str]) -> None:
"""Run the test suite."""
Expand Down
41 changes: 38 additions & 3 deletions src/nox_poetry/poetry.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Poetry interface."""
import sys
from enum import Enum
from importlib import metadata
from pathlib import Path
from typing import Any
from typing import Iterable
Expand All @@ -10,6 +11,11 @@

import tomlkit
from nox.sessions import Session
from packaging.version import Version


class IncompatiblePoetryVersionError(Exception):
"""Installed poetry version does not meet requirements."""


class CommandSkippedError(Exception):
Expand All @@ -26,6 +32,9 @@ class DistributionFormat(str, Enum):
class Config:
"""Poetry configuration."""

"""Minimum version of poetry that can support group dependencies"""
MINIMUM_VERSION_SUPPORTING_GROUP_DEPS = Version("1.2.0")

def __init__(self, project: Path) -> None:
"""Initialize."""
path = project / "pyproject.toml"
Expand All @@ -49,6 +58,16 @@ def extras(self) -> List[str]:
)
return list(extras)

@classmethod
def version(cls) -> Version:
"""Current installed version of poetry."""
return Version(metadata.version("poetry"))

@classmethod
def is_compatible_with_group_deps(cls) -> bool:
"""Test that installed version of poetry can support group dependencies."""
return cls.version() >= cls.MINIMUM_VERSION_SUPPORTING_GROUP_DEPS


class Poetry:
"""Helper class for invoking Poetry inside a Nox session.
Expand All @@ -69,22 +88,38 @@ def config(self) -> Config:
self._config = Config(Path.cwd())
return self._config

def export(self) -> str:
def export(
self,
groups: Optional[List[str]] = None,
) -> str:
"""Export the lock file to requirements format.

Args:
groups: optional list of poetry depedency groups to --only install.

Returns:
The generated requirements as text.

Raises:
CommandSkippedError: The command `poetry export` was not executed.
"""
output = self.session.run_always(
args = [
"poetry",
"export",
"--format=requirements.txt",
"--dev",
*[f"--extras={extra}" for extra in self.config.extras],
"--without-hashes",
]

if groups:
args.extend(f"--only={group}" for group in groups)
elif self.config.is_compatible_with_group_deps():
args.append("--with=dev")
else:
args.append("--dev")

output = self.session.run_always(
*args,
external=True,
silent=True,
stderr=None,
Expand Down
83 changes: 74 additions & 9 deletions src/nox_poetry/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Any
from typing import Iterable
from typing import Iterator
from typing import List
from typing import Optional
from typing import Tuple

Expand All @@ -15,6 +16,7 @@

from nox_poetry.poetry import CommandSkippedError
from nox_poetry.poetry import DistributionFormat
from nox_poetry.poetry import IncompatiblePoetryVersionError
from nox_poetry.poetry import Poetry


Expand Down Expand Up @@ -196,36 +198,95 @@ def installroot(

self.session.install(f"--constraint={requirements}", package)

def export_requirements(self) -> Path:
def install_groups(self, *args: str, **kwargs: Any) -> None:
"""Install all packages in the given Poetry dependency groups.

Args:
args: The poetry dependency groups to install.
kwargs: Keyword-arguments for ``session.install``. These are the same
as those for :meth:`nox.sessions.Session.run`.

Raises:
ValueError: if no groups are provided to install.
"""
groups = [*args]
if not groups:
raise ValueError("At least one argument required to install_groups().")

try:
requirements = self.export_requirements(groups=groups)
except CommandSkippedError:
return

self.session.install("-r", str(requirements), **kwargs)

def export_requirements(
self,
groups: Optional[List[str]] = None,
) -> Path:
"""Export a requirements file from Poetry.

This function uses `poetry export <https://python-poetry.org/docs/cli/#export>`_
to generate a :ref:`requirements file <Requirements Files>` containing the
project dependencies at the versions specified in ``poetry.lock``. The
requirements file includes both core and development dependencies.
project dependencies at the versions specified in ``poetry.lock``.

If a list of groups is not provided, then a constraints.txt file will be
generated that includes both main and dev group dependencies.

If a list of groups is provided, then a requirements.txt file will be
generated that includes only the specified group dependencies.

Each constraints/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 or groups have not changed since the last
run.

Args:
groups: optional list of poetry depedency groups to --only install.
Passing groups will generate a requirements.txt file to install
all packages in those groups, rather than generating a constraints.txt
file for installing individual packages.

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.
Raises:
IncompatiblePoetryVersionError: The version of poetry installed is less than
v1.2.0, which is not compatible with installing dependency groups.

Returns:
The path to the requirements file.
"""
# Avoid ``session.virtualenv.location`` because PassthroughEnv does not
# have it. We'll just create a fake virtualenv directory in this case.

"""
If no groups are provided, then export requirements as a constraints.txt
file. Otherwise, export requirements as a requirements.txt file.
"""
if groups and not self.poetry.config.is_compatible_with_group_deps():
raise IncompatiblePoetryVersionError(
f"Installed version of poetry must be >="
f" {self.poetry.config.MINIMUM_VERSION_SUPPORTING_GROUP_DEPS} in"
" order to install dependency groups. Current version installed:"
f" {self.poetry.config.version()}"
)

tmpdir = Path(self.session._runner.envdir) / "tmp"
tmpdir.mkdir(exist_ok=True, parents=True)

path = tmpdir / "requirements.txt"
if groups:
filename = ",".join(groups) + "-" + "requirements.txt"
else:
filename = "constraints.txt"
path = tmpdir / filename
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:
constraints = to_constraints(self.poetry.export())
path.write_text(constraints)
contents = self.poetry.export(groups=groups)
if not groups:
contents = to_constraints(contents)
path.write_text(contents)
hashfile.write_text(digest)

return path
Expand Down Expand Up @@ -290,3 +351,7 @@ def __init__(self, session: nox.Session) -> None:
def install(self, *args: str, **kwargs: Any) -> None:
"""Install packages into a Nox session using Poetry."""
return self.poetry.install(*args, **kwargs)

def install_groups(self, *args: str, **kwargs: Any) -> None:
"""Install all packages from given Poetry dependency groups."""
return self.poetry.install_groups(*args, **kwargs)
4 changes: 3 additions & 1 deletion src/nox_poetry/sessions.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ class _PoetrySession:
def installroot(
self, *, distribution_format: str = ..., extras: Iterable[str] = ...
) -> None: ...
def export_requirements(self) -> Path: ...
def export_requirements(self, groups: Optional[List[str]] = None) -> Path: ...
def build_package(self, *, distribution_format: str = ...) -> str: ...
def install_groups(self, *args: str, **kwargs: Any) -> None: ...

class Session(nox.Session):
def __init__(self, session: nox.Session) -> None: ...
def install_groups(self, *args: str, **kwargs: Any) -> None: ...
poetry: _PoetrySession

SessionFunction = Callable[..., None]
Expand Down
6 changes: 6 additions & 0 deletions tests/functional/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ def project(shared_datadir: Path) -> Project:
return Project(shared_datadir / "example")


@pytest.fixture
def group_project(shared_datadir: Path) -> Project:
"""Return an example Poetry project using v1.2.0 dependency groups."""
return Project(shared_datadir / "example-v1.2.0")


def _run_nox(project: Project, *nox_args: str) -> CompletedProcess:
env = os.environ.copy()
env.pop("NOXSESSION", None)
Expand Down
90 changes: 90 additions & 0 deletions tests/functional/data/example-v1.2.0/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions tests/functional/data/example-v1.2.0/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[tool.poetry]
name = "example-v1.2.0"
version = "0.1.0"
description = ""
authors = ["Your Name <[email protected]>"]

[tool.poetry.dependencies]
python = "^3.6.1"
first = "^2.0.0"
Pygments = {version = "^2.7.1", optional = true}

[tool.poetry.group.dev.dependencies]
pyflakes = "^2.1.1"
pycodestyle = "^2.5.0"

[tool.poetry.group.test.dependencies]
isort = ">=5.10.1"

[tool.poetry.group.lint.dependencies]
darglint = ">=1.8.1"

[tool.poetry.extras]
pygments = ["pygments"]

[build-system]
requires = ["poetry-core>=1.2.0"]
build-backend = "poetry.core.masonry.api"
Loading