Files
planet/scripts/compute_aiprovider_dependency_fingerprint.py
2026-04-22 23:42:10 +08:00

187 lines
6.1 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import ast
import json
import re
import sys
from pathlib import Path
from typing import Any
IMPORT_TO_DISTRIBUTION = {
"pydantic_settings": "pydantic-settings",
}
# Some runtime dependencies are referenced indirectly:
# - uvicorn is launched by the container command rather than imported.
# - python-dotenv is used by pydantic-settings when loading the local .env file.
EXTRA_RUNTIME_DISTRIBUTIONS = {
"python-dotenv",
"uvicorn",
}
def normalize_name(value: str) -> str:
return value.strip().lower().replace("_", "-").replace(".", "-")
def extract_table(text: str, table_name: str) -> str:
pattern = re.compile(
rf"(?ms)^\[{re.escape(table_name)}\]\s*$\n(.*?)(?=^\[[^\]]+\]\s*$|\Z)"
)
match = pattern.search(text)
return match.group(1) if match else ""
def extract_string_value(text: str, key: str) -> str | None:
match = re.search(rf'(?m)^{re.escape(key)}\s*=\s*"([^"]+)"\s*$', text)
return match.group(1) if match else None
def extract_string_list_value(text: str, key: str) -> list[str]:
match = re.search(rf"(?ms)^{re.escape(key)}\s*=\s*\[(.*?)\]\s*$", text)
if not match:
return []
return re.findall(r'"([^"]+)"', match.group(1))
def project_dependency_map(pyproject_text: str) -> tuple[str | None, dict[str, str]]:
project_block = extract_table(pyproject_text, "project")
requires_python = extract_string_value(project_block, "requires-python")
dependencies = extract_string_list_value(project_block, "dependencies")
mapping: dict[str, str] = {}
for spec in dependencies:
name = spec.split(";", 1)[0].strip()
for marker in ("<", ">", "=", "!", "~"):
if marker in name:
name = name.split(marker, 1)[0].strip()
if "[" in name:
name = name.split("[", 1)[0].strip()
mapping[normalize_name(name)] = spec.strip()
return requires_python, mapping
def collect_runtime_imports(aiprovider_dir: Path) -> set[str]:
imports: set[str] = set()
stdlib = set(sys.stdlib_module_names)
for path in sorted(aiprovider_dir.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.add(alias.name.split(".", 1)[0])
elif isinstance(node, ast.ImportFrom) and node.module:
imports.add(node.module.split(".", 1)[0])
return {
name
for name in imports
if name not in stdlib and name != "aiprovider"
}
def resolve_relevant_root_dependencies(
pyproject_deps: dict[str, str],
imported_modules: set[str],
) -> dict[str, str]:
relevant_names: set[str] = set(EXTRA_RUNTIME_DISTRIBUTIONS)
for module_name in imported_modules:
mapped_name = IMPORT_TO_DISTRIBUTION.get(module_name, module_name)
normalized_name = normalize_name(mapped_name)
if normalized_name in pyproject_deps:
relevant_names.add(normalized_name)
return {
name: pyproject_deps[name]
for name in sorted(relevant_names)
if name in pyproject_deps
}
def lock_metadata(lock_text: str) -> dict[str, str | None]:
return {
"version": extract_string_value(lock_text, "version") or re.search(r"(?m)^version\s*=\s*(\d+)\s*$", lock_text).group(1),
"revision": extract_string_value(lock_text, "revision") or re.search(r"(?m)^revision\s*=\s*(\d+)\s*$", lock_text).group(1),
"requires_python": extract_string_value(lock_text, "requires-python"),
}
def lock_package_map(lock_text: str) -> dict[str, dict[str, Any]]:
mapping: dict[str, dict[str, Any]] = {}
sections = re.split(r"(?m)^\[\[package\]\]\s*$\n?", lock_text)
for section in sections[1:]:
raw_section = section.strip()
name = extract_string_value(raw_section, "name")
if not name:
continue
version = extract_string_value(raw_section, "version")
dependency_names = [
normalize_name(dep_name)
for dep_name in re.findall(r'\{\s*name\s*=\s*"([^"]+)"', raw_section)
]
mapping[normalize_name(name)] = {
"name": name,
"version": version,
"dependencies": dependency_names,
"raw": raw_section,
}
return mapping
def dependency_closure(
lock_packages: dict[str, dict[str, Any]],
root_dependencies: dict[str, str],
) -> list[dict[str, Any]]:
pending = list(root_dependencies.keys())
visited: set[str] = set()
resolved: list[dict[str, Any]] = []
while pending:
current = pending.pop()
if current in visited:
continue
visited.add(current)
package = lock_packages.get(current)
if package is None:
continue
resolved.append(package)
for dep_name in package.get("dependencies", []):
pending.append(dep_name)
resolved.sort(key=lambda pkg: (normalize_name(pkg["name"]), pkg.get("version", "")))
return resolved
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
pyproject_path = repo_root / "pyproject.toml"
uv_lock_path = repo_root / "uv.lock"
aiprovider_dir = repo_root / "aiprovider"
pyproject_text = pyproject_path.read_text(encoding="utf-8")
uv_lock_text = uv_lock_path.read_text(encoding="utf-8")
requires_python, pyproject_deps = project_dependency_map(pyproject_text)
imported_modules = collect_runtime_imports(aiprovider_dir)
relevant_roots = resolve_relevant_root_dependencies(pyproject_deps, imported_modules)
relevant_packages = dependency_closure(lock_package_map(uv_lock_text), relevant_roots)
fingerprint_payload = {
"requires_python": requires_python,
"relevant_root_dependencies": relevant_roots,
"lock": {**lock_metadata(uv_lock_text), "packages": relevant_packages},
}
print(json.dumps(fingerprint_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())