bom.py
python
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