Skip to content

Commit 9abc988

Browse files
authored
feat: add pylock.toml as export format (#357)
1 parent 02effdf commit 9abc988

File tree

9 files changed

+1383
-21
lines changed

9 files changed

+1383
-21
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,14 @@ poetry export -f requirements.txt --output requirements.txt
5353
> which are exported with their resolved hashes, are included.
5454
5555
> [!NOTE]
56-
> Only the `constraints.txt` and `requirements.txt` formats are currently supported.
56+
> The following formats are currently supported:
57+
> * `requirements.txt`
58+
> * `constraints.txt`
59+
> * `pylock.toml`
5760
5861
### Available options
5962

60-
* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported.
63+
* `--format (-f)`: The format to export to (default: `requirements.txt`). Additionally, `constraints.txt` and `pylock.toml` are supported.
6164
* `--output (-o)`: The name of the output file. If omitted, print to standard output.
6265
* `--with`: The optional and non-optional dependency groups to include. By default, only the main dependencies are included.
6366
* `--only`: The only dependency groups to include. It is possible to exclude the `main` group this way.

docs/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ poetry export --only test,docs
6565

6666
### Available options
6767

68-
* `--format (-f)`: The format to export to (default: `requirements.txt`). Currently, only `constraints.txt` and `requirements.txt` are supported.
68+
* `--format (-f)`: The format to export to (default: `requirements.txt`). Additionally, `constraints.txt` and `pylock.toml` are supported.
6969
* `--output (-o)`: The name of the output file. If omitted, print to standard output.
7070
* `--with`: The optional and non-optional dependency groups to include. By default, only the main dependencies are included.
7171
* `--only`: The only dependency groups to include. It is possible to exclude the `main` group this way.

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ license = "MIT"
77
readme = "README.md"
88
requires-python = ">=3.10,<4.0"
99
dependencies = [
10-
"poetry>=2.1.0,<3.0.0",
11-
"poetry-core>=2.1.0,<3.0.0",
10+
"poetry (>=2.1.0,<3.0.0)",
11+
"poetry-core (>=2.1.0,<3.0.0)",
12+
"tomlkit (>=0.11.4,<1.0.0)",
1213
]
1314
dynamic = ["classifiers"]
1415

src/poetry_plugin_export/command.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import re
4+
35
from pathlib import Path
46
from typing import TYPE_CHECKING
57

@@ -24,8 +26,7 @@ class ExportCommand(GroupCommand):
2426
option(
2527
"format",
2628
"f",
27-
"Format to export to. Currently, only constraints.txt and"
28-
" requirements.txt are supported.",
29+
"Format to export to: constraints.txt, requirements.txt, pylock.toml",
2930
flag=False,
3031
default=Exporter.FORMAT_REQUIREMENTS_TXT,
3132
),
@@ -89,6 +90,21 @@ def handle(self) -> int:
8990

9091
output = self.option("output")
9192

93+
pylock_pattern = r"^pylock\.([^.]+)\.toml$"
94+
if (
95+
fmt == Exporter.FORMAT_PYLOCK_TOML
96+
and output
97+
and Path(output).name != "pylock.toml"
98+
and not re.match(pylock_pattern, Path(output).name)
99+
):
100+
self.line_error(
101+
"<error>"
102+
'The output file for pylock.toml export must be named "pylock.toml"'
103+
f' or must follow the regex "{pylock_pattern}", e.g. "pylock.dev.toml"'
104+
"</error>"
105+
)
106+
return 1
107+
92108
locker = self.poetry.locker
93109
if not locker.is_locked():
94110
self.line_error("<comment>The lock file does not exist. Locking.</comment>")

src/poetry_plugin_export/exporter.py

Lines changed: 208 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
from __future__ import annotations
22

3+
import contextlib
4+
import itertools
35
import urllib.parse
46

7+
from datetime import datetime
58
from functools import partialmethod
9+
from importlib import metadata
610
from typing import TYPE_CHECKING
11+
from typing import Any
712

813
from cleo.io.io import IO
14+
from poetry.core.constraints.version.version import Version
915
from poetry.core.packages.dependency_group import MAIN_GROUP
16+
from poetry.core.packages.directory_dependency import DirectoryDependency
17+
from poetry.core.packages.file_dependency import FileDependency
18+
from poetry.core.packages.url_dependency import URLDependency
1019
from poetry.core.packages.utils.utils import create_nested_marker
20+
from poetry.core.packages.vcs_dependency import VCSDependency
1121
from poetry.core.version.markers import parse_marker
1222
from poetry.repositories.http_repository import HTTPRepository
1323

@@ -22,6 +32,7 @@
2232
from typing import ClassVar
2333

2434
from packaging.utils import NormalizedName
35+
from poetry.core.packages.package import PackageFile
2536
from poetry.poetry import Poetry
2637

2738

@@ -32,11 +43,13 @@ class Exporter:
3243

3344
FORMAT_CONSTRAINTS_TXT = "constraints.txt"
3445
FORMAT_REQUIREMENTS_TXT = "requirements.txt"
46+
FORMAT_PYLOCK_TOML = "pylock.toml"
3547
ALLOWED_HASH_ALGORITHMS = ("sha256", "sha384", "sha512")
3648

3749
EXPORT_METHODS: ClassVar[dict[str, str]] = {
3850
FORMAT_CONSTRAINTS_TXT: "_export_constraints_txt",
3951
FORMAT_REQUIREMENTS_TXT: "_export_requirements_txt",
52+
FORMAT_PYLOCK_TOML: "_export_pylock_toml",
4053
}
4154

4255
def __init__(self, poetry: Poetry, io: IO) -> None:
@@ -81,11 +94,20 @@ def export(self, fmt: str, cwd: Path, output: IO | str) -> None:
8194
if not self.is_format_supported(fmt):
8295
raise ValueError(f"Invalid export format: {fmt}")
8396

84-
getattr(self, self.EXPORT_METHODS[fmt])(cwd, output)
97+
out_dir = cwd
98+
if isinstance(output, str):
99+
out_dir = (cwd / output).parent
100+
content = getattr(self, self.EXPORT_METHODS[fmt])(out_dir)
101+
102+
if isinstance(output, IO):
103+
output.write(content)
104+
else:
105+
with (cwd / output).open("w", encoding="utf-8") as txt:
106+
txt.write(content)
85107

86108
def _export_generic_txt(
87-
self, cwd: Path, output: IO | str, with_extras: bool, allow_editable: bool
88-
) -> None:
109+
self, out_dir: Path, with_extras: bool, allow_editable: bool
110+
) -> str:
89111
from poetry.core.packages.utils.utils import path_to_url
90112

