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 #1080

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ show_column_numbers = true
show_error_codes = true
show_error_context = true

[[tool.mypy.overrides]]
module = "importlib_metadata"
ignore_missing_imports = true

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
46 changes: 43 additions & 3 deletions src/nox_poetry/poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@

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


if sys.version_info >= (3, 8):
from importlib import metadata
else:
import importlib_metadata as metadata


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


class CommandSkippedError(Exception):
Expand All @@ -26,6 +37,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 +63,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 +93,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")
Comment on lines +119 to +124
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the check on self.config.is_compatible_with_group_deps() only for --with? Isn't --only also only available for poetry>=1.2?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we want to do is export the "main" group along with the "dev" group. poetry export --with=dev implicitly exports the "main" group as well.

If we ran poetry export --only=dev then we'd only be exporting the "dev" group without "main," which is not what we want.

poetry export --with=dev is the equivalent of poetry export --dev. We want the expected default behavior to be the same regardless of what version of poetry the user is using.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading this again, it looks like I misunderstood your comment. Ok. You're asking why the first if groups: clause doesn't also check for self.config.is_compatible_with_group_deps() since that if groups: clause uses the --only flag which is only available for poetry v>=1.2.

The reason is because earlier in the code we have a check within _PoetrySession.export_requirements that makes sure that if you're using "groups" that you're using the right version of poetry. And there's a unit test to ensure that.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Thanks!


output = self.session.run_always(
*args,
external=True,
silent=True,
stderr=None,
Expand Down
80 changes: 71 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,92 @@ 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.

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.
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.

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.
"""
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()}"
)

# Avoid ``session.virtualenv.location`` because PassthroughEnv does not
# have it. We'll just create a fake virtualenv directory in this case.

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 +348,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.

Loading