bom.py python
213 lines 6.9 KB
Raw
sha256:2313b6a6353294a0ca08ff579624198da250b99163a86215aed31a905912e360 Land Scooling Lab T0/T2 training contract and seven-tier tests Human minor ⚠ breaking 42 days ago
1 """Generate and validate Scooling Lab dependency inventory evidence."""
2
3 from __future__ import annotations
4
5 import argparse
6 import sys
7 import time
8 import tomllib
9 from pathlib import Path
10
11 from scooling_lab.license_policy import (
12 BomEntry,
13 LicensePolicyError,
14 validate_entries,
15 validate_source_path,
16 )
17
18
19 SKIP_AUDIT_DIRS: frozenset[str] = frozenset(
20 {
21 ".git",
22 ".muse/cache",
23 ".muse/code",
24 ".muse/objects",
25 ".muse/snapshots",
26 "node_modules",
27 ".pnpm-store",
28 "build",
29 "dist",
30 "coverage",
31 "playwright-report",
32 "test-results",
33 "__pycache__",
34 }
35 )
36
37
38 def project_entry(pyproject_path: Path) -> BomEntry:
39 """Read project metadata from pyproject.toml into the first BOM row."""
40
41 data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
42 project = data.get("project")
43 if not isinstance(project, dict):
44 raise LicensePolicyError("pyproject.toml must contain [project]")
45 name = project.get("name")
46 version = project.get("version")
47 license_data = project.get("license")
48 license_id = ""
49 if isinstance(license_data, dict):
50 text = license_data.get("text")
51 if isinstance(text, str):
52 license_id = text
53 elif isinstance(license_data, str):
54 license_id = license_data
55 if not isinstance(name, str) or not isinstance(version, str):
56 raise LicensePolicyError("project name and version are required")
57 return BomEntry(
58 name=name,
59 version=version,
60 license=license_id,
61 source_path="pyproject.toml; src/scooling_lab",
62 evidence="[project] metadata and committed source tree",
63 )
64
65
66 def dependency_entries(lockfile_path: Path) -> list[BomEntry]:
67 """Read locked dependency evidence rows from requirements.lock.
68
69 The lockfile format is intentionally small for T0:
70 ``name==version # license=MIT source=third_party/name evidence=license-file``.
71 Empty and comment-only lockfiles are valid while Scooling Lab has no runtime
72 dependencies.
73 """
74
75 if not lockfile_path.exists():
76 raise LicensePolicyError("requirements.lock is required for BOM validation")
77
78 entries: list[BomEntry] = []
79 for raw_line in lockfile_path.read_text(encoding="utf-8").splitlines():
80 line = raw_line.strip()
81 if not line or line.startswith("#"):
82 continue
83 requirement, _, metadata = line.partition("#")
84 name, separator, version = requirement.strip().partition("==")
85 if separator != "==":
86 raise LicensePolicyError("locked dependencies must use name==version")
87 fields = parse_lock_metadata(metadata)
88 entries.append(
89 BomEntry(
90 name=name.strip(),
91 version=version.strip(),
92 license=fields["license"],
93 source_path=fields["source"],
94 evidence=fields["evidence"],
95 )
96 )
97 return entries
98
99
100 def parse_lock_metadata(metadata: str) -> dict[str, str]:
101 """Parse required license, source, and evidence metadata from a lock row."""
102
103 fields: dict[str, str] = {}
104 for token in metadata.strip().split():
105 key, separator, value = token.partition("=")
106 if separator == "=":
107 fields[key] = value
108 required = {"license", "source", "evidence"}
109 if set(fields) != required:
110 raise LicensePolicyError("lock rows require license, source, and evidence")
111 return fields
112
113
114 def collect_entries(root: Path) -> list[BomEntry]:
115 """Collect and validate project plus locked dependency rows."""
116
117 entries = [project_entry(root / "pyproject.toml")]
118 entries.extend(dependency_entries(root / "requirements.lock"))
119 validate_entries(entries)
120 return entries
121
122
123 def audit_repository_paths(root: Path) -> None:
124 """Fail when committed source paths enter blocked AGPL directory names."""
125
126 for path in root.rglob("*"):
127 relative = path.relative_to(root).as_posix()
128 if should_skip_path(relative):
129 continue
130 validate_source_path(relative)
131
132
133 def should_skip_path(relative_path: str) -> bool:
134 """Return true for generated or local-only directories outside BOM scope."""
135
136 parts = relative_path.split("/")
137 for index in range(len(parts)):
138 prefix = "/".join(parts[: index + 1])
139 if prefix in SKIP_AUDIT_DIRS:
140 return True
141 return False
142
143
144 def render_markdown(entries: list[BomEntry]) -> str:
145 """Render dependency evidence as committed Markdown."""
146
147 lines = [
148 "# Scooling Lab Dependency Inventory",
149 "",
150 "Generated by `python -m scooling_lab.bom`. CI fails when this file is stale, when a",
151 "license is not allowlisted, or when a blocked source path such as `studio/` or",
152 "`unsloth_cli/` appears in the audited tree.",
153 "",
154 "| Name | Version | License | Source path evidence | License evidence |",
155 "| --- | --- | --- | --- | --- |",
156 ]
157 for entry in sorted(entries, key=lambda item: item.name):
158 lines.append(
159 f"| {entry.name} | {entry.version} | {entry.license} | "
160 f"{entry.source_path} | {entry.evidence} |"
161 )
162 lines.extend(
163 [
164 "",
165 "No Unsloth package is installed or locked in this phase. The pinned Unsloth",
166 "candidate is recorded as evidence only in `docs/UNSLOTH-LICENSE-EVIDENCE.md`.",
167 "",
168 ]
169 )
170 return "\n".join(lines)
171
172
173 def build_parser() -> argparse.ArgumentParser:
174 """Build the BOM CLI argument parser."""
175
176 parser = argparse.ArgumentParser(description="Generate Scooling Lab BOM")
177 parser.add_argument("--root", type=Path, default=Path.cwd())
178 parser.add_argument("--output", type=Path, default=Path("DEPENDENCIES.md"))
179 parser.add_argument("--check", action="store_true")
180 parser.add_argument("--budget-seconds", type=float, default=30.0)
181 return parser
182
183
184 def main(argv: list[str] | None = None) -> int:
185 """Run BOM generation, policy validation, and optional stale-file check."""
186
187 start = time.perf_counter()
188 args = build_parser().parse_args(argv)
189 root = args.root.resolve()
190 entries = collect_entries(root)
191 audit_repository_paths(root)
192 markdown = render_markdown(entries)
193 output_path = args.output
194 if not output_path.is_absolute():
195 output_path = root / output_path
196 if args.check:
197 existing = output_path.read_text(encoding="utf-8")
198 if existing != markdown:
199 raise LicensePolicyError("DEPENDENCIES.md is stale")
200 else:
201 output_path.write_text(markdown, encoding="utf-8")
202 elapsed = time.perf_counter() - start
203 if elapsed > args.budget_seconds:
204 raise LicensePolicyError("BOM generation exceeded CI time budget")
205 return 0
206
207
208 if __name__ == "__main__":
209 try:
210 raise SystemExit(main())
211 except LicensePolicyError as exc:
212 print(f"BOM policy failure: {exc}", file=sys.stderr)
213 raise SystemExit(1) from exc
File History 1 commit
sha256:2313b6a6353294a0ca08ff579624198da250b99163a86215aed31a905912e360 Land Scooling Lab T0/T2 training contract and seven-tier tests Human minor 42 days ago