1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171
|
#!/usr/bin/env python3
# /// script
# dependencies = [
# "tomli; python_version < '3.11'",
# "packaging",
# ]
# ///
from __future__ import annotations
import argparse
import sys
import tomllib
from collections import deque
from contextlib import ExitStack
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING
from packaging.requirements import Requirement
from packaging.version import Version
if TYPE_CHECKING:
from collections.abc import Generator, Iterable, Sequence
from collections.abc import Set as AbstractSet
from typing import Any, Self
def min_dep(req: Requirement) -> Requirement:
"""
Given a requirement, return the minimum version specifier.
Example
-------
>>> min_dep(Requirement("numpy>=1.0"))
<Requirement('numpy==1.0.*')>
>>> min_dep(Requirement("numpy<3.0"))
<Requirement('numpy<3.0')>
"""
req_name = req.name
if req.extras:
req_name = f"{req_name}[{','.join(req.extras)}]"
filter_specs = [
spec for spec in req.specifier if spec.operator in {"==", "~=", ">=", ">"}
]
if not filter_specs:
# TODO: handle markers
return Requirement(f"{req_name}{req.specifier}")
min_version = Version("0.0.0.a1")
for spec in filter_specs:
if spec.operator in {">", ">=", "~="}:
min_version = max(min_version, Version(spec.version))
elif spec.operator == "==":
min_version = Version(spec.version)
return Requirement(f"{req_name}=={min_version}.*")
def extract_min_deps(
dependencies: Iterable[Requirement], *, pyproject
) -> Generator[Requirement, None, None]:
dependencies = deque(dependencies) # We'll be mutating this
project_name = pyproject["project"]["name"]
while len(dependencies) > 0:
req = dependencies.pop()
# If we are referring to other optional dependency lists, resolve them
if req.name == project_name:
assert req.extras, (
f"Project included itself as dependency, without specifying extras: {req}"
)
for extra in req.extras:
extra_deps = pyproject["project"]["optional-dependencies"][extra]
dependencies += map(Requirement, extra_deps)
else:
yield min_dep(req)
class Args(argparse.Namespace):
"""\
Parse a pyproject.toml file and output a list of minimum dependencies.
Output is optimized for `[uv] pip install` (see `-o`/`--output` for details).
"""
_path: Path
output: Path | None
_extras: list[str]
_all_extras: bool
@classmethod
def parse(cls, argv: Sequence[str] | None = None) -> Self:
return cls.parser().parse_args(argv, cls())
@classmethod
def parser(cls) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="min-deps",
description=cls.__doc__,
usage="pip install `python min-deps.py pyproject.toml`",
)
parser.add_argument(
"_path",
metavar="pyproject.toml",
type=Path,
help="Path to pyproject.toml to parse minimum dependencies from",
)
parser.add_argument(
"--extras",
dest="_extras",
metavar="EXTRA",
type=str,
nargs="*",
default=(),
help="extras to install",
)
parser.add_argument(
"--all-extras",
dest="_all_extras",
action="store_true",
help="get all extras",
)
parser.add_argument(
*("--output", "-o"),
metavar="FILE",
type=Path,
default=None,
help=(
"output file (default: stdout). "
"Without this option, output is space-separated for direct passing to `pip install`. "
"With this option, output written to a file newline-separated file usable as `requirements.txt` or `constraints.txt`."
),
)
return parser
@cached_property
def pyproject(self) -> dict[str, Any]:
return tomllib.loads(self._path.read_text())
@cached_property
def extras(self) -> AbstractSet[str]:
if self._extras:
if self._all_extras:
sys.exit("Cannot specify both --extras and --all-extras")
return dict.fromkeys(self._extras).keys()
if not self._all_extras:
return set()
return self.pyproject["project"]["optional-dependencies"].keys()
def main(argv: Sequence[str] | None = None) -> None:
args = Args.parse(argv)
project_name = args.pyproject["project"]["name"]
deps = [
*map(Requirement, args.pyproject["project"]["dependencies"]),
*(Requirement(f"{project_name}[{extra}]") for extra in args.extras),
]
min_deps = extract_min_deps(deps, pyproject=args.pyproject)
sep = "\n" if args.output else " "
with ExitStack() as stack:
f = stack.enter_context(args.output.open("w")) if args.output else sys.stdout
print(sep.join(map(str, min_deps)), file=f)
if __name__ == "__main__":
main()
|