From e4871db718b878c1f3fd893b1ea979c50aa7bb4d Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Wed, 25 Mar 2026 09:43:48 +0100 Subject: [PATCH 1/5] feat: replace regex TOML parsing with stdlib tomllib The regex-based parse_pack_toml() worked for simple flat fields but would break on multiline strings, inline tables, or other valid TOML constructs. Switch to Python's stdlib tomllib (3.11+) for spec-compliant parsing. Pin Python 3.12 in CI via actions/setup-python to guarantee tomllib availability regardless of runner image updates. Output is byte-for-byte identical to the regex-based parser for all 13 current packs. Closes #5 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/publish.yml | 4 ++++ scripts/generate.py | 45 ++++++++++++----------------------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4a47931..8d79a8c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,6 +19,10 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Regenerate packs/*.json and index.json run: python3 scripts/generate.py diff --git a/scripts/generate.py b/scripts/generate.py index ab2856a..be786b5 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -15,8 +15,8 @@ """ import json -import re import sys +import tomllib from pathlib import Path # Schema version for all generated registry files (index.json, packs/*.json). @@ -26,38 +26,23 @@ REGISTRY_SCHEMA_VERSION = 1 -def parse_pack_toml(text: str) -> dict: - """Extract scalar fields from the [pack] section of a pack.toml. +def parse_pack_toml(pack_toml_path: Path) -> dict: + """Parse a pack.toml file and extract metadata from the [pack] section.""" + with open(pack_toml_path, "rb") as f: + data = tomllib.load(f) - Uses simple regex — a full TOML parser is not needed here because pack.toml - fields are always plain strings, booleans, or inline arrays. - """ - # Find the [pack] section (everything up to the next [] section or EOF) - pack_section_match = re.search( - r"^\[pack\](.*?)(?=^\[[^\[]|\Z)", text, re.DOTALL | re.MULTILINE - ) - if not pack_section_match: + if "pack" not in data: raise ValueError("No [pack] section found in pack.toml") - section = pack_section_match.group(1) - - def get(key: str): - m = re.search(rf'^{key}\s*=\s*"([^"]*)"', section, re.MULTILINE) - return m.group(1) if m else None - - def get_array(key: str) -> list[str]: - m = re.search(rf"^{key}\s*=\s*\[([^\]]*)\]", section, re.MULTILINE) - if not m: - return [] - return [v.strip().strip('"') for v in m.group(1).split(",") if v.strip().strip('"')] + pack = data["pack"] return { - "name": get("name"), - "version": get("version"), - "description": get("description"), - "authors": get_array("authors"), - "license": get("license"), - "repository": get("repository"), - "keywords": get_array("keywords"), + "name": pack.get("name"), + "version": pack.get("version"), + "description": pack.get("description"), + "authors": pack.get("authors", []), + "license": pack.get("license"), + "repository": pack.get("repository"), + "keywords": pack.get("keywords", []), } @@ -88,7 +73,7 @@ def process_pack(src_dir: Path, packs_dir: Path, pack_name: str) -> dict: print(f" SKIP {pack_name}: no pack.toml found", file=sys.stderr) return {} - meta = parse_pack_toml(pack_toml_path.read_text(encoding="utf-8")) + meta = parse_pack_toml(pack_toml_path) # Ensure pack.toml name matches the directory name to prevent registry inconsistencies. if meta["name"] and meta["name"] != pack_name: From 15eb29e189e31a29ceb0893408b79e968afdb7be Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Wed, 25 Mar 2026 09:47:10 +0100 Subject: [PATCH 2/5] feat: add pack validation to generate.py and CI Add validate_pack() and validate_unique_server_names() that check: - Required fields (name, version, description) - Name format (lowercase, digits, hyphens, no trailing hyphen) - Semver format (X.Y.Z) - authors/keywords are string lists - [[servers]] syntax (not [servers]) - Server names: format, uniqueness within and across packs - Server command/url required based on transport type Validation runs before generation on every invocation. Add --validate-only flag for local/CI use without writing files. Add validate.yml workflow that runs on PRs touching src/** or scripts/generate.py so contributors get feedback before merge. Closes #6 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate.yml | 21 ++++ scripts/generate.py | 174 ++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/validate.yml diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..8cca1fa --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,21 @@ +name: Validate packs + +on: + pull_request: + paths: + - "src/**" + - "scripts/generate.py" + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate all packs + run: python3 scripts/generate.py --validate-only diff --git a/scripts/generate.py b/scripts/generate.py index be786b5..d72913f 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -14,7 +14,9 @@ set to the highest semver version present in each packs/{name}.json. """ +import argparse import json +import re import sys import tomllib from pathlib import Path @@ -26,14 +28,23 @@ REGISTRY_SCHEMA_VERSION = 1 -def parse_pack_toml(pack_toml_path: Path) -> dict: - """Parse a pack.toml file and extract metadata from the [pack] section.""" +_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$") +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") +_VALID_TRANSPORTS = {"stdio", "http"} + + +def parse_pack_toml_full(pack_toml_path: Path) -> dict: + """Parse a pack.toml file and return the full TOML dict.""" with open(pack_toml_path, "rb") as f: data = tomllib.load(f) - if "pack" not in data: raise ValueError("No [pack] section found in pack.toml") + return data + +def parse_pack_toml(pack_toml_path: Path) -> dict: + """Parse a pack.toml file and extract metadata from the [pack] section.""" + data = parse_pack_toml_full(pack_toml_path) pack = data["pack"] return { "name": pack.get("name"), @@ -46,6 +57,123 @@ def parse_pack_toml(pack_toml_path: Path) -> dict: } +def validate_pack(pack_toml_path: Path, pack_dir_name: str) -> list[str]: + """Validate a pack.toml file. Returns a list of error messages (empty = valid).""" + errors: list[str] = [] + + try: + data = parse_pack_toml_full(pack_toml_path) + except (ValueError, tomllib.TOMLDecodeError) as e: + return [f"{pack_dir_name}: {e}"] + + pack = data["pack"] + + # Required fields + for field in ("name", "version", "description"): + if field not in pack or not pack[field]: + errors.append(f"{pack_dir_name}: missing required field '{field}' in [pack]") + + name = pack.get("name", "") + if name and not _NAME_RE.match(name): + errors.append( + f"{pack_dir_name}: invalid pack name '{name}' — " + "must be lowercase letters, digits, and hyphens only" + ) + if name and name != pack_dir_name: + errors.append( + f"{pack_dir_name}: pack name '{name}' does not match directory name" + ) + + version = pack.get("version", "") + if version and not _SEMVER_RE.match(version): + errors.append( + f"{pack_dir_name}: invalid version '{version}' — must be X.Y.Z" + ) + + # Type checks for optional list fields + for field in ("authors", "keywords"): + val = pack.get(field) + if val is not None: + if not isinstance(val, list) or not all(isinstance(v, str) for v in val): + errors.append(f"{pack_dir_name}: '{field}' must be a list of strings") + + # Server validation + servers = data.get("servers", []) + if not isinstance(servers, list): + errors.append( + f"{pack_dir_name}: 'servers' must use [[servers]] (array of tables), " + "not [servers]" + ) + return errors + + seen_server_names: set[str] = set() + for i, server in enumerate(servers): + srv_name = server.get("name") + if not srv_name: + errors.append(f"{pack_dir_name}: server #{i + 1} is missing 'name'") + elif not _NAME_RE.match(srv_name): + errors.append( + f"{pack_dir_name}: invalid server name '{srv_name}' — " + "must be lowercase letters, digits, and hyphens only" + ) + elif srv_name in seen_server_names: + errors.append( + f"{pack_dir_name}: duplicate server name '{srv_name}' within pack" + ) + else: + seen_server_names.add(srv_name) + + transport = server.get("transport", "stdio") + if transport not in _VALID_TRANSPORTS: + errors.append( + f"{pack_dir_name}: server '{srv_name or f'#{i + 1}'}' has invalid " + f"transport '{transport}' — must be 'stdio' or 'http'" + ) + + # Validate command/url based on transport + if transport == "stdio" and not server.get("command"): + errors.append( + f"{pack_dir_name}: server '{srv_name or f'#{i + 1}'}' uses stdio " + "transport but is missing 'command'" + ) + elif transport == "http" and not server.get("url"): + errors.append( + f"{pack_dir_name}: server '{srv_name or f'#{i + 1}'}' uses http " + "transport but is missing 'url'" + ) + + return errors + + +def validate_unique_server_names(src_dir: Path) -> list[str]: + """Check that server names are globally unique across all packs.""" + errors: list[str] = [] + seen: dict[str, str] = {} # server_name -> pack_name + + for pack_dir in sorted(src_dir.iterdir()): + pack_toml_path = pack_dir / "pack.toml" + if not pack_dir.is_dir() or not pack_toml_path.exists(): + continue + try: + data = parse_pack_toml_full(pack_toml_path) + except (ValueError, tomllib.TOMLDecodeError): + continue + + for server in data.get("servers", []): + srv_name = server.get("name") + if not srv_name: + continue + if srv_name in seen: + errors.append( + f"{pack_dir.name}: server name '{srv_name}' conflicts with " + f"pack '{seen[srv_name]}'" + ) + else: + seen[srv_name] = pack_dir.name + + return errors + + def semver_key(version_str: str) -> tuple[int, ...]: """Return a sortable tuple for semver comparison.""" try: @@ -179,17 +307,53 @@ def regenerate_index(packs_dir: Path) -> dict: def main() -> None: + parser = argparse.ArgumentParser(description="Generate or validate the pack registry.") + parser.add_argument( + "--validate-only", + action="store_true", + help="Validate all packs without writing any files.", + ) + args = parser.parse_args() + repo_root = Path(__file__).parent.parent src_dir = repo_root / "src" packs_dir = repo_root / "packs" index_path = repo_root / "index.json" - packs_dir.mkdir(exist_ok=True) - pack_names = sorted( p.name for p in src_dir.iterdir() if p.is_dir() and (p / "pack.toml").exists() ) + # --- Validation pass --- + print(f"Validating {len(pack_names)} pack(s):") + all_errors: list[str] = [] + for name in pack_names: + pack_errors = validate_pack(src_dir / name / "pack.toml", name) + if pack_errors: + all_errors.extend(pack_errors) + for err in pack_errors: + print(f" ERROR: {err}", file=sys.stderr) + else: + print(f" {name}: OK") + + # Cross-pack checks + cross_errors = validate_unique_server_names(src_dir) + all_errors.extend(cross_errors) + for err in cross_errors: + print(f" ERROR: {err}", file=sys.stderr) + + if all_errors: + print(f"\nValidation failed with {len(all_errors)} error(s).", file=sys.stderr) + sys.exit(1) + + print("Validation passed.\n") + + if args.validate_only: + return + + # --- Generation pass --- + packs_dir.mkdir(exist_ok=True) + print(f"Processing {len(pack_names)} pack(s):") for name in pack_names: process_pack(src_dir, packs_dir, name) From 89a21267f2351df42530376060b270283f6ee646 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Wed, 25 Mar 2026 09:50:35 +0100 Subject: [PATCH 3/5] feat: add test suite for generate.py Add 34 pytest tests covering: - TOML parsing (valid, missing section, optional fields) - Semver sorting and fallback - File map building - Pack validation (all rules: names, versions, servers, transports) - Cross-pack server name uniqueness - Version immutability enforcement - Index regeneration edge cases - Full end-to-end generation with multi-version accumulation Update validate.yml to install pytest and run tests on PRs. Closes #7 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate.yml | 8 +- scripts/test_generate.py | 427 +++++++++++++++++++++++++++++++++ 2 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 scripts/test_generate.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8cca1fa..95a8a91 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -4,7 +4,7 @@ on: pull_request: paths: - "src/**" - - "scripts/generate.py" + - "scripts/**" jobs: validate: @@ -19,3 +19,9 @@ jobs: - name: Validate all packs run: python3 scripts/generate.py --validate-only + + - name: Install test dependencies + run: pip install pytest + + - name: Run tests + run: python -m pytest scripts/test_generate.py -v diff --git a/scripts/test_generate.py b/scripts/test_generate.py new file mode 100644 index 0000000..cec73a4 --- /dev/null +++ b/scripts/test_generate.py @@ -0,0 +1,427 @@ +"""Tests for generate.py — pack registry generation and validation.""" + +import json +import sys +from pathlib import Path + +import pytest + +# Ensure scripts/ is importable +sys.path.insert(0, str(Path(__file__).parent)) + +from generate import ( + build_files_map, + parse_pack_toml, + parse_pack_toml_full, + process_pack, + regenerate_index, + semver_key, + validate_pack, + validate_unique_server_names, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +VALID_PACK_TOML = """\ +[pack] +name = "my-pack" +version = "0.1.0" +description = "A test pack" +authors = ["Test Author"] +license = "MIT" +repository = "https://github.com/test/test" +keywords = ["test", "example"] +""" + +VALID_PACK_WITH_SERVER = """\ +[pack] +name = "server-pack" +version = "1.0.0" +description = "A pack with a server" +authors = ["Test"] +license = "MIT" +keywords = ["mcp"] + +[[servers]] +name = "my-server" +command = "npx" +args = ["-y", "@org/pkg@latest"] +transport = "stdio" +""" + + +def _write_pack(tmp_path: Path, name: str, toml_content: str) -> Path: + """Create a pack directory with a pack.toml and return the pack dir.""" + pack_dir = tmp_path / name + pack_dir.mkdir(parents=True) + (pack_dir / "pack.toml").write_text(toml_content, encoding="utf-8") + return pack_dir + + +# --------------------------------------------------------------------------- +# parse_pack_toml +# --------------------------------------------------------------------------- + + +class TestParsePackToml: + def test_valid(self, tmp_path: Path): + pack_dir = _write_pack(tmp_path, "my-pack", VALID_PACK_TOML) + meta = parse_pack_toml(pack_dir / "pack.toml") + assert meta["name"] == "my-pack" + assert meta["version"] == "0.1.0" + assert meta["description"] == "A test pack" + assert meta["authors"] == ["Test Author"] + assert meta["license"] == "MIT" + assert meta["keywords"] == ["test", "example"] + + def test_missing_pack_section(self, tmp_path: Path): + pack_dir = _write_pack(tmp_path, "bad", "[other]\nfoo = 1\n") + with pytest.raises(ValueError, match="No \\[pack\\] section"): + parse_pack_toml(pack_dir / "pack.toml") + + def test_missing_optional_fields(self, tmp_path: Path): + minimal = '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n' + pack_dir = _write_pack(tmp_path, "x", minimal) + meta = parse_pack_toml(pack_dir / "pack.toml") + assert meta["name"] == "x" + assert meta["license"] is None + assert meta["repository"] is None + assert meta["authors"] == [] + assert meta["keywords"] == [] + + +# --------------------------------------------------------------------------- +# semver_key +# --------------------------------------------------------------------------- + + +class TestSemverKey: + def test_basic(self): + assert semver_key("1.2.3") == (1, 2, 3) + + def test_sorting(self): + versions = ["0.1.0", "1.0.0", "0.2.0", "0.1.1", "2.0.0"] + sorted_versions = sorted(versions, key=semver_key, reverse=True) + assert sorted_versions == ["2.0.0", "1.0.0", "0.2.0", "0.1.1", "0.1.0"] + + def test_invalid_fallback(self): + assert semver_key("not-a-version") == (0, 0, 0) + + +# --------------------------------------------------------------------------- +# build_files_map +# --------------------------------------------------------------------------- + + +class TestBuildFilesMap: + def test_basic(self, tmp_path: Path): + pack_dir = tmp_path / "my-pack" + pack_dir.mkdir() + (pack_dir / "pack.toml").write_text("content", encoding="utf-8") + prompts = pack_dir / "prompts" + prompts.mkdir() + (prompts / "claude.md").write_text("prompt", encoding="utf-8") + + files = build_files_map(pack_dir) + assert "pack.toml" in files + assert "prompts/claude.md" in files + assert files["pack.toml"] == "content" + assert files["prompts/claude.md"] == "prompt" + + def test_skips_directories(self, tmp_path: Path): + pack_dir = tmp_path / "my-pack" + pack_dir.mkdir() + (pack_dir / "subdir").mkdir() + (pack_dir / "file.txt").write_text("hi", encoding="utf-8") + + files = build_files_map(pack_dir) + assert len(files) == 1 + assert "file.txt" in files + + +# --------------------------------------------------------------------------- +# validate_pack +# --------------------------------------------------------------------------- + + +class TestValidatePack: + def test_valid_pack(self, tmp_path: Path): + _write_pack(tmp_path, "my-pack", VALID_PACK_TOML) + errors = validate_pack(tmp_path / "my-pack" / "pack.toml", "my-pack") + assert errors == [] + + def test_valid_pack_with_server(self, tmp_path: Path): + _write_pack(tmp_path, "server-pack", VALID_PACK_WITH_SERVER) + errors = validate_pack(tmp_path / "server-pack" / "pack.toml", "server-pack") + assert errors == [] + + def test_missing_required_field(self, tmp_path: Path): + toml = '[pack]\nname = "x"\nversion = "0.1.0"\n' + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("description" in e for e in errors) + + def test_invalid_name_format(self, tmp_path: Path): + toml = '[pack]\nname = "My_Pack"\nversion = "0.1.0"\ndescription = "d"\n' + _write_pack(tmp_path, "My_Pack", toml) + errors = validate_pack(tmp_path / "My_Pack" / "pack.toml", "My_Pack") + assert any("invalid pack name" in e for e in errors) + + def test_trailing_hyphen_rejected(self, tmp_path: Path): + toml = '[pack]\nname = "bad-"\nversion = "0.1.0"\ndescription = "d"\n' + _write_pack(tmp_path, "bad-", toml) + errors = validate_pack(tmp_path / "bad-" / "pack.toml", "bad-") + assert any("invalid pack name" in e for e in errors) + + def test_name_dir_mismatch(self, tmp_path: Path): + toml = '[pack]\nname = "foo"\nversion = "0.1.0"\ndescription = "d"\n' + _write_pack(tmp_path, "bar", toml) + errors = validate_pack(tmp_path / "bar" / "pack.toml", "bar") + assert any("does not match directory" in e for e in errors) + + def test_invalid_version(self, tmp_path: Path): + toml = '[pack]\nname = "x"\nversion = "abc"\ndescription = "d"\n' + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("invalid version" in e for e in errors) + + def test_keywords_not_list(self, tmp_path: Path): + toml = '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\nkeywords = "bad"\n' + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("keywords" in e and "list of strings" in e for e in errors) + + def test_server_missing_name(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\ncommand = "npx"\ntransport = "stdio"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("missing 'name'" in e for e in errors) + + def test_server_invalid_transport(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "s"\ncommand = "npx"\ntransport = "sse"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("invalid transport" in e for e in errors) + + def test_server_stdio_missing_command(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "s"\ntransport = "stdio"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("missing 'command'" in e for e in errors) + + def test_server_http_missing_url(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "s"\ntransport = "http"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("missing 'url'" in e for e in errors) + + def test_duplicate_server_names_within_pack(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "dup"\ncommand = "a"\ntransport = "stdio"\n\n' + '[[servers]]\nname = "dup"\ncommand = "b"\ntransport = "stdio"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("duplicate server name" in e for e in errors) + + def test_servers_table_not_array(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[servers]\nname = "s"\ncommand = "npx"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("[[servers]]" in e for e in errors) + + def test_server_invalid_name_format(self, tmp_path: Path): + toml = ( + '[pack]\nname = "x"\nversion = "0.1.0"\ndescription = "d"\n\n' + '[[servers]]\nname = "My_Server"\ncommand = "npx"\ntransport = "stdio"\n' + ) + _write_pack(tmp_path, "x", toml) + errors = validate_pack(tmp_path / "x" / "pack.toml", "x") + assert any("invalid server name" in e for e in errors) + + def test_malformed_toml(self, tmp_path: Path): + _write_pack(tmp_path, "bad", "this is not valid TOML [[[") + errors = validate_pack(tmp_path / "bad" / "pack.toml", "bad") + assert len(errors) == 1 + assert "bad" in errors[0] + + +# --------------------------------------------------------------------------- +# validate_unique_server_names +# --------------------------------------------------------------------------- + + +class TestValidateUniqueServerNames: + def test_no_conflicts(self, tmp_path: Path): + _write_pack(tmp_path, "a", VALID_PACK_WITH_SERVER) + toml_b = VALID_PACK_WITH_SERVER.replace("server-pack", "b").replace( + "my-server", "other-server" + ) + _write_pack(tmp_path, "b", toml_b) + errors = validate_unique_server_names(tmp_path) + assert errors == [] + + def test_conflict_detected(self, tmp_path: Path): + _write_pack(tmp_path, "a", VALID_PACK_WITH_SERVER) + toml_b = VALID_PACK_WITH_SERVER.replace("server-pack", "b") + # Keep same server name "my-server" + _write_pack(tmp_path, "b", toml_b) + errors = validate_unique_server_names(tmp_path) + assert len(errors) == 1 + assert "my-server" in errors[0] + assert "conflicts" in errors[0] + + +# --------------------------------------------------------------------------- +# version immutability (process_pack) +# --------------------------------------------------------------------------- + + +class TestVersionImmutability: + def test_same_content_ok(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + _write_pack(src, "my-pack", VALID_PACK_TOML) + + # First generation + result1 = process_pack(src, packs, "my-pack") + assert result1["latest_version"] == "0.1.0" + + # Second generation with same content — should not fail + result2 = process_pack(src, packs, "my-pack") + assert result2["latest_version"] == "0.1.0" + + def test_different_content_fails(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + _write_pack(src, "my-pack", VALID_PACK_TOML) + + # First generation + process_pack(src, packs, "my-pack") + + # Modify a file without bumping version + (src / "my-pack" / "extra.txt").write_text("new file", encoding="utf-8") + + with pytest.raises(SystemExit) as exc_info: + process_pack(src, packs, "my-pack") + assert exc_info.value.code == 1 + + def test_no_pack_toml_skipped(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + (src / "empty-pack").mkdir(parents=True) + + result = process_pack(src, packs, "empty-pack") + assert result == {} + assert not (packs / "empty-pack.json").exists() + + def test_name_mismatch_skipped(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + toml = VALID_PACK_TOML.replace('name = "my-pack"', 'name = "other-name"') + _write_pack(src, "my-pack", toml) + + result = process_pack(src, packs, "my-pack") + assert result == {} + + +# --------------------------------------------------------------------------- +# Full end-to-end generation +# --------------------------------------------------------------------------- + + +class TestRegenerateIndex: + def test_empty_packs_dir(self, tmp_path: Path): + packs = tmp_path / "packs" + packs.mkdir() + index = regenerate_index(packs) + assert index["schema_version"] == 1 + assert index["packs"] == {} + + def test_pack_with_no_versions_skipped(self, tmp_path: Path): + packs = tmp_path / "packs" + packs.mkdir() + # Write a pack JSON with empty versions + (packs / "empty.json").write_text( + json.dumps({"name": "empty", "versions": []}), encoding="utf-8" + ) + index = regenerate_index(packs) + assert "empty" not in index["packs"] + + +class TestFullGeneration: + def test_end_to_end(self, tmp_path: Path): + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + + _write_pack(src, "alpha", VALID_PACK_TOML.replace("my-pack", "alpha")) + toml_beta = ( + VALID_PACK_TOML.replace("my-pack", "beta") + .replace("0.1.0", "0.2.0") + ) + _write_pack(src, "beta", toml_beta) + + # Generate both packs + for name in ["alpha", "beta"]: + process_pack(src, packs, name) + + # Verify pack JSONs + alpha_json = json.loads((packs / "alpha.json").read_text(encoding="utf-8")) + assert alpha_json["name"] == "alpha" + assert alpha_json["schema_version"] == 1 + assert len(alpha_json["versions"]) == 1 + assert alpha_json["versions"][0]["version"] == "0.1.0" + assert "pack.toml" in alpha_json["versions"][0]["files"] + + beta_json = json.loads((packs / "beta.json").read_text(encoding="utf-8")) + assert beta_json["versions"][0]["version"] == "0.2.0" + + # Regenerate index + index = regenerate_index(packs) + assert index["schema_version"] == 1 + assert "alpha" in index["packs"] + assert "beta" in index["packs"] + assert index["packs"]["alpha"]["latest_version"] == "0.1.0" + assert index["packs"]["beta"]["latest_version"] == "0.2.0" + + def test_multi_version(self, tmp_path: Path): + """Verify that bumping the version adds a new entry without removing the old.""" + src = tmp_path / "src" + packs = tmp_path / "packs" + packs.mkdir() + + _write_pack(src, "my-pack", VALID_PACK_TOML) + process_pack(src, packs, "my-pack") + + # Bump version + toml_v2 = VALID_PACK_TOML.replace("0.1.0", "0.2.0") + (src / "my-pack" / "pack.toml").write_text(toml_v2, encoding="utf-8") + process_pack(src, packs, "my-pack") + + pack_json = json.loads((packs / "my-pack.json").read_text(encoding="utf-8")) + versions = [v["version"] for v in pack_json["versions"]] + assert versions == ["0.2.0", "0.1.0"] # sorted descending From 623e1d73dfe48336f9bfc66a34ffb141b1f9519c Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Thu, 26 Mar 2026 07:21:14 +0100 Subject: [PATCH 4/5] fix: address code review feedback on validation - Add isinstance check for [pack] section (must be a table, not scalar) - Add string type checks for required fields (name, version, description) - Guard server entries with isinstance(server, dict) checks - Harden validate_unique_server_names() against non-list/non-dict servers - Skip cross-pack validation when per-pack errors already exist - Use consistent `python` (not `python3`) in CI workflows - Add scripts/generate.py to publish.yml path triggers Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/publish.yml | 3 +- .github/workflows/validate.yml | 4 +- scripts/__pycache__/generate.cpython-312.pyc | Bin 0 -> 16052 bytes ...test_generate.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 49255 bytes scripts/generate.py | 40 +++++++++++++----- scripts/test_generate.py | 1 - 6 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 scripts/__pycache__/generate.cpython-312.pyc create mode 100644 scripts/__pycache__/test_generate.cpython-312-pytest-9.0.2.pyc diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8d79a8c..71cb91f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "src/**" + - "scripts/generate.py" # Allow manual re-generation of all packs from the Actions tab workflow_dispatch: @@ -24,7 +25,7 @@ jobs: python-version: "3.12" - name: Regenerate packs/*.json and index.json - run: python3 scripts/generate.py + run: python scripts/generate.py - name: Commit generated files run: | diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 95a8a91..3192829 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -18,10 +18,10 @@ jobs: python-version: "3.12" - name: Validate all packs - run: python3 scripts/generate.py --validate-only + run: python scripts/generate.py --validate-only - name: Install test dependencies - run: pip install pytest + run: python -m pip install pytest - name: Run tests run: python -m pytest scripts/test_generate.py -v diff --git a/scripts/__pycache__/generate.cpython-312.pyc b/scripts/__pycache__/generate.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d10571f748a80f4f326d2b0bb87b8aafa3ac716e GIT binary patch literal 16052 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 literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/test_generate.cpython-312-pytest-9.0.2.pyc b/scripts/__pycache__/test_generate.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..946ef9be873abf45911250b748a4a47e3fb8d2ae GIT binary patch literal 49255 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 F{}0}ja4!G= literal 0 HcmV?d00001 diff --git a/scripts/generate.py b/scripts/generate.py index d72913f..0124091 100644 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -39,6 +39,8 @@ def parse_pack_toml_full(pack_toml_path: Path) -> dict: data = tomllib.load(f) if "pack" not in data: raise ValueError("No [pack] section found in pack.toml") + if not isinstance(data["pack"], dict): + raise ValueError("[pack] must be a table, not a scalar") return data @@ -68,24 +70,27 @@ def validate_pack(pack_toml_path: Path, pack_dir_name: str) -> list[str]: pack = data["pack"] - # Required fields + # Required fields — must be present and must be strings for field in ("name", "version", "description"): - if field not in pack or not pack[field]: + val = pack.get(field) + if not val: errors.append(f"{pack_dir_name}: missing required field '{field}' in [pack]") + elif not isinstance(val, str): + errors.append(f"{pack_dir_name}: '{field}' must be a string") name = pack.get("name", "") - if name and not _NAME_RE.match(name): + if isinstance(name, str) and name and not _NAME_RE.match(name): errors.append( f"{pack_dir_name}: invalid pack name '{name}' — " "must be lowercase letters, digits, and hyphens only" ) - if name and name != pack_dir_name: + if isinstance(name, str) and name and name != pack_dir_name: errors.append( f"{pack_dir_name}: pack name '{name}' does not match directory name" ) version = pack.get("version", "") - if version and not _SEMVER_RE.match(version): + if isinstance(version, str) and version and not _SEMVER_RE.match(version): errors.append( f"{pack_dir_name}: invalid version '{version}' — must be X.Y.Z" ) @@ -108,6 +113,12 @@ def validate_pack(pack_toml_path: Path, pack_dir_name: str) -> list[str]: seen_server_names: set[str] = set() for i, server in enumerate(servers): + if not isinstance(server, dict): + errors.append( + f"{pack_dir_name}: server #{i + 1} must be a table " + f"(got {type(server).__name__})" + ) + continue srv_name = server.get("name") if not srv_name: errors.append(f"{pack_dir_name}: server #{i + 1} is missing 'name'") @@ -159,7 +170,13 @@ def validate_unique_server_names(src_dir: Path) -> list[str]: except (ValueError, tomllib.TOMLDecodeError): continue - for server in data.get("servers", []): + servers = data.get("servers") + if not isinstance(servers, list): + continue + + for server in servers: + if not isinstance(server, dict): + continue srv_name = server.get("name") if not srv_name: continue @@ -336,11 +353,12 @@ def main() -> None: else: print(f" {name}: OK") - # Cross-pack checks - cross_errors = validate_unique_server_names(src_dir) - all_errors.extend(cross_errors) - for err in cross_errors: - print(f" ERROR: {err}", file=sys.stderr) + # Cross-pack checks (skip if per-pack validation already failed) + if not all_errors: + cross_errors = validate_unique_server_names(src_dir) + all_errors.extend(cross_errors) + for err in cross_errors: + print(f" ERROR: {err}", file=sys.stderr) if all_errors: print(f"\nValidation failed with {len(all_errors)} error(s).", file=sys.stderr) diff --git a/scripts/test_generate.py b/scripts/test_generate.py index cec73a4..ff0f39c 100644 --- a/scripts/test_generate.py +++ b/scripts/test_generate.py @@ -12,7 +12,6 @@ from generate import ( build_files_map, parse_pack_toml, - parse_pack_toml_full, process_pack, regenerate_index, semver_key, From 1a411f5bc46e274b48edc52349899b3ee7184b3d Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Thu, 26 Mar 2026 07:21:52 +0100 Subject: [PATCH 5/5] chore: add __pycache__ to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + scripts/__pycache__/generate.cpython-312.pyc | Bin 16052 -> 0 bytes .../test_generate.cpython-312-pytest-9.0.2.pyc | Bin 49255 -> 0 bytes 3 files changed, 1 insertion(+) create mode 100644 .gitignore delete mode 100644 scripts/__pycache__/generate.cpython-312.pyc delete mode 100644 scripts/__pycache__/test_generate.cpython-312-pytest-9.0.2.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e30f246 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +scripts/__pycache__/ diff --git a/scripts/__pycache__/generate.cpython-312.pyc b/scripts/__pycache__/generate.cpython-312.pyc deleted file mode 100644 index d10571f748a80f4f326d2b0bb87b8aafa3ac716e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16052 zcmcJ0dvH@%n&-XJ)%$Jh;kPe-#FoJ_1QWn9Av_G2he^Oqf(cQC?ls8RmUFKRSdlyK zY^GZDcnUL}S&MC`DAm0?qC#&wRsDyvHC4k-x@ME=sTv8HSh?|3QM37DyK4V1ZFKl&`RvX!F#E8ZwcpTAgnlcuOy zilYW7j^H#$=nE{5BngI=z>S67OZa_y<95bL-QM`JnOzF5-a+;5! zZ%hNmr`FLH>XYFmnc8#~FFcAj6s7X9lb#_hz`ak$VeohdK`4UJ3mVYB?*s zDdTLUrSg>F?C`XlgL|m!;o=A80Kyq&if-nih6hxFuzw;s~gGN|V& z;CTgy&*ZsP!hI!I!&O09#aDCH7h5Sxz}=|&n5>$jXlg<=q4L&_cc>`Jc!EFAkMcqw z!n0$6;6<^c%{L^5N7=wA$A(5Z{)&7%D1=8?Q3!VUjK&k=qb#|PT;N%O9}A12NLZL) zg>X31Wn>|ZO++q)N4K(KPza4h#13T$-`IrFcpxmWd?0v%jK_|{7y{|}ozcJuKiRQ~ zja&$c`KH+279V?p4{#zI7#?N^L&H2=!Q{@3hlcUtC`@xWG|IDYk#}#gBY`otRp5sM zkh}Q=_KJQ~+2?!!|giH2C!Xv|MB+T+xB0?Y-VMq8#fD1$d zZ0jXn5JTb7O)SUDOCdLbaaf%oZesaJ&__Dl>SJFMLXmujVu!pGdFTFh>H{_?@GzUv z^K2->!s^GzpnJT<4a|?h!sK8NL!<22a3ILTHuqoPM`3%Fy%S51BLE9$2Vse95GFbt zKF@{+$^MWtWG{zc(eRcRBmR8=W7V&CnQEfr}A>hz7LKA1@|96c9i8p!H7>%3FrDG2HQlN z`oioPygCb~5{v+yv4dgwHV8o|MCxTECOpQEN;=%G;m|pW84d?H$#^O-JkIwBLRj#s z1zdnc4@wNuu!tK1U*zM)eO+(t=P$hh1Qim2(%#5_n{O!e1>dIR9K+87&Otd5oUu?2 zT{OYJxEHc1>KlV4KAPYb42{XRmu&89v8%Dfp1*uI&U~w;j7<-8mYM#Hu5!s*k*?gG zwZ5FzznnAKrjC4TfVXKw<73eP-TaliTm5&MJsRDrb4JNP&e;qny!xD#vzVk~APbOR zPO4(6p~88?gM8vtO5LZtSAWO*!nr@Cz2-cn(9Kh76y(QK-s`?&2KwR%oZ+VKB;_?m z_dkQip5R;s_Pm;=e1Zu!Nn()GO1eB}kW3}qP15DLo1_~K1^H2tmyAUgFX=Dx6PLpR zCwg^)3%U~An0YX(zzoktsKTroGOvozj-)=%NAiRzIPfLTLZM6ng|uW_LwQ0iJZ;Bn zmmve9Gu0=pSyRWCM_@O0Utbz|fRr zA)f;m68yMr!92IYR}@k*q1Oi*q}TI6lZI8?Njc|9bxh5v3Ds&iW^q zvME=^GFG5T(esQs7-Bk@gRyiD(05Ht7o#t3hm(C)TN7z(Ue$K}xm!x+u8-+q?sjFS z>&{)T?DI3{ZjI?-?zYnLIXj%iE95NJok^_lB~Kb+hADd56G3iBQp^x>D%Z+R3*`~~ z7)atA?^7|ueoE21lsO9Ybk{R>xQv`~dFd>tPrxaw*F9xtkyA_>OXg;La&Dsg8Kb&x z6}To%p|p!2L6W4*f6^4ekD8>IQCZD?YKA`hGDW=s-^HYP(h{?*JI?}jOF^jsG^$IQOL0_pa?$a$5tcZ=-Am4DS zK(R2kjjJX4f9F$X(NNg2#y@M!#?sxGp{KPlViS%is^IQvw9u@KDqt=4&l>r;H6!nX zFNEl5kph2?6g0pg)F0QRK~)nalua^(v(ho%O>wG8=KIXae0iC^Oy%{w zmgrGXv_ha(fo8Wx)k7Nv)mGHYhe5$YeVj*y46F%J44enkpp_pPi%hV)*h|=n*B4cF zv3*1of^1wZOrup@>_|uy(MAyXx5q;Q&w=T~4|8lw)YO7Xp)A-*JBf%P;z0niVK z)_`nfl^zJvl-dgaE8UPC0bP5Ricx2Q+k%E))cxt=Su5Ytzolnskcw`GnxKBJzD(by z`@Aa27=oP?BZ1K%FR4NKg-0siW{JKeBC)Wntb!Q2Q5C*2CcG4tj|k40sKUPP;czf8 zEWYF`)CfR35%rw^M*V(@N^kAYymIpMEx$ZG|MssM7dsEY-5*3GzxS$Y=%~62POZA7 za0W#Rfw{mDi;_;{1>CLZ9;lkX&etD@qdIdY|Lp8pwlyFKfe9q9NZ=gU!ro2fKngXY z#xAfRus60*z$rw_STLs*YHGogYKeM^r z6dCPkVPOjZ4?vTVEp~Hm91PWxb>-`~2>7i_>TzK>+LmvMJwBr)RBiFviKZ%`VkB6w z2OHXH`}ts)lMO3L8yExAixX<_t=`|a=U9*bM31B)pMqrcpX@nys^pMiAAN9)zM)XusqC=8aejw>Z;SzZ&Y=p`pDpOz+u1fMZ zU?Dt2A$(ykRK!nE^n;c=MLnpfnmVw|s5RatTVt{>Yug%Umi3g`E-U0KTB@uwuF09~ z2_|EzN$yX%etINlF1vQ@>aoPx#PRgTp1b_r*FHazu6q5x`Sh{|s*~o|c3j;tb0D!d zYpS2x|CP@4!J*l{>wU@21!rqcZ@p%@YRTDZ;^B1d^K+eZ&n;8*&b>6`mc8^Nwc22O zsHgPi875)5XKya8pE#T9Og)$0d^~M_<&j!tFv4KA@@p5aUYMCkHe{{MQwP7&SwDy_ zdRj7`mXvP6v$?c+x_0xNXU>q`c{FW4M%u3!sWLAt&{{@18UM?0T%9vnukKzduT2Dh z*11sL`b%|&FoW#Y{`;Hzb9VQv<+>$Vo3y3GxovYT>7D)Qy6@e$pISCRSIgG5^I0;v zX3pP?ESQ=~X98=jtHA}=koCec?C!Ybn}+6}Hm4calB+IpdC}E!)exuSFXYNAXS=R< zB?hzQ&2jx#Hc!a`q`Kw;^BX?l=Uz*9_ob_j-#5RqhKb&|cbujDfj2m($=R!?;#?)TgOR_3;=-#G%^Bd;(+@(M5ncqHd zN^jf`qxaBtxw@9bnRM&HWi{>S#e;`2%#_)c(A_iDr?kH?-ZswdTxi|(NA|Z3*R<__ zC`es5uBkk&6SL=?n zs(;-;<8`YBau5?j&<{d)m8>jaCi4U{0UMMKfgM^RC#m&N5=E<5bTP1l{vF6yv{UJ* z39cBhIFPZgNlH7x9%F_ISXr@>@?NB;y z;01+J;Mw!T;d3BLCIBuEy*&=@Cxum6vm?Ou>ng0WfOrwghbR<&;|11-P3|V z_#nvlh$y4P*o*~<-QqQYxBzL+bCM3AYys#@(uPE&H;_hGK$?kbWTemm3nTJS*o;@G z!+Q0?KD_RNOkz+dNqQUtyOG5k0f1h{I(r}!(dDBEB3TC26l9RR*i}+ZI0p4Uz=r=F zvb;#jRo71K|HfIB*#6<}8@sdBo4$0m$Mw0&4ap;!%8s}-XY*Wp_v*U~Ha2BlY~7V< z-L=rVJ7;z-tJDV9Qh7_tnl0ZR-w&(}pd$o@CQ=Pwy4&Lia-Q1R==Eswxva+Zc*XIrcJJ?;Ds|B8SO_LuDhNPx63fgO^A4H-M@JXkQ4tOsnUM zJrk|}dAKNO{h*Sq*7_BWmDlwhimop-K{Ieos6*i9Q3)+@C(&0*ReZq<&!V;RH2eT% zuA|xi5~_%Z;mDf3vpduLVv_#gWWq6f3NG_%y~{&Ov=8hyG$4wZS1oDCa>2y`N_2$i zlU`cr!%op~$(tJBj6u%}PC+;Yk3Yfp+aQ~wP`#>LvN@0SVUm&J+_hhsi8ytAL$OYWG0{7deQPeVU2zf;r%m2d=#%KS$C~lTVEc~S zw4~=p!4v>v&8rn)?UbYukS7DH6#dvrGAW&bD@)P`L_fMkuSf>){BiyW0Q6o)c#Sk9 zZc533h3xTCLsMccA|I4_ppAGpNzbAmW-IW}^i%j3&qD@mucmIQXUSEaIJ#Kl%hdQ% zVzy@MmoC(+Y!!<(He+KKYz@HTa#qixwJ~FDOdiczw=7z_GS;rFwHx$SSKXqkHREap zrPS4$b#=ux@!*e)pqIM00jDxMrj7~-N=Zbp$YOw8$|yL}kdg{0xE81ZE%lg+Xb`2k z1T%!>za}XsEp90K?Kq$+ki+U*>SyCOcNoRyr-6zAuY8K8!D&#k3mq|yd=*m>oVw+S z5dnplYryEbTbe&@bZD!`aZqDfXElaDc{L_wt6~LO2VF-S(}e(izp0+o1LfmBnQqB@ z4e0cxk52UQ^|s5%Rxe$ou6CE2N%`NyhhBSTGE;F&gN0qyP8m__!3tlOtORPeeXh8)w8 zbKG8Xjt;UT3V!}4oM5ED#n#gkIj6#{08#xT*Lxn<3p-x2UM6P?pFVf0>Ng z$}ugn(l0bXCo(w8b`^p>5XD_5!ayy1bk9K($;|PHc!A^#@nTd%!dZj??CcC=eUd(} zd5Qv7kLpMOLKhn~6b6YNMT`zGDiDGb?AZ7@0L3lup-F#-xrouqE7`pD95(?sKf0^AhvJwle3 zx%%$mz{oi+@KUsHOb7=7wqJ|>=HLHsY>4c1iu#!`wIy}>cJ~6igGkGMfR@#u)k!*m z$2IV>Zyf1&T?e^dvWmeA{766v(;=t$REN4S3>``I<=>#%dEmAR3^Y8Sh-3!Peq7-F z0WlZ~Nwo3L-6dfZXBx(A46>5#5#EOT@7ftVY-fIF?o`d~nG1b#biuoa>$+!-eyXcjt&9Ix;lW#>ktm(W6c;~=76k<6CxO_5r`fWNU#7= zkpxtQB?cqd1T?*6w3Y$$H9~AuFiF8Ikvu1Q4)&hxKXKZBa^InzV|)C7+@9<`4#7Aa zP@tr_jKN%zdL(egTPEAHgV-7BUU_pCbZa@#hQVktLRqp(N7OaRG|FH0gIdRfrzIcZ zgmWVLo`5nI6iPIyL7^mcD3vR>{&N!wEl7s&FemGP^1;iI2ugUGq?I>_7>0@oT`Xlq z(p*Z)!r#Eizk+{p2?z+ZNSixRnXxrY9m=_ClctPo<5XYHRJmwkGbVPy)Uc$t%;*w^ znRn6+FQl#8|1S9VL!S=a*Z1aZj=13)Q&r-ntf_5k|FRbF*Z6_=VhJX3>V`RGxL2_S zn&zyonb6FcGz+Nhy!MmsIbZr^f7<-klEXP$b-n6$8h~yQ$}Ec;9$3p}c3uqw9&0Kq z8FX*jy6>MF|GDjpw)^_;$%ESKllrXPJJkz=I;(Szs$50YLmgADojL+&pu0ZVn|v|F zr#EzFT%A*WI5YR6t|p_aSi|9n=_TA-QJAJo2%HE zI+&^GT&&oUsn{{^x!aJf=!wG)RU~@v*_xN^uAI9z5lWm%8RlB%DghpN@esnPu3q|^ z%9;<*#MxUrcP6fBSCbr?Jm3 zXLq0a>pkiE7vuVL`D;17{hIBnEn&K^Z+hUYpV<}HLwk+ohsSrG}9+ zeMb#*(L9LhILl3N!pAhkjQ(9e2vy}-+cTq~fbbRf7a&@}rmdVF<14V&8h}orcvSxdGhto7e`_LqxcK=U=D(!{p>! zZ($%Y$TF=QFI}P+5nlcOO8_?%1A_f>cy`e_omWpp8b~Uc*Gq?bV%t+O-GW^l|Vc(6u z)Q)UT=a;T+aRYLAZ}R+oi*Ihj5`+ZqyuLH>>W{nQ`;o(UJg`+<`~KDM#~CFK@Ni0% zE#H{;k~u*x&Agsz>{)OexI2+?yc*X&$j1eqO7>@+ZMo|D4|m+ykvul{e75?9Tt&@? z`Wt%iSe?jLY|hm+0dw*g0Yu?`WLkDm?iU`_Acs;QD%(n{I35~ItfS&+z`3A6MeKATe2KF`1h_hfGE5=L$ zq9SaM>DIHlV+Lh6U@r~OCkuU+MBj1Nm=WTMR7G2Ss3t$blH!g{VUrNC(g2>MzLGWB zNvxuybOqCI0&^@_&L!dj;wsmjrKx0=rvF#7glNxQCG#vR+53W(4SQb>h*trL_^#C# z;|hmhC)cJ@{H0`MSIO$=p@y}sfWOca6o{ziC*Kgn1o#OinkwnDqGUCNIJ&izM5qY8 z68cDs+y~H&I>rF)6uz)&@Jf{QTUnfI1bq-Bm3TzWk&T6?fXZ;ycWRWrKvA$n+LgCM zKIN)d#>_DbSIgDiss9)_1}30N%g`42x-{q5;ygI`brn}Y)K=}nhLT;#qjgF<*oy{0 z@EQ>x0tzqqbXT=+Qfk95jh?_$rnlvVcXMB~<)HG@BS1qGuOG4hQTX6*U%*5XCVb#Q zK}i+0wYMv^+A%^+GKvs=#``0}I3I04Rfx?~yrKkW2t%L{+H)977Z{z8e?jB}J6#LE z^?~16z|T_av$B3dvIf@c`k>Q_cc}dN&fGF~u&)5%MKiQu4hc8A=ugK^PnM6(T z2X1&@JX+T0^9kcPj{>44T!PyJ!ex9Ig=`#M)of`SjHHZKD(l(=KT<2jZgvsPDXKN@ z2V~FZtpqoaaGw0GL9*@<&O`XuXoUQTLy*i60_KOei?(Y-vwx%8)ibzrbwGft&8|*Y>J7v*d0} z9!zl`_065hG`)PcDeFEMH+^Mt=gjU!b8W_4o4B}OZu{EPFrx;{AYn*0CS7TF%bnma zhHejiY2W!!O;t346>lk<**g=N8BFY->9}uhShhjPz#}K++&rhtmhYH4nq#bsj4Q*q zW`-A-#;@^Po9znb%ZTH@3Obvb9k@j}Y zx23H;|FrL)4}Wp^zW%i(z3H0%s(!{XZTg0RMX?Lah6g&+qOS6ut`b7j5Nfdg@c0jo zC;F$3FE9;IX`OA5cR=r)c`Xr2Iq&OzFgi>d+8WbF?*h}dWUfh^&YD|5sfQnz4J8^= zy8DLBb9)qkp*i75ZkYpsVdolvVT;^}x%Or-*__gSD!F!)^;_uM5qz#*8xB2I)jlCPxUu-}u06#mVHNPL&DB2}`-Wo~g_jBQ( z-!J@octDIF_}LyWe{N_WOFDEO!*B7(mrHCQ61+NwkUYv@0cD1;6RR@#vp+eA27|qX z=P>KUY%gZy!|lhbS24rD02$OQezEu!;56}b^ZRd8vQ>wXA(Fm(B+QKu^Dhbi0Jo^! zh`S+&5CNKg$XI9wKyuptHD&smGJH)LzoxAJLRA3hRb7*&JaCR?cl`NlU01u(?j2dv z&MAG);)$QQHgI(yUD1}cY@9OYTs4Wow6i%)IhM6r+MaSQQ&7w`tzfZSW~Q}Ch}DB4 z)xUzpa^3f6x-8kWOkp`E%H@3I?!HeaR>-sEF;x{^mVz2k07|I-WakQAE_Z3@%EUf^ zdg#hzPpUB`rdmHbzJj;Qc0KLDKA}i;&Z*~4&gpNzw1T(G?tYrqCg2^GsZP0^hi>rx w?*0{0E|01_FjHokf+FQy!D6Mei*_wXX^Jt&qxaO6UorZ>H~>V-RFMt-KWR*lJpcdz diff --git a/scripts/__pycache__/test_generate.cpython-312-pytest-9.0.2.pyc b/scripts/__pycache__/test_generate.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 946ef9be873abf45911250b748a4a47e3fb8d2ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49255 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 F{}0}ja4!G=