Skip to content

Commit b2699b5

Browse files
authored
Convert exported requirements to constraints format (#308)
* test: Use Poetry 1.0 compatible lock file in test data * test: Run tests againsts Poetry 1.0 as well * test: Fix missing subdependencies in Project.dependencies * test: Replace list_packages fixture by a plain function * test: Replace run_nox_with_noxfile fixture by plain function * test: Remove unused fixture run_nox * test: Remove unused fixture write_noxfile * style: Reformat test_functional * test: Handle URL and path dependencies in list_packages fixture * test: Handle URL dependencies in Project.get_dependency * test: Add test data for URL dependencies * test: Add functional test for URL dependencies Add a failing test for URL dependencies. Test output below: nox > Command python -m pip install --constraint=.nox/test/tmp/requirements.txt file:///…/url-dependency/dist/url_dependency-0.1.0-py3-none-any.whl failed with exit code 1: DEPRECATION: Constraints are only allowed to take the form of a package name and a version specifier. Other forms were originally permitted as an accident of the implementation, but were undocumented. The new implementation of the resolver no longer supports these forms. A possible replacement is replacing the constraint with a requirement.. You can find discussion regarding this at pypa/pip#8210. ERROR: Links are not allowed as constraints * build: Add dependency on packaging >= 20.9 * refactor(poetry): Do not write exported requirements to disk * fix: Convert exported requirements to constraints format * test: Add unit tests for to_constraints * test: Use canonicalize_name from packaging.utils * test: Add test data for path dependencies * test: Add functional test for path dependency * test: Mark test for path dependencies as XFAIL
1 parent 1a000fe commit b2699b5

File tree

15 files changed

+313
-223
lines changed

15 files changed

+313
-223
lines changed

noxfile.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sys
44
from pathlib import Path
55
from textwrap import dedent
6+
from typing import Optional
67

78
import nox
89

@@ -117,7 +118,8 @@ def mypy(session: Session) -> None:
117118

118119

119120
@session(python=python_versions)
120-
def tests(session: Session) -> None:
121+
@nox.parametrize("poetry", ["1.0.10", None])
122+
def tests(session: Session, poetry: Optional[str]) -> None:
121123
"""Run the test suite."""
122124
session.install(".")
123125
session.install(
@@ -130,6 +132,14 @@ def tests(session: Session) -> None:
130132
if session.python == "3.6":
131133
session.install("dataclasses")
132134

135+
if poetry is not None:
136+
if session.python != python_versions[0]:
137+
session.skip()
138+
139+
session.run_always(
140+
"python", "-m", "pip", "install", f"poetry=={poetry}", silent=True
141+
)
142+
133143
try:
134144
session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs)
135145
finally:

poetry.lock

+3-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Changelog = "https://github.com/cjolowicz/nox-poetry/releases"
2323
python = "^3.6.1"
2424
nox = ">=2020.8.22"
2525
tomlkit = "^0.7.0"
26+
packaging = ">=20.9"
2627

2728
[tool.poetry.dev-dependencies]
2829
pytest = "^6.2.2"

src/nox_poetry/poetry.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -61,22 +61,25 @@ def config(self) -> Config:
6161
self._config = Config(Path.cwd())
6262
return self._config
6363

64-
def export(self, path: Path) -> None:
64+
def export(self) -> str:
6565
"""Export the lock file to requirements format.
6666
67-
Args:
68-
path: The destination path.
67+
Returns:
68+
The generated requirements as text.
6969
"""
70-
self.session.run_always(
70+
output = self.session.run_always(
7171
"poetry",
7272
"export",
7373
"--format=requirements.txt",
74-
f"--output={path}",
7574
"--dev",
7675
*[f"--extras={extra}" for extra in self.config.extras],
7776
"--without-hashes",
7877
external=True,
78+
silent=True,
79+
stderr=None,
7980
)
81+
assert isinstance(output, str) # noqa: S101
82+
return output
8083

8184
def build(self, *, format: str) -> str:
8285
"""Build the package.

src/nox_poetry/sessions.py

+38-1
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
from pathlib import Path
66
from typing import Any
77
from typing import Iterable
8+
from typing import Iterator
89
from typing import Optional
910
from typing import Tuple
1011

1112
import nox
13+
from packaging.requirements import InvalidRequirement
14+
from packaging.requirements import Requirement
1215

1316
from nox_poetry.poetry import DistributionFormat
1417
from nox_poetry.poetry import Poetry
@@ -52,6 +55,39 @@ def _split_extras(arg: str) -> Tuple[str, Optional[str]]:
5255
return arg, None
5356

5457

58+
def to_constraint(requirement_string: str, line: int) -> Optional[str]:
59+
"""Convert requirement to constraint."""
60+
if any(
61+
requirement_string.startswith(prefix)
62+
for prefix in ("-e ", "file://", "git+https://", "http://", "https://")
63+
):
64+
return None
65+
66+
try:
67+
requirement = Requirement(requirement_string)
68+
except InvalidRequirement as error:
69+
raise RuntimeError(f"line {line}: {requirement_string!r}: {error}")
70+
71+
if not (requirement.name and requirement.specifier):
72+
return None
73+
74+
constraint = f"{requirement.name}{requirement.specifier}"
75+
return f"{constraint}; {requirement.marker}" if requirement.marker else constraint
76+
77+
78+
def to_constraints(requirements: str) -> str:
79+
"""Convert requirements to constraints."""
80+
81+
def _to_constraints() -> Iterator[str]:
82+
lines = requirements.strip().splitlines()
83+
for line, requirement in enumerate(lines, start=1):
84+
constraint = to_constraint(requirement, line)
85+
if constraint is not None:
86+
yield constraint
87+
88+
return "\n".join(_to_constraints())
89+
90+
5591
class _PoetrySession:
5692
"""Poetry-related utilities for session functions."""
5793

@@ -170,7 +206,8 @@ def export_requirements(self) -> Path:
170206
digest = hashlib.blake2b(lockdata).hexdigest()
171207

172208
if not hashfile.is_file() or hashfile.read_text() != digest:
173-
self.poetry.export(path)
209+
constraints = to_constraints(self.poetry.export())
210+
path.write_text(constraints)
174211
hashfile.write_text(digest)
175212

176213
return path

tests/functional/conftest.py

+22-72
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
"""Fixtures for functional tests."""
2-
import functools
32
import inspect
43
import os
5-
import re
64
import subprocess # noqa: S404
75
import sys
86
from dataclasses import dataclass
@@ -17,6 +15,7 @@
1715

1816
import pytest
1917
import tomlkit
18+
from packaging.utils import canonicalize_name
2019

2120

2221
if TYPE_CHECKING:
@@ -53,6 +52,10 @@ def get_dependency(self, name: str) -> Package:
5352
data = self._read_toml("poetry.lock")
5453
for package in data["package"]:
5554
if package["name"] == name:
55+
url = package.get("source", {}).get("url")
56+
if url is not None:
57+
# Abuse Package.version to store the URL (for ``list_packages``).
58+
return Package(name, url)
5659
return Package(name, package["version"])
5760
raise ValueError(f"{name}: package not found")
5861

@@ -66,15 +69,11 @@ def package(self) -> Package:
6669
@property
6770
def dependencies(self) -> List[Package]:
6871
"""Return the package dependencies."""
69-
table = self._get_config("dependencies")
72+
data = self._read_toml("poetry.lock")
7073
dependencies: List[str] = [
71-
package
72-
for package, info in table.items()
73-
if not (
74-
package == "python"
75-
or isinstance(info, dict)
76-
and info.get("optional", None)
77-
)
74+
package["name"]
75+
for package in data["package"]
76+
if package["category"] == "main" and not package["optional"]
7877
]
7978
return [self.get_dependency(package) for package in dependencies]
8079

@@ -109,15 +108,6 @@ def _run_nox(project: Project) -> CompletedProcess:
109108
raise RuntimeError(f"{error}\n{error.stderr}")
110109

111110

112-
RunNox = Callable[[], CompletedProcess]
113-
114-
115-
@pytest.fixture
116-
def run_nox(project: Project) -> RunNox:
117-
"""Invoke Nox in the project."""
118-
return functools.partial(_run_nox, project)
119-
120-
121111
SessionFunction = Callable[..., Any]
122112

123113

@@ -134,54 +124,18 @@ def _write_noxfile(
134124
path.write_text(text)
135125

136126

137-
WriteNoxfile = Callable[
138-
[
139-
Iterable[SessionFunction],
140-
Iterable[ModuleType],
141-
],
142-
None,
143-
]
144-
145-
146-
@pytest.fixture
147-
def write_noxfile(project: Project) -> WriteNoxfile:
148-
"""Write a noxfile with the given session functions."""
149-
return functools.partial(_write_noxfile, project)
150-
151-
152-
def _run_nox_with_noxfile(
127+
def run_nox_with_noxfile(
153128
project: Project,
154129
sessions: Iterable[SessionFunction],
155130
imports: Iterable[ModuleType],
156131
) -> None:
132+
"""Write a noxfile and run Nox in the project."""
157133
_write_noxfile(project, sessions, imports)
158134
_run_nox(project)
159135

160136

161-
RunNoxWithNoxfile = Callable[
162-
[
163-
Iterable[SessionFunction],
164-
Iterable[ModuleType],
165-
],
166-
None,
167-
]
168-
169-
170-
@pytest.fixture
171-
def run_nox_with_noxfile(project: Project) -> RunNoxWithNoxfile:
172-
"""Write a noxfile and run Nox in the project."""
173-
return functools.partial(_run_nox_with_noxfile, project)
174-
175-
176-
_CANONICALIZE_PATTERN = re.compile(r"[-_.]+")
177-
178-
179-
def _canonicalize_name(name: str) -> str:
180-
# From ``packaging.utils.canonicalize_name`` (PEP 503)
181-
return _CANONICALIZE_PATTERN.sub("-", name).lower()
182-
183-
184-
def _list_packages(project: Project, session: SessionFunction) -> List[Package]:
137+
def list_packages(project: Project, session: SessionFunction) -> List[Package]:
138+
"""List the installed packages for a session in the given project."""
185139
bindir = "Scripts" if sys.platform == "win32" else "bin"
186140
pip = project.path / ".nox" / session.__name__ / bindir / "pip"
187141
process = subprocess.run( # noqa: S603
@@ -194,19 +148,15 @@ def _list_packages(project: Project, session: SessionFunction) -> List[Package]:
194148

195149
def parse(line: str) -> Package:
196150
name, _, version = line.partition("==")
197-
name = _canonicalize_name(name)
198-
if not version and name.startswith(f"{project.package.name} @ file://"):
199-
# Local package is listed without version, but it does not matter.
200-
return project.package
201-
return Package(name, version)
202-
203-
return [parse(line) for line in process.stdout.splitlines()]
204-
151+
if not version and " @ " in line:
152+
# Abuse Package.version to store the URL or path.
153+
name, _, version = line.partition(" @ ")
205154

206-
ListPackages = Callable[[SessionFunction], List[Package]]
155+
if name == project.package.name:
156+
# But use the known version for the local package.
157+
return project.package
207158

159+
name = canonicalize_name(name)
160+
return Package(name, version)
208161

209-
@pytest.fixture
210-
def list_packages(project: Project) -> ListPackages:
211-
"""Return a function that lists the installed packages for a session."""
212-
return functools.partial(_list_packages, project)
162+
return [parse(line) for line in process.stdout.splitlines()]

0 commit comments

Comments
 (0)