diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8d2863abbf..50593edf23 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,11 +15,8 @@ on: paths: - ".github/workflows/docs.yml" - ".github/docs-versions.yml" - - "build_scripts/inject_version_picker.py" - - "build_scripts/version_picker_assets/**" + - "build_scripts/**" - "doc/**" - - "build_scripts/pydoc2json.py" - - "build_scripts/gen_api_md.py" - "pyrit/**" workflow_dispatch: @@ -62,7 +59,7 @@ jobs: with: sparse-checkout: | .github/docs-versions.yml - build_scripts/resolve_docs_matrix.py + /build_scripts/ sparse-checkout-cone-mode: false - name: Set up Python 3.13 @@ -76,7 +73,7 @@ jobs: - name: Compute matrix id: matrix run: | - python build_scripts/resolve_docs_matrix.py \ + python -m build_scripts.resolve_docs_matrix \ --config .github/docs-versions.yml \ --github-output "$GITHUB_OUTPUT" @@ -164,8 +161,8 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: | source .venv/bin/activate - python build_scripts/pydoc2json.py pyrit --submodules -o doc/_api/pyrit_all.json - python build_scripts/gen_api_md.py + python -m build_scripts.pydoc2json pyrit --submodules -o doc/_api/pyrit_all.json + python -m build_scripts.gen_api_md - name: Build the static HTML site if: steps.cache.outputs.cache-hit != 'true' @@ -208,10 +205,7 @@ jobs: with: sparse-checkout: | .github/docs-versions.yml - build_scripts/inject_version_picker.py - build_scripts/version_picker_assets - build_scripts/generate_pages_manifest.py - build_scripts/compose_docs_dist.py + /build_scripts/ sparse-checkout-cone-mode: false - name: Set up Python 3.13 @@ -235,7 +229,7 @@ jobs: # auto-redirect script that uses the manifest to find the closest # sibling page in the same version). run: | - python build_scripts/compose_docs_dist.py \ + python -m build_scripts.compose_docs_dist \ --artifacts-dir artifacts \ --dist-dir dist \ --config .github/docs-versions.yml \ @@ -243,7 +237,7 @@ jobs: - name: Inject version picker run: | - python build_scripts/inject_version_picker.py \ + python -m build_scripts.inject_version_picker \ --site-dir dist \ --base "${DOCS_BASE}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f90f187e72..3d1dab6da2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,46 +10,46 @@ repos: hooks: - id: sanitize-notebook-paths name: Sanitize Notebook Paths - entry: python ./build_scripts/sanitize_notebook_paths.py + entry: python -m build_scripts.sanitize_notebook_paths language: python files: ^doc.*\.(ipynb)$ - id: strip-notebook-progress-bars name: Strip Notebook Progress Bars - entry: python ./build_scripts/strip_notebook_progress_bars.py + entry: python -m build_scripts.strip_notebook_progress_bars language: python files: ^doc.*\.(ipynb)$ - id: strip-py-kernelspec name: Strip kernelspec from jupytext .py files - entry: python ./build_scripts/strip_py_kernelspec.py + entry: python -m build_scripts.strip_py_kernelspec language: python files: ^doc/.*\.py$ - id: validate-docs name: Validate Documentation Structure - entry: python ./build_scripts/validate_docs.py + entry: python -m build_scripts.validate_docs language: python files: ^(doc/.*\.(py|ipynb|md)|doc/myst\.yml)$ pass_filenames: false additional_dependencies: ['pyyaml'] - id: check-no-rest-roles name: Reject Sphinx reST cross-reference roles - entry: python ./build_scripts/check_no_rest_roles.py + entry: python -m build_scripts.check_no_rest_roles language: python files: ^pyrit/.*\.py$ - id: enforce_alembic_revision_immutability name: Enforce Alembic Revision Immutability - entry: python ./build_scripts/enforce_alembic_revision_immutability.py + entry: python -m build_scripts.enforce_alembic_revision_immutability language: python files: ^pyrit/memory/alembic/versions/.*\.py$ pass_filenames: false - id: check-async-suffix name: Enforce _async Suffix on async def - entry: python ./build_scripts/check_async_suffix.py + entry: python -m build_scripts.check_async_suffix language: python files: ^pyrit/.*\.py$ pass_filenames: false - id: memory-migrations-check name: Check Memory Migrations - entry: python ./build_scripts/memory_migrations.py check + entry: python -m build_scripts.memory_migrations check language: system pass_filenames: false files: ^pyrit/memory/(memory_models\.py|alembic/.*|migration\.py)$ diff --git a/Makefile b/Makefile index e42722b191..fffa180d2f 100644 --- a/Makefile +++ b/Makefile @@ -23,11 +23,11 @@ ty: # 3. Build the Jupyter Book site (HTML only — fast, no LaTeX needed) # 4. Generate RSS feed docs-build: - uv run python build_scripts/pydoc2json.py pyrit --submodules -o doc/_api/pyrit_all.json - uv run python build_scripts/gen_api_md.py + uv run python -m build_scripts.pydoc2json pyrit --submodules -o doc/_api/pyrit_all.json + uv run python -m build_scripts.gen_api_md # --strict validates URLs and cross-refs; skips are configured in doc/myst.yml under error_rules cd doc && uv run jupyter-book build --all --html --strict - uv run ./build_scripts/generate_rss.py + uv run python -m build_scripts.generate_rss # Build the full documentation site including the PDF export. # Mirrors the ReadTheDocs build (.readthedocs.yaml) so CI catches PDF-only issues @@ -35,16 +35,16 @@ docs-build: # Requires xelatex / latexmk on PATH (texlive-xetex + texlive-fonts-recommended + # texlive-plain-generic + latexmk on Ubuntu). docs-build-all: - uv run python build_scripts/pydoc2json.py pyrit --submodules -o doc/_api/pyrit_all.json - uv run python build_scripts/gen_api_md.py + uv run python -m build_scripts.pydoc2json pyrit --submodules -o doc/_api/pyrit_all.json + uv run python -m build_scripts.gen_api_md # --strict validates URLs and cross-refs; skips are configured in doc/myst.yml under error_rules cd doc && uv run jupyter-book build --all --html --pdf --strict - uv run ./build_scripts/generate_rss.py + uv run python -m build_scripts.generate_rss # Regenerate only the API reference pages (without building the full site) docs-api: - uv run python build_scripts/pydoc2json.py pyrit --submodules -o doc/_api/pyrit_all.json - uv run python build_scripts/gen_api_md.py + uv run python -m build_scripts.pydoc2json pyrit --submodules -o doc/_api/pyrit_all.json + uv run python -m build_scripts.gen_api_md # Because of import time, "auto" seemed to actually go slower than just using 4 processes unit-test: diff --git a/build_scripts/__init__.py b/build_scripts/__init__.py new file mode 100644 index 0000000000..d96d1494f8 --- /dev/null +++ b/build_scripts/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Repository build and maintenance scripts. + +Modules here are executed as ``python -m build_scripts.`` from the +repository root so sibling imports resolve through the package rather than +through ``sys.path`` mutation. +""" diff --git a/build_scripts/check_no_rest_roles.py b/build_scripts/check_no_rest_roles.py index 2f28eba6cf..4eec1310e5 100644 --- a/build_scripts/check_no_rest_roles.py +++ b/build_scripts/check_no_rest_roles.py @@ -12,7 +12,7 @@ This hook flags any newly introduced reST role inside ``pyrit/`` so it can be replaced before landing. Run it manually with:: - uv run python build_scripts/check_no_rest_roles.py + uv run python -m build_scripts.check_no_rest_roles or rely on the ``check-no-rest-roles`` pre-commit hook in ``.pre-commit-config.yaml``. diff --git a/build_scripts/compose_docs_dist.py b/build_scripts/compose_docs_dist.py index 906173cf0e..f3820825b4 100644 --- a/build_scripts/compose_docs_dist.py +++ b/build_scripts/compose_docs_dist.py @@ -22,7 +22,7 @@ tweaking the 404 markup is a code change, not a shell-quoting puzzle. Usage: - python build_scripts/compose_docs_dist.py \\ + python -m build_scripts.compose_docs_dist \\ --artifacts-dir artifacts \\ --dist-dir dist \\ --config .github/docs-versions.yml \\ @@ -39,7 +39,8 @@ from typing import Any import yaml -from generate_pages_manifest import collect_pages + +from build_scripts.generate_pages_manifest import collect_pages # Single source of truth for the closest-page algorithm. Both the version # picker (inject_version_picker.py) and the 404 page below concat the diff --git a/build_scripts/evaluate_scorers.py b/build_scripts/evaluate_scorers.py index 27ba1a74e1..1fcefb6709 100644 --- a/build_scripts/evaluate_scorers.py +++ b/build_scripts/evaluate_scorers.py @@ -8,10 +8,10 @@ scorer performance. Results are saved to the scorer_evals directory and checked in. Usage: - python build_scripts/evaluate_scorers.py - python build_scripts/evaluate_scorers.py --tags refusal - python build_scripts/evaluate_scorers.py --tags refusal,default - python build_scripts/evaluate_scorers.py --max-concurrency 3 + python -m build_scripts.evaluate_scorers + python -m build_scripts.evaluate_scorers --tags refusal + python -m build_scripts.evaluate_scorers --tags refusal,default + python -m build_scripts.evaluate_scorers --max-concurrency 3 """ import argparse diff --git a/build_scripts/example_index.py b/build_scripts/example_index.py index 863824efc7..9abd3f0489 100644 --- a/build_scripts/example_index.py +++ b/build_scripts/example_index.py @@ -6,15 +6,13 @@ import ast import json import re -import sys import textwrap from dataclasses import dataclass from pathlib import Path import yaml -sys.path.insert(0, str(Path(__file__).parent)) -import validate_docs +from build_scripts import validate_docs @dataclass(frozen=True) diff --git a/build_scripts/gen_api_md.py b/build_scripts/gen_api_md.py index cc21078bc9..1b9e81e84c 100644 --- a/build_scripts/gen_api_md.py +++ b/build_scripts/gen_api_md.py @@ -13,7 +13,7 @@ MyST markdown pages suitable for Jupyter Book 2. Usage: - python build_scripts/gen_api_md.py + python -m build_scripts.gen_api_md """ import json @@ -22,9 +22,8 @@ from pathlib import Path # Import sibling script for post-generation TOC validation. -sys.path.insert(0, str(Path(__file__).parent)) -import validate_docs -from example_index import ExampleReference, SymbolEntry, _build_example_index +from build_scripts import validate_docs +from build_scripts.example_index import ExampleReference, SymbolEntry, _build_example_index DOC_ROOT = Path("doc") API_JSON_DIR = DOC_ROOT / "_api" diff --git a/build_scripts/generate_pages_manifest.py b/build_scripts/generate_pages_manifest.py index 9476d5aa6d..0c1f027fbb 100644 --- a/build_scripts/generate_pages_manifest.py +++ b/build_scripts/generate_pages_manifest.py @@ -3,7 +3,7 @@ """Generate a pages.json manifest listing every rendered HTML page in a built site. Usage: - python build_scripts/generate_pages_manifest.py \\ + python -m build_scripts.generate_pages_manifest \\ --site-dir dist/0.13.0 \\ --output dist/0.13.0/pages.json diff --git a/build_scripts/inject_version_picker.py b/build_scripts/inject_version_picker.py index 00164d69bb..cbfc4852cd 100644 --- a/build_scripts/inject_version_picker.py +++ b/build_scripts/inject_version_picker.py @@ -3,7 +3,7 @@ """Inject the PyRIT version picker into a built doc site. Usage: - python build_scripts/inject_version_picker.py \\ + python -m build_scripts.inject_version_picker \\ --site-dir dist \\ --base /PyRIT diff --git a/build_scripts/migrate_prod_memory_schema.py b/build_scripts/migrate_prod_memory_schema.py index cac39672e5..d9e4714e79 100644 --- a/build_scripts/migrate_prod_memory_schema.py +++ b/build_scripts/migrate_prod_memory_schema.py @@ -19,7 +19,7 @@ - Exits non-zero on any failure. Usage: - python build_scripts/migrate_prod_memory_schema.py + python -m build_scripts.migrate_prod_memory_schema The script reads the production connection string from AZURE_SQL_DB_CONNECTION_STRING_PROD (loaded from ~/.pyrit/.env). diff --git a/build_scripts/preview_server.py b/build_scripts/preview_server.py index 058b169ecb..9a276e280e 100644 --- a/build_scripts/preview_server.py +++ b/build_scripts/preview_server.py @@ -10,7 +10,7 @@ Run from the directory you want to serve: - python build_scripts/preview_server.py [--port 8000] [--directory dist] + python -m build_scripts.preview_server [--port 8000] [--directory dist] Resolution rules, in order: 1. If the requested path is a file, serve it. diff --git a/build_scripts/resolve_docs_matrix.py b/build_scripts/resolve_docs_matrix.py index 72301530dd..f71e110d5b 100644 --- a/build_scripts/resolve_docs_matrix.py +++ b/build_scripts/resolve_docs_matrix.py @@ -7,7 +7,7 @@ or to stdout when ``--github-output`` is not provided (for local testing). Usage: - python build_scripts/resolve_docs_matrix.py \\ + python -m build_scripts.resolve_docs_matrix \\ --config .github/docs-versions.yml \\ --github-output "$GITHUB_OUTPUT" diff --git a/doc/code/scoring/4_scorer_metrics.ipynb b/doc/code/scoring/4_scorer_metrics.ipynb index 2240530eb8..3a3dd5d686 100644 --- a/doc/code/scoring/4_scorer_metrics.ipynb +++ b/doc/code/scoring/4_scorer_metrics.ipynb @@ -688,14 +688,14 @@ "\n", "```bash\n", "# Evaluate all registered scorers (long-running — can take hours)\n", - "python build_scripts/evaluate_scorers.py\n", + "python -m build_scripts.evaluate_scorers\n", "\n", "# Evaluate only scorers with specific tags\n", - "python build_scripts/evaluate_scorers.py --tags refusal\n", - "python build_scripts/evaluate_scorers.py --tags refusal,default\n", + "python -m build_scripts.evaluate_scorers --tags refusal\n", + "python -m build_scripts.evaluate_scorers --tags refusal,default\n", "\n", "# Control parallelism (default: 5, lower if hitting rate limits)\n", - "python build_scripts/evaluate_scorers.py --max-concurrency 3\n", + "python -m build_scripts.evaluate_scorers --max-concurrency 3\n", "```\n", "\n", "### Tags\n", @@ -715,7 +715,7 @@ "**Step 1: Evaluate refusal scorers first**\n", "\n", "```bash\n", - "python build_scripts/evaluate_scorers.py --tags refusal\n", + "python -m build_scripts.evaluate_scorers --tags refusal\n", "```\n", "\n", "This evaluates only the 4 refusal variants and writes results to\n", @@ -725,7 +725,7 @@ "**Step 2: Re-evaluate all scorers**\n", "\n", "```bash\n", - "python build_scripts/evaluate_scorers.py\n", + "python -m build_scripts.evaluate_scorers\n", "```\n", "\n", "On the next full run, `ScorerInitializer` reads the refusal metrics from Step 1, picks the best\n", diff --git a/doc/code/scoring/4_scorer_metrics.py b/doc/code/scoring/4_scorer_metrics.py index 0134165a7c..512c6aedb4 100644 --- a/doc/code/scoring/4_scorer_metrics.py +++ b/doc/code/scoring/4_scorer_metrics.py @@ -368,14 +368,14 @@ # # ```bash # # Evaluate all registered scorers (long-running — can take hours) -# python build_scripts/evaluate_scorers.py +# python -m build_scripts.evaluate_scorers # # # Evaluate only scorers with specific tags -# python build_scripts/evaluate_scorers.py --tags refusal -# python build_scripts/evaluate_scorers.py --tags refusal,default +# python -m build_scripts.evaluate_scorers --tags refusal +# python -m build_scripts.evaluate_scorers --tags refusal,default # # # Control parallelism (default: 5, lower if hitting rate limits) -# python build_scripts/evaluate_scorers.py --max-concurrency 3 +# python -m build_scripts.evaluate_scorers --max-concurrency 3 # ``` # # ### Tags @@ -395,7 +395,7 @@ # **Step 1: Evaluate refusal scorers first** # # ```bash -# python build_scripts/evaluate_scorers.py --tags refusal +# python -m build_scripts.evaluate_scorers --tags refusal # ``` # # This evaluates only the 4 refusal variants and writes results to @@ -405,7 +405,7 @@ # **Step 2: Re-evaluate all scorers** # # ```bash -# python build_scripts/evaluate_scorers.py +# python -m build_scripts.evaluate_scorers # ``` # # On the next full run, `ScorerInitializer` reads the refusal metrics from Step 1, picks the best diff --git a/doc/contributing/10_release_process.md b/doc/contributing/10_release_process.md index 15c1de7040..a92d90d118 100644 --- a/doc/contributing/10_release_process.md +++ b/doc/contributing/10_release_process.md @@ -55,7 +55,7 @@ for private vulnerability reporting. - **Verify build pipelines.** Confirm that all integration tests and end-to-end tests are passing in the CI pipelines. If any tests are failing, fix them before proceeding. - **Partner integration tests.** Ensure the partner integration tests are also passing. These tests validate that we are not breaking contracts with partner teams (e.g., Foundry). If any are failing, coordinate with the affected partner teams before proceeding with the release. - **Azure key-based auth is disabled in our tenant.** Our Azure subscription has API-key (local) auth turned off, so Azure target integration tests authenticate with Microsoft Entra ID. Tests that run notebooks requiring Azure API keys are deliberately skipped; otherwise they fail with HTTP 403 `AuthenticationTypeDisabled` ("Key based authentication is disabled for this resource"). Do not re-enable key auth for our tenant. When validating a release manually, authenticate Azure targets with Entra (`az login`) rather than API keys. -- **Update scorer metrics.** Run `python .\build_scripts\evaluate_scorers.py` and commit the results so that scorer evaluation metrics are up to date. +- **Update scorer metrics.** Run `python -m build_scripts.evaluate_scorers` and commit the results so that scorer evaluation metrics are up to date. ## 2. Decide the Next Version @@ -155,7 +155,7 @@ The PyRIT package includes a web-based frontend that must be built before packag Run the prepare script to build the frontend and copy it into the package structure: ```bash -python build_scripts/prepare_package.py +python -m build_scripts.prepare_package ``` This will: @@ -237,7 +237,7 @@ uv run python -c "import pyrit; print(pyrit.__version__)" # verify: x.y.z (no . **Run the migration** (reads `AZURE_SQL_DB_CONNECTION_STRING_PROD` from `~/.pyrit/.env`): ```bash -uv run python build_scripts/migrate_prod_memory_schema.py +uv run python -m build_scripts.migrate_prod_memory_schema ``` The script validates the environment (release branch, clean tree, no `.dev` version), diff --git a/doc/contributing/11_memory_models.md b/doc/contributing/11_memory_models.md index dc7d78b88b..56db805e91 100644 --- a/doc/contributing/11_memory_models.md +++ b/doc/contributing/11_memory_models.md @@ -41,7 +41,7 @@ Make your changes in `pyrit/memory/memory_models.py`. Follow these conventions: ### 2. Generate a migration ```bash -python build_scripts/memory_migrations.py generate -m "short description of change" +python -m build_scripts.memory_migrations generate -m "short description of change" ``` This creates a new revision file under `pyrit/memory/alembic/versions/`. **Review the generated file carefully** — auto-generated migrations may need manual adjustments (e.g. for data migrations or default values). @@ -49,7 +49,7 @@ This creates a new revision file under `pyrit/memory/alembic/versions/`. **Revie ### 3. Validate the migration ```bash -python build_scripts/memory_migrations.py check +python -m build_scripts.memory_migrations check ``` This verifies the schema produced by running all migrations matches the current models. Both pre-commit hooks (see below) and CI run this check. diff --git a/docker/Dockerfile b/docker/Dockerfile index 424640ae91..71e9f9acda 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -64,7 +64,7 @@ RUN if [ "$PYRIT_SOURCE" = "pypi" ]; then \ echo "Installing PyRIT from local source"; \ uv pip install --python /opt/venv/bin/python -e .[speech,opencv,fairness_bias,fastapi,playwright]; \ echo "Building frontend..."; \ - /opt/venv/bin/python build_scripts/prepare_package.py; \ + /opt/venv/bin/python -m build_scripts.prepare_package; \ fi && \ echo "Creating build info..." && \ /opt/venv/bin/python -c "import json; import pyrit; \ diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py index 18825fa3ac..b195ae3b9c 100644 --- a/pyrit/backend/main.py +++ b/pyrit/backend/main.py @@ -177,5 +177,5 @@ def setup_frontend() -> None: print("⚠️ WARNING: Frontend not found!") print(f" Expected location: {frontend_path}") print(" The frontend must be built and included in the package.") - print(" Run: python build_scripts/prepare_package.py") + print(" Run: python -m build_scripts.prepare_package") print(" API endpoints will still work but the UI won't be available.") diff --git a/tests/unit/build_scripts/test_compose_docs_dist.py b/tests/unit/build_scripts/test_compose_docs_dist.py index 9543371af7..51736bfa89 100644 --- a/tests/unit/build_scripts/test_compose_docs_dist.py +++ b/tests/unit/build_scripts/test_compose_docs_dist.py @@ -4,33 +4,20 @@ from __future__ import annotations -import importlib.util import json -import sys -from pathlib import Path +from typing import TYPE_CHECKING import pytest -REPO_ROOT = Path(__file__).resolve().parents[3] -SCRIPT = REPO_ROOT / "build_scripts" / "compose_docs_dist.py" -BUILD_SCRIPTS = REPO_ROOT / "build_scripts" +from build_scripts import compose_docs_dist + +if TYPE_CHECKING: + from pathlib import Path @pytest.fixture(scope="module") def module(): - # compose_docs_dist imports generate_pages_manifest from the same dir. - sys.path.insert(0, str(BUILD_SCRIPTS)) - try: - spec = importlib.util.spec_from_file_location("compose_docs_dist", SCRIPT) - assert spec is not None and spec.loader is not None - mod = importlib.util.module_from_spec(spec) - sys.modules["compose_docs_dist"] = mod - spec.loader.exec_module(mod) - return mod - finally: - # Leave the sys.path entry so generate_pages_manifest stays importable - # for the rest of the test session. - pass + return compose_docs_dist def _make_artifacts(tmp_path: Path, slugs: list[str]) -> Path: diff --git a/tests/unit/build_scripts/test_import_hygiene.py b/tests/unit/build_scripts/test_import_hygiene.py new file mode 100644 index 0000000000..304e81ffd8 --- /dev/null +++ b/tests/unit/build_scripts/test_import_hygiene.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Guards against sys.path manipulation and bare sibling imports in build_scripts/.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +BUILD_SCRIPTS = Path(__file__).resolve().parents[3] / "build_scripts" +SCRIPT_PATHS = sorted(BUILD_SCRIPTS.glob("*.py")) +SIBLING_MODULES = {path.stem for path in SCRIPT_PATHS} - {"__init__"} + + +def _parse(path: Path) -> ast.Module: + return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +def _mutates_sys_path(node: ast.AST) -> bool: + """Detect ``sys.path.insert(...)``/``append(...)`` and ``sys.path[...] = ...`` writes.""" + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Attribute): + return func.value.attr == "path" and isinstance(func.value.value, ast.Name) and func.value.value.id == "sys" + if isinstance(node, (ast.Assign, ast.AugAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + value = target.value if isinstance(target, ast.Subscript) else target + if isinstance(value, ast.Attribute) and value.attr == "path": + return isinstance(value.value, ast.Name) and value.value.id == "sys" + return False + + +def test_build_scripts_is_a_package() -> None: + assert (BUILD_SCRIPTS / "__init__.py").is_file() + + +def test_script_paths_were_discovered() -> None: + # Guards against the glob silently matching nothing and vacuously passing. + assert len(SIBLING_MODULES) > 1 + + +@pytest.mark.parametrize("script", SCRIPT_PATHS, ids=lambda p: p.name) +def test_script_does_not_mutate_sys_path(script: Path) -> None: + offenders = [node.lineno for node in ast.walk(_parse(script)) if _mutates_sys_path(node)] + assert not offenders, ( + f"{script.name} mutates sys.path at line(s) {offenders}. " + "Import siblings as 'from build_scripts import ' and run the script " + "with 'python -m build_scripts.' instead." + ) + + +@pytest.mark.parametrize("script", SCRIPT_PATHS, ids=lambda p: p.name) +def test_script_imports_siblings_through_the_package(script: Path) -> None: + offenders: list[str] = [] + for node in ast.walk(_parse(script)): + if isinstance(node, ast.Import): + offenders += [alias.name for alias in node.names if alias.name in SIBLING_MODULES] + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module in SIBLING_MODULES: + offenders.append(node.module) + + assert not offenders, ( + f"{script.name} imports sibling module(s) {sorted(set(offenders))} by bare name. " + "Use the package-qualified form 'from build_scripts import '." + ) diff --git a/tests/unit/memory/test_migration.py b/tests/unit/memory/test_migration.py index aadbaa8e70..45ccc895dd 100644 --- a/tests/unit/memory/test_migration.py +++ b/tests/unit/memory/test_migration.py @@ -659,11 +659,7 @@ def test_memory_interface_check_schema_migration_raises_without_engine(): def test_memory_migrations_head_command(capsys): """The 'head' subcommand of memory_migrations.py prints the current Alembic head revision.""" - import sys - - # Import the module's main function - sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "build_scripts")) - from memory_migrations import _cmd_head + from build_scripts.memory_migrations import _cmd_head _cmd_head() captured = capsys.readouterr()