Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
20 changes: 18 additions & 2 deletions src/poetry_plugin_export/command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import re

from pathlib import Path
from typing import TYPE_CHECKING

Expand All @@ -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,
),
Expand Down Expand Up @@ -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(
"<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"'
"</error>"
)
return 1

locker = self.poetry.locker
if not locker.is_locked():
self.line_error("<comment>The lock file does not exist. Locking.</comment>")
Expand Down
216 changes: 208 additions & 8 deletions src/poetry_plugin_export/exporter.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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


Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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"
6 changes: 2 additions & 4 deletions src/poetry_plugin_export/walker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading