187 lines
6.1 KiB
Python
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())
|