From 9f88d716965e2655b121c8e7b0a32fe2c28ba430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:54:41 +0100 Subject: [PATCH] feat: add `pylock.toml` as export format --- README.md | 7 +- docs/_index.md | 2 +- poetry.lock | 2 +- pyproject.toml | 5 +- src/poetry_plugin_export/command.py | 20 +- src/poetry_plugin_export/exporter.py | 216 ++++- src/poetry_plugin_export/walker.py | 6 +- tests/command/test_command_export.py | 63 +- tests/test_exporter_pylock_toml.py | 1083 ++++++++++++++++++++++++++ 9 files changed, 1383 insertions(+), 21 deletions(-) create mode 100644 tests/test_exporter_pylock_toml.py diff --git a/README.md b/README.md index 932b546..49ca30d 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,14 @@ poetry export -f requirements.txt --output requirements.txt > which are exported with their resolved hashes, are included. > [!NOTE] -> Only the `constraints.txt` and `requirements.txt` formats are currently supported. +> The following formats are currently supported: +> * `requirements.txt` +> * `constraints.txt` +> * `pylock.toml` ### Available options -* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported. +* `--format (-f)`: The format to export to (default: `requirements.txt`). Additionally, `constraints.txt` and `pylock.toml` are supported. * `--output (-o)`: The name of the output file. If omitted, print to standard output. * `--with`: The optional and non-optional dependency groups to include. By default, only the main dependencies are included. * `--only`: The only dependency groups to include. It is possible to exclude the `main` group this way. diff --git a/docs/_index.md b/docs/_index.md index fc9251c..22ea85d 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -65,7 +65,7 @@ poetry export --only test,docs ### Available options -* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported. +* `--format (-f)`: The format to export to (default: `requirements.txt`). Additionally, `constraints.txt` and `pylock.toml` are supported. * `--output (-o)`: The name of the output file. If omitted, print to standard output. * `--with`: The optional and non-optional dependency groups to include. By default, only the main dependencies are included. * `--only`: The only dependency groups to include. It is possible to exclude the `main` group this way. diff --git a/poetry.lock b/poetry.lock index 07d80ca..8edad44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2154,4 +2154,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "9d3737621fb95fb1048deaf04626cbf1b731d899a527e1b8fe650b376cd100e8" +content-hash = "6ec57b1e3957342a212f3f02a5fdbc7055564c8d03a411f6784c4d2503e3953f" diff --git a/pyproject.toml b/pyproject.toml index 113f4b9..64c68b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,9 @@ license = "MIT" readme = "README.md" requires-python = ">=3.10,<4.0" dependencies = [ - "poetry>=2.1.0,<3.0.0", - "poetry-core>=2.1.0,<3.0.0", + "poetry (>=2.1.0,<3.0.0)", + "poetry-core (>=2.1.0,<3.0.0)", + "tomlkit (>=0.11.4,<1.0.0)", ] dynamic = ["classifiers"] diff --git a/src/poetry_plugin_export/command.py b/src/poetry_plugin_export/command.py index 51db1e7..3f838bd 100644 --- a/src/poetry_plugin_export/command.py +++ b/src/poetry_plugin_export/command.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + from pathlib import Path from typing import TYPE_CHECKING @@ -24,8 +26,7 @@ class ExportCommand(GroupCommand): option( "format", "f", - "Format to export to. Currently, only constraints.txt and" - " requirements.txt are supported.", + "Format to export to: constraints.txt, requirements.txt, pylock.toml", flag=False, default=Exporter.FORMAT_REQUIREMENTS_TXT, ), @@ -89,6 +90,21 @@ def handle(self) -> int: output = self.option("output") + pylock_pattern = r"^pylock\.([^.]+)\.toml$" + if ( + fmt == Exporter.FORMAT_PYLOCK_TOML + and output + and Path(output).name != "pylock.toml" + and not re.match(pylock_pattern, Path(output).name) + ): + self.line_error( + "" + 'The output file for pylock.toml export must be named "pylock.toml"' + f' or must follow the regex "{pylock_pattern}", e.g. "pylock.dev.toml"' + "" + ) + return 1 + locker = self.poetry.locker if not locker.is_locked(): self.line_error("The lock file does not exist. Locking.") diff --git a/src/poetry_plugin_export/exporter.py b/src/poetry_plugin_export/exporter.py index 38753ff..e030275 100644 --- a/src/poetry_plugin_export/exporter.py +++ b/src/poetry_plugin_export/exporter.py @@ -1,13 +1,23 @@ from __future__ import annotations +import contextlib +import itertools import urllib.parse +from datetime import datetime from functools import partialmethod +from importlib import metadata from typing import TYPE_CHECKING +from typing import Any from cleo.io.io import IO +from poetry.core.constraints.version.version import Version from poetry.core.packages.dependency_group import MAIN_GROUP +from poetry.core.packages.directory_dependency import DirectoryDependency +from poetry.core.packages.file_dependency import FileDependency +from poetry.core.packages.url_dependency import URLDependency from poetry.core.packages.utils.utils import create_nested_marker +from poetry.core.packages.vcs_dependency import VCSDependency from poetry.core.version.markers import parse_marker from poetry.repositories.http_repository import HTTPRepository @@ -22,6 +32,7 @@ from typing import ClassVar from packaging.utils import NormalizedName + from poetry.core.packages.package import PackageFile from poetry.poetry import Poetry @@ -32,11 +43,13 @@ class Exporter: FORMAT_CONSTRAINTS_TXT = "constraints.txt" FORMAT_REQUIREMENTS_TXT = "requirements.txt" + FORMAT_PYLOCK_TOML = "pylock.toml" ALLOWED_HASH_ALGORITHMS = ("sha256", "sha384", "sha512") EXPORT_METHODS: ClassVar[dict[str, str]] = { FORMAT_CONSTRAINTS_TXT: "_export_constraints_txt", FORMAT_REQUIREMENTS_TXT: "_export_requirements_txt", + FORMAT_PYLOCK_TOML: "_export_pylock_toml", } def __init__(self, poetry: Poetry, io: IO) -> None: @@ -81,11 +94,20 @@ def export(self, fmt: str, cwd: Path, output: IO | str) -> None: if not self.is_format_supported(fmt): raise ValueError(f"Invalid export format: {fmt}") - getattr(self, self.EXPORT_METHODS[fmt])(cwd, output) + out_dir = cwd + if isinstance(output, str): + out_dir = (cwd / output).parent + content = getattr(self, self.EXPORT_METHODS[fmt])(out_dir) + + if isinstance(output, IO): + output.write(content) + else: + with (cwd / output).open("w", encoding="utf-8") as txt: + txt.write(content) def _export_generic_txt( - self, cwd: Path, output: IO | str, with_extras: bool, allow_editable: bool - ) -> None: + self, out_dir: Path, with_extras: bool, allow_editable: bool + ) -> str: from poetry.core.packages.utils.utils import path_to_url indexes = set() @@ -219,11 +241,7 @@ def _export_generic_txt( content = indexes_header + "\n" + content - if isinstance(output, IO): - output.write(content) - else: - with (cwd / output).open("w", encoding="utf-8") as txt: - txt.write(content) + return content _export_constraints_txt = partialmethod( _export_generic_txt, with_extras=False, allow_editable=False @@ -232,3 +250,185 @@ def _export_generic_txt( _export_requirements_txt = partialmethod( _export_generic_txt, with_extras=True, allow_editable=True ) + + def _get_poetry_version(self) -> str: + return metadata.version("poetry") + + def _export_pylock_toml(self, out_dir: Path) -> str: + from tomlkit import aot + from tomlkit import array + from tomlkit import document + from tomlkit import inline_table + from tomlkit import table + + min_poetry_version = "2.3.0" + if Version.parse(self._get_poetry_version()) < Version.parse( + min_poetry_version + ): + raise RuntimeError( + "Exporting pylock.toml requires Poetry version" + f" {min_poetry_version} or higher." + ) + + if not self._poetry.locker.is_locked_groups_and_markers(): + raise RuntimeError( + "Cannot export pylock.toml because the lock file is not at least version 2.1" + ) + + def add_file_info( + archive: dict[str, Any], + locked_file_info: PackageFile, + additional_file_info: PackageFile | None = None, + ) -> None: + # We only use additional_file_info for url, upload_time and size + # because they are not in locked_file_info. + if additional_file_info: + archive["name"] = locked_file_info["file"] + url = additional_file_info.get("url") + assert url, "url must be present in additional_file_info" + archive["url"] = url + if upload_time := additional_file_info.get("upload_time"): + with contextlib.suppress(ValueError): + # Python < 3.11 does not support 'Z' suffix for UTC, replace it with '+00:00' + archive["upload-time"] = datetime.fromisoformat( + upload_time.replace("Z", "+00:00") + ) + if size := additional_file_info.get("size"): + archive["size"] = size + archive["hashes"] = dict([locked_file_info["hash"].split(":", 1)]) + + python_constraint = self._poetry.package.python_constraint + python_marker = parse_marker( + create_nested_marker("python_version", python_constraint) + ) + + lock = document() + lock["lock-version"] = "1.0" + if self._poetry.package.python_versions != "*": + lock["environments"] = [str(python_marker)] + lock["requires-python"] = str(python_constraint) + lock["created-by"] = "poetry-plugin-export" + + packages = aot() + for dependency_package in get_project_dependency_packages2( + self._poetry.locker, + groups=set(self._groups), + extras=self._extras, + ): + dependency = dependency_package.dependency + package = dependency_package.package + data = table() + data["name"] = package.name + data["version"] = str(package.version) + if not package.marker.is_any(): + data["marker"] = str(package.marker) + if not package.python_constraint.is_any(): + data["requires-python"] = str(package.python_constraint) + packages.append(data) + match dependency: + case VCSDependency(): + vcs = {} + vcs["type"] = "git" + vcs["url"] = dependency.source + vcs["requested-revision"] = dependency.reference + assert dependency.source_resolved_reference, ( + "VCSDependency must have a resolved reference" + ) + vcs["commit-id"] = dependency.source_resolved_reference + if dependency.directory: + vcs["subdirectory"] = dependency.directory + data["vcs"] = vcs + case DirectoryDependency(): + # The version MUST NOT be included when it cannot be guaranteed + # to be consistent with the code used + del data["version"] + dir_: dict[str, Any] = {} + try: + dir_["path"] = dependency.full_path.relative_to( + out_dir + ).as_posix() + except ValueError: + dir_["path"] = dependency.full_path.as_posix() + if package.develop: + dir_["editable"] = package.develop + data["directory"] = dir_ + case FileDependency(): + archive = inline_table() + try: + archive["path"] = dependency.full_path.relative_to( + out_dir + ).as_posix() + except ValueError: + archive["path"] = dependency.full_path.as_posix() + assert len(package.files) == 1, ( + "FileDependency must have exactly one file" + ) + add_file_info(archive, package.files[0]) + if dependency.directory: + archive["subdirectory"] = dependency.directory + data["archive"] = archive + case URLDependency(): + archive = inline_table() + archive["url"] = dependency.url + assert len(package.files) == 1, ( + "URLDependency must have exactly one file" + ) + add_file_info(archive, package.files[0]) + if dependency.directory: + archive["subdirectory"] = dependency.directory + data["archive"] = archive + case _: + data["index"] = package.source_url or "https://pypi.org/simple" + pool_info = { + p["file"]: p + for p in self._poetry.pool.package( + package.name, + package.version, + package.source_reference or "PyPI", + ).files + } + artifacts = { + k: list(v) + for k, v in itertools.groupby( + package.files, + key=( + lambda x: "wheel" + if x["file"].endswith(".whl") + else "sdist" + ), + ) + } + + sdist_files = list(artifacts.get("sdist", [])) + for sdist in sdist_files: + sdist_table = inline_table() + data["sdist"] = sdist_table + add_file_info(sdist_table, sdist, pool_info[sdist["file"]]) + if wheels := list(artifacts.get("wheel", [])): + wheel_array = array() + data["wheels"] = wheel_array + wheel_array.multiline(True) + for wheel in wheels: + wheel_table = inline_table() + add_file_info(wheel_table, wheel, pool_info[wheel["file"]]) + wheel_array.append(wheel_table) + + lock["packages"] = packages if packages else [] + + lock["tool"] = {} + lock["tool"]["poetry-plugin-export"] = {} # type: ignore[index] + lock["tool"]["poetry-plugin-export"]["groups"] = sorted( # type: ignore[index] + self._groups, key=lambda x: (x != "main", x) + ) + lock["tool"]["poetry-plugin-export"]["extras"] = sorted(self._extras) # type: ignore[index] + + # Poetry writes invalid requires-python for "or" relations. + # Though Poetry could parse it, other tools would fail. + # Since requires-python is redundant with markers, we just comment it out. + lock_lines = [ + f"# {line}" + if line.startswith("requires-python = ") and "||" in line + else line + for line in lock.as_string().splitlines() + ] + return "\n".join(lock_lines) + "\n" diff --git a/src/poetry_plugin_export/walker.py b/src/poetry_plugin_export/walker.py index 224e3d9..9921755 100644 --- a/src/poetry_plugin_export/walker.py +++ b/src/poetry_plugin_export/walker.py @@ -276,14 +276,12 @@ def get_project_dependency_packages2( if not marker.validate({"extra": extras}): continue + marker = marker.without_extras() + if project_python_marker: marker = project_python_marker.intersect(marker) package.marker = marker - # Set python_versions to any because they are already incorporated - # in the locked marker and only cause additional computing without - # actually changing anything. - package.python_versions = "*" yield DependencyPackage(dependency=package.to_dependency(), package=package) diff --git a/tests/command/test_command_export.py b/tests/command/test_command_export.py index 792265b..fb8674c 100644 --- a/tests/command/test_command_export.py +++ b/tests/command/test_command_export.py @@ -9,6 +9,7 @@ from poetry.core.packages.dependency_group import MAIN_GROUP from poetry.core.packages.package import Package +from poetry.repositories import Repository from poetry_plugin_export.exporter import Exporter from tests.markers import MARKER_PY @@ -20,7 +21,6 @@ from _pytest.monkeypatch import MonkeyPatch from cleo.testers.command_tester import CommandTester from poetry.poetry import Poetry - from poetry.repositories import Repository from pytest_mock import MockerFixture from tests.types import CommandTesterFactory @@ -323,3 +323,64 @@ def test_export_exports_constraints_txt_with_warnings( assert develop_warning in tester.io.fetch_error() assert tester.io.fetch_output() == expected + + +def test_export_pylock_toml( + mocker: MockerFixture, poetry: Poetry, tester: CommandTester, do_lock: None +) -> None: + mocker.patch( + "poetry_plugin_export.exporter.Exporter._get_poetry_version", + return_value="2.3.0", + ) + poetry.package.python_versions = "*" + repo = Repository("PyPI") + poetry.pool.add_repository(repo) + repo.add_package(Package("foo", "1.0")) + + assert tester.execute("--format pylock.toml") == 0 + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "foo" +version = "1.0.0" +index = "https://pypi.org/simple" + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + assert tester.io.fetch_output() == expected + + +@pytest.mark.parametrize("name", ["pylock.toml", "pylock.dev.toml"]) +def test_export_pylock_toml_valid_file_names( + mocker: MockerFixture, + poetry: Poetry, + tester: CommandTester, + do_lock: None, + name: str, +) -> None: + export_mock = mocker.patch("poetry_plugin_export.command.Exporter.export") + + assert tester.execute(f"--format pylock.toml --output somedir/{name}") == 0 + assert export_mock.call_count == 1 + + +@pytest.mark.parametrize("name", ["pylock-dev.toml", "pylock.dev.test.toml"]) +def test_export_pylock_toml_invalid_file_names( + mocker: MockerFixture, + poetry: Poetry, + tester: CommandTester, + do_lock: None, + name: str, +) -> None: + export_mock = mocker.patch("poetry_plugin_export.command.Exporter.export") + + assert tester.execute(f"--format pylock.toml --output somedir/{name}") == 1 + assert export_mock.call_count == 0 + assert ( + "The output file for pylock.toml export must be named" + in tester.io.fetch_error() + ) diff --git a/tests/test_exporter_pylock_toml.py b/tests/test_exporter_pylock_toml.py new file mode 100644 index 0000000..df40a7a --- /dev/null +++ b/tests/test_exporter_pylock_toml.py @@ -0,0 +1,1083 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Any + +import pytest + +from cleo.io.null_io import NullIO +from packaging.utils import canonicalize_name +from poetry.core.constraints.version import Version +from poetry.core.packages.dependency_group import MAIN_GROUP +from poetry.core.packages.package import Package +from poetry.factory import Factory +from poetry.packages import Locker as BaseLocker +from poetry.repositories import Repository + +from poetry_plugin_export.exporter import Exporter + + +if TYPE_CHECKING: + from pathlib import Path + + from poetry.poetry import Poetry + from pytest_mock import MockerFixture + + +DEV_GROUP = canonicalize_name("dev") + + +class Locker(BaseLocker): + def __init__(self, fixture_root: Path) -> None: + super().__init__(fixture_root / "poetry.lock", {}) + self._locked = True + + def locked(self, is_locked: bool = True) -> Locker: + self._locked = is_locked + + return self + + def mock_lock_data(self, data: dict[str, Any]) -> None: + self._lock_data = data + + def is_locked(self) -> bool: + return self._locked + + def is_fresh(self) -> bool: + return True + + def _get_content_hash(self, *, with_dependency_groups: bool = True) -> str: + return "123456789" + + +@pytest.fixture +def locker(fixture_root: Path) -> Locker: + return Locker(fixture_root) + + +@pytest.fixture +def pypi_repo() -> Repository: + repo = Repository("PyPI") + foo = Package("foo", "1.0") + foo.files = [ + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + "url": "https://example.org/foo-1.0-py3-none-any.whl", + "upload_time": "2025-12-28T12:34:56.789Z", + "size": 12345, + }, + { + "file": "foo-1.0.tar.gz", + "hash": "sha256:0123456789abcdef", + "url": "https://example.org/foo-1.0.tar.gz", + }, + ] + repo.add_package(foo) + return repo + + +@pytest.fixture +def legacy_repositories() -> list[Repository]: + repos = [] + for repo_name in ("legacy1", "legacy2"): + repo = Repository(repo_name) + repos.append(repo) + for package_name in ("foo", "bar"): + package = Package( + package_name, + "1.0", + source_type="legacy", + source_url=f"https://{repo_name}.org/simple", + source_reference=repo_name, + ) + package.files = [ + { + "file": f"{package_name}-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + "url": f"https://{repo_name}.org/{package_name}-1.0-py3-none-any.whl", + }, + { + "file": f"{package_name}-1.0.tar.gz", + "hash": "sha256:0123456789abcdef", + "url": f"https://{repo_name}.org/{package_name}-1.0.tar.gz", + "upload_time": "2025-12-27T12:34:56.789Z", + "size": 42, + }, + ] + repo.add_package(package) + return repos + + +@pytest.fixture +def poetry( + fixture_root: Path, + locker: Locker, + pypi_repo: Repository, + legacy_repositories: list[Repository], +) -> Poetry: + p = Factory().create_poetry(fixture_root / "sample_project") + p.package.python_versions = "*" + p._locker = locker + p.pool.remove_repository("PyPI") + p.pool.add_repository(pypi_repo) + for repo in legacy_repositories: + p.pool.add_repository(repo) + + return p + + +@pytest.fixture(autouse=True) +def mock_poetry_version(mocker: MockerFixture) -> None: + mocker.patch( + "poetry_plugin_export.exporter.Exporter._get_poetry_version", + return_value="2.3.0", + ) + + +def test_exporter_raises_error_on_old_poetry_version( + mocker: MockerFixture, tmp_path: Path, poetry: Poetry +) -> None: + mocker.patch( + "poetry_plugin_export.exporter.Exporter._get_poetry_version", + return_value="2.2.1", + ) + + lock_data = {"metadata": {"lock-version": "2.0"}} + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + with pytest.raises(RuntimeError) as exc_info: + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + assert str(exc_info.value) == ( + "Exporting pylock.toml requires Poetry version 2.3.0 or higher." + ) + + +def test_exporter_raises_error_on_old_lock_version( + tmp_path: Path, poetry: Poetry +) -> None: + lock_data = {"metadata": {"lock-version": "2.0"}} + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + with pytest.raises(RuntimeError) as exc_info: + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + assert str(exc_info.value) == ( + "Cannot export pylock.toml because the lock file is not at least version 2.1" + ) + + +def test_exporter_locks_exported_groups_and_extras( + tmp_path: Path, poetry: Poetry +) -> None: + lock_data = {"package": [], "metadata": {"lock-version": "2.1"}} + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + exporter.only_groups([DEV_GROUP]) + exporter.with_extras([canonicalize_name("extra1"), canonicalize_name("extra2")]) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" +packages = [] + +[tool.poetry-plugin-export] +groups = ["dev"] +extras = ["extra1", "extra2"] +""" + + assert content == expected + + +@pytest.mark.parametrize( + ("python_versions", "expected_python", "expected_marker"), + [ + (">=3.9", ">=3.9", 'python_version >= "3.9"'), + ("~3.9", ">=3.9,<3.10", 'python_version == "3.9"'), + ("^3.9", ">=3.9,<4.0", 'python_version >= "3.9" and python_version < "4.0"'), + ], +) +def test_exporter_python_constraint( + tmp_path: Path, + poetry: Poetry, + python_versions: str, + expected_python: str, + expected_marker: str, +) -> None: + poetry.package.python_versions = python_versions + lock_data = {"package": [], "metadata": {"lock-version": "2.1"}} + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected_marker = expected_marker.replace('"', '\\"') + expected = f"""\ +lock-version = "1.0" +environments = ["{expected_marker}"] +requires-python = "{expected_python}" +created-by = "poetry-plugin-export" +packages = [] + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +@pytest.mark.parametrize( + ("python_versions", "expected_python", "expected_marker"), + [ + ( + "~2.7 | ^3.9", + ">=2.7,<2.8 || >=3.9,<4.0", + 'python_version == "2.7" or python_version >= "3.9" and python_version < "4.0"', + ), + ], +) +def test_exporter_does_not_write_invalid_python_constraint( + tmp_path: Path, + poetry: Poetry, + python_versions: str, + expected_python: str, + expected_marker: str, +) -> None: + poetry.package.python_versions = python_versions + lock_data = {"package": [], "metadata": {"lock-version": "2.1"}} + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected_marker = expected_marker.replace('"', '\\"') + expected = f"""\ +lock-version = "1.0" +environments = ["{expected_marker}"] +# requires-python = "{expected_python}" +created-by = "poetry-plugin-export" +packages = [] + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_vcs_dependencies(tmp_path: Path, poetry: Poetry) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.2.3", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "git", + "url": "https://github.com/foo/foo.git", + "reference": "123456", + "resolved_reference": "abcdef", + }, + }, + { + "name": "bar", + "version": "2.3", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "git", + "url": "https://github.com/bar/bar.git", + "reference": "123456", + "resolved_reference": "abcdef", + "subdirectory": "subdir", + }, + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "foo" +version = "1.2.3" + +[packages.vcs] +type = "git" +url = "https://github.com/foo/foo.git" +requested-revision = "123456" +commit-id = "abcdef" + +[[packages]] +name = "bar" +version = "2.3" + +[packages.vcs] +type = "git" +url = "https://github.com/bar/bar.git" +requested-revision = "123456" +commit-id = "abcdef" +subdirectory = "subdir" + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_directory_dependencies(tmp_path: Path, poetry: Poetry) -> None: + tmp_project = tmp_path / "tmp_project" + tmp_project.mkdir() + lock_data = { + "package": [ + { + "name": "simple_project", + "version": "1.2.3", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "directory", + "url": "simple_project", + }, + }, + { + "name": "tmp-project", + "version": "1.2.3", + "optional": False, + "python-versions": "*", + "develop": True, + "groups": [MAIN_GROUP], + "source": { + "type": "directory", + "url": tmp_project.as_posix(), + }, + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = f"""\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "simple-project" + +[packages.directory] +path = "{(poetry.locker.lock.parent / "simple_project").as_posix()}" + +[[packages]] +name = "tmp-project" + +[packages.directory] +path = "tmp_project" +editable = true + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_file_dependencies( + tmp_path: Path, poetry: Poetry, fixture_root: Path +) -> None: + tmp_project = tmp_path / "files" / "tmp_project.zip" + tmp_project.parent.mkdir() + tmp_project.touch() + lock_data = { + "package": [ + { + "name": "demo", + "version": "0.1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "file", + "url": "distributions/demo-0.2.0-py3-none-any.whl", + }, + "files": [ + { + "file": "demo-0.2.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + } + ], + }, + { + "name": "simple-project", + "version": "1.2.3", + "optional": False, + "python-versions": "*", + "develop": True, + "groups": [MAIN_GROUP], + "source": { + "type": "directory", + "url": "simple_project/dist/simple_project-0.1.0.tar.gz", + }, + "files": [ + { + "file": "simple_project-0.1.0.tar.gz", + "hash": "sha256:1234567890abcdef", + } + ], + }, + { + "name": "tmp-project", + "version": "3", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "file", + "url": f"{tmp_project.as_posix()}", + "subdirectory": "sub", + }, + "files": [ + { + "file": "tmp_project.zip", + "hash": "sha256:fedcba0987654321", + } + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = f"""\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "demo" +version = "0.1.0" +archive = {{path = "{fixture_root.as_posix()}/distributions/demo-0.2.0-py3-none-any.whl", hashes = {{sha256 = "abcdef1234567890"}}}} + +[[packages]] +name = "simple-project" + +[packages.directory] +path = "{(poetry.locker.lock.parent / "simple_project" / "dist" / "simple_project-0.1.0.tar.gz").as_posix()}" +editable = true + +[[packages]] +name = "tmp-project" +version = "3" +archive = {{path = "files/tmp_project.zip", hashes = {{sha256 = "fedcba0987654321"}}, subdirectory = "sub"}} + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_url_dependencies(tmp_path: Path, poetry: Poetry) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "url", + "url": "https://example.org/foo-1.0-py3-none-any.whl", + }, + "files": [ + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + } + ], + }, + { + "name": "bar", + "version": "3", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "url", + "url": "https://example.org/bar.zip#subdir=sub", + "subdirectory": "sub", + }, + "files": [ + { + "file": "bar.zip", + "hash": "sha256:fedcba0987654321", + } + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "foo" +version = "1.0" +archive = {url = "https://example.org/foo-1.0-py3-none-any.whl", hashes = {sha256 = "abcdef1234567890"}} + +[[packages]] +name = "bar" +version = "3" +archive = {url = "https://example.org/bar.zip#subdir=sub", hashes = {sha256 = "fedcba0987654321"}, subdirectory = "sub"} + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_pypi_dependencies(tmp_path: Path, poetry: Poetry) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "files": [ + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + }, + { + "file": "foo-1.0.tar.gz", + "hash": "sha256:0123456789abcdef", + }, + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "foo" +version = "1.0" +index = "https://pypi.org/simple" +sdist = {name = "foo-1.0.tar.gz", url = "https://example.org/foo-1.0.tar.gz", hashes = {sha256 = "0123456789abcdef"}} +wheels = [ + {name = "foo-1.0-py3-none-any.whl", url = "https://example.org/foo-1.0-py3-none-any.whl", upload-time = 2025-12-28T12:34:56.789000Z, size = 12345, hashes = {sha256 = "abcdef1234567890"}}, +] + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_pypi_dependencies_sdist_only(tmp_path: Path, poetry: Poetry) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "files": [ + { + "file": "foo-1.0.tar.gz", + "hash": "sha256:0123456789abcdef", + }, + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + poetry.pool.repository("PyPI").package("foo", Version.parse("1.0")).files = [ + { + "file": "foo-1.0.tar.gz", + "hash": "sha256:0123456789abcdef", + "url": "https://example.org/foo-1.0.tar.gz", + }, + ] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "foo" +version = "1.0" +index = "https://pypi.org/simple" +sdist = {name = "foo-1.0.tar.gz", url = "https://example.org/foo-1.0.tar.gz", hashes = {sha256 = "0123456789abcdef"}} + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_pypi_dependencies_wheel_only(tmp_path: Path, poetry: Poetry) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "files": [ + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + }, + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + poetry.pool.repository("PyPI").package("foo", Version.parse("1.0")).files = [ + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + "url": "https://example.org/foo-1.0-py3-none-any.whl", + }, + ] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "foo" +version = "1.0" +index = "https://pypi.org/simple" +wheels = [ + {name = "foo-1.0-py3-none-any.whl", url = "https://example.org/foo-1.0-py3-none-any.whl", hashes = {sha256 = "abcdef1234567890"}}, +] + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_pypi_dependencies_multiple_wheels( + tmp_path: Path, poetry: Poetry +) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "files": [ + { + "file": "foo-1.0-py2-none-any.whl", + "hash": "sha256:abcdef1234567891", + }, + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + }, + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + poetry.pool.repository("PyPI").package("foo", Version.parse("1.0")).files = [ + { + "file": "foo-1.0-py2-none-any.whl", + "hash": "sha256:abcdef1234567891", + "url": "https://example.org/foo-1.0-py2-none-any.whl", + }, + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + "url": "https://example.org/foo-1.0-py3-none-any.whl", + }, + ] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "foo" +version = "1.0" +index = "https://pypi.org/simple" +wheels = [ + {name = "foo-1.0-py2-none-any.whl", url = "https://example.org/foo-1.0-py2-none-any.whl", hashes = {sha256 = "abcdef1234567891"}}, + {name = "foo-1.0-py3-none-any.whl", url = "https://example.org/foo-1.0-py3-none-any.whl", hashes = {sha256 = "abcdef1234567890"}}, +] + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +def test_export_legacy_repo_dependencies(tmp_path: Path, poetry: Poetry) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "legacy", + "url": "https://legacy1.org/simple", + "reference": "legacy1", + }, + "files": [ + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + }, + { + "file": "foo-1.0.tar.gz", + "hash": "sha256:0123456789abcdef", + }, + ], + }, + { + "name": "bar", + "version": "1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP], + "source": { + "type": "legacy", + "url": "https://legacy2.org/simple", + "reference": "legacy2", + }, + "files": [ + { + "file": "bar-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + }, + { + "file": "bar-1.0.tar.gz", + "hash": "sha256:0123456789abcdef", + }, + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + expected = """\ +lock-version = "1.0" +created-by = "poetry-plugin-export" + +[[packages]] +name = "foo" +version = "1.0" +index = "https://legacy1.org/simple" +sdist = {name = "foo-1.0.tar.gz", url = "https://legacy1.org/foo-1.0.tar.gz", upload-time = 2025-12-27T12:34:56.789000Z, size = 42, hashes = {sha256 = "0123456789abcdef"}} +wheels = [ + {name = "foo-1.0-py3-none-any.whl", url = "https://legacy1.org/foo-1.0-py3-none-any.whl", hashes = {sha256 = "abcdef1234567890"}}, +] + +[[packages]] +name = "bar" +version = "1.0" +index = "https://legacy2.org/simple" +sdist = {name = "bar-1.0.tar.gz", url = "https://legacy2.org/bar-1.0.tar.gz", upload-time = 2025-12-27T12:34:56.789000Z, size = 42, hashes = {sha256 = "0123456789abcdef"}} +wheels = [ + {name = "bar-1.0-py3-none-any.whl", url = "https://legacy2.org/bar-1.0-py3-none-any.whl", hashes = {sha256 = "abcdef1234567890"}}, +] + +[tool.poetry-plugin-export] +groups = ["main"] +extras = [] +""" + + assert content == expected + + +@pytest.mark.parametrize( + ("groups", "extras", "marker", "expected"), + [ + ({"main"}, set(), 'python_version >= "3.6"', 'python_version >= "3.6"'), + ({"other"}, set(), 'python_version >= "3.6"', ""), + ( + {"main"}, + set(), + {"main": 'python_version >= "3.6"'}, + 'python_version >= "3.6"', + ), + ({"dev"}, set(), {"main": 'python_version >= "3.6"'}, "*"), + ( + {"dev"}, + set(), + {"main": 'python_version >= "3.6"', "dev": 'python_version < "3.6"'}, + 'python_version < "3.6"', + ), + ( + {"main", "dev"}, + set(), + {"main": 'python_version >= "3.6"', "dev": 'python_version < "3.6"'}, + "*", + ), + ( + {"main", "dev"}, + set(), + {"main": 'python_version >= "3.6"', "dev": 'sys_platform == "linux"'}, + 'python_version >= "3.6" or sys_platform == "linux"', + ), + # extras + ({"main"}, {}, 'python_version >= "3.6" and extra == "extra1"', ""), + ( + {"main"}, + {}, + 'python_version >= "3.6" or extra == "extra1"', + 'python_version >= "3.6"', + ), + ( + {"main"}, + {}, + 'python_version >= "3.6" and extra != "extra1"', + 'python_version >= "3.6"', + ), + ( + {"main"}, + {"extra1"}, + 'python_version >= "3.6" and extra == "extra1"', + 'python_version >= "3.6"', + ), + ( + {"main"}, + {"extra1"}, + 'python_version >= "3.6" or extra == "extra1"', + 'python_version >= "3.6"', + ), + ({"main"}, {"extra1"}, 'python_version >= "3.6" and extra != "extra1"', ""), + ], +) +def test_export_markers( + tmp_path: Path, + poetry: Poetry, + groups: set[str], + extras: set[str], + marker: str | dict[str, str], + expected: str, +) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.0", + "optional": False, + "python-versions": "*", + "groups": [MAIN_GROUP, DEV_GROUP], + "markers": marker, + "source": { + "type": "url", + "url": "https://example.org/foo-1.0-py3-none-any.whl", + }, + "files": [ + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + } + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + exporter.only_groups({canonicalize_name(g) for g in groups}) + if extras: + exporter.with_extras({canonicalize_name(e) for e in extras}) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + match expected: + case "": + assert 'name = "foo"' not in content + case "*": + assert 'name = "foo"' in content.splitlines() + assert "marker = " not in content + case _: + expected = expected.replace('"', '\\"') + assert f'marker = "{expected}"' in content.splitlines() + + +@pytest.mark.parametrize( + ("python_versions", "expected"), + [ + (">=3.9", ">=3.9"), + ("*", None), + ("~2.7 | ^3.9", "# >=2.7,<2.8 || >=3.9,<4.0"), + ], +) +def test_export_requires_python( + tmp_path: Path, poetry: Poetry, python_versions: str, expected: str | None +) -> None: + lock_data = { + "package": [ + { + "name": "foo", + "version": "1.0", + "optional": False, + "python-versions": python_versions, + "groups": [MAIN_GROUP], + "source": { + "type": "url", + "url": "https://example.org/foo-1.0-py3-none-any.whl", + }, + "files": [ + { + "file": "foo-1.0-py3-none-any.whl", + "hash": "sha256:abcdef1234567890", + } + ], + }, + ], + "metadata": {"lock-version": "2.1"}, + } + poetry.locker.mock_lock_data(lock_data) # type: ignore[attr-defined] + + exporter = Exporter(poetry, NullIO()) + + exporter.export("pylock.toml", tmp_path, "pylock.toml") + + with (tmp_path / "pylock.toml").open(encoding="utf-8") as f: + content = f.read() + + if expected is None: + assert "requires-python" not in content + else: + prefix = "# " if expected.startswith("#") else "" + expected = expected.removeprefix("# ") + assert f'{prefix}requires-python = "{expected}"' in content.splitlines()