91113
indexes = set()
@@ -219,11 +241,7 @@ def _export_generic_txt(
219241

220242
content = indexes_header + "\n" + content
221243

222-
if isinstance(output, IO):
223-
output.write(content)
224-
else:
225-
with (cwd / output).open("w", encoding="utf-8") as txt:
226-
txt.write(content)
244+
return content
227245

228246
_export_constraints_txt = partialmethod(
229247
_export_generic_txt, with_extras=False, allow_editable=False
@@ -232,3 +250,185 @@ def _export_generic_txt(
232250
_export_requirements_txt = partialmethod(
233251
_export_generic_txt, with_extras=True, allow_editable=True
234252
)
253+
254+
def _get_poetry_version(self) -> str:
255+
return metadata.version("poetry")
256+
257+
def _export_pylock_toml(self, out_dir: Path) -> str:
258+
from tomlkit import aot
259+
from tomlkit import array
260+
from tomlkit import document
261+
from tomlkit import inline_table
262+
from tomlkit import table
263+
264+
min_poetry_version = "2.3.0"
265+
if Version.parse(self._get_poetry_version()) < Version.parse(
266+
min_poetry_version
267+
):
268+
raise RuntimeError(
269+
"Exporting pylock.toml requires Poetry version"
270+
f" {min_poetry_version} or higher."
271+
)
272+
273+
if not self._poetry.locker.is_locked_groups_and_markers():
274+
raise RuntimeError(
275+
"Cannot export pylock.toml because the lock file is not at least version 2.1"
276+
)
277+
278+
def add_file_info(
279+
archive: dict[str, Any],
280+
locked_file_info: PackageFile,
281+
additional_file_info: PackageFile | None = None,
282+
) -> None:
283+
# We only use additional_file_info for url, upload_time and size
284+
# because they are not in locked_file_info.
285+
if additional_file_info:
286+
archive["name"] = locked_file_info["file"]
287+
url = additional_file_info.get("url")
288+
assert url, "url must be present in additional_file_info"
289+
archive["url"] = url
290+
if upload_time := additional_file_info.get("upload_time"):
291+
with contextlib.suppress(ValueError):
292+
# Python < 3.11 does not support 'Z' suffix for UTC, replace it with '+00:00'
293+
archive["upload-time"] = datetime.fromisoformat(
294+
upload_time.replace("Z", "+00:00")
295+
)
296+
if size := additional_file_info.get("size"):
297+
archive["size"] = size
298+
archive["hashes"] = dict([locked_file_info["hash"].split(":", 1)])
299+
300+
python_constraint = self._poetry.package.python_constraint
301+
python_marker = parse_marker(
302+
create_nested_marker("python_version", python_constraint)
303+
)
304+
305+
lock = document()
306+
lock["lock-version"] = "1.0"
307+
if self._poetry.package.python_versions != "*":
308+
lock["environments"] = [str(python_marker)]
309+
lock["requires-python"] = str(python_constraint)
310+
lock["created-by"] = "poetry-plugin-export"
311+
312+
packages = aot()
313+
for dependency_package in get_project_dependency_packages2(
314+
self._poetry.locker,
315+
groups=set(self._groups),
316+
extras=self._extras,
317+
):
318+
dependency = dependency_package.dependency
319+
package = dependency_package.package
320+
data = table()
321+
data["name"] = package.name
322+
data["version"] = str(package.version)
323+
if not package.marker.is_any():
324+
data["marker"] = str(package.marker)
325+
if not package.python_constraint.is_any():
326+
data["requires-python"] = str(package.python_constraint)
327+
packages.append(data)
328+
match dependency:
329+
case VCSDependency():
330+
vcs = {}
331+
vcs["type"] = "git"
332+
vcs["url"] = dependency.source
333+
vcs["requested-revision"] = dependency.reference
334+
assert dependency.source_resolved_reference, (
335+
"VCSDependency must have a resolved reference"
336+
)
337+
vcs["commit-id"] = dependency.source_resolved_reference
338+
if dependency.directory:
339+
vcs["subdirectory"] = dependency.directory
340+
data["vcs"] = vcs
341+
case DirectoryDependency():
342+
# The version MUST NOT be included when it cannot be guaranteed
343+
# to be consistent with the code used
344+
del data["version"]
345+
dir_: dict[str, Any] = {}
346+
try:
347+
dir_["path"] = dependency.full_path.relative_to(
348+
out_dir
349+
).as_posix()
350+
except ValueError:
351+
dir_["path"] = dependency.full_path.as_posix()
352+
if package.develop:
353+
dir_["editable"] = package.develop
354+
data["directory"] = dir_
355+
case FileDependency():
356+
archive = inline_table()
357+
try:
358+
archive["path"] = dependency.full_path.relative_to(
359+
out_dir
360+
).as_posix()
361+
except ValueError:
362+
archive["path"] = dependency.full_path.as_posix()
363+
assert len(package.files) == 1, (
364+
"FileDependency must have exactly one file"
365+
)
366+
add_file_info(archive, package.files[0])
367+
if dependency.directory:
368+
archive["subdirectory"] = dependency.directory
369+
data["archive"] = archive
370+
case URLDependency():
371+
archive = inline_table()
372+
archive["url"] = dependency.url
373+
assert len(package.files) == 1, (
374+
"URLDependency must have exactly one file"
375+
)
376+
add_file_info(archive, package.files[0])
377+
if dependency.directory:
378+
archive["subdirectory"] = dependency.directory
379+
data["archive"] = archive
380+
case _:
381+
data["index"] = package.source_url or "https://pypi.org/simple"
382+
pool_info = {
383+
p["file"]: p
384+
for p in self._poetry.pool.package(
385+
package.name,
386+
package.version,
387+
package.source_reference or "PyPI",
388+
).files
389+
}
390+
artifacts = {
391+
k: list(v)
392+
for k, v in itertools.groupby(
393+
package.files,
394+
key=(
395+
lambda x: "wheel"
396+
if x["file"].endswith(".whl")
397+
else "sdist"
398+
),
399+
)
400+
}
401+
402+
sdist_files = list(artifacts.get("sdist", []))
403+
for sdist in sdist_files:
404+
sdist_table = inline_table()
405+
data["sdist"] = sdist_table
406+
add_file_info(sdist_table, sdist, pool_info[sdist["file"]])
407+
if wheels := list(artifacts.get("wheel", [])):
408+
wheel_array = array()
409+
data["wheels"] = wheel_array
410+
wheel_array.multiline(True)
411+
for wheel in wheels:
412+
wheel_table = inline_table()
413+
add_file_info(wheel_table, wheel, pool_info[wheel["file"]])
414+
wheel_array.append(wheel_table)
415+
416+
lock["packages"] = packages if packages else []
417+
418+
lock["tool"] = {}
419+
lock["tool"]["poetry-plugin-export"] = {} # type: ignore[index]
420+
lock["tool"]["poetry-plugin-export"]["groups"] = sorted( # type: ignore[index]
421+
self._groups, key=lambda x: (x != "main", x)
422+
)
423+
lock["tool"]["poetry-plugin-export"]["extras"] = sorted(self._extras) # type: ignore[index]
424+
425+
# Poetry writes invalid requires-python for "or" relations.
426+
# Though Poetry could parse it, other tools would fail.
427+
# Since requires-python is redundant with markers, we just comment it out.
428+
lock_lines = [
429+
f"# {line}"
430+
if line.startswith("requires-python = ") and "||" in line
431+
else line
432+
for line in lock.as_string().splitlines()
433+
]
434+
return "\n".join(lock_lines) + "\n"

src/poetry_plugin_export/walker.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,14 +276,12 @@ def get_project_dependency_packages2(
276276
if not marker.validate({"extra": extras}):
277277
continue
278278

279+
marker = marker.without_extras()
280+
279281
if project_python_marker:
280282
marker = project_python_marker.intersect(marker)
281283

282284
package.marker = marker
283-
# Set python_versions to any because they are already incorporated
284-
# in the locked marker and only cause additional computing without
285-
# actually changing anything.
286-
package.python_versions = "*"
287285

288286
yield DependencyPackage(dependency=package.to_dependency(), package=package)
289287

0 commit comments

Comments
 (0)