Skip to content

Commit 371c334

Browse files
bmwneersighted
andauthored
feat: constraints.txt support
`constraints.txt` does not support develop (editable/`-e`) dependencies, or extras, but is otherwise symmetrical with `requirements.txt` Fixes #125 Co-authored-by: Bjorn Neergaard <[email protected]>
1 parent db9647e commit 371c334

File tree

5 files changed

+166
-15
lines changed

5 files changed

+166
-15
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This package is a plugin that allows the export of locked packages to various formats.
44

5-
**Note**: For now, only the `requirements.txt` format is available.
5+
**Note**: For now, only the `constraints.txt` and `requirements.txt` formats are available.
66

77
This plugin provides the same features as the existing `export` command of Poetry which it will eventually replace.
88

@@ -36,11 +36,11 @@ The plugin provides an `export` command to export to the desired format.
3636
poetry export -f requirements.txt --output requirements.txt
3737
```
3838

39-
**Note**: Only the `requirements.txt` format is currently supported.
39+
**Note**: Only the `constraints.txt` and `requirements.txt` formats are currently supported.
4040

4141
### Available options
4242

43-
* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `requirements.txt` is supported.
43+
* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported.
4444
* `--output (-o)`: The name of the output file. If omitted, print to standard output.
4545
* `--without`: The dependency groups to ignore when exporting.
4646
* `--with`: The optional dependency groups to include when exporting.

docs/_index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ menu:
1414
The export plugin allows the export of locked packages to various formats.
1515

1616
{{% note %}}
17-
Only the `requirements.txt` format is currently supported.
17+
Only the `constraints.txt` and `requirements.txt` formats are currently supported.
1818
{{% /note %}}
1919

2020
## Exporting packages
@@ -65,7 +65,7 @@ poetry export --only test,docs
6565

6666
### Available options
6767

68-
* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `requirements.txt` is supported.
68+
* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported.
6969
* `--output (-o)`: The name of the output file. If omitted, print to standard output.
7070
* `--without`: The dependency groups to ignore when exporting.
7171
* `--with`: The optional dependency groups to include when exporting.

src/poetry_plugin_export/command.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ class ExportCommand(GroupCommand):
1515
option(
1616
"format",
1717
"f",
18-
"Format to export to. Currently, only requirements.txt is supported.",
18+
"Format to export to. Currently, only constraints.txt and requirements.txt"
19+
" are supported.",
1920
flag=False,
2021
default=Exporter.FORMAT_REQUIREMENTS_TXT,
2122
),

src/poetry_plugin_export/exporter.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import urllib.parse
44

5+
from functools import partialmethod
56
from typing import TYPE_CHECKING
67
from typing import Iterable
78

@@ -23,10 +24,14 @@ class Exporter:
2324
Exporter class to export a lock file to alternative formats.
2425
"""
2526

27+
FORMAT_CONSTRAINTS_TXT = "constraints.txt"
2628
FORMAT_REQUIREMENTS_TXT = "requirements.txt"
2729
ALLOWED_HASH_ALGORITHMS = ("sha256", "sha384", "sha512")
2830

29-
EXPORT_METHODS = {FORMAT_REQUIREMENTS_TXT: "_export_requirements_txt"}
31+
EXPORT_METHODS = {
32+
FORMAT_CONSTRAINTS_TXT: "_export_constraints_txt",
33+
FORMAT_REQUIREMENTS_TXT: "_export_requirements_txt",
34+
}
3035

3136
def __init__(self, poetry: Poetry) -> None:
3237
self._poetry = poetry
@@ -71,7 +76,9 @@ def export(self, fmt: str, cwd: Path, output: IO | str) -> None:
7176

7277
getattr(self, self.EXPORT_METHODS[fmt])(cwd, output)
7378

74-
def _export_requirements_txt(self, cwd: Path, output: IO | str) -> None:
79+
def _export_generic_txt(
80+
self, cwd: Path, output: IO | str, with_extras: bool, allow_editable: bool
81+
) -> None:
7582
from poetry.core.packages.utils.utils import path_to_url
7683

7784
indexes = set()
@@ -90,10 +97,18 @@ def _export_requirements_txt(self, cwd: Path, output: IO | str) -> None:
9097
):
9198
line = ""
9299

100+
if not with_extras:
101+
dependency_package = dependency_package.without_features()
102+
93103
dependency = dependency_package.dependency
94104
package = dependency_package.package
95105

96106
if package.develop:
107+
if not allow_editable:
108+
raise RuntimeError(
109+
f"{package.pretty_name} is locked in develop (editable) mode,"
110+
" which is incompatible with the constraints.txt format."
111+
)
97112
line += "-e "
98113

99114
requirement = dependency.to_pep_508(with_extras=False)
@@ -182,12 +197,16 @@ def _export_requirements_txt(self, cwd: Path, output: IO | str) -> None:
182197

183198
content = indexes_header + "\n" + content
184199

185-
self._output(content, cwd, output)
186-
187-
def _output(self, content: str, cwd: Path, output: IO | str) -> None:
188200
if isinstance(output, IO):
189201
output.write(content)
190202
else:
191-
filepath = cwd / output
192-
with filepath.open("w", encoding="utf-8") as f:
193-
f.write(content)
203+
with (cwd / output).open("w", encoding="utf-8") as txt:
204+
txt.write(content)
205+
206+
_export_constraints_txt = partialmethod(
207+
_export_generic_txt, with_extras=False, allow_editable=False
208+
)
209+
210+
_export_requirements_txt = partialmethod(
211+
_export_generic_txt, with_extras=True, allow_editable=True
212+
)

tests/test_exporter.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ def test_exporter_can_export_requirements_txt_with_standard_packages_and_sorted_
664664
assert content == expected
665665

666666

667-
def test_exporter_requirements_txt_with_standard_packages_and_hashes_disabled(
667+
def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes_disabled( # noqa: E501
668668
tmp_dir: str, poetry: Poetry
669669
) -> None:
670670
poetry.locker.mock_lock_data( # type: ignore[attr-defined]
@@ -2479,6 +2479,137 @@ def test_exporter_omits_unwanted_extras(
24792479
assert io.fetch_output() == "\n".join(expected) + "\n"
24802480

24812481

2482+
@pytest.mark.parametrize(
2483+
["fmt", "expected"],
2484+
[
2485+
(
2486+
"constraints.txt",
2487+
[
2488+
f"bar==4.5.6 ; {MARKER_PY}",
2489+
f"baz==7.8.9 ; {MARKER_PY}",
2490+
f"foo==1.2.3 ; {MARKER_PY}",
2491+
],
2492+
),
2493+
(
2494+
"requirements.txt",
2495+
[
2496+
f"bar==4.5.6 ; {MARKER_PY}",
2497+
f"bar[baz]==4.5.6 ; {MARKER_PY}",
2498+
f"baz==7.8.9 ; {MARKER_PY}",
2499+
f"foo==1.2.3 ; {MARKER_PY}",
2500+
],
2501+
),
2502+
],
2503+
)
2504+
def test_exporter_omits_and_includes_extras_for_txt_formats(
2505+
tmp_dir: str, poetry: Poetry, fmt: str, expected: list[str]
2506+
) -> None:
2507+
poetry.locker.mock_lock_data( # type: ignore[attr-defined]
2508+
{
2509+
"package": [
2510+
{
2511+
"name": "foo",
2512+
"version": "1.2.3",
2513+
"category": "main",
2514+
"optional": False,
2515+
"python-versions": "*",
2516+
"dependencies": {
2517+
"bar": {
2518+
"extras": ["baz"],
2519+
"version": ">=0.1.0",
2520+
}
2521+
},
2522+
},
2523+
{
2524+
"name": "bar",
2525+
"version": "4.5.6",
2526+
"category": "main",
2527+
"optional": False,
2528+
"python-versions": "*",
2529+
"dependencies": {
2530+
"baz": {
2531+
"version": ">=0.1.0",
2532+
"optional": True,
2533+
"markers": "extra == 'baz'",
2534+
}
2535+
},
2536+
"extras": {"baz": ["baz (>=0.1.0)"]},
2537+
},
2538+
{
2539+
"name": "baz",
2540+
"version": "7.8.9",
2541+
"category": "main",
2542+
"optional": False,
2543+
"python-versions": "*",
2544+
},
2545+
],
2546+
"metadata": {
2547+
"python-versions": "*",
2548+
"content-hash": "123456789",
2549+
"files": {"foo": [], "bar": [], "baz": []},
2550+
},
2551+
}
2552+
)
2553+
set_package_requires(poetry)
2554+
2555+
exporter = Exporter(poetry)
2556+
exporter.export(fmt, Path(tmp_dir), "exported.txt")
2557+
2558+
with (Path(tmp_dir) / "exported.txt").open(encoding="utf-8") as f:
2559+
content = f.read()
2560+
2561+
assert content == "\n".join(expected) + "\n"
2562+
2563+
2564+
def test_exporter_raises_exception_for_constraints_txt_with_editable_packages(
2565+
tmp_dir: str, poetry: Poetry
2566+
) -> None:
2567+
poetry.locker.mock_lock_data( # type: ignore[attr-defined]
2568+
{
2569+
"package": [
2570+
{
2571+
"name": "foo",
2572+
"version": "1.2.3",
2573+
"category": "main",
2574+
"optional": False,
2575+
"python-versions": "*",
2576+
"source": {
2577+
"type": "git",
2578+
"url": "https://github.com/foo/foo.git",
2579+
"reference": "123456",
2580+
},
2581+
"develop": True,
2582+
},
2583+
{
2584+
"name": "bar",
2585+
"version": "4.5.6",
2586+
"category": "main",
2587+
"optional": False,
2588+
"python-versions": "*",
2589+
"source": {
2590+
"type": "directory",
2591+
"url": "tests/fixtures/sample_project",
2592+
"reference": "",
2593+
},
2594+
"develop": True,
2595+
},
2596+
],
2597+
"metadata": {
2598+
"python-versions": "*",
2599+
"content-hash": "123456789",
2600+
"files": {"foo": [], "bar": []},
2601+
},
2602+
}
2603+
)
2604+
set_package_requires(poetry)
2605+
2606+
with pytest.raises(RuntimeError):
2607+
exporter = Exporter(poetry)
2608+
exporter.export("constraints.txt", Path(tmp_dir), "constraints.txt")
2609+
2610+
assert not (Path(tmp_dir) / "constraints.txt").exists()
2611+
2612+
24822613
def test_exporter_respects_package_sources(tmp_dir: str, poetry: Poetry) -> None:
24832614
poetry.locker.mock_lock_data( # type: ignore[attr-defined]
24842615
{

0 commit comments

Comments
 (0)