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.
@@ -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,8 @@
1
+ """Allow ``python -m dependency_support_policy``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -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