Skip to content

Commit e80554e

Browse files
committed
feat(deps): add template dependency updater
1 parent b7a7c7e commit e80554e

4 files changed

Lines changed: 349 additions & 24 deletions

File tree

CONTEXT.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ _Avoid_:顺带重设计、迁移即改版、自由清理
2020
生成项目文档明确承诺的文件、命令、环境变量、import 和扩展 seam;未文档化内部模块、符号及源码文本不属于兼容面。
2121
_Avoid_:全部生成实现、偶然可导入符号、源码快照
2222

23+
**模板依赖基线(Template Dependency Baseline)**
24+
模板为全部可生成能力声明的直接运行依赖、开发依赖与构建依赖版本集合;它覆盖所有条件能力,不等同于任一生成项目实际启用的依赖集合。
25+
_Avoid_:生成项目依赖、当前环境依赖、lockfile 版本
26+
2327
**迁移兼容垫片(Migration Compatibility Shim)**
2428
仅为复现迁移前实现或绕过迁移期依赖差异而存在的版本限制、workaround 或兼容分支;不包括公开产品契约或模型接口的协议兼容性。
2529
_Avoid_:所有 compatibility、公开兼容承诺、Chat Completions-compatible
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
# /// script
2+
# requires-python = ">=3.14"
3+
# dependencies = [
4+
# "packaging>=25.0",
5+
# ]
6+
# ///
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
import re
13+
import sys
14+
import tempfile
15+
from collections.abc import Iterable
16+
from dataclasses import dataclass
17+
from http.client import HTTPException
18+
from pathlib import Path
19+
from typing import Any
20+
from urllib.error import HTTPError, URLError
21+
from urllib.parse import quote
22+
from urllib.request import Request, urlopen
23+
24+
from packaging.requirements import InvalidRequirement, Requirement
25+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
26+
from packaging.utils import canonicalize_name
27+
from packaging.version import InvalidVersion, Version
28+
29+
ROOT = Path(__file__).parents[1]
30+
TEMPLATE_PYPROJECT = ROOT / "template" / "pyproject.toml.jinja"
31+
MINIMUM_PYTHON = Version("3.12")
32+
PYPI_SIMPLE_MEDIA_TYPE = "application/vnd.pypi.simple.v1+json"
33+
REQUEST_TIMEOUT_SECONDS = 30
34+
35+
SECTION_PATTERN = re.compile(r"^\[([^]]+)]$")
36+
ARRAY_START_PATTERN = re.compile(r"^[A-Za-z0-9_-]+\s*=\s*\[$")
37+
ARRAY_REQUIREMENT_PATTERN = re.compile(r'^\s*"([^"]+)",\s*$')
38+
INLINE_REQUIRES_PATTERN = re.compile(r"^requires\s*=\s*\[(.*)]$")
39+
QUOTED_VALUE_PATTERN = re.compile(r'"([^"]+)"')
40+
41+
42+
class UpdateError(Exception):
43+
"""A dependency update error safe to show to the user."""
44+
45+
46+
@dataclass(frozen=True)
47+
class DeclaredDependency:
48+
raw: str
49+
name: str
50+
current_version: Version | None
51+
current_specifier: str | None
52+
53+
54+
def parse_declared_dependency(raw: str) -> DeclaredDependency:
55+
try:
56+
requirement = Requirement(raw)
57+
except InvalidRequirement as exc:
58+
raise UpdateError(f"Invalid dependency requirement: {raw}") from exc
59+
60+
if requirement.url is not None:
61+
raise UpdateError(f"Dependency must use a >= lower bound, not a URL: {raw}")
62+
63+
specifiers = list(requirement.specifier)
64+
if not specifiers:
65+
return DeclaredDependency(
66+
raw=raw,
67+
name=requirement.name,
68+
current_version=None,
69+
current_specifier=None,
70+
)
71+
if len(specifiers) != 1 or specifiers[0].operator != ">=":
72+
raise UpdateError(f"Dependency must use exactly one >= lower bound: {raw}")
73+
74+
try:
75+
current_version = Version(specifiers[0].version)
76+
except InvalidVersion as exc:
77+
raise UpdateError(f"Invalid dependency version in requirement: {raw}") from exc
78+
79+
return DeclaredDependency(
80+
raw=raw,
81+
name=requirement.name,
82+
current_version=current_version,
83+
current_specifier=f">={specifiers[0].version}",
84+
)
85+
86+
87+
def discover_dependencies(template: str) -> list[DeclaredDependency]:
88+
dependencies: list[DeclaredDependency] = []
89+
section = ""
90+
active_array = ""
91+
found_project_dependencies = False
92+
found_build_requires = False
93+
found_dependency_group = False
94+
95+
for line_number, line in enumerate(template.splitlines(), start=1):
96+
stripped = line.strip()
97+
if not stripped or stripped.startswith("[%") or stripped.startswith("#"):
98+
continue
99+
100+
section_match = SECTION_PATTERN.fullmatch(stripped)
101+
if section_match and not active_array:
102+
section = section_match.group(1)
103+
continue
104+
105+
if active_array:
106+
if stripped == "]":
107+
active_array = ""
108+
continue
109+
requirement_match = ARRAY_REQUIREMENT_PATTERN.fullmatch(line)
110+
if not requirement_match:
111+
raise UpdateError(f"Unexpected dependency entry on line {line_number}: {stripped}")
112+
dependencies.append(parse_declared_dependency(requirement_match.group(1)))
113+
continue
114+
115+
if section == "project" and stripped == "dependencies = [":
116+
found_project_dependencies = True
117+
active_array = "project.dependencies"
118+
continue
119+
120+
if section == "dependency-groups" and ARRAY_START_PATTERN.fullmatch(stripped):
121+
found_dependency_group = True
122+
active_array = "dependency-group"
123+
continue
124+
125+
if section == "build-system":
126+
requires_match = INLINE_REQUIRES_PATTERN.fullmatch(stripped)
127+
if requires_match:
128+
raw_requirements = QUOTED_VALUE_PATTERN.findall(requires_match.group(1))
129+
if not raw_requirements:
130+
raise UpdateError("[build-system].requires must not be empty")
131+
dependencies.extend(map(parse_declared_dependency, raw_requirements))
132+
found_build_requires = True
133+
134+
if active_array:
135+
raise UpdateError(f"Unclosed dependency array: {active_array}")
136+
if not found_project_dependencies:
137+
raise UpdateError("Missing [project].dependencies")
138+
if not found_build_requires:
139+
raise UpdateError("Missing [build-system].requires")
140+
if not found_dependency_group:
141+
raise UpdateError("Missing [dependency-groups]")
142+
if not dependencies:
143+
raise UpdateError("No dependencies found")
144+
145+
return dependencies
146+
147+
148+
def fetch_json(url: str, accept: str) -> dict[str, Any]:
149+
request = Request(
150+
url,
151+
headers={
152+
"Accept": accept,
153+
"User-Agent": "copier-fastapi-forge-template-dependency-updater",
154+
},
155+
)
156+
try:
157+
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
158+
payload = json.load(response)
159+
except (HTTPError, URLError, HTTPException, TimeoutError, json.JSONDecodeError) as exc:
160+
raise UpdateError(f"PyPI request failed for {url}: {exc}") from exc
161+
162+
if not isinstance(payload, dict):
163+
raise UpdateError(f"PyPI returned an invalid response for {url}")
164+
return payload
165+
166+
167+
def release_files(name: str, raw_version: str) -> list[dict[str, Any]]:
168+
project = quote(canonicalize_name(name), safe="")
169+
version = quote(raw_version, safe="")
170+
payload = fetch_json(
171+
f"https://pypi.org/pypi/{project}/{version}/json",
172+
"application/json",
173+
)
174+
files = payload.get("urls")
175+
if not isinstance(files, list) or not all(isinstance(file, dict) for file in files):
176+
raise UpdateError(f"PyPI returned invalid release files for {name} {raw_version}")
177+
return files
178+
179+
180+
def supports_minimum_python(files: Iterable[dict[str, Any]]) -> bool:
181+
for file in files:
182+
requires_python = file.get("requires_python")
183+
if requires_python is None:
184+
return True
185+
if not isinstance(requires_python, str):
186+
raise UpdateError("PyPI returned an invalid Requires-Python value")
187+
try:
188+
if SpecifierSet(requires_python).contains(MINIMUM_PYTHON, prereleases=True):
189+
return True
190+
except InvalidSpecifier as exc:
191+
raise UpdateError(f"PyPI returned invalid Requires-Python: {requires_python}") from exc
192+
return False
193+
194+
195+
def latest_version(name: str) -> Version:
196+
project = quote(canonicalize_name(name), safe="")
197+
payload = fetch_json(
198+
f"https://pypi.org/simple/{project}/",
199+
PYPI_SIMPLE_MEDIA_TYPE,
200+
)
201+
raw_versions = payload.get("versions")
202+
if not isinstance(raw_versions, list) or not raw_versions:
203+
raise UpdateError(f"PyPI returned no versions for {name}")
204+
205+
versions: list[tuple[Version, str]] = []
206+
for raw_version in raw_versions:
207+
if not isinstance(raw_version, str):
208+
raise UpdateError(f"PyPI returned an invalid version for {name}")
209+
try:
210+
versions.append((Version(raw_version), raw_version))
211+
except InvalidVersion as exc:
212+
raise UpdateError(f"PyPI returned invalid version for {name}: {raw_version}") from exc
213+
214+
stable = sorted(
215+
(item for item in versions if not item[0].is_prerelease),
216+
reverse=True,
217+
)
218+
prerelease = sorted(
219+
(item for item in versions if item[0].is_prerelease),
220+
reverse=True,
221+
)
222+
223+
for candidates in (stable, prerelease):
224+
for candidate, raw_candidate in candidates:
225+
files = release_files(name, raw_candidate)
226+
available_files = [file for file in files if file.get("yanked") is False]
227+
if not available_files:
228+
continue
229+
if not supports_minimum_python(available_files):
230+
raise UpdateError(
231+
f"Latest release of {name} ({candidate}) does not support "
232+
f"Python {MINIMUM_PYTHON}"
233+
)
234+
return candidate
235+
236+
raise UpdateError(f"PyPI returned no non-yanked releases for {name}")
237+
238+
239+
def replace_versions(
240+
template: str,
241+
dependencies: Iterable[DeclaredDependency],
242+
latest_versions: dict[str, Version],
243+
) -> str:
244+
updated = template
245+
for dependency in dependencies:
246+
latest = latest_versions[canonicalize_name(dependency.name)]
247+
if dependency.current_version is not None and latest < dependency.current_version:
248+
raise UpdateError(
249+
f"Refusing to downgrade {dependency.name} from "
250+
f"{dependency.current_version} to {latest}"
251+
)
252+
if latest == dependency.current_version:
253+
continue
254+
255+
if dependency.current_specifier is None:
256+
requirement, separator, marker = dependency.raw.partition(";")
257+
updated_requirement = f"{requirement}>={latest}"
258+
if separator:
259+
updated_requirement = f"{updated_requirement};{marker}"
260+
else:
261+
updated_requirement = dependency.raw.replace(
262+
dependency.current_specifier,
263+
f">={latest}",
264+
1,
265+
)
266+
quoted_current = f'"{dependency.raw}"'
267+
if quoted_current not in updated:
268+
raise UpdateError(f"Could not locate dependency requirement: {dependency.raw}")
269+
updated = updated.replace(quoted_current, f'"{updated_requirement}"')
270+
271+
return updated
272+
273+
274+
def write_atomically(path: Path, content: str) -> None:
275+
temporary_path: Path | None = None
276+
try:
277+
with tempfile.NamedTemporaryFile(
278+
mode="w",
279+
encoding="utf-8",
280+
newline="",
281+
dir=path.parent,
282+
prefix=f".{path.name}.",
283+
suffix=".tmp",
284+
delete=False,
285+
) as temporary_file:
286+
temporary_file.write(content)
287+
temporary_path = Path(temporary_file.name)
288+
os.replace(temporary_path, path)
289+
finally:
290+
if temporary_path is not None:
291+
temporary_path.unlink(missing_ok=True)
292+
293+
294+
def main() -> int:
295+
try:
296+
template = TEMPLATE_PYPROJECT.read_text(encoding="utf-8")
297+
dependencies = discover_dependencies(template)
298+
299+
latest_versions: dict[str, Version] = {}
300+
for dependency in dependencies:
301+
canonical_name = canonicalize_name(dependency.name)
302+
if canonical_name not in latest_versions:
303+
latest_versions[canonical_name] = latest_version(dependency.name)
304+
current = dependency.current_version or "unversioned"
305+
print(f"{dependency.name}: {current} -> {latest_versions[canonical_name]}")
306+
307+
updated = replace_versions(template, dependencies, latest_versions)
308+
if updated == template:
309+
print("Template dependencies are already up to date")
310+
return 0
311+
312+
write_atomically(TEMPLATE_PYPROJECT, updated)
313+
print(f"Updated {TEMPLATE_PYPROJECT.relative_to(ROOT)}")
314+
return 0
315+
except (OSError, UpdateError) as exc:
316+
print(f"error: {exc}", file=sys.stderr)
317+
return 1
318+
319+
320+
if __name__ == "__main__":
321+
raise SystemExit(main())

0 commit comments

Comments
 (0)