diff --git a/README.md b/README.md index 6dd88af..3b3c4b0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This package is a plugin that allows the export of locked packages to various formats. -**Note**: For now, only the `requirements.txt` format is available. +**Note**: For now, only the `constraints.txt` and `requirements.txt` formats are available. This plugin provides the same features as the existing `export` command of Poetry which it will eventually replace. @@ -36,11 +36,11 @@ The plugin provides an `export` command to export to the desired format. poetry export -f requirements.txt --output requirements.txt ``` -**Note**: Only the `requirements.txt` format is currently supported. +**Note**: Only the `constraints.txt` and `requirements.txt` formats are currently supported. ### Available options -* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `requirements.txt` is supported. +* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported. * `--output (-o)`: The name of the output file. If omitted, print to standard output. * `--without`: The dependency groups to ignore when exporting. * `--with`: The optional dependency groups to include when exporting. diff --git a/docs/_index.md b/docs/_index.md index 6849144..cd34c3d 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -14,7 +14,7 @@ menu: The export plugin allows the export of locked packages to various formats. {{% note %}} -Only the `requirements.txt` format is currently supported. +Only the `constraints.txt` and `requirements.txt` formats are currently supported. {{% /note %}} ## Exporting packages @@ -65,7 +65,7 @@ poetry export --only test,docs ### Available options -* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `requirements.txt` is supported. +* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported. * `--output (-o)`: The name of the output file. If omitted, print to standard output. * `--without`: The dependency groups to ignore when exporting. * `--with`: The optional dependency groups to include when exporting. diff --git a/src/poetry_plugin_export/command.py b/src/poetry_plugin_export/command.py index ee60220..1deadd0 100644 --- a/src/poetry_plugin_export/command.py +++ b/src/poetry_plugin_export/command.py @@ -15,7 +15,8 @@ class ExportCommand(GroupCommand): option( "format", "f", - "Format to export to. Currently, only requirements.txt is supported.", + "Format to export to. Currently, only constraints.txt and requirements.txt" + " are supported.", flag=False, default=Exporter.FORMAT_REQUIREMENTS_TXT, ), diff --git a/src/poetry_plugin_export/exporter.py b/src/poetry_plugin_export/exporter.py index cacb439..135ebd2 100644 --- a/src/poetry_plugin_export/exporter.py +++ b/src/poetry_plugin_export/exporter.py @@ -2,6 +2,7 @@ import urllib.parse +from functools import partialmethod from typing import TYPE_CHECKING from typing import Iterable @@ -23,10 +24,14 @@ class Exporter: Exporter class to export a lock file to alternative formats. """ + FORMAT_CONSTRAINTS_TXT = "constraints.txt" FORMAT_REQUIREMENTS_TXT = "requirements.txt" ALLOWED_HASH_ALGORITHMS = ("sha256", "sha384", "sha512") - EXPORT_METHODS = {FORMAT_REQUIREMENTS_TXT: "_export_requirements_txt"} + EXPORT_METHODS = { + FORMAT_CONSTRAINTS_TXT: "_export_constraints_txt", + FORMAT_REQUIREMENTS_TXT: "_export_requirements_txt", + } def __init__(self, poetry: Poetry) -> None: self._poetry = poetry @@ -71,7 +76,9 @@ def export(self, fmt: str, cwd: Path, output: IO | str) -> None: getattr(self, self.EXPORT_METHODS[fmt])(cwd, output) - def _export_requirements_txt(self, cwd: Path, output: IO | str) -> None: + def _export_generic_txt( + self, cwd: Path, output: IO | str, with_extras: bool, allow_editable: bool + ) -> None: from poetry.core.packages.utils.utils import path_to_url indexes = set() @@ -90,10 +97,18 @@ def _export_requirements_txt(self, cwd: Path, output: IO | str) -> None: ): line = "" + if not with_extras: + dependency_package = dependency_package.without_features() + dependency = dependency_package.dependency package = dependency_package.package if package.develop: + if not allow_editable: + raise RuntimeError( + f"{package.pretty_name} is locked in develop (editable) mode," + " which is incompatible with the constraints.txt format." + ) line += "-e " requirement = dependency.to_pep_508(with_extras=False) @@ -182,12 +197,16 @@ def _export_requirements_txt(self, cwd: Path, output: IO | str) -> None: content = indexes_header + "\n" + content - self._output(content, cwd, output) - - def _output(self, content: str, cwd: Path, output: IO | str) -> None: if isinstance(output, IO): output.write(content) else: - filepath = cwd / output - with filepath.open("w", encoding="utf-8") as f: - f.write(content) + with (cwd / output).open("w", encoding="utf-8") as txt: + txt.write(content) + + _export_constraints_txt = partialmethod( + _export_generic_txt, with_extras=False, allow_editable=False + ) + + _export_requirements_txt = partialmethod( + _export_generic_txt, with_extras=True, allow_editable=True + ) diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 85d041f..2f86e9b 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -664,7 +664,7 @@ def test_exporter_can_export_requirements_txt_with_standard_packages_and_sorted_ assert content == expected -def test_exporter_requirements_txt_with_standard_packages_and_hashes_disabled( +def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes_disabled( # noqa: E501 tmp_dir: str, poetry: Poetry ) -> None: poetry.locker.mock_lock_data( # type: ignore[attr-defined] @@ -2479,6 +2479,137 @@ def test_exporter_omits_unwanted_extras( assert io.fetch_output() == "\n".join(expected) + "\n" +@pytest.mark.parametrize( + ["fmt", "expected"], + [ + ( + "constraints.txt", + [ + f"bar==4.5.6 ; {MARKER_PY}", + f"baz==7.8.9 ; {MARKER_PY}", + f"foo==1.2.3 ; {MARKER_PY}", + ], + ), + ( + "requirements.txt", + [ + f"bar==4.5.6 ; {MARKER_PY}", + f"bar[baz]==4.5.6 ; {MARKER_PY}", + f"baz==7.8.9 ; {MARKER_PY}", + f"foo==1.2.3 ; {MARKER_PY}", + ], + ), + ], +) +def test_exporter_omits_and_includes_extras_for_txt_formats( + tmp_dir: str, poetry: Poetry, fmt: str, expected: list[str] +) -> None: + poetry.locker.mock_lock_data( # type: ignore[attr-defined] + { + "package": [ + { + "name": "foo", + "version": "1.2.3", + "category": "main", + "optional": False, + "python-versions": "*", + "dependencies": { + "bar": { + "extras": ["baz"], + "version": ">=0.1.0", + } + }, + }, + { + "name": "bar", + "version": "4.5.6", + "category": "main", + "optional": False, + "python-versions": "*", + "dependencies": { + "baz": { + "version": ">=0.1.0", + "optional": True, + "markers": "extra == 'baz'", + } + }, + "extras": {"baz": ["baz (>=0.1.0)"]}, + }, + { + "name": "baz", + "version": "7.8.9", + "category": "main", + "optional": False, + "python-versions": "*", + }, + ], + "metadata": { + "python-versions": "*", + "content-hash": "123456789", + "files": {"foo": [], "bar": [], "baz": []}, + }, + } + ) + set_package_requires(poetry) + + exporter = Exporter(poetry) + exporter.export(fmt, Path(tmp_dir), "exported.txt") + + with (Path(tmp_dir) / "exported.txt").open(encoding="utf-8") as f: + content = f.read() + + assert content == "\n".join(expected) + "\n" + + +def test_exporter_raises_exception_for_constraints_txt_with_editable_packages( + tmp_dir: str, poetry: Poetry +) -> None: + poetry.locker.mock_lock_data( # type: ignore[attr-defined] + { + "package": [ + { + "name": "foo", + "version": "1.2.3", + "category": "main", + "optional": False, + "python-versions": "*", + "source": { + "type": "git", + "url": "https://github.com/foo/foo.git", + "reference": "123456", + }, + "develop": True, + }, + { + "name": "bar", + "version": "4.5.6", + "category": "main", + "optional": False, + "python-versions": "*", + "source": { + "type": "directory", + "url": "tests/fixtures/sample_project", + "reference": "", + }, + "develop": True, + }, + ], + "metadata": { + "python-versions": "*", + "content-hash": "123456789", + "files": {"foo": [], "bar": []}, + }, + } + ) + set_package_requires(poetry) + + with pytest.raises(RuntimeError): + exporter = Exporter(poetry) + exporter.export("constraints.txt", Path(tmp_dir), "constraints.txt") + + assert not (Path(tmp_dir) / "constraints.txt").exists() + + def test_exporter_respects_package_sources(tmp_dir: str, poetry: Poetry) -> None: poetry.locker.mock_lock_data( # type: ignore[attr-defined] {