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 4f5f487..e7d84be 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "anyio"
@@ -569,43 +569,38 @@ files = [
[[package]]
name = "dulwich"
-version = "0.24.10"
+version = "0.25.0"
description = "Python Git Library"
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "dulwich-0.24.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1f511f7afe1f36e37193214e4e069685d7d0378e756cc96a2fcb138bdf9fefca"},
- {file = "dulwich-0.24.10-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:2a56f9838e5d2414a2b57bab370b73b9803fefd98836ef841f0fd489b5cc1349"},
- {file = "dulwich-0.24.10-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:90b24c0827299cfb53c4f4d4fedc811be5c4b10c11172ff6e5a5c52277fe0b3a"},
- {file = "dulwich-0.24.10-cp310-cp310-win32.whl", hash = "sha256:0dfae8c59b97964a907fdf4c5809154a18fd8c55f2eb6d8fd1607464165a9aa2"},
- {file = "dulwich-0.24.10-cp310-cp310-win_amd64.whl", hash = "sha256:0e1601789554e3d15b294356c78a5403521c27d5460e64dbbc44ffd5b10af4c3"},
- {file = "dulwich-0.24.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fbf94fa73211d2f029751a72e1ca3a2fd35c6f5d9bb434acdf10a4a79ca322dd"},
- {file = "dulwich-0.24.10-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b715a9f85ed71bef8027275c1bded064e4925071ae8c8a8d9a20c67b31faf3cd"},
- {file = "dulwich-0.24.10-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:858fae0c7121715282a993abb1919385a28e1a9c4f136f568748d283c2ba874f"},
- {file = "dulwich-0.24.10-cp311-cp311-win32.whl", hash = "sha256:393e9c3cdd382cff20b5beb66989376d6da69e3b0dfec046a884707ab5d27ac9"},
- {file = "dulwich-0.24.10-cp311-cp311-win_amd64.whl", hash = "sha256:470d6cd8207e1a5ff1fb34c4c6fac2ec9a96d618f7062e5fb96c5260927bb9a7"},
- {file = "dulwich-0.24.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c724e5fc67c45f3c813f2630795ac388e3e6310534212f799a7a6bf230648c8"},
- {file = "dulwich-0.24.10-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6a25ca1605a94090514af408f9df64427281aefbb726f542e97d86d3a7c8ec18"},
- {file = "dulwich-0.24.10-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d9793fc1e42149a650a017dc8ce38485368a41729b9937e1dfcfedd0591ebe9d"},
- {file = "dulwich-0.24.10-cp312-cp312-win32.whl", hash = "sha256:1601bfea3906b52c924fae5b6ba32a0b087fb8fae927607e6b5381e6f7559611"},
- {file = "dulwich-0.24.10-cp312-cp312-win_amd64.whl", hash = "sha256:f7bfa9f0bfae57685754b163eef6641609047460939d28052e3beeb63efa6795"},
- {file = "dulwich-0.24.10-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:843de5f678436a27b33aea0f2b87fd0453afdd0135f885a3ca44bc3147846dd2"},
- {file = "dulwich-0.24.10-cp313-cp313-android_21_x86_64.whl", hash = "sha256:4914abb6408a719b7a1f7d9a182d1efd92c326e178b440faf582df50f9f032db"},
- {file = "dulwich-0.24.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ce6e05ec50f258ccd14d83114eb32cc5bb241ae4a8c7199d014fd7568de285b1"},
- {file = "dulwich-0.24.10-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3581ae0af33f28e6c0834d2f41ca67ca81cd92a589e6a5f985e6c64373232958"},
- {file = "dulwich-0.24.10-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:019af16c850ae85254289f9633a29dea02f45351c4182ea20b0c1394c074a13b"},
- {file = "dulwich-0.24.10-cp313-cp313-win32.whl", hash = "sha256:4b5c225477a529e1d4a2b5e51272a418177e34803938391ce41b7573b2e5b0d0"},
- {file = "dulwich-0.24.10-cp313-cp313-win_amd64.whl", hash = "sha256:752c32d517dc608dbb8414061eaaec8ac8a05591b29531f81a83336b018b26c6"},
- {file = "dulwich-0.24.10-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:44f62e0244531a8c43ca7771e201ec9e7f6a2fb27f8c3c623939bc03c1f50423"},
- {file = "dulwich-0.24.10-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e2eda4a634d6f1ac4c0d4786f8772495c8840dfc2b3e595507376bf5e5b0f9c5"},
- {file = "dulwich-0.24.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b19af8a3ab051003ba05f15fc5c0d6f0d427e795639490790f34ec0558e99e3"},
- {file = "dulwich-0.24.10-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:90028182b9a47ea4efed51c81298f3a98e279d7bf5c1f91c47101927a309ee45"},
- {file = "dulwich-0.24.10-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:8df79c8080471f363e4dfcfc4e0d2e61e6da73af1fd7d31cb6ae0d34af45a6b4"},
- {file = "dulwich-0.24.10-cp39-cp39-win32.whl", hash = "sha256:f102c38207540fa485e85e0b763ce3725a2d49d846dbf316ed271e27fd85ff21"},
- {file = "dulwich-0.24.10-cp39-cp39-win_amd64.whl", hash = "sha256:c262ffc94338999e7808b434dccafaccd572d03b42d4ef140059d4b7cad765a3"},
- {file = "dulwich-0.24.10-py3-none-any.whl", hash = "sha256:15b32f8c3116a1c0a042dde8da96f65a607e263e860ee42b3d4a98ce2c2f4a06"},
- {file = "dulwich-0.24.10.tar.gz", hash = "sha256:30e028979b6fa7220c913da9c786026611c10746c06496149742602b36a11f6b"},
+ {file = "dulwich-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e7e9233686fd49c7fa311e1a9f769ce0fa9eb57e546b6ccd88d2dafb5d7cb6bd"},
+ {file = "dulwich-0.25.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:47f0328af2c0e5149f356b27d1ac5b2860049c29bf32d2e5994d33f879909dd6"},
+ {file = "dulwich-0.25.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6ca746bd4f8a6a7b849a759c34e960dd7b6fa573225a571e23ea9c73377175d2"},
+ {file = "dulwich-0.25.0-cp310-cp310-win32.whl", hash = "sha256:4a98628ae4150f5084e0e0eab884c967d9f499304ff220f558ebe523868fd564"},
+ {file = "dulwich-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:db89094df6567721ec1eae8a70f85afd22e07eefa86a1b11194247407a3426ee"},
+ {file = "dulwich-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d342daf24cc544f1ccc7e6cf6b8b22d10a4381c1c7ed2bf0e2024a48be9218f"},
+ {file = "dulwich-0.25.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1575e7bf93cbc9ae93d6653fe29962357b96a1f5943275ff55cbb772e61359e2"},
+ {file = "dulwich-0.25.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:63846a66254dd89bec7b3df75dda61fc37f9c53aa93cddf46d063a9e1f832634"},
+ {file = "dulwich-0.25.0-cp311-cp311-win32.whl", hash = "sha256:92cc60a9cfd027b0bbaeb588ab06577d58e2b1a41c824f069bd53544f0cccdf3"},
+ {file = "dulwich-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:f9d5710c8dbaefe6254bbefb409c612485e32d983df9a1299459987b13f2ac3f"},
+ {file = "dulwich-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866dcf6103ca4dddf9db5c307700b5b47dd49ddadb63423d957bb24d438a87d2"},
+ {file = "dulwich-0.25.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b074a82f40a3ab4068e2f01697a65b6239db55a3335e5c2e9b2a630601c1aa05"},
+ {file = "dulwich-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8ad390efed25a4fad288f80449a2180bfdb17db19bed4916630c01d20084c4b"},
+ {file = "dulwich-0.25.0-cp312-cp312-win32.whl", hash = "sha256:14c9aba34e1ac262806174304a5a17a78a0f83d0a6960e506005d3aa1cf9004e"},
+ {file = "dulwich-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:caeb9740f6e0d5a3fa48e1a009dee2f99f47be1836c6bc252022aa25327fcb0e"},
+ {file = "dulwich-0.25.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:c1731f45fd24b05a01ac32dc0f7e96337a3bd78ab33a230b2035a82f624d112e"},
+ {file = "dulwich-0.25.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c0bbe69be332d4cee36f628ba5feaf731c6a53dbe1ea1cf40324a4954a92093a"},
+ {file = "dulwich-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7b88ef0402ce2a94db5ae926e6be8048e59e8cdcc889a71e332d0e7bcc59f8b7"},
+ {file = "dulwich-0.25.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ae6f4c99a3978ff4fb1f537d16435d75a17f97ec84f61e3a9ac2b7b879b4dae8"},
+ {file = "dulwich-0.25.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4b46836c467bd898fd2ff1d4ebe511d2956f7f3f181dccbdde8631d4031cd0fa"},
+ {file = "dulwich-0.25.0-cp313-cp313-win32.whl", hash = "sha256:757ab788d2d87d96e4b5e84eaddc32d7b8e5b57a221f43b8cbb694787a9c1b80"},
+ {file = "dulwich-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:97f05e8a38f0e1a872b623e094bd270760318c9ab947ff65359192c9a692bda1"},
+ {file = "dulwich-0.25.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:b2eb2c727cfa173a48b65fbfc67b170f47c5b28d483759a1fc26886b01770345"},
+ {file = "dulwich-0.25.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:83e1cbff47ce1dc7d44a20f624c0d2fcbc6a70a458c5fe8e0f8bbf84f32aeb1c"},
+ {file = "dulwich-0.25.0-py3-none-any.whl", hash = "sha256:b5459ed202fcc7bdaaf619b4bd2718fc7ac7c5dea9c0be682f7e64bf145749e5"},
+ {file = "dulwich-0.25.0.tar.gz", hash = "sha256:baa84b539fea0e6a925a9159c3e0a1d08cceeea5260732b84200e077444a4b0e"},
]
[package.dependencies]
@@ -614,7 +609,7 @@ urllib3 = ">=2.2.2"
[package.extras]
colordiff = ["rich"]
-dev = ["codespell (==2.4.1)", "dissolve (>=0.1.1)", "mypy (==1.18.2)", "ruff (==0.13.2)"]
+dev = ["codespell (==2.4.1)", "dissolve (>=0.1.1)", "mypy (==1.19.0)", "ruff (==0.14.7)"]
fastimport = ["fastimport"]
fuzzing = ["atheris"]
https = ["urllib3 (>=2.2.2)"]
@@ -1300,27 +1295,25 @@ name = "poetry"
version = "2.2.1"
description = "Python dependency management and packaging made easy."
optional = false
-python-versions = "<4.0,>=3.9"
+python-versions = ">=3.10,<4.0"
groups = ["main"]
-files = [
- {file = "poetry-2.2.1-py3-none-any.whl", hash = "sha256:f5958b908b96c5824e2acbb8b19cdef8a3351c62142d7ecff2d705396c8ca34c"},
- {file = "poetry-2.2.1.tar.gz", hash = "sha256:bef9aa4bb00ce4c10b28b25e7bac724094802d6958190762c45df6c12749b37c"},
-]
+files = []
+develop = false
[package.dependencies]
build = ">=1.2.1,<2.0.0"
cachecontrol = {version = ">=0.14.0,<0.15.0", extras = ["filecache"]}
cleo = ">=2.1.0,<3.0.0"
-dulwich = ">=0.24.0,<0.25.0"
+dulwich = ">=0.25.0,<0.26.0"
fastjsonschema = ">=2.18.0,<3.0.0"
findpython = ">=0.6.2,<0.8.0"
installer = ">=0.7.0,<0.8.0"
keyring = ">=25.1.0,<26.0.0"
packaging = ">=24.2"
-pbs-installer = {version = ">=2025.1.6,<2026.0.0", extras = ["download", "install"]}
+pbs-installer = {version = ">=2025.6.10", extras = ["download", "install"]}
pkginfo = ">=1.12,<2.0"
platformdirs = ">=3.0.0,<5"
-poetry-core = "2.2.1"
+poetry-core = {git = "https://github.com/radoering/poetry-core.git", rev = "file-url-size-upload-time"}
pyproject-hooks = ">=1.0.0,<2.0.0"
requests = ">=2.26,<3.0"
requests-toolbelt = ">=1.0.0,<2.0.0"
@@ -1331,17 +1324,27 @@ trove-classifiers = ">=2022.5.19"
virtualenv = ">=20.26.6"
xattr = {version = ">=1.0.0,<2.0.0", markers = "sys_platform == \"darwin\""}
+[package.source]
+type = "git"
+url = "https://github.com/radoering/poetry.git"
+reference = "repo-file-url-size-upload-time"
+resolved_reference = "aa6647e3502944cec3950d972fba60ebc249b091"
+
[[package]]
name = "poetry-core"
version = "2.2.1"
description = "Poetry PEP 517 Build Backend"
optional = false
-python-versions = "<4.0,>=3.9"
+python-versions = ">=3.10, <4.0"
groups = ["main"]
-files = [
- {file = "poetry_core-2.2.1-py3-none-any.whl", hash = "sha256:bdfce710edc10bfcf9ab35041605c480829be4ab23f5bc01202cfe5db8f125ab"},
- {file = "poetry_core-2.2.1.tar.gz", hash = "sha256:97e50d8593c8729d3f49364b428583e044087ee3def1e010c6496db76bd65ac5"},
-]
+files = []
+develop = false
+
+[package.source]
+type = "git"
+url = "https://github.com/radoering/poetry-core.git"
+reference = "file-url-size-upload-time"
+resolved_reference = "c206e05be1ecbadfa90b17b0dc244581bf51aafd"
[[package]]
name = "pre-commit"
@@ -2135,9 +2138,9 @@ files = [
]
[package.extras]
-cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
+cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<4.0"
-content-hash = "9d3737621fb95fb1048deaf04626cbf1b731d899a527e1b8fe650b376cd100e8"
+content-hash = "71851a4fd525096ec87b433f7e31e3bbb06b1aeea7603d3369ce533d36f26393"
diff --git a/pyproject.toml b/pyproject.toml
index 113f4b9..2c029e2 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 @ git+https://github.com/radoering/poetry.git@repo-file-url-size-upload-time",
"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 c2b63d4..9921755 100644
--- a/src/poetry_plugin_export/walker.py
+++ b/src/poetry_plugin_export/walker.py
@@ -276,6 +276,8 @@ 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)
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.py b/tests/test_exporter.py
index 7563f73..b56c976 100644
--- a/tests/test_exporter.py
+++ b/tests/test_exporter.py
@@ -72,7 +72,7 @@ def is_locked(self) -> bool:
def is_fresh(self) -> bool:
return True
- def _get_content_hash(self) -> str:
+ def _get_content_hash(self, *, with_dependency_groups: bool = True) -> str:
return "123456789"
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()