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()