Files
stack/dev/scripts/pkg_inventory.py
kert eba1b77b8f
Some checks failed
CI / lint (push) Successful in 1m2s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Deploy / report (push) Successful in 17s
CI / test (push) Failing after 17m33s
fix(ci): finish gitea→git container rename in pkg scripts; skip gitignored Dockerfiles in inventory
pkg-supply-chain has failed on every run since the April service rename
(b361cd1 fixed the URLs but not the docker exec/cp container name), and
the script masked the real error: a failed 'docker exec gitea' subprocess
was reported as a bodyless HTTP 500 for all 39 wheel uploads.

- pkg_mirror_sync.py: docker exec/cp target is now GITEA_CONTAINER
  ("git") at every call site; pkg_issues.py: same rename at its one site
- pkg_inventory.py: drop gitignored paths (git check-ignore) from the
  Dockerfile scan so vendored checkouts like infra/marimo/src can't
  contaminate the manifest — that's where the unresolvable
  marimo==${marimo_version} entry in the committed manifest came from
- data/pkg-manifest.json: regenerated; now matches what CI generates

Verified: pkg_mirror_sync.py --type pypi uploads all 39 wheels
(36 uploaded, 3 already present, 0 failed).
2026-07-09 21:27:02 -04:00

428 lines
14 KiB
Python

