diff --git a/noxfile.py b/noxfile.py index aec99075..108531ee 100644 --- a/noxfile.py +++ b/noxfile.py @@ -140,7 +140,7 @@ def safety(session: Session) -> None: def mypy(session: Session) -> None: """Type-check using mypy.""" args = session.posargs or ["src", "tests", "docs/conf.py"] - session.install(".", "mypy", "pytest") + session.install(".", "mypy", "pytest", "importlib-metadata") session.run("mypy", *args) if not session.posargs and session.python == python_versions[0]: session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py") diff --git a/poetry.lock b/poetry.lock index d5a0c8e8..9c16c85b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2751,4 +2751,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "c775ce885fb19f2f2ffbf0dd782c7db6a4cc7aea7ab46e9d21dd5b02ce63bcbc" +content-hash = "775a746ab8d5769a69c768e4fba256f0b4de0c4c2d3bf24a7bd0a6ffee9fca9d" diff --git a/pyproject.toml b/pyproject.toml index a03cd4c1..5c632b36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ pyupgrade = ">=2.31.0" isort = ">=5.10.1" myst-parser = ">=0.16.1" +[tool.poetry.group.dev.dependencies] +importlib-metadata = {version = "<6.8", python = "<3.8"} + [tool.coverage.paths] source = ["src", "*/site-packages"] diff --git a/src/nox_poetry/poetry.py b/src/nox_poetry/poetry.py index e56299fe..af36f17d 100644 --- a/src/nox_poetry/poetry.py +++ b/src/nox_poetry/poetry.py @@ -1,4 +1,5 @@ """Poetry interface.""" +import re import sys from enum import Enum from pathlib import Path @@ -49,6 +50,17 @@ def extras(self) -> List[str]: ) return list(extras) + @property + def dependency_groups(self) -> List[str]: + """Return the dependency groups.""" + groups = self._config.get("group", {}) + if not groups and "dev-dependencies" in self._config: + return ["dev"] + return list(groups) + + +VERSION_PATTERN = re.compile(r"[0-9]+(\.[0-9+])+[-+.0-9a-zA-Z]+") + class Poetry: """Helper class for invoking Poetry inside a Nox session. @@ -61,6 +73,42 @@ def __init__(self, session: Session) -> None: """Initialize.""" self.session = session self._config: Optional[Config] = None + self._version: Optional[str] = None + + @property + def version(self) -> str: + """Return the Poetry version.""" + if self._version is not None: + return self._version + + output = self.session.run_always( + "poetry", + "--version", + "--no-ansi", + external=True, + silent=True, + stderr=None, + ) + if output is None: + raise CommandSkippedError( + "The command `poetry --version` was not executed" + " (a possible cause is specifying `--no-install`)" + ) + + assert isinstance(output, str) # noqa: S101 + + match = VERSION_PATTERN.search(output) + if match: + self._version = match.group() + return self._version + + raise RuntimeError("Cannot parse output of `poetry --version`") + + @property + def has_dependency_groups(self) -> bool: + """Return True if Poetry version supports dependency groups.""" + version = tuple(int(part) for part in self.version.split(".")[:2]) + return version >= (1, 2) @property def config(self) -> Config: @@ -78,11 +126,17 @@ def export(self) -> str: Raises: CommandSkippedError: The command `poetry export` was not executed. """ + dependency_groups = ( + [f"--with={group}" for group in self.config.dependency_groups] + if self.has_dependency_groups + else ["--dev"] + ) + output = self.session.run_always( "poetry", "export", "--format=requirements.txt", - "--dev", + *dependency_groups, *[f"--extras={extra}" for extra in self.config.extras], "--without-hashes", external=True, @@ -91,7 +145,7 @@ def export(self) -> str: ) if output is None: - raise CommandSkippedError( + raise CommandSkippedError( # pragma: no cover "The command `poetry export` was not executed" " (a possible cause is specifying `--no-install`)" ) diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index d2ac5912..382ca1f7 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -47,9 +47,11 @@ def _get_config(self, key: str) -> Any: data: Any = self._read_toml("pyproject.toml") return data["tool"]["poetry"][key] - def get_dependency(self, name: str) -> Package: + def get_dependency(self, name: str, data: Any = None) -> Package: """Return the package with the given name.""" - data = self._read_toml("poetry.lock") + if data is None: + data = self._read_toml("poetry.lock") + for package in data["package"]: if package["name"] == name: url = package.get("source", {}).get("url") @@ -83,6 +85,14 @@ def development_dependencies(self) -> List[Package]: dependencies: List[str] = list(self._get_config("dev-dependencies")) return [self.get_dependency(package) for package in dependencies] + @property + def locked_packages(self) -> List[Package]: + """Return all packages from the lockfile.""" + data = self._read_toml("poetry.lock") + return [ + self.get_dependency(package["name"], data) for package in data["package"] + ] + @pytest.fixture def project(shared_datadir: Path) -> Project: diff --git a/tests/functional/data/dependency-group/dependency_group.py b/tests/functional/data/dependency-group/dependency_group.py new file mode 100644 index 00000000..f2120bfa --- /dev/null +++ b/tests/functional/data/dependency-group/dependency_group.py @@ -0,0 +1 @@ +"""Empty module.""" diff --git a/tests/functional/data/dependency-group/poetry.lock b/tests/functional/data/dependency-group/poetry.lock new file mode 100644 index 00000000..ed6465be --- /dev/null +++ b/tests/functional/data/dependency-group/poetry.lock @@ -0,0 +1,17 @@ +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. + +[[package]] +name = "pyflakes" +version = "2.5.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"}, + {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.7" +content-hash = "13f4afa7c2506acf4d51c9d917da388c36353b4b4c0860cb52d35aeb3c6102eb" diff --git a/tests/functional/data/dependency-group/pyproject.toml b/tests/functional/data/dependency-group/pyproject.toml new file mode 100644 index 00000000..dea69ab5 --- /dev/null +++ b/tests/functional/data/dependency-group/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "dependency-group" +version = "0.1.0" +description = "" +authors = ["Your Name "] + +[tool.poetry.dependencies] +python = "^3.7" + +[tool.poetry.group.lint.dependencies] +pyflakes = "^2.1.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/functional/test_session.py b/tests/functional/test_session.py index d4e337d5..ec6468cd 100644 --- a/tests/functional/test_session.py +++ b/tests/functional/test_session.py @@ -1,8 +1,10 @@ """Functional tests for the `@session` decorator.""" +import sys from pathlib import Path import nox.sessions import pytest +from packaging.version import Version import nox_poetry from tests.functional.conftest import Project @@ -10,6 +12,12 @@ from tests.functional.conftest import run_nox_with_noxfile +if sys.version_info >= (3, 8): + from importlib.metadata import version +else: + from importlib_metadata import version + + def test_local(project: Project) -> None: """It installs the local package.""" @@ -158,3 +166,24 @@ def test(session: nox_poetry.Session) -> None: process = run_nox_with_noxfile(project, [test], [nox_poetry]) assert "Warning:" in process.stderr + + +@pytest.mark.skipif( + Version(version("poetry")) < Version("1.2"), + reason="Poetry < 1.2 does not support dependency groups", +) +def test_dependency_group(shared_datadir: Path) -> None: + """It pins packages in dependency groups.""" + project = Project(shared_datadir / "dependency-group") + + @nox_poetry.session + def test(session: nox_poetry.Session) -> None: + """Install the dependencies.""" + session.install(".", "pyflakes") + + run_nox_with_noxfile(project, [test], [nox_poetry]) + + expected = [project.package, *project.locked_packages] + packages = list_packages(project, test) + + assert set(expected) == set(packages) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 82333a0a..4220a6ba 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -37,6 +37,9 @@ def run_always(self, *args: str, **kargs: Any) -> Optional[str]: if self.no_install: return None + if args[:2] == ("poetry", "--version"): + return "1.1.15" + path = Path("dist") / "example.whl" path.touch() return path.name diff --git a/tests/unit/test_poetry.py b/tests/unit/test_poetry.py index d7ce94f3..9ea7a2ca 100644 --- a/tests/unit/test_poetry.py +++ b/tests/unit/test_poetry.py @@ -1,6 +1,8 @@ """Unit tests for the poetry module.""" from pathlib import Path +from textwrap import dedent from typing import Any +from typing import Callable from typing import Dict import nox._options @@ -12,20 +14,68 @@ from nox_poetry import poetry -def test_config_non_ascii(tmp_path: Path) -> None: - """It decodes non-ASCII characters in pyproject.toml.""" - text = """\ -[tool.poetry] -name = "África" -""" +CreateConfig = Callable[[str], poetry.Config] + + +@pytest.fixture +def create_config(tmp_path: Path) -> CreateConfig: + """Factory fixture for a Poetry config.""" + + def _(text: str) -> poetry.Config: + path = tmp_path / "pyproject.toml" + path.write_text(dedent(text), encoding="utf-8") - path = tmp_path / "pyproject.toml" - path.write_text(text, encoding="utf-8") + return poetry.Config(path.parent) - config = poetry.Config(path.parent) + return _ + + +def test_config_non_ascii(create_config: CreateConfig) -> None: + """It decodes non-ASCII characters in pyproject.toml.""" + config = create_config( + """ + [tool.poetry] + name = "África" + """ + ) assert config.name == "África" +def test_config_dependency_groups(create_config: CreateConfig) -> None: + """It returns the dependency groups from pyproject.toml.""" + config = create_config( + """ + [tool.poetry.group.tests.dependencies] + pytest = "^1.0.0" + + [tool.poetry.group.docs.dependencies] + sphinx = "^1.0.0" + """ + ) + assert config.dependency_groups == ["tests", "docs"] + + +def test_config_no_dependency_groups(create_config: CreateConfig) -> None: + """It returns an empty list.""" + config = create_config( + """ + [tool.poetry] + """ + ) + assert config.dependency_groups == [] + + +def test_config_dev_dependency_group(create_config: CreateConfig) -> None: + """It returns the dev dependency group.""" + config = create_config( + """ + [tool.poetry.dev-dependencies] + pytest = "^1.0.0" + """ + ) + assert config.dependency_groups == ["dev"] + + @pytest.fixture def session(monkeypatch: pytest.MonkeyPatch) -> nox.Session: """Fixture for a Nox session.""" @@ -43,6 +93,57 @@ def test(session: nox.Session) -> None: return nox.Session(runner) +def test_poetry_version(session: nox.Session) -> None: + """It returns the Poetry version.""" + version = poetry.Poetry(session).version + assert all(part.isnumeric() for part in version.split(".")[:3]) + + +def test_poetry_cached_version(session: nox.Session) -> None: + """It caches the Poetry version.""" + p = poetry.Poetry(session) + assert p.version == p.version + + +def test_poetry_invalid_version( + session: nox.Session, monkeypatch: pytest.MonkeyPatch +) -> None: + """It raises an exception if the Poetry version cannot be parsed.""" + + def _run(*args: Any, **kwargs: Any) -> str: + return "bogus" + + monkeypatch.setattr("nox.command.run", _run) + + with pytest.raises(RuntimeError): + poetry.Poetry(session).version + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("1.0.10", False), + ("1.1.15", False), + ("1.2.2", True), + ("1.6.0.dev0", True), + ], +) +def test_poetry_has_dependency_groups( + session: nox.Session, + monkeypatch: pytest.MonkeyPatch, + version: str, + expected: bool, +) -> None: + """It returns True if Poetry supports dependency groups.""" + + def _run(*args: Any, **kwargs: Any) -> str: + return version + + monkeypatch.setattr("nox.command.run", _run) + + assert poetry.Poetry(session).has_dependency_groups is expected + + def test_export_with_warnings( session: nox.Session, monkeypatch: pytest.MonkeyPatch ) -> None: