dependency-support-policy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dependency_support_policy/__init__.py +12 -0
- dependency_support_policy/__main__.py +8 -0
- dependency_support_policy/cli.py +220 -0
- dependency_support_policy/config.py +195 -0
- dependency_support_policy/dates.py +31 -0
- dependency_support_policy/errors.py +23 -0
- dependency_support_policy/lockfile.py +45 -0
- dependency_support_policy/planner.py +248 -0
- dependency_support_policy/policies.py +86 -0
- dependency_support_policy/pyproject_edit.py +140 -0
- dependency_support_policy/python_releases.py +55 -0
- dependency_support_policy/registry.py +146 -0
- dependency_support_policy/requirements.py +204 -0
- dependency_support_policy-0.1.0.dist-info/METADATA +253 -0
- dependency_support_policy-0.1.0.dist-info/RECORD +18 -0
- dependency_support_policy-0.1.0.dist-info/WHEEL +4 -0
- dependency_support_policy-0.1.0.dist-info/entry_points.txt +2 -0
- dependency_support_policy-0.1.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Manage rolling minimum-supported versions for Python projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("dependency-support-policy")
|
|
9
|
+
except PackageNotFoundError: # pragma: no cover - only when running from a raw checkout
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Command-line interface: check, plan, and update modes.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 = compliant / success, 1 = drift found (check mode only),
|
|
4
|
+
2 = configuration, registry, or lockfile error.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, TextIO
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .config import LockMode, load_config
|
|
21
|
+
from .dates import parse_iso_date
|
|
22
|
+
from .errors import ConfigError, DependencyPolicyError
|
|
23
|
+
from .planner import ApplyResult, ChangePlan, apply_plan, build_plan
|
|
24
|
+
from .policies import available_policies
|
|
25
|
+
from .pyproject_edit import load_document
|
|
26
|
+
from .registry import PyPIReleaseFetcher, ReleaseFetcher
|
|
27
|
+
|
|
28
|
+
EXIT_OK = 0
|
|
29
|
+
EXIT_DRIFT = 1
|
|
30
|
+
EXIT_ERROR = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="dependency-support-policy",
|
|
36
|
+
description="Manage rolling minimum-supported versions for Python projects.",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
39
|
+
subparsers = parser.add_subparsers(dest="mode", required=True, metavar="{check,plan,update}")
|
|
40
|
+
modes = {
|
|
41
|
+
"check": "Report drift; exit 1 if any floor is below the policy.",
|
|
42
|
+
"plan": "Print the machine-readable change plan as JSON; never writes.",
|
|
43
|
+
"update": "Apply floor updates to pyproject.toml (and uv.lock if configured).",
|
|
44
|
+
}
|
|
45
|
+
for mode, help_text in modes.items():
|
|
46
|
+
sub = subparsers.add_parser(mode, help=help_text, description=help_text)
|
|
47
|
+
_add_common_arguments(sub)
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
|
52
|
+
parser.add_argument("--pyproject", type=Path, default=Path("pyproject.toml"), help="Path to pyproject.toml.")
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--reference-date",
|
|
55
|
+
default=None,
|
|
56
|
+
help="Evaluate support windows as of this date (YYYY-MM-DD); defaults to today (UTC).",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument("--policy", default=None, choices=available_policies(), help="Support policy to apply.")
|
|
59
|
+
parser.add_argument("--python-support-months", type=int, default=None, help="Override the Python support window.")
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--package-support-months", type=int, default=None, help="Override the default package support window."
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--package-override",
|
|
65
|
+
action="append",
|
|
66
|
+
default=None,
|
|
67
|
+
metavar="NAME=MONTHS",
|
|
68
|
+
help="Per-package support window (repeatable).",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--include",
|
|
72
|
+
action="append",
|
|
73
|
+
default=None,
|
|
74
|
+
metavar="NAMES",
|
|
75
|
+
help="Only manage these packages (comma separated, repeatable).",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--exclude",
|
|
79
|
+
action="append",
|
|
80
|
+
default=None,
|
|
81
|
+
metavar="NAMES",
|
|
82
|
+
help="Never touch these packages (comma separated, repeatable).",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--groups",
|
|
86
|
+
default=None,
|
|
87
|
+
help="Comma-separated dependency collections to manage: project, optional[:name], group[:name], all.",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--manage-python",
|
|
91
|
+
default=None,
|
|
92
|
+
choices=("true", "false"),
|
|
93
|
+
help="Whether to manage the requires-python floor (default: true).",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--lock",
|
|
97
|
+
default=None,
|
|
98
|
+
choices=[mode.value for mode in LockMode],
|
|
99
|
+
help="uv.lock handling after updates (default: off).",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument("--output-json", type=Path, default=None, help="Also write the change plan JSON to this file.")
|
|
102
|
+
parser.add_argument("--quiet", action="store_true", help="Suppress the human-readable summary.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _split_names(values: Sequence[str] | None) -> list[str] | None:
|
|
106
|
+
if values is None:
|
|
107
|
+
return None
|
|
108
|
+
names = [name.strip() for value in values for name in value.split(",") if name.strip()]
|
|
109
|
+
return names
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_override_flags(values: Sequence[str] | None) -> dict[str, int] | None:
|
|
113
|
+
if values is None:
|
|
114
|
+
return None
|
|
115
|
+
overrides: dict[str, int] = {}
|
|
116
|
+
for value in values:
|
|
117
|
+
name, separator, months = value.partition("=")
|
|
118
|
+
if not separator or not name.strip():
|
|
119
|
+
raise ConfigError(f"invalid --package-override {value!r}: expected NAME=MONTHS")
|
|
120
|
+
try:
|
|
121
|
+
overrides[name.strip()] = int(months)
|
|
122
|
+
except ValueError:
|
|
123
|
+
raise ConfigError(f"invalid --package-override {value!r}: MONTHS must be an integer") from None
|
|
124
|
+
return overrides
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _cli_overrides(args: argparse.Namespace) -> dict[str, Any]:
|
|
128
|
+
manage_python = None if args.manage_python is None else args.manage_python == "true"
|
|
129
|
+
groups = None
|
|
130
|
+
if args.groups is not None:
|
|
131
|
+
groups = [token.strip() for token in args.groups.split(",") if token.strip()]
|
|
132
|
+
return {
|
|
133
|
+
"policy_name": args.policy,
|
|
134
|
+
"python_support_months": args.python_support_months,
|
|
135
|
+
"package_support_months": args.package_support_months,
|
|
136
|
+
"package_overrides": _parse_override_flags(args.package_override),
|
|
137
|
+
"include": _split_names(args.include),
|
|
138
|
+
"exclude": _split_names(args.exclude),
|
|
139
|
+
"groups": groups,
|
|
140
|
+
"manage_python": manage_python,
|
|
141
|
+
"lock": args.lock,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _summarize(plan: ChangePlan, mode: str, apply_result: ApplyResult | None, stream: TextIO) -> None:
|
|
146
|
+
print(f"policy: {plan.policy} (reference date {plan.reference_date.isoformat()})", file=stream)
|
|
147
|
+
if not plan.changed:
|
|
148
|
+
print("all managed floors comply with the policy", file=stream)
|
|
149
|
+
for change in plan.dependency_changes:
|
|
150
|
+
print(f" {change.group}: {change.old_requirement!r} -> {change.new_requirement!r}", file=stream)
|
|
151
|
+
if plan.python_change is not None:
|
|
152
|
+
print(
|
|
153
|
+
f" requires-python: {plan.python_change.old_requires_python!r} -> "
|
|
154
|
+
f"{plan.python_change.new_requires_python!r}",
|
|
155
|
+
file=stream,
|
|
156
|
+
)
|
|
157
|
+
for skipped in plan.skipped:
|
|
158
|
+
print(f" skipped {skipped.requirement!r}: {skipped.reason}", file=stream)
|
|
159
|
+
for note in plan.notes:
|
|
160
|
+
print(f" note: {note}", file=stream)
|
|
161
|
+
if mode == "update" and apply_result is not None:
|
|
162
|
+
if apply_result.changed_files:
|
|
163
|
+
print(f"updated files: {', '.join(apply_result.changed_files)}", file=stream)
|
|
164
|
+
else:
|
|
165
|
+
print("no files changed", file=stream)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _write_github_outputs(plan: ChangePlan, apply_result: ApplyResult | None) -> None:
|
|
169
|
+
output_path = os.environ.get("GITHUB_OUTPUT")
|
|
170
|
+
if not output_path:
|
|
171
|
+
return
|
|
172
|
+
floors_changed = [
|
|
173
|
+
{"name": change.name, "group": change.group, "old": change.old_floor, "new": change.new_floor}
|
|
174
|
+
for change in plan.dependency_changes
|
|
175
|
+
]
|
|
176
|
+
changed_files = apply_result.changed_files if apply_result is not None else []
|
|
177
|
+
values = {
|
|
178
|
+
"changed": str(plan.changed).lower(),
|
|
179
|
+
"python-floor-changed": str(plan.python_change is not None).lower(),
|
|
180
|
+
"dependency-floors-changed": json.dumps(floors_changed),
|
|
181
|
+
"files-changed": json.dumps(changed_files),
|
|
182
|
+
"plan": json.dumps(plan.to_dict()),
|
|
183
|
+
}
|
|
184
|
+
with open(output_path, "a", encoding="utf-8") as handle:
|
|
185
|
+
for key, value in values.items():
|
|
186
|
+
delimiter = f"dspa-{uuid.uuid4()}"
|
|
187
|
+
handle.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv: Sequence[str] | None = None, *, fetcher: ReleaseFetcher | None = None) -> int:
|
|
191
|
+
"""CLI entry point; ``fetcher`` is injectable for tests."""
|
|
192
|
+
args = build_parser().parse_args(argv)
|
|
193
|
+
try:
|
|
194
|
+
reference_date = (
|
|
195
|
+
parse_iso_date(args.reference_date) if args.reference_date is not None else dt.datetime.now(dt.UTC).date()
|
|
196
|
+
)
|
|
197
|
+
document = load_document(args.pyproject)
|
|
198
|
+
config = load_config(args.pyproject, document, reference_date, _cli_overrides(args))
|
|
199
|
+
plan = build_plan(document, config, fetcher if fetcher is not None else PyPIReleaseFetcher())
|
|
200
|
+
apply_result = apply_plan(document, plan, config) if args.mode == "update" else None
|
|
201
|
+
except DependencyPolicyError as exc:
|
|
202
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
203
|
+
return EXIT_ERROR
|
|
204
|
+
|
|
205
|
+
plan_json = json.dumps(plan.to_dict(), indent=2)
|
|
206
|
+
if args.output_json is not None:
|
|
207
|
+
args.output_json.write_text(plan_json + "\n", encoding="utf-8")
|
|
208
|
+
if args.mode == "plan":
|
|
209
|
+
print(plan_json)
|
|
210
|
+
elif not args.quiet:
|
|
211
|
+
_summarize(plan, args.mode, apply_result, sys.stdout)
|
|
212
|
+
_write_github_outputs(plan, apply_result)
|
|
213
|
+
|
|
214
|
+
if args.mode == "check" and plan.changed:
|
|
215
|
+
return EXIT_DRIFT
|
|
216
|
+
return EXIT_OK
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__": # pragma: no cover
|
|
220
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Run configuration: defaults, the pyproject tool table, and CLI overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from packaging.utils import NormalizedName, canonicalize_name
|
|
14
|
+
|
|
15
|
+
from .dates import parse_iso_date
|
|
16
|
+
from .errors import ConfigError
|
|
17
|
+
|
|
18
|
+
TOOL_TABLE = "dependency-support-policy"
|
|
19
|
+
|
|
20
|
+
_PYTHON_SERIES_RE = re.compile(r"^(\d+)\.(\d+)$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LockMode(Enum):
|
|
24
|
+
"""How to treat uv.lock after pyproject.toml changes."""
|
|
25
|
+
|
|
26
|
+
OFF = "off"
|
|
27
|
+
MINIMAL = "minimal"
|
|
28
|
+
UPGRADE = "upgrade"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class RunConfig:
|
|
33
|
+
"""Fully resolved configuration for one evaluation run."""
|
|
34
|
+
|
|
35
|
+
pyproject: Path
|
|
36
|
+
reference_date: dt.date
|
|
37
|
+
policy_name: str = "spec0"
|
|
38
|
+
python_support_months: int | None = None
|
|
39
|
+
package_support_months: int | None = None
|
|
40
|
+
package_overrides: Mapping[NormalizedName, int] = field(default_factory=dict)
|
|
41
|
+
include: frozenset[NormalizedName] | None = None
|
|
42
|
+
exclude: frozenset[NormalizedName] = frozenset()
|
|
43
|
+
groups: tuple[str, ...] = ("project",)
|
|
44
|
+
manage_python: bool = True
|
|
45
|
+
lock: LockMode = LockMode.OFF
|
|
46
|
+
python_releases: Mapping[tuple[int, int], dt.date] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _require(value: Any, expected: type, key: str) -> Any:
|
|
50
|
+
if expected is int and isinstance(value, bool):
|
|
51
|
+
raise ConfigError(f"invalid value for {key!r}: expected {expected.__name__}, got bool")
|
|
52
|
+
if not isinstance(value, expected):
|
|
53
|
+
raise ConfigError(f"invalid value for {key!r}: expected {expected.__name__}, got {type(value).__name__}")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _positive_months(value: Any, key: str) -> int:
|
|
58
|
+
months = int(_require(value, int, key))
|
|
59
|
+
if months <= 0:
|
|
60
|
+
raise ConfigError(f"invalid value for {key!r}: months must be positive")
|
|
61
|
+
return months
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _name_list(value: Any, key: str) -> frozenset[NormalizedName]:
|
|
65
|
+
if not isinstance(value, (list, tuple)):
|
|
66
|
+
raise ConfigError(f"invalid value for {key!r}: expected an array of package names")
|
|
67
|
+
return frozenset(canonicalize_name(_require(item, str, key)) for item in value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_python_releases(value: Any, key: str) -> dict[tuple[int, int], dt.date]:
|
|
71
|
+
if not isinstance(value, Mapping):
|
|
72
|
+
raise ConfigError(f"invalid value for {key!r}: expected a table of '<major>.<minor>' = date")
|
|
73
|
+
releases: dict[tuple[int, int], dt.date] = {}
|
|
74
|
+
for series_text, raw_date in value.items():
|
|
75
|
+
match = _PYTHON_SERIES_RE.match(str(series_text))
|
|
76
|
+
if match is None:
|
|
77
|
+
raise ConfigError(f"invalid Python series {series_text!r} in {key!r}: expected '<major>.<minor>'")
|
|
78
|
+
if isinstance(raw_date, dt.date) and not isinstance(raw_date, dt.datetime):
|
|
79
|
+
released = raw_date
|
|
80
|
+
elif isinstance(raw_date, str):
|
|
81
|
+
released = parse_iso_date(raw_date)
|
|
82
|
+
else:
|
|
83
|
+
raise ConfigError(f"invalid release date for {series_text!r} in {key!r}: expected a date")
|
|
84
|
+
releases[(int(match.group(1)), int(match.group(2)))] = released
|
|
85
|
+
return releases
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_lock_mode(value: Any, key: str) -> LockMode:
|
|
89
|
+
text = _require(value, str, key)
|
|
90
|
+
try:
|
|
91
|
+
return LockMode(text)
|
|
92
|
+
except ValueError:
|
|
93
|
+
options = ", ".join(mode.value for mode in LockMode)
|
|
94
|
+
raise ConfigError(f"invalid value for {key!r}: expected one of {options}") from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_package_overrides(pairs: Mapping[str, Any]) -> dict[NormalizedName, int]:
|
|
98
|
+
"""Parse a ``name -> months`` mapping, canonicalizing names."""
|
|
99
|
+
overrides: dict[NormalizedName, int] = {}
|
|
100
|
+
for name, months in pairs.items():
|
|
101
|
+
overrides[canonicalize_name(name)] = _positive_months(months, f"package-support.{name}")
|
|
102
|
+
return overrides
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_config(
|
|
106
|
+
pyproject: Path,
|
|
107
|
+
document: Mapping[str, Any],
|
|
108
|
+
reference_date: dt.date,
|
|
109
|
+
cli_overrides: Mapping[str, Any] | None = None,
|
|
110
|
+
) -> RunConfig:
|
|
111
|
+
"""Merge tool-table settings with CLI overrides (CLI wins) into a RunConfig.
|
|
112
|
+
|
|
113
|
+
``cli_overrides`` keys mirror RunConfig field names; only keys with
|
|
114
|
+
non-None values participate.
|
|
115
|
+
"""
|
|
116
|
+
overrides = {key: value for key, value in (cli_overrides or {}).items() if value is not None}
|
|
117
|
+
tool = document.get("tool", {})
|
|
118
|
+
table = tool.get(TOOL_TABLE, {}) if isinstance(tool, Mapping) else {}
|
|
119
|
+
if not isinstance(table, Mapping):
|
|
120
|
+
raise ConfigError(f"[tool.{TOOL_TABLE}] must be a table")
|
|
121
|
+
known_keys = {
|
|
122
|
+
"policy",
|
|
123
|
+
"python-support-months",
|
|
124
|
+
"package-support-months",
|
|
125
|
+
"package-support",
|
|
126
|
+
"include",
|
|
127
|
+
"exclude",
|
|
128
|
+
"groups",
|
|
129
|
+
"manage-python",
|
|
130
|
+
"lock",
|
|
131
|
+
"python-releases",
|
|
132
|
+
}
|
|
133
|
+
unknown = sorted(set(table) - known_keys)
|
|
134
|
+
if unknown:
|
|
135
|
+
raise ConfigError(f"unknown key(s) in [tool.{TOOL_TABLE}]: {', '.join(unknown)}")
|
|
136
|
+
|
|
137
|
+
def setting(cli_key: str, table_key: str) -> Any:
|
|
138
|
+
if cli_key in overrides:
|
|
139
|
+
return overrides[cli_key]
|
|
140
|
+
return table.get(table_key)
|
|
141
|
+
|
|
142
|
+
policy_name = setting("policy_name", "policy")
|
|
143
|
+
python_months = setting("python_support_months", "python-support-months")
|
|
144
|
+
package_months = setting("package_support_months", "package-support-months")
|
|
145
|
+
package_support = setting("package_overrides", "package-support")
|
|
146
|
+
include = setting("include", "include")
|
|
147
|
+
exclude = setting("exclude", "exclude")
|
|
148
|
+
groups = setting("groups", "groups")
|
|
149
|
+
manage_python = setting("manage_python", "manage-python")
|
|
150
|
+
lock = setting("lock", "lock")
|
|
151
|
+
python_releases = table.get("python-releases")
|
|
152
|
+
|
|
153
|
+
if groups is not None:
|
|
154
|
+
groups_tuple = tuple(_require(item, str, "groups") for item in _require_list(groups, "groups"))
|
|
155
|
+
if not groups_tuple:
|
|
156
|
+
raise ConfigError("'groups' must not be empty")
|
|
157
|
+
else:
|
|
158
|
+
groups_tuple = ("project",)
|
|
159
|
+
|
|
160
|
+
return RunConfig(
|
|
161
|
+
pyproject=pyproject,
|
|
162
|
+
reference_date=reference_date,
|
|
163
|
+
policy_name=_require(policy_name, str, "policy") if policy_name is not None else "spec0",
|
|
164
|
+
python_support_months=(
|
|
165
|
+
_positive_months(python_months, "python-support-months") if python_months is not None else None
|
|
166
|
+
),
|
|
167
|
+
package_support_months=(
|
|
168
|
+
_positive_months(package_months, "package-support-months") if package_months is not None else None
|
|
169
|
+
),
|
|
170
|
+
package_overrides=(
|
|
171
|
+
parse_package_overrides(_require_mapping(package_support, "package-support"))
|
|
172
|
+
if package_support is not None
|
|
173
|
+
else {}
|
|
174
|
+
),
|
|
175
|
+
include=_name_list(include, "include") if include is not None else None,
|
|
176
|
+
exclude=_name_list(exclude, "exclude") if exclude is not None else frozenset(),
|
|
177
|
+
groups=groups_tuple,
|
|
178
|
+
manage_python=bool(_require(manage_python, bool, "manage-python")) if manage_python is not None else True,
|
|
179
|
+
lock=_parse_lock_mode(lock, "lock") if lock is not None else LockMode.OFF,
|
|
180
|
+
python_releases=_parse_python_releases(python_releases, "python-releases")
|
|
181
|
+
if python_releases is not None
|
|
182
|
+
else {},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _require_list(value: Any, key: str) -> list[Any]:
|
|
187
|
+
if not isinstance(value, (list, tuple)):
|
|
188
|
+
raise ConfigError(f"invalid value for {key!r}: expected an array")
|
|
189
|
+
return list(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _require_mapping(value: Any, key: str) -> Mapping[str, Any]:
|
|
193
|
+
if not isinstance(value, Mapping):
|
|
194
|
+
raise ConfigError(f"invalid value for {key!r}: expected a table")
|
|
195
|
+
return value
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Deterministic calendar arithmetic used by support-window calculations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
from .errors import ConfigError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def months_before(reference: date, months: int) -> date:
|
|
12
|
+
"""Return the date ``months`` calendar months before ``reference``.
|
|
13
|
+
|
|
14
|
+
The day of month is preserved, clamped to the length of the target month
|
|
15
|
+
(e.g. 2024-03-31 minus one month is 2024-02-29).
|
|
16
|
+
"""
|
|
17
|
+
if months < 0:
|
|
18
|
+
raise ValueError("months must be non-negative")
|
|
19
|
+
total = reference.year * 12 + reference.month - 1 - months
|
|
20
|
+
year, month_index = divmod(total, 12)
|
|
21
|
+
month = month_index + 1
|
|
22
|
+
day = min(reference.day, calendar.monthrange(year, month)[1])
|
|
23
|
+
return date(year, month, day)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_iso_date(text: str) -> date:
|
|
27
|
+
"""Parse a ``YYYY-MM-DD`` date, raising :class:`ConfigError` on bad input."""
|
|
28
|
+
try:
|
|
29
|
+
return date.fromisoformat(text)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise ConfigError(f"invalid date {text!r}: expected YYYY-MM-DD") from exc
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Exception hierarchy for dependency-support-policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DependencyPolicyError(Exception):
|
|
7
|
+
"""Base class for all errors raised by this package."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(DependencyPolicyError):
|
|
11
|
+
"""Invalid configuration (CLI flags, action inputs, or the tool table)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RegistryError(DependencyPolicyError):
|
|
15
|
+
"""Failure to retrieve or parse package release metadata."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PolicyError(DependencyPolicyError):
|
|
19
|
+
"""A support policy could not be evaluated (e.g. no stable releases)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LockfileError(DependencyPolicyError):
|
|
23
|
+
"""uv lockfile regeneration failed."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""uv lockfile regeneration with rollback on failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import LockMode
|
|
9
|
+
from .errors import LockfileError
|
|
10
|
+
|
|
11
|
+
_UV_TIMEOUT_SECONDS = 600.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def regenerate_lockfile(project_dir: Path, mode: LockMode, *, uv_executable: str = "uv") -> bool:
|
|
15
|
+
"""Run ``uv lock`` in ``project_dir``; return True if uv.lock content changed.
|
|
16
|
+
|
|
17
|
+
``LockMode.MINIMAL`` performs the default minimal re-lock; ``LockMode.UPGRADE``
|
|
18
|
+
passes ``--upgrade``. Raises :class:`LockfileError` on failure — the caller
|
|
19
|
+
is responsible for rolling back file snapshots.
|
|
20
|
+
"""
|
|
21
|
+
if mode is LockMode.OFF:
|
|
22
|
+
return False
|
|
23
|
+
lock_path = project_dir / "uv.lock"
|
|
24
|
+
before = lock_path.read_bytes() if lock_path.exists() else None
|
|
25
|
+
command = [uv_executable, "lock"]
|
|
26
|
+
if mode is LockMode.UPGRADE:
|
|
27
|
+
command.append("--upgrade")
|
|
28
|
+
try:
|
|
29
|
+
completed = subprocess.run( # noqa: S603 - fixed argument list, no shell
|
|
30
|
+
command,
|
|
31
|
+
cwd=project_dir,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
timeout=_UV_TIMEOUT_SECONDS,
|
|
35
|
+
check=False,
|
|
36
|
+
)
|
|
37
|
+
except FileNotFoundError as exc:
|
|
38
|
+
raise LockfileError(f"uv executable not found: {uv_executable!r}") from exc
|
|
39
|
+
except subprocess.TimeoutExpired as exc:
|
|
40
|
+
raise LockfileError(f"'{' '.join(command)}' timed out after {_UV_TIMEOUT_SECONDS:.0f}s") from exc
|
|
41
|
+
if completed.returncode != 0:
|
|
42
|
+
detail = (completed.stderr or completed.stdout or "").strip()
|
|
43
|
+
raise LockfileError(f"'{' '.join(command)}' failed with exit code {completed.returncode}:\n{detail}")
|
|
44
|
+
after = lock_path.read_bytes() if lock_path.exists() else None
|
|
45
|
+
return after != before
|