"""Scan the repo for all apt, apk, and PyPI packages in use.
Parses Dockerfiles, pyproject.toml, CI workflows, and shell scripts to
produce a canonical JSON manifest at data/pkg-manifest.json.
Usage:
uv run python dev/scripts/pkg_inventory.py
uv run python dev/scripts/pkg_inventory.py --check # exit 1 if manifest is stale
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MANIFEST_PATH = ROOT / "data" / "pkg-manifest.json"
# Directories to skip when scanning Dockerfiles
SKIP_DIRS = {"node_modules", ".git", "__pycache__", "plugins"}
def _drop_gitignored(paths: list[Path]) -> list[Path]:
"""Filter out gitignored paths (e.g. vendored checkouts like
infra/marimo/src) so local manifests match CI, where ignored
files are absent from the checkout."""
if not paths:
return paths
try:
result = subprocess.run(
["git", "-C", str(ROOT), "check-ignore", "--stdin"],
input="\n".join(str(p) for p in paths),
capture_output=True,
text=True,
timeout=30,
)
except (OSError, subprocess.TimeoutExpired):
return paths
# exit 0: some ignored, 1: none ignored, 128: fatal (not a repo)
if result.returncode not in (0, 1):
return paths
ignored = set(result.stdout.splitlines())
return [p for p in paths if str(p) not in ignored]
# ---------------------------------------------------------------------------
# Dockerfile parsers
# ---------------------------------------------------------------------------
def _join_continuation_lines(text: str) -> str:
"""Merge backslash-continued lines into single lines."""
return re.sub(r"\\\s*\n\s*", " ", text)
def _parse_apt_packages(text: str) -> list[str]:
"""Extract packages from apt-get install commands."""
text = _join_continuation_lines(text)
pkgs: set[str] = set()
for m in re.finditer(r"apt-get\s+install\s+(?:-\S+\s+)*(.+?)(?:&&|;|\n|$)", text):
tokens = m.group(1).split()
for tok in tokens:
tok = tok.strip()
if tok and not tok.startswith("-") and not tok.startswith("#"):
# strip version pin like =1.2.3
name = re.split(r"[=<>]", tok)[0]
if name and not name.startswith("/"):
pkgs.add(name)
return sorted(pkgs)
def _parse_apk_packages(text: str) -> list[str]:
"""Extract packages from apk add commands."""
text = _join_continuation_lines(text)
pkgs: set[str] = set()
for m in re.finditer(r"apk\s+add\s+(?:-\S+\s+)*(.+?)(?:&&|;|\n|$)", text):
tokens = m.group(1).split()
for tok in tokens:
tok = tok.strip()
if tok and not tok.startswith("-") and not tok.startswith("#"):
name = re.split(r"[=<>~]", tok)[0]
if name:
pkgs.add(name)
return sorted(pkgs)
def _parse_uv_add_packages(text: str) -> list[dict]:
"""Extract packages from 'uv add' commands in Dockerfiles."""
text = _join_continuation_lines(text)
pkgs: list[dict] = []
for m in re.finditer(r"uv\s+add\s+(.+?)(?:&&|;|\n|$)", text):
tokens = m.group(1).split()
for tok in tokens:
tok = tok.strip().strip('"').strip("'")
if tok.startswith("-"):
continue
if tok.startswith("http"):
continue
if tok:
pkgs.append(_parse_pypi_spec(tok))
return pkgs
def _parse_pip_install_packages(text: str) -> list[dict]:
"""Extract packages from 'pip install' commands in Dockerfiles."""
text = _join_continuation_lines(text)
pkgs: list[dict] = []
for m in re.finditer(r"pip\s+install\s+(.+?)(?:&&|;|\n|$)", text):
tokens = m.group(1).split()
for tok in tokens:
tok = tok.strip().strip('"').strip("'")
if tok.startswith("-"):
continue
if tok and tok != ".":
pkgs.append(_parse_pypi_spec(tok))
return pkgs
def _parse_uv_run_with_packages(text: str) -> list[dict]:
"""Extract packages from 'uv run --with pkg' commands."""
text = _join_continuation_lines(text)
pkgs: list[dict] = []
for m in re.finditer(r"uv\s+run\s+--with\s+(\S+)", text):
tok = m.group(1).strip('"').strip("'")
if tok:
pkgs.append(_parse_pypi_spec(tok))
return pkgs
def _parse_pypi_spec(spec: str) -> dict:
"""Parse a PEP 508 spec like 'narwhals>=2.17.0' or 'marimo[recommended]'."""
# strip extras
extras = ""
if "[" in spec:
base, rest = spec.split("[", 1)
extras = rest.split("]")[0]
spec = base + rest.split("]")[-1] if "]" in rest else base
m = re.match(r"([a-zA-Z0-9_-]+)(.*)", spec)
if not m:
return {"name": spec, "version": "", "extras": ""}
return {
"name": m.group(1).lower().replace("_", "-"),
"version": m.group(2).strip(),
"extras": extras,
}
def _parse_base_images(text: str) -> list[str]:
"""Extract FROM base images from Dockerfiles."""
images: list[str] = []
for m in re.finditer(r"^FROM\s+(\S+)", text, re.MULTILINE):
img = m.group(1)
if img not in images:
images.append(img)
return images
# ---------------------------------------------------------------------------
# CI workflow parser
# ---------------------------------------------------------------------------
def _parse_ci_curl_tools(text: str) -> list[dict]:
"""Extract tools installed via curl in CI workflows."""
tools: list[dict] = []
patterns = [
(r"astral\.sh/uv/install\.sh", "uv", "latest"),
(r"go-containerregistry.*crane", "crane", "latest"),
(r"aquasecurity/trivy", "trivy", "latest"),
]
for pattern, name, version in patterns:
if re.search(pattern, text):
tools.append({"name": name, "version": version})
return tools
# ---------------------------------------------------------------------------
# pyproject.toml parser (simple, no toml dependency)
# ---------------------------------------------------------------------------
def _is_self_ref(spec: str) -> bool:
"""Check if a dependency spec is a self-reference (e.g. stack[aco])."""
name = re.split(r"[\[>=<~!]", spec)[0].strip().lower().replace("_", "-")
return name == "stack"
def _extract_dep_specs(block: str) -> list[str]:
"""Extract dependency spec strings from a TOML array body.
Pulls the first quoted string from each non-comment line. This
correctly skips inline comments (e.g. ``"pymupdf>=1.24", # AGPL``),
trailing commas, and surrounding whitespace — the previous chained
``.strip()`` approach broke on inline comments.
"""
specs: list[str] = []
for line in block.splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
m = re.match(r'["\']([^"\']+)["\']', s)
if m:
specs.append(m.group(1).strip())
return specs
def _parse_pyproject_deps(text: str) -> tuple[list[dict], list[dict]]:
"""Parse dependencies from pyproject.toml without a TOML library.
Extracts:
- [project] dependencies (prod)
- [project.optional-dependencies] all extras (prod)
- [dependency-groups] dev (dev)
- [build-system] requires (prod)
Filters out self-references (stack[*]) which can't be downloaded.
"""
prod: list[dict] = []
dev: list[dict] = []
# prod deps — [project] dependencies
m = re.search(r"^dependencies\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL)
if m:
for spec in _extract_dep_specs(m.group(1)):
if not _is_self_ref(spec):
prod.append(_parse_pypi_spec(spec))
# optional deps — [project.optional-dependencies] all sections
# Extract the section content, then parse each "name = [...]" block.
# Can't use simple .*? because extras brackets (e.g. stack[conf])
# contain ] which terminates the match early.
opt_section = re.search(
r"\[project\.optional-dependencies\](.*?)(?:\n\[|\Z)",
text,
re.DOTALL,
)
if opt_section:
# Match each key = [ ... ] block by finding balanced brackets
for m in re.finditer(
r"^(\w+)\s*=\s*\[",
opt_section.group(1),
re.MULTILINE,
):
start = m.end()
rest = opt_section.group(1)[start:]
# Find closing ] that isn't inside a string bracket
depth = 1
i = 0
while i < len(rest) and depth > 0:
if rest[i] == "[":
depth += 1
elif rest[i] == "]":
depth -= 1
i += 1
block = rest[: i - 1] if depth == 0 else rest
for spec in _extract_dep_specs(block):
if not _is_self_ref(spec):
prod.append(_parse_pypi_spec(spec))
# dev deps — [dependency-groups] dev
m = re.search(r"dev\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL)
if m:
for spec in _extract_dep_specs(m.group(1)):
if not _is_self_ref(spec):
dev.append(_parse_pypi_spec(spec))
# build-system requires
m = re.search(
r"\[build-system\].*?requires\s*=\s*\[(.*?)\]",
text,
re.MULTILINE | re.DOTALL,
)
if m:
for spec in _extract_dep_specs(m.group(1)):
if not _is_self_ref(spec):
prod.append(_parse_pypi_spec(spec))
return prod, dev
# ---------------------------------------------------------------------------
# pnpm / Node parser
# ---------------------------------------------------------------------------
def _parse_package_json_deps(text: str) -> list[dict]:
"""Parse npm dependencies from package.json."""
data = json.loads(text)
pkgs: list[dict] = []
for section in ("dependencies", "devDependencies"):
for name, version in data.get(section, {}).items():
pkgs.append({"name": name, "version": version})
return pkgs
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def scan() -> dict:
"""Scan repo and return the package manifest."""
manifest: dict = {
"apt": {},
"apk": {},
"pypi": {
"project_prod": [],
"project_dev": [],
"notebook": [],
"dockerfile_adhoc": [],
},
"npm": [],
"ci_tools": [],
"base_images": {},
}
# --- Dockerfiles ---
dockerfiles = list(ROOT.glob("**/Dockerfile")) + list(ROOT.glob("**/Dockerfile.*"))
dockerfiles = [
df for df in dockerfiles if not any(skip in df.parts for skip in SKIP_DIRS)
]
dockerfiles = _drop_gitignored(dockerfiles)
for df in sorted(dockerfiles):
rel = str(df.relative_to(ROOT))
text = df.read_text()
apt = _parse_apt_packages(text)
if apt:
manifest["apt"][rel] = apt
apk = _parse_apk_packages(text)
if apk:
manifest["apk"][rel] = apk
images = _parse_base_images(text)
if images:
manifest["base_images"][rel] = images
# uv add in Dockerfiles (notebook pattern)
uv_pkgs = _parse_uv_add_packages(text)
if uv_pkgs:
manifest["pypi"]["notebook"].extend(uv_pkgs)
# pip install in Dockerfiles
pip_pkgs = _parse_pip_install_packages(text)
if pip_pkgs:
manifest["pypi"]["dockerfile_adhoc"].extend(pip_pkgs)
# uv run --with
with_pkgs = _parse_uv_run_with_packages(text)
if with_pkgs:
manifest["pypi"]["dockerfile_adhoc"].extend(with_pkgs)
# --- pyproject.toml ---
pyproject = ROOT / "pyproject.toml"
if pyproject.exists():
prod, dev = _parse_pyproject_deps(pyproject.read_text())
manifest["pypi"]["project_prod"] = prod
manifest["pypi"]["project_dev"] = dev
# --- package.json (docs) ---
pkg_json = ROOT / "docs" / "package.json"
if pkg_json.exists():
manifest["npm"] = _parse_package_json_deps(pkg_json.read_text())
# --- CI workflows ---
ci_tools_seen: set[str] = set()
for wf in sorted((ROOT / ".gitea" / "workflows").glob("*.yml")):
text = wf.read_text()
for tool in _parse_ci_curl_tools(text):
if tool["name"] not in ci_tools_seen:
ci_tools_seen.add(tool["name"])
manifest["ci_tools"].append(tool)
# --- Deduplicate & sort ---
for section in ("notebook", "dockerfile_adhoc"):
seen: set[str] = set()
deduped: list[dict] = []
for pkg in manifest["pypi"][section]:
if pkg["name"] not in seen:
seen.add(pkg["name"])
deduped.append(pkg)
manifest["pypi"][section] = sorted(deduped, key=lambda p: p["name"])
return manifest
def main() -> None:
manifest = scan()
output = json.dumps(manifest, indent=2, sort_keys=False) + "\n"
if "--check" in sys.argv:
if MANIFEST_PATH.exists():
existing = MANIFEST_PATH.read_text()
if existing == output:
print("pkg-manifest.json is up to date.")
raise SystemExit(0)
else:
print("pkg-manifest.json is STALE — re-run without --check.")
raise SystemExit(1)
else:
print("pkg-manifest.json does not exist — run without --check first.")
raise SystemExit(1)
MANIFEST_PATH.write_text(output)
# Summary
apt_count = sum(len(v) for v in manifest["apt"].values())
apk_count = sum(len(v) for v in manifest["apk"].values())
pypi_count = sum(
len(manifest["pypi"][k])
for k in ("project_prod", "project_dev", "notebook", "dockerfile_adhoc")
)
npm_count = len(manifest["npm"])
ci_count = len(manifest["ci_tools"])
img_count = sum(len(v) for v in manifest["base_images"].values())
print(f"Wrote {MANIFEST_PATH.relative_to(ROOT)}")
print(
f" apt: {apt_count} apk: {apk_count} pypi: {pypi_count}"
f" npm: {npm_count} ci_tools: {ci_count} base_images: {img_count}"
)
if __name__ == "__main__":
main()