diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2c504d1be..f2dfbf246 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -134,6 +134,9 @@ jobs: run: uv lock --check - name: Test the rules registry tool run: uv run --frozen --extra dev python -m unittest discover -s scripts/rules/tests -t . + # The v2 import contracts in [tool.importlinter] in pyproject.toml. + - name: Check v2 import contracts with Import Linter + run: uv run --frozen --extra dev lint-imports --config pyproject.toml - name: Install shellcheck (for the ci-scripts guard) run: pip install "shellcheck-py==0.11.0.1" - name: Run quality guards diff --git a/AGENTS.md b/AGENTS.md index 9fbc04cc4..1430243e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,10 +87,9 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. ### API v2 layer boundaries -- Checked: `api.v2.connectors-mutations-no-reads`, `api.v2.connectors-reads-no-mutations`, `api.v2.crud-checks-cover-every-resource`, `api.v2.session-modules-no-queries`, `api.v2.user-services-no-sql`, `api.v2.validators-transformations-no-db` +- Checked: `api.v2.connectors-mutations-no-reads`, `api.v2.connectors-no-cross-calls`, `api.v2.connectors-reads-no-mutations`, `api.v2.crud-checks-cover-every-resource`, `api.v2.import-layers`, `api.v2.pure-modules-no-database`, `api.v2.routes-import-no-database-access`, `api.v2.session-modules-no-queries`, `api.v2.types-transformations-no-validators`, `api.v2.user-services-no-sql`, `api.v2.validators-transformations-no-db` - `api.routes.shared-query-parameters`: Routes that accept query parameters define them through the shared typed query models, never with a second manual parser. (doc-only, not checked: follow it yourself) - `api.v2.connectors-never-commit`: v2 connectors receive a session from the service and never open, commit, roll back or close it. (doc-only, not checked: follow it yourself) -- `api.v2.connectors-no-cross-calls`: v2 connector modules never call one another to sequence a workflow; the service does the sequencing. (doc-only, not checked: follow it yourself) - `api.v2.routes-no-sessions-or-sql`: v2 route modules never open database sessions or construct SQL; they call one service method and map typed failures to HTTP. (doc-only, not checked: follow it yourself) - `api.v2.services-no-sql`: v2 services sequence work but never construct SQL expressions, call Session.exec, or define HTTP response models. (doc-only, not checked: follow it yourself) diff --git a/changelog.d/4181.added.md b/changelog.d/4181.added.md new file mode 100644 index 000000000..469e3595e --- /dev/null +++ b/changelog.d/4181.added.md @@ -0,0 +1 @@ +Check the v2 import architecture with Import Linter in the quality-guards CI job: v2 routes, services and data import each other in one direction only, v2 validators, transformations and types reach no database code except the table models, v2 routes import no connector or session module, types and transformations import no validators, and connectors import no other connector. Today's exceptions are listed as shrink-only baselines. diff --git a/docs/engineering/skills/rules-registry.md b/docs/engineering/skills/rules-registry.md index 385d0c7b7..1dbc221fa 100644 --- a/docs/engineering/skills/rules-registry.md +++ b/docs/engineering/skills/rules-registry.md @@ -72,7 +72,8 @@ gap is visible, rather than leaving it out. | `ruff` | `mode` = `format`, `lint` (with `codes`) or `per-file-ignore` (with `pattern`, `codes`) | Codes are selected / the ignore entry exists and covers only [files allowed file-level exceptions](#file-level-exceptions) | | `mypy` | `paths` | Each path is in `[tool.mypy].files` | | `workflow-job` | optional `step` | The CI job exists (the job itself is the check) | -| `pyright`, `import-linter` | `config` (+ `contract` for Import Linter) | The config file exists | +| `pyright` | `config` | The config file exists | +| `import-linter` | `config`, `contract`, `contract_sha256`, optional `allowed_ignore_imports` | The contract with that `id` exists; its definition hashes to `contract_sha256`; and its `ignore_imports` equals the rule's baseline entries plus `allowed_ignore_imports`, with no wildcards and with `unmatched_ignore_imports_alerting = "error"` (see "Baselines") | | `docs-guard` | `doc`, `marker` | The doc exists | | `llm-review` | `prompt`, `model` | The prompt exists; judgment tier only | | `none` | | Nothing; only for `doc-only` and `advisory` rules | @@ -124,6 +125,36 @@ fail the guard yet. - Other enforcers (mypy, Pyright, Import Linter, custom guards) may start `ratcheted` with a baseline file of tolerated violations. Baselines may only shrink: the guard compares counts against the base branch. +- **Import Linter** tolerates a violation through the contract's + `ignore_imports` in `pyproject.toml`. The rule's baseline file lists the same + entries, one per line, so adding one needs a change under the code-owned + `rules/` directory; imports the rule allows by design, rather than tolerates, + go in `enforcer.allowed_ignore_imports`. The guard fails when the contract's + `ignore_imports` and those two lists differ, and + `unmatched_ignore_imports_alerting = "error"` makes Import Linter fail on an + entry that no longer matches, so fixing an import forces the entry out. + No entry may use a wildcard, so each names one importer and one imported + module, and a new module's own imports never match an old entry (it can + still reach a forbidden module through a tolerated import; see below). CI + runs exactly `lint-imports --config pyproject.toml`, which the guard checks, + so a `setup.cfg` or `.importlinter` file can't take the contracts' place and + `--contract` or `|| true` can't skip them. +- `enforcer.allowed_ignore_imports` has no count limit. Moving a tolerated + import from the baseline into it lowers the baseline's count without fixing + anything, so code-owner review of `rules/registry.toml` is the check on it. +- An Import Linter rule also pins the rest of its contract: the SHA-256 of the + top-level `[tool.importlinter]` settings and every contract field except + `ignore_imports` (`scripts/rules/import_linter.py`, `contract_fingerprint`) + must equal `enforcer.contract_sha256`. Narrowing a contract's modules, or a + setting such as `exclude_type_checking_imports`, therefore needs a registry + change too; the guard's message gives the new value. +- Ignoring an import removes it from the graph the contract checks, so the + exception also covers every chain from another module that passes through + it, including from modules added later. Each such chain goes away when the + tolerated import is fixed. +- Each rule's proof fixture breaks the contract once per source and forbidden + module pattern, and lists those violations in `expected_violations`; the + proof requires Import Linter to report exactly them. - Moving a rule from enforced/ratcheted to advisory/doc-only needs an explicit `[rule.downgrade] reason`. @@ -186,6 +217,7 @@ describe: - a ruff format check in CI, an explicitly selected ruff code, or a ruff per-file ignore; - a `[tool.mypy].files` entry; +- an Import Linter contract in `pyproject.toml`; - a test matching `[discovery].guard_test_globs` or listed in `extra_guard_tests`; - a job in a `[discovery].workflows` file. diff --git a/docs/engineering/skills/v2-code-organization.md b/docs/engineering/skills/v2-code-organization.md index a02ce5dfe..23a0b5f17 100644 --- a/docs/engineering/skills/v2-code-organization.md +++ b/docs/engineering/skills/v2-code-organization.md @@ -118,7 +118,11 @@ database connectors. Names should describe the represented data, such as `PolicyCreationInput`, not an architectural pattern such as “command.” Types may enforce their own field-level invariants through Pydantic validation, -but multi-record or catalog validation belongs in `validators.py`. +but multi-record or catalog validation belongs in `validators.py`. Types and +transformations never import a validators module, directly or through other +modules; validators import types (rule +`api.v2.types-transformations-no-validators`, with today's exceptions in its +baseline). ### `database_session.py` @@ -146,7 +150,8 @@ Connector functions receive a session from the service. They do not open, commit, roll back, or close it. They do not call route functions, perform request-level or business validation, or convert rows into public response types. Connector modules do not call one another to sequence a workflow; the -service makes that ordering explicit. +service makes that ordering explicit. They never import one another (rule +`api.v2.connectors-no-cross-calls`, with today's exceptions in its baseline). The module name also decides which table privileges the connector needs (rule `api.v2.runtime-privileges-declared`): `reads` modules need SELECT; @@ -173,6 +178,33 @@ route -> service -> database session + database connectors -> SQLModel tables -> types ``` +Import Linter checks these directions in CI with the contracts in +`[tool.importlinter]` in `pyproject.toml`. Imports made only under +`TYPE_CHECKING` count: + +- v2 routes may import v2 services and v2 data modules, and v2 services may + import v2 data modules, never the reverse, directly or through other modules + (rule `api.v2.import-layers`); +- v2 route modules never import a connector, a `database_session` module or + `policyengine_api.data.v2.database` themselves (rule + `api.v2.routes-import-no-database-access`). They reach them through + `fastapi_routes/dependencies.py` and the services they call, so this + contract checks direct imports only; +- validators, transformations and types never reach SQLAlchemy, SQLModel, a + connector, a `database_session` module or the v2 database module, directly + or through other modules, except through the table models (rule + `api.v2.pure-modules-no-database`). + +The table models' own imports of SQLAlchemy and SQLModel are allowed, one +model module at a time, so a new module in `policyengine_api/data/v2/models/` +needs its two imports added to the rule. Import Linter reads import +statements. It does not see a name that a module re-exports from another, and +a tolerated exception covers every import between its two modules and every +chain through them: today `identity.types -> sqlmodel` also covers +`identity.transformations`, `user_accounts.types`, +`user_accounts.transformations` and `user_accounts.validators`, which reach +SQLModel only through it. + Catalog version validation, its error types and `SelectedCatalog` live in `policyengine_api/data/v2/catalog/catalog_selection_validation.py`, which loads no database code, so validators, transformations, types and routes may diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 91f413a01..46a6496d3 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 61 | 0 | 1 | 12 | 74 | +| mechanical | 63 | 3 | 1 | 11 | 78 | | metric | 1 | 0 | 0 | 0 | 1 | | judgment | 0 | 0 | 0 | 1 | 1 | -**Blocking:** 62 of 76 rules. +**Blocking:** 67 of 80 rules. ## Gaps @@ -28,7 +28,6 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce - `api.process.rule-machinery-code-owned`: Changes to the rule machinery (rules/, scripts/rules/, scripts/guards/, scripts/run_quality_guards.py and CODEOWNERS) need approval from a core developer other than the author. - `api.routes.shared-query-parameters`: Routes that accept query parameters define them through the shared typed query models, never with a second manual parser. - `api.v2.connectors-never-commit`: v2 connectors receive a session from the service and never open, commit, roll back or close it. -- `api.v2.connectors-no-cross-calls`: v2 connector modules never call one another to sequence a workflow; the service does the sequencing. - `api.v2.routes-no-sessions-or-sql`: v2 route modules never open database sessions or construct SQL; they call one service method and map typed failures to HTTP. - `api.v2.services-no-sql`: v2 services sequence work but never construct SQL expressions, call Session.exec, or define HTTP response models. @@ -65,7 +64,9 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce ### Ratcheted (existing violations still tolerated) -- None. +- `api.v2.connectors-no-cross-calls`: 7 remaining in `rules/baselines/import-linter-api-v2-connectors-independent.txt` +- `api.v2.pure-modules-no-database`: 1 remaining in `rules/baselines/import-linter-api-v2-pure-modules-no-database.txt` +- `api.v2.types-transformations-no-validators`: 4 remaining in `rules/baselines/import-linter-api-v2-types-transformations-no-validators.txt` ### Exceptions and review dates @@ -84,12 +85,16 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce | `api.routes.shared-query-parameters` | Routes that accept query parameters define them through the shared typed query models, never with a second manual parser. | mechanical | doc-only | no | nothing | — | — | | `api.v2.connectors-mutations-no-reads` | v2 mutation connectors (creates, updates, deletes) never issue select or get reads. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** | | `api.v2.connectors-never-commit` | v2 connectors receive a session from the service and never open, commit, roll back or close it. | mechanical | doc-only | no | nothing | — | — | -| `api.v2.connectors-no-cross-calls` | v2 connector modules never call one another to sequence a workflow; the service does the sequencing. | mechanical | doc-only | no | nothing | — | — | +| `api.v2.connectors-no-cross-calls` | v2 connector modules never import one another, so they cannot call one another to sequence a workflow; the service does the sequencing. | mechanical | ratcheted | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes | | `api.v2.connectors-reads-no-mutations` | v2 read connectors never insert, add or delete rows. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** | | `api.v2.crud-checks-cover-every-resource` | The v2 CRUD boundary checks find modules by filename, so every resource package is covered, and every connector module is named as a create, read, update or delete module. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** | +| `api.v2.import-layers` | v2 route modules may import v2 services and v2 data modules, and v2 services may import v2 data modules, never the reverse, directly or through other modules. | mechanical | enforced | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes | +| `api.v2.pure-modules-no-database` | v2 validators, transformations and types never reach SQLAlchemy, SQLModel, a connector, a database_session module or the v2 database module, directly or through other modules, except through the v2 table models; imports under TYPE_CHECKING count. | mechanical | ratcheted | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes | +| `api.v2.routes-import-no-database-access` | v2 route modules never import a connector, a database_session module or the v2 database module; they call a service, which does. | mechanical | enforced | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes | | `api.v2.routes-no-sessions-or-sql` | v2 route modules never open database sessions or construct SQL; they call one service method and map typed failures to HTTP. | mechanical | doc-only | no | nothing | — | — | | `api.v2.services-no-sql` | v2 services sequence work but never construct SQL expressions, call Session.exec, or define HTTP response models. | mechanical | doc-only | no | nothing | — | — | | `api.v2.session-modules-no-queries` | v2 database_session modules own session lifetime only and never build select, insert, update, delete or exec calls. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** | +| `api.v2.types-transformations-no-validators` | v2 types and transformations modules never import a validators module, directly or through other modules; validators import types, not the reverse. | mechanical | ratcheted | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes | | `api.v2.user-services-no-sql` | v2 user services (identity, accounts, legacy references and user associations) never build or run SQL themselves or import SQLAlchemy; their connectors do. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | yes | | `api.v2.validators-transformations-no-db` | v2 validators and transformations never import SQLAlchemy or SQLModel query or session APIs. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | yes | @@ -101,24 +106,35 @@ flowchart LR e_pytest_tests_unit_v2_test_data_crud_boundaries_py["pytest tests/unit/v2/test_data_crud_boundaries.py"] j_CI_pr_yml_test["CI pr.yml:test"] r_api_v2_connectors_never_commit("api.v2.connectors-never-commit (doc-only)") - r_api_v2_connectors_no_cross_calls("api.v2.connectors-no-cross-calls (doc-only)") + r_api_v2_connectors_no_cross_calls("api.v2.connectors-no-cross-calls (ratcheted)") + e_import_linter_pyproject_toml["import-linter pyproject.toml"] + j_CI_pr_yml_quality_guards["CI pr.yml:quality-guards"] r_api_v2_connectors_reads_no_mutations("api.v2.connectors-reads-no-mutations (enforced)") r_api_v2_crud_checks_cover_every_resource("api.v2.crud-checks-cover-every-resource (enforced)") + r_api_v2_import_layers("api.v2.import-layers (enforced)") + r_api_v2_pure_modules_no_database("api.v2.pure-modules-no-database (ratcheted)") + r_api_v2_routes_import_no_database_access("api.v2.routes-import-no-database-access (enforced)") r_api_v2_routes_no_sessions_or_sql("api.v2.routes-no-sessions-or-sql (doc-only)") r_api_v2_services_no_sql("api.v2.services-no-sql (doc-only)") r_api_v2_session_modules_no_queries("api.v2.session-modules-no-queries (enforced)") + r_api_v2_types_transformations_no_validators("api.v2.types-transformations-no-validators (ratcheted)") r_api_v2_user_services_no_sql("api.v2.user-services-no-sql (enforced)") r_api_v2_validators_transformations_no_db("api.v2.validators-transformations-no-db (enforced)") r_api_routes_shared_query_parameters -.-> gap_unenforced r_api_v2_connectors_mutations_no_reads --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py e_pytest_tests_unit_v2_test_data_crud_boundaries_py --> j_CI_pr_yml_test r_api_v2_connectors_never_commit -.-> gap_unenforced - r_api_v2_connectors_no_cross_calls -.-> gap_unenforced + r_api_v2_connectors_no_cross_calls --> e_import_linter_pyproject_toml + e_import_linter_pyproject_toml --> j_CI_pr_yml_quality_guards r_api_v2_connectors_reads_no_mutations --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py r_api_v2_crud_checks_cover_every_resource --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py + r_api_v2_import_layers --> e_import_linter_pyproject_toml + r_api_v2_pure_modules_no_database --> e_import_linter_pyproject_toml + r_api_v2_routes_import_no_database_access --> e_import_linter_pyproject_toml r_api_v2_routes_no_sessions_or_sql -.-> gap_unenforced r_api_v2_services_no_sql -.-> gap_unenforced r_api_v2_session_modules_no_queries --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py + r_api_v2_types_transformations_no_validators --> e_import_linter_pyproject_toml r_api_v2_user_services_no_sql --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py r_api_v2_validators_transformations_no_db --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py ``` diff --git a/pyproject.toml b/pyproject.toml index b32f436d1..a96dd144f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dev = [ "pytest-snapshot", "pytest-timeout", "pytest-xdist", + "import-linter>=2.15,<3", "ruff>=0.9.0", "towncrier>=24.8.0", ] @@ -177,6 +178,135 @@ warn_return_any = true warn_unused_ignores = true show_error_codes = true +# Import Linter checks which v2 modules may import which, directly or through +# other modules (docs/engineering/skills/v2-code-organization.md). Each contract +# is a rule in rules/registry.toml (enforcer kind import-linter), and CI runs +# `lint-imports` in the quality-guards job. Imports made only under +# TYPE_CHECKING count too, so moving an import there can't pass a contract. +# +# A contract's ignore_imports must equal its rule's baseline file under +# rules/baselines/ plus the rule's enforcer.allowed_ignore_imports; the rules +# guard fails otherwise, and baseline entries may not use wildcards. +# unmatched_ignore_imports_alerting = "error" makes an exception that no longer +# matches fail, so the lists only shrink. An exception covers every import +# between its two modules, not one import statement. +[tool.importlinter] +root_package = "policyengine_api" +include_external_packages = true +exclude_type_checking_imports = false + +[[tool.importlinter.contracts]] +id = "api-v2-layers" +name = "v2 routes import v2 services and data, and v2 services import v2 data, never the reverse (rule api.v2.import-layers)" +type = "layers" +layers = [ + "policyengine_api.fastapi_routes.v2", + "policyengine_api.services.v2", + "policyengine_api.data.v2", +] + +[[tool.importlinter.contracts]] +id = "api-v2-pure-modules-no-database" +name = "v2 validators, transformations and types reach no SQLAlchemy, SQLModel, connector, session or v2 database module except through the table models (rule api.v2.pure-modules-no-database)" +type = "forbidden" +source_modules = [ + "policyengine_api.services.v2.**.validators", + "policyengine_api.services.v2.**.transformations", + "policyengine_api.services.v2.**.types", +] +forbidden_modules = [ + "sqlalchemy", + "sqlmodel", + "policyengine_api.services.v2.**.database_connectors", + "policyengine_api.services.v2.**.database_session", + "policyengine_api.data.v2.database", +] +ignore_imports = [ + # Allowed by design: validators and transformations may use loaded table + # rows, and the table models are SQLModel classes. Each model module is + # listed, so a new module in the models package needs a registry change. + "policyengine_api.data.v2.models -> sqlmodel", + "policyengine_api.data.v2.models.associations -> sqlalchemy", + "policyengine_api.data.v2.models.associations -> sqlmodel", + "policyengine_api.data.v2.models.base -> sqlalchemy", + "policyengine_api.data.v2.models.base -> sqlmodel", + "policyengine_api.data.v2.models.comparison_runs -> sqlalchemy", + "policyengine_api.data.v2.models.comparison_runs -> sqlmodel", + "policyengine_api.data.v2.models.countries -> sqlalchemy", + "policyengine_api.data.v2.models.countries -> sqlmodel", + "policyengine_api.data.v2.models.household_mappings -> sqlalchemy", + "policyengine_api.data.v2.models.household_mappings -> sqlmodel", + "policyengine_api.data.v2.models.households -> sqlalchemy", + "policyengine_api.data.v2.models.households -> sqlmodel", + "policyengine_api.data.v2.models.metadata -> sqlalchemy", + "policyengine_api.data.v2.models.metadata -> sqlmodel", + "policyengine_api.data.v2.models.policies -> sqlalchemy", + "policyengine_api.data.v2.models.policies -> sqlmodel", + "policyengine_api.data.v2.models.policy_mappings -> sqlalchemy", + "policyengine_api.data.v2.models.policy_mappings -> sqlmodel", + "policyengine_api.data.v2.models.reports -> sqlalchemy", + "policyengine_api.data.v2.models.reports -> sqlmodel", + "policyengine_api.data.v2.models.simulations -> sqlalchemy", + "policyengine_api.data.v2.models.simulations -> sqlmodel", + "policyengine_api.data.v2.models.users -> sqlalchemy", + "policyengine_api.data.v2.models.users -> sqlmodel", + # Tolerated: rules/baselines/import-linter-api-v2-pure-modules-no-database.txt + "policyengine_api.services.v2.identity.types -> sqlmodel", +] +unmatched_ignore_imports_alerting = "error" + +[[tool.importlinter.contracts]] +id = "api-v2-routes-no-database-access" +name = "v2 route modules import no connector, database_session or v2 database module (rule api.v2.routes-import-no-database-access)" +type = "forbidden" +source_modules = ["policyengine_api.fastapi_routes.v2"] +forbidden_modules = [ + "policyengine_api.services.v2.**.database_connectors", + "policyengine_api.services.v2.**.database_session", + "policyengine_api.data.v2.database", +] +# Direct imports only. Route modules reach connectors and session modules +# through fastapi_routes/dependencies.py, which builds each service with its +# session, and through the services they call. What routes call is the +# concern of rule api.v2.routes-no-sessions-or-sql. +allow_indirect_imports = true +unmatched_ignore_imports_alerting = "error" + +[[tool.importlinter.contracts]] +id = "api-v2-types-transformations-no-validators" +name = "v2 types and transformations import no validators module (rule api.v2.types-transformations-no-validators)" +type = "forbidden" +source_modules = [ + "policyengine_api.services.v2.**.types", + "policyengine_api.services.v2.**.transformations", +] +forbidden_modules = ["policyengine_api.services.v2.**.validators"] +ignore_imports = [ + # Tolerated: rules/baselines/import-linter-api-v2-types-transformations-no-validators.txt + "policyengine_api.services.v2.households.transformations -> policyengine_api.services.v2.households.validators", + "policyengine_api.services.v2.households.types -> policyengine_api.services.v2.households.validators", + "policyengine_api.services.v2.policies.transformations -> policyengine_api.services.v2.policies.validators", + "policyengine_api.services.v2.policies.types -> policyengine_api.services.v2.policies.validators", +] +unmatched_ignore_imports_alerting = "error" + +[[tool.importlinter.contracts]] +id = "api-v2-connectors-independent" +name = "v2 connector modules import no other connector module (rule api.v2.connectors-no-cross-calls)" +type = "independence" +modules = ["policyengine_api.services.v2.**.database_connectors.**"] +ignore_imports = [ + # Tolerated: rules/baselines/import-linter-api-v2-connectors-independent.txt + "policyengine_api.services.v2.metadata.database_connectors.reads_datasets -> policyengine_api.services.v2.metadata.database_connectors.reads", + "policyengine_api.services.v2.metadata.database_connectors.reads_parameter_nodes -> policyengine_api.services.v2.metadata.database_connectors.reads", + "policyengine_api.services.v2.metadata.database_connectors.reads_parameter_search_file -> policyengine_api.services.v2.metadata.database_connectors.reads", + "policyengine_api.services.v2.metadata.database_connectors.reads_parameter_tree -> policyengine_api.services.v2.metadata.database_connectors.reads", + "policyengine_api.services.v2.metadata.database_connectors.reads_parameters -> policyengine_api.services.v2.metadata.database_connectors.reads", + "policyengine_api.services.v2.metadata.database_connectors.reads_regions -> policyengine_api.services.v2.metadata.database_connectors.reads", + "policyengine_api.services.v2.metadata.database_connectors.reads_variables -> policyengine_api.services.v2.metadata.database_connectors.reads", +] +unmatched_ignore_imports_alerting = "error" + # .github/changelog_fragments.py reads `directory` and the types' `directory` # names below, so the PR check, the version bump in .github/bump_version.py and # Towncrier share one list (rule api.process.changelog-fragment). Adding or diff --git a/rules/baselines/import-linter-api-v2-connectors-independent.txt b/rules/baselines/import-linter-api-v2-connectors-independent.txt new file mode 100644 index 000000000..d91c73002 --- /dev/null +++ b/rules/baselines/import-linter-api-v2-connectors-independent.txt @@ -0,0 +1,9 @@ +# Tolerated existing violations. This list may only shrink. +# Each line is one entry of the Import Linter contract api-v2-connectors-independent's ignore_imports (pyproject.toml). +policyengine_api.services.v2.metadata.database_connectors.reads_datasets -> policyengine_api.services.v2.metadata.database_connectors.reads +policyengine_api.services.v2.metadata.database_connectors.reads_parameter_nodes -> policyengine_api.services.v2.metadata.database_connectors.reads +policyengine_api.services.v2.metadata.database_connectors.reads_parameter_search_file -> policyengine_api.services.v2.metadata.database_connectors.reads +policyengine_api.services.v2.metadata.database_connectors.reads_parameter_tree -> policyengine_api.services.v2.metadata.database_connectors.reads +policyengine_api.services.v2.metadata.database_connectors.reads_parameters -> policyengine_api.services.v2.metadata.database_connectors.reads +policyengine_api.services.v2.metadata.database_connectors.reads_regions -> policyengine_api.services.v2.metadata.database_connectors.reads +policyengine_api.services.v2.metadata.database_connectors.reads_variables -> policyengine_api.services.v2.metadata.database_connectors.reads diff --git a/rules/baselines/import-linter-api-v2-pure-modules-no-database.txt b/rules/baselines/import-linter-api-v2-pure-modules-no-database.txt new file mode 100644 index 000000000..b833e63e2 --- /dev/null +++ b/rules/baselines/import-linter-api-v2-pure-modules-no-database.txt @@ -0,0 +1,4 @@ +# Tolerated existing violations. This list may only shrink. +# Each line is one entry of the Import Linter contract api-v2-pure-modules-no-database's ignore_imports (pyproject.toml). +# A Session annotation in a Protocol, imported under TYPE_CHECKING. +policyengine_api.services.v2.identity.types -> sqlmodel diff --git a/rules/baselines/import-linter-api-v2-types-transformations-no-validators.txt b/rules/baselines/import-linter-api-v2-types-transformations-no-validators.txt new file mode 100644 index 000000000..a6b359dad --- /dev/null +++ b/rules/baselines/import-linter-api-v2-types-transformations-no-validators.txt @@ -0,0 +1,6 @@ +# Tolerated existing violations. This list may only shrink. +# Each line is one entry of the Import Linter contract api-v2-types-transformations-no-validators's ignore_imports (pyproject.toml). +policyengine_api.services.v2.households.transformations -> policyengine_api.services.v2.households.validators +policyengine_api.services.v2.households.types -> policyengine_api.services.v2.households.validators +policyengine_api.services.v2.policies.transformations -> policyengine_api.services.v2.policies.validators +policyengine_api.services.v2.policies.types -> policyengine_api.services.v2.policies.validators diff --git a/rules/fixtures/api.v2.connectors-no-cross-calls/bad_package.toml b/rules/fixtures/api.v2.connectors-no-cross-calls/bad_package.toml new file mode 100644 index 000000000..45675fbc9 --- /dev/null +++ b/rules/fixtures/api.v2.connectors-no-cross-calls/bad_package.toml @@ -0,0 +1,28 @@ +# Proof fixture for rule api.v2.connectors-no-cross-calls. The contract must +# reject it, reporting exactly the expected violations: one read connector +# imports another, as the metadata reads_* connectors import reads today; a +# connector imports another resource's connector; and a nested connector +# imports one. + +expected_violations = [ + "fixture_api.services.v2.metadata.database_connectors.reads_variables -> fixture_api.services.v2.metadata.database_connectors.reads", + "fixture_api.services.v2.metadata.parameters.database_connectors.reads -> fixture_api.services.v2.metadata.database_connectors.reads", + "fixture_api.services.v2.policies.database_connectors.creates -> fixture_api.services.v2.households.database_connectors.reads", +] + +[modules] +"fixture_api/services/v2/metadata/database_connectors/reads.py" = """ +from sqlmodel import Session, select +""" +"fixture_api/services/v2/metadata/database_connectors/reads_variables.py" = """ +from fixture_api.services.v2.metadata.database_connectors.reads import read_rows +""" +"fixture_api/services/v2/policies/database_connectors/creates.py" = """ +from fixture_api.services.v2.households.database_connectors.reads import read_household +""" +"fixture_api/services/v2/households/database_connectors/reads.py" = """ +from sqlmodel import Session, select +""" +"fixture_api/services/v2/metadata/parameters/database_connectors/reads.py" = """ +from fixture_api.services.v2.metadata.database_connectors.reads import read_rows +""" diff --git a/rules/fixtures/api.v2.connectors-no-cross-calls/good_package.toml b/rules/fixtures/api.v2.connectors-no-cross-calls/good_package.toml new file mode 100644 index 000000000..97af12ee0 --- /dev/null +++ b/rules/fixtures/api.v2.connectors-no-cross-calls/good_package.toml @@ -0,0 +1,19 @@ +# Proof fixture for rule api.v2.connectors-no-cross-calls. The contract must +# accept it: the connectors import only SQLModel and the table models, and the +# service calls each one. + +[modules] +"fixture_api/services/v2/metadata/database_connectors/reads.py" = """ +from sqlmodel import Session, select +from fixture_api.data.v2.models import Dataset +""" +"fixture_api/services/v2/metadata/database_connectors/reads_variables.py" = """ +from sqlmodel import Session, select +from fixture_api.data.v2.models import Variable +""" +"fixture_api/services/v2/metadata/services.py" = """ +from fixture_api.services.v2.metadata.database_connectors import reads, reads_variables +""" +"fixture_api/data/v2/models.py" = """ +import sqlmodel +""" diff --git a/rules/fixtures/api.v2.import-layers/bad_package.toml b/rules/fixtures/api.v2.import-layers/bad_package.toml new file mode 100644 index 000000000..b3b8b5628 --- /dev/null +++ b/rules/fixtures/api.v2.import-layers/bad_package.toml @@ -0,0 +1,28 @@ +# Proof fixture for rule api.v2.import-layers. The contract must reject it, +# reporting exactly the expected violations: v2 data modules import a v2 +# service and a v2 route module, and a v2 service imports a v2 route module. +# tests/unit/v2/test_import_linter_contracts.py writes each module below into a +# temporary fixture_api package and runs the rule's contract on it. + +expected_violations = [ + "fixture_api.data.v2 -> fixture_api.fastapi_routes.v2", + "fixture_api.data.v2 -> fixture_api.services.v2", + "fixture_api.services.v2 -> fixture_api.fastapi_routes.v2", +] + +[modules] +"fixture_api/fastapi_routes/v2/policies/routes.py" = """ +from fixture_api.services.v2.policies import services +""" +"fixture_api/services/v2/policies/services.py" = """ +from fixture_api.data.v2 import models +""" +"fixture_api/services/v2/households/services.py" = """ +from fixture_api.fastapi_routes.v2.policies.routes import create_policy_route +""" +"fixture_api/data/v2/models.py" = """ +from fixture_api.services.v2.policies.services import create_policy +""" +"fixture_api/data/v2/catalog.py" = """ +from fixture_api.fastapi_routes.v2.policies.routes import create_policy_route +""" diff --git a/rules/fixtures/api.v2.import-layers/good_package.toml b/rules/fixtures/api.v2.import-layers/good_package.toml new file mode 100644 index 000000000..cc15764d5 --- /dev/null +++ b/rules/fixtures/api.v2.import-layers/good_package.toml @@ -0,0 +1,13 @@ +# Proof fixture for rule api.v2.import-layers. The contract must accept it: +# routes import services, and services import data. + +[modules] +"fixture_api/fastapi_routes/v2/policies/routes.py" = """ +from fixture_api.services.v2.policies import services +""" +"fixture_api/services/v2/policies/services.py" = """ +from fixture_api.data.v2 import models +""" +"fixture_api/data/v2/models.py" = """ +import sqlmodel +""" diff --git a/rules/fixtures/api.v2.pure-modules-no-database/bad_package.toml b/rules/fixtures/api.v2.pure-modules-no-database/bad_package.toml new file mode 100644 index 000000000..5298cbe7f --- /dev/null +++ b/rules/fixtures/api.v2.pure-modules-no-database/bad_package.toml @@ -0,0 +1,48 @@ +# Proof fixture for rule api.v2.pure-modules-no-database. The contract must +# reject it, reporting exactly the expected violations: one for each forbidden +# module, from each kind of source module, including a nested one. +# +# policies.types has the shape of the former catalog selection module: it +# imports shared query parameters, which import version validation kept beside +# a catalog query, so the types module reaches SQLModel. + +expected_violations = [ + "fixture_api.services.v2.households.types -> fixture_api.services.v2.households.database_session", + "fixture_api.services.v2.households.validators -> fixture_api.data.v2.database", + "fixture_api.services.v2.metadata.parameters.validators -> sqlalchemy", + "fixture_api.services.v2.policies.transformations -> fixture_api.services.v2.policies.database_connectors", + "fixture_api.services.v2.policies.types -> sqlmodel", + "fixture_api.services.v2.policies.validators -> sqlalchemy", +] + +[modules] +"fixture_api/services/v2/policies/types.py" = """ +from fixture_api.query_parameters import PolicyEngineVersion +""" +"fixture_api/query_parameters.py" = """ +from fixture_api.data.v2.catalog.selection import validate_policyengine_version +""" +"fixture_api/data/v2/catalog/selection.py" = """ +from sqlmodel import Session, select +""" +"fixture_api/services/v2/policies/validators.py" = """ +import sqlalchemy +""" +"fixture_api/services/v2/policies/transformations.py" = """ +from fixture_api.services.v2.policies.database_connectors.reads import read_policy +""" +"fixture_api/services/v2/policies/database_connectors/reads.py" = """ +""" +"fixture_api/services/v2/households/types.py" = """ +from fixture_api.services.v2.households.database_session import HouseholdDatabaseSession +""" +"fixture_api/services/v2/households/database_session.py" = """ +""" +"fixture_api/services/v2/households/validators.py" = """ +from fixture_api.data.v2.database import get_v2_session_factory +""" +"fixture_api/data/v2/database.py" = """ +""" +"fixture_api/services/v2/metadata/parameters/validators.py" = """ +from sqlalchemy import select +""" diff --git a/rules/fixtures/api.v2.pure-modules-no-database/good_package.toml b/rules/fixtures/api.v2.pure-modules-no-database/good_package.toml new file mode 100644 index 000000000..1197ebcf1 --- /dev/null +++ b/rules/fixtures/api.v2.pure-modules-no-database/good_package.toml @@ -0,0 +1,29 @@ +# Proof fixture for rule api.v2.pure-modules-no-database. The contract must +# accept it: the types module's version validation imports no database code, +# and the validator reaches SQLModel only through the table models, which the +# rule allows. + +[modules] +"fixture_api/services/v2/policies/types.py" = """ +from fixture_api.query_parameters import PolicyEngineVersion +""" +"fixture_api/services/v2/policies/validators.py" = """ +from fixture_api.data.v2.models import Policy +""" +"fixture_api/query_parameters.py" = """ +from fixture_api.data.v2.catalog.selection_validation import validate_policyengine_version +""" +"fixture_api/data/v2/catalog/selection_validation.py" = """ +from packaging.version import Version +""" +"fixture_api/data/v2/catalog/selection_reads.py" = """ +from sqlmodel import Session, select +""" +"fixture_api/data/v2/models/__init__.py" = """ +import sqlmodel +from fixture_api.data.v2.models.policies import Policy +""" +"fixture_api/data/v2/models/policies.py" = """ +import sqlalchemy +import sqlmodel +""" diff --git a/rules/fixtures/api.v2.routes-import-no-database-access/bad_package.toml b/rules/fixtures/api.v2.routes-import-no-database-access/bad_package.toml new file mode 100644 index 000000000..d77345733 --- /dev/null +++ b/rules/fixtures/api.v2.routes-import-no-database-access/bad_package.toml @@ -0,0 +1,27 @@ +# Proof fixture for rule api.v2.routes-import-no-database-access. The contract +# must reject it, reporting exactly the expected violations: route modules +# import a connector, a session module and the v2 database module. Import +# Linter names the source and forbidden modules as the contract lists them. + +expected_violations = [ + "fixture_api.fastapi_routes.v2 -> fixture_api.data.v2.database", + "fixture_api.fastapi_routes.v2 -> fixture_api.services.v2.households.database_session", + "fixture_api.fastapi_routes.v2 -> fixture_api.services.v2.policies.database_connectors", +] + +[modules] +"fixture_api/fastapi_routes/v2/policies/routes.py" = """ +from fixture_api.services.v2.policies.database_connectors.reads import read_policy +""" +"fixture_api/fastapi_routes/v2/households/routes.py" = """ +from fixture_api.services.v2.households.database_session import HouseholdDatabaseSession +""" +"fixture_api/fastapi_routes/v2/metadata/common.py" = """ +from fixture_api.data.v2.database import get_v2_session_factory +""" +"fixture_api/services/v2/policies/database_connectors/reads.py" = """ +""" +"fixture_api/services/v2/households/database_session.py" = """ +""" +"fixture_api/data/v2/database.py" = """ +""" diff --git a/rules/fixtures/api.v2.routes-import-no-database-access/good_package.toml b/rules/fixtures/api.v2.routes-import-no-database-access/good_package.toml new file mode 100644 index 000000000..c333af010 --- /dev/null +++ b/rules/fixtures/api.v2.routes-import-no-database-access/good_package.toml @@ -0,0 +1,18 @@ +# Proof fixture for rule api.v2.routes-import-no-database-access. The contract +# must accept it: the route imports the service, and only the service imports +# the connector and the session module. + +[modules] +"fixture_api/fastapi_routes/v2/policies/routes.py" = """ +from fixture_api.services.v2.policies.services import V2PolicyService +""" +"fixture_api/services/v2/policies/services.py" = """ +from fixture_api.services.v2.policies.database_connectors.reads import read_policy +from fixture_api.services.v2.policies.database_session import PolicyDatabaseSession +""" +"fixture_api/services/v2/policies/database_connectors/reads.py" = """ +from sqlmodel import Session, select +""" +"fixture_api/services/v2/policies/database_session.py" = """ +from sqlmodel import Session +""" diff --git a/rules/fixtures/api.v2.routes-import-no-database-access/type_checking_package.toml b/rules/fixtures/api.v2.routes-import-no-database-access/type_checking_package.toml new file mode 100644 index 000000000..8a3120c11 --- /dev/null +++ b/rules/fixtures/api.v2.routes-import-no-database-access/type_checking_package.toml @@ -0,0 +1,15 @@ +# Fixture for rule api.v2.routes-import-no-database-access: a route module +# imports a connector only under TYPE_CHECKING, for an annotation. Imports made +# only under TYPE_CHECKING count (decided 2026-11-04), so the contract must +# reject it. + +[modules] +"fixture_api/fastapi_routes/v2/policies/routes.py" = """ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fixture_api.services.v2.policies.database_connectors.reads import PolicyRow +""" +"fixture_api/services/v2/policies/database_connectors/reads.py" = """ +from sqlmodel import Session, select +""" diff --git a/rules/fixtures/api.v2.types-transformations-no-validators/bad_package.toml b/rules/fixtures/api.v2.types-transformations-no-validators/bad_package.toml new file mode 100644 index 000000000..b7ce9079e --- /dev/null +++ b/rules/fixtures/api.v2.types-transformations-no-validators/bad_package.toml @@ -0,0 +1,31 @@ +# Proof fixture for rule api.v2.types-transformations-no-validators. The +# contract must reject it, reporting exactly the expected violations: a types +# module reaches its validators through a helper module, a transformations +# module imports its validators as the households one does today, and a +# nested types module imports its validators. + +expected_violations = [ + "fixture_api.services.v2.households.transformations -> fixture_api.services.v2.households.validators", + "fixture_api.services.v2.metadata.parameters.types -> fixture_api.services.v2.metadata.parameters.validators", + "fixture_api.services.v2.policies.types -> fixture_api.services.v2.policies.validators", +] + +[modules] +"fixture_api/services/v2/policies/types.py" = """ +from fixture_api.services.v2.policies.json_values import StrictJsonValue +""" +"fixture_api/services/v2/policies/json_values.py" = """ +from fixture_api.services.v2.policies.validators import require_json_value +""" +"fixture_api/services/v2/policies/validators.py" = """ +""" +"fixture_api/services/v2/households/transformations.py" = """ +from fixture_api.services.v2.households.validators import normalize_household_document +""" +"fixture_api/services/v2/households/validators.py" = """ +""" +"fixture_api/services/v2/metadata/parameters/types.py" = """ +from fixture_api.services.v2.metadata.parameters.validators import validate_path +""" +"fixture_api/services/v2/metadata/parameters/validators.py" = """ +""" diff --git a/rules/fixtures/api.v2.types-transformations-no-validators/good_package.toml b/rules/fixtures/api.v2.types-transformations-no-validators/good_package.toml new file mode 100644 index 000000000..3881d030c --- /dev/null +++ b/rules/fixtures/api.v2.types-transformations-no-validators/good_package.toml @@ -0,0 +1,13 @@ +# Proof fixture for rule api.v2.types-transformations-no-validators. The +# contract must accept it: the validators module imports the types module. + +[modules] +"fixture_api/services/v2/policies/types.py" = """ +from pydantic import BaseModel +""" +"fixture_api/services/v2/policies/validators.py" = """ +from fixture_api.services.v2.policies.types import PolicyCreationInput +""" +"fixture_api/services/v2/policies/transformations.py" = """ +from fixture_api.services.v2.policies.types import PolicyRead +""" diff --git a/rules/registry.toml b/rules/registry.toml index 9ed53232d..2002491f8 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -240,14 +240,168 @@ kind = "none" [[rule]] id = "api.v2.connectors-no-cross-calls" category = "v2-layers" -statement = "v2 connector modules never call one another to sequence a workflow; the service does the sequencing." +statement = "v2 connector modules never import one another, so they cannot call one another to sequence a workflow; the service does the sequencing." rationale = "docs/engineering/skills/v2-code-organization.md#database_connectors" scope = ["policyengine_api/services/v2/*/database_connectors/*.py"] tier = "mechanical" -status = "doc-only" +status = "ratcheted" autofix = false [rule.enforcer] -kind = "none" +kind = "import-linter" +config = "pyproject.toml" +contract = "api-v2-connectors-independent" +contract_sha256 = "6da44212adfe1bc6110ebb9651d282269f353639ae8f644b575c25ace4749de9" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "lint-imports --config pyproject.toml" +[rule.proof] +bad_fixture = "rules/fixtures/api.v2.connectors-no-cross-calls/bad_package.toml" +good_fixture = "rules/fixtures/api.v2.connectors-no-cross-calls/good_package.toml" +test = "tests/unit/v2/test_import_linter_contracts.py::test_rule_proof_connectors_no_cross_calls" +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" +[rule.baseline] +file = "rules/baselines/import-linter-api-v2-connectors-independent.txt" +count = 7 + +[[rule]] +id = "api.v2.import-layers" +category = "v2-layers" +statement = "v2 route modules may import v2 services and v2 data modules, and v2 services may import v2 data modules, never the reverse, directly or through other modules." +rationale = "docs/engineering/skills/v2-code-organization.md#dependency-direction" +scope = ["policyengine_api/fastapi_routes/v2/**/*.py", "policyengine_api/services/v2/**/*.py", "policyengine_api/data/v2/**/*.py"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "import-linter" +config = "pyproject.toml" +contract = "api-v2-layers" +contract_sha256 = "d91c05303198345699ff66356881f18d5bb3ea5bbf44cd9a86346ee7ff5f17b7" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "lint-imports --config pyproject.toml" +[rule.proof] +bad_fixture = "rules/fixtures/api.v2.import-layers/bad_package.toml" +good_fixture = "rules/fixtures/api.v2.import-layers/good_package.toml" +test = "tests/unit/v2/test_import_linter_contracts.py::test_rule_proof_import_layers" +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" + +[[rule]] +id = "api.v2.pure-modules-no-database" +category = "v2-layers" +statement = "v2 validators, transformations and types never reach SQLAlchemy, SQLModel, a connector, a database_session module or the v2 database module, directly or through other modules, except through the v2 table models; imports under TYPE_CHECKING count." +rationale = "docs/engineering/skills/v2-code-organization.md#dependency-direction" +scope = ["policyengine_api/services/v2/*/validators.py", "policyengine_api/services/v2/*/transformations.py", "policyengine_api/services/v2/*/types.py"] +tier = "mechanical" +status = "ratcheted" +autofix = false +[rule.enforcer] +kind = "import-linter" +config = "pyproject.toml" +contract = "api-v2-pure-modules-no-database" +contract_sha256 = "b5317b97f5680dc5f38b16fe33bd4dc29715a8fb97f52f9da56585069994565d" +allowed_ignore_imports = [ + "policyengine_api.data.v2.models -> sqlmodel", + "policyengine_api.data.v2.models.associations -> sqlalchemy", + "policyengine_api.data.v2.models.associations -> sqlmodel", + "policyengine_api.data.v2.models.base -> sqlalchemy", + "policyengine_api.data.v2.models.base -> sqlmodel", + "policyengine_api.data.v2.models.comparison_runs -> sqlalchemy", + "policyengine_api.data.v2.models.comparison_runs -> sqlmodel", + "policyengine_api.data.v2.models.countries -> sqlalchemy", + "policyengine_api.data.v2.models.countries -> sqlmodel", + "policyengine_api.data.v2.models.household_mappings -> sqlalchemy", + "policyengine_api.data.v2.models.household_mappings -> sqlmodel", + "policyengine_api.data.v2.models.households -> sqlalchemy", + "policyengine_api.data.v2.models.households -> sqlmodel", + "policyengine_api.data.v2.models.metadata -> sqlalchemy", + "policyengine_api.data.v2.models.metadata -> sqlmodel", + "policyengine_api.data.v2.models.policies -> sqlalchemy", + "policyengine_api.data.v2.models.policies -> sqlmodel", + "policyengine_api.data.v2.models.policy_mappings -> sqlalchemy", + "policyengine_api.data.v2.models.policy_mappings -> sqlmodel", + "policyengine_api.data.v2.models.reports -> sqlalchemy", + "policyengine_api.data.v2.models.reports -> sqlmodel", + "policyengine_api.data.v2.models.simulations -> sqlalchemy", + "policyengine_api.data.v2.models.simulations -> sqlmodel", + "policyengine_api.data.v2.models.users -> sqlalchemy", + "policyengine_api.data.v2.models.users -> sqlmodel", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "lint-imports --config pyproject.toml" +[rule.proof] +bad_fixture = "rules/fixtures/api.v2.pure-modules-no-database/bad_package.toml" +good_fixture = "rules/fixtures/api.v2.pure-modules-no-database/good_package.toml" +test = "tests/unit/v2/test_import_linter_contracts.py::test_rule_proof_pure_modules_no_database" +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" +[rule.baseline] +file = "rules/baselines/import-linter-api-v2-pure-modules-no-database.txt" +count = 1 + +[[rule]] +id = "api.v2.routes-import-no-database-access" +category = "v2-layers" +statement = "v2 route modules never import a connector, a database_session module or the v2 database module; they call a service, which does." +rationale = "docs/engineering/skills/v2-code-organization.md#http-adapters" +scope = ["policyengine_api/fastapi_routes/v2/**/*.py"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "import-linter" +config = "pyproject.toml" +contract = "api-v2-routes-no-database-access" +contract_sha256 = "03fe42ee3a3fa858932ff92cc6d1df4bf60db0d75dd99344e52c2167391c074b" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "lint-imports --config pyproject.toml" +[rule.proof] +bad_fixture = "rules/fixtures/api.v2.routes-import-no-database-access/bad_package.toml" +good_fixture = "rules/fixtures/api.v2.routes-import-no-database-access/good_package.toml" +test = "tests/unit/v2/test_import_linter_contracts.py::test_rule_proof_routes_import_no_database_access" +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" + +[[rule]] +id = "api.v2.types-transformations-no-validators" +category = "v2-layers" +statement = "v2 types and transformations modules never import a validators module, directly or through other modules; validators import types, not the reverse." +rationale = "docs/engineering/skills/v2-code-organization.md#typespy" +scope = ["policyengine_api/services/v2/*/types.py", "policyengine_api/services/v2/*/transformations.py"] +tier = "mechanical" +status = "ratcheted" +autofix = false +[rule.enforcer] +kind = "import-linter" +config = "pyproject.toml" +contract = "api-v2-types-transformations-no-validators" +contract_sha256 = "22a4a86fefa71f6097c03ab55eb513a9df045676414b410216ddcd7295e8f711" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "lint-imports --config pyproject.toml" +[rule.proof] +bad_fixture = "rules/fixtures/api.v2.types-transformations-no-validators/bad_package.toml" +good_fixture = "rules/fixtures/api.v2.types-transformations-no-validators/good_package.toml" +test = "tests/unit/v2/test_import_linter_contracts.py::test_rule_proof_types_transformations_no_validators" +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" +[rule.baseline] +file = "rules/baselines/import-linter-api-v2-types-transformations-no-validators.txt" +count = 4 [[rule]] id = "api.routes.shared-query-parameters" diff --git a/scripts/rules/discover.py b/scripts/rules/discover.py index 165c05a75..8cf1680f0 100644 --- a/scripts/rules/discover.py +++ b/scripts/rules/discover.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING +from scripts.rules.import_linter import unregistered_contracts from scripts.rules.model import Kind, Registry, Violation from scripts.rules.resolve import ( DEFAULT_RUNNER, @@ -137,6 +138,9 @@ def discover_hidden(registry: Registry, root: Path) -> list[Violation]: if path not in covered ) + # Import Linter contracts. + violations.extend(unregistered_contracts(rules, root)) + # Guard-style tests. registered_nodes = _registered_nodes(registry) ignored_tests = { diff --git a/scripts/rules/import_linter.py b/scripts/rules/import_linter.py new file mode 100644 index 000000000..43de89890 --- /dev/null +++ b/scripts/rules/import_linter.py @@ -0,0 +1,300 @@ +"""Check that an Import Linter rule matches its contract and its baseline. + +An Import Linter contract tolerates existing violations through its +``ignore_imports``. The rule that registers the contract lists the same +entries in its baseline file under ``rules/baselines/`` (code-owned; the rules +guard stops its count growing), plus any imports the rule allows by design in +``enforcer.allowed_ignore_imports``. The rule also pins the rest of the +contract, and the top-level ``[tool.importlinter]`` settings, with +``enforcer.contract_sha256``. This module fails the guard when the registry +and ``pyproject.toml`` disagree, so neither an exception nor a narrower +contract can be made in ``pyproject.toml`` alone. + +Standard library only, like the rest of the rules tool. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import tomllib +from typing import TYPE_CHECKING, Any + +from scripts.rules.model import Kind, Rule, Violation +from scripts.rules.workflows import parse_jobs + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + +class ConfigNotTomlError(ValueError): + """The named Import Linter config is not a TOML file.""" + + +def _violation(rule: Rule, message: str, fix: str) -> Violation: + return Violation(rule.id, "enforcer", message, fix) + + +def _settings(config: Path) -> dict[str, Any]: + """Return ``[tool.importlinter]`` from a TOML config, or ``{}`` if absent.""" + if not config.is_file(): + return {} + try: + document = tomllib.loads(config.read_text()) + except tomllib.TOMLDecodeError as error: + raise ConfigNotTomlError(config.name) from error + settings = document.get("tool", {}).get("importlinter") + return settings if isinstance(settings, dict) else {} + + +def _contract_tables(settings: dict[str, Any]) -> list[dict[str, Any]]: + tables = settings.get("contracts", []) + return [table for table in tables if isinstance(table, dict)] + + +def contracts(config: Path) -> list[dict[str, Any]]: + """Return the ``[[tool.importlinter.contracts]]`` tables of a TOML config.""" + try: + return _contract_tables(_settings(config)) + except ConfigNotTomlError: + return [] + + +def contract_fingerprint(settings: dict[str, Any], contract: dict[str, Any]) -> str: + """Return the SHA-256 that a rule's ``enforcer.contract_sha256`` must equal. + + It covers the top-level settings and every contract field except + ``ignore_imports``, which the baseline and ``allowed_ignore_imports`` pin. + """ + definition = { + "settings": { + key: value for key, value in settings.items() if key != "contracts" + }, + "contract": { + key: value for key, value in contract.items() if key != "ignore_imports" + }, + } + canonical = json.dumps(definition, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def _baseline_entries(rule: Rule, root: Path) -> list[str]: + if rule.baseline is None: + return [] + path = root / rule.baseline.file + if not path.is_file(): + return [] + return [ + line.strip() + for line in path.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +def _exception_violations( + rule: Rule, root: Path, contract_id: str, contract: dict[str, Any] +) -> list[Violation]: + """Compare the contract's ignore_imports with the rule's baseline and allowances.""" + ignored = [str(entry).strip() for entry in contract.get("ignore_imports", [])] + allowed = rule.enforcer.get("allowed_ignore_imports", []) + expected = set(_baseline_entries(rule, root)) | set(allowed) + violations = [ + _violation( + rule, + f"contract '{contract_id}' ignores {entry}, a wildcard that would also " + "ignore imports added later", + "list each import by its full module names", + ) + for entry in ignored + if "*" in entry + ] + unlisted = [entry for entry in ignored if entry not in expected] + if unlisted: + violations.append( + _violation( + rule, + f"contract '{contract_id}' ignores imports that neither the rule's " + "baseline nor enforcer.allowed_ignore_imports lists: " + f"{', '.join(unlisted)}", + "fix the import instead, or list it in the rule's baseline file " + "under rules/baselines/", + ) + ) + not_ignored = sorted(expected - set(ignored)) + if not_ignored: + violations.append( + _violation( + rule, + "the rule's baseline and enforcer.allowed_ignore_imports list " + f"imports that contract '{contract_id}' does not ignore: " + f"{', '.join(not_ignored)}", + "remove them from the baseline, or add them to the contract's " + "ignore_imports", + ) + ) + if ignored and contract.get("unmatched_ignore_imports_alerting") != "error": + violations.append( + _violation( + rule, + f"contract '{contract_id}' has ignore_imports but does not set " + 'unmatched_ignore_imports_alerting = "error", so an exception that ' + "no longer matches would pass", + "set it on the contract", + ) + ) + return violations + + +def resolve_import_linter(rule: Rule, root: Path) -> list[Violation]: + """Check the rule's contract exists, is the one it pins, and has its exceptions.""" + config_name = rule.enforcer.get("config") + contract_id = rule.enforcer.get("contract") + if not config_name or not contract_id: + return [ + _violation( + rule, + "enforcer.config and enforcer.contract must name the config file " + "and the contract's id", + 'set both, for example config = "pyproject.toml"', + ) + ] + config = root / config_name + if not config.exists(): + return [ + _violation( + rule, + f"config file {config_name} does not exist", + "create it or fix enforcer.config", + ) + ] + allowed = rule.enforcer.get("allowed_ignore_imports", []) + if not isinstance(allowed, list) or not all( + isinstance(entry, str) for entry in allowed + ): + return [ + _violation( + rule, + "enforcer.allowed_ignore_imports must be a list of imports written " + "as Import Linter writes them, such as 'pkg.models -> sqlalchemy'", + "make it a list of strings", + ) + ] + try: + settings = _settings(config) + except ConfigNotTomlError: + return [ + _violation( + rule, + f"{config_name} is not a TOML file, so the rules guard can't read " + "its contracts", + "keep the Import Linter contracts in [tool.importlinter] in " + "pyproject.toml", + ) + ] + contract = next( + ( + table + for table in _contract_tables(settings) + if table.get("id") == contract_id + ), + None, + ) + if contract is None: + return [ + _violation( + rule, + f"{config_name} has no Import Linter contract with id '{contract_id}'", + "add the contract to [tool.importlinter] or fix enforcer.contract", + ) + ] + + violations = [] + pinned = rule.enforcer.get("contract_sha256") or "" + actual = contract_fingerprint(settings, contract) + if pinned != actual: + violations.append( + _violation( + rule, + f"contract '{contract_id}' is not the definition the rule pins " + f"(enforcer.contract_sha256 is {pinned or 'empty'}, the contract's " + f"is {actual})", + "if the change is intended, set enforcer.contract_sha256 = " + f'"{actual}" in rules/registry.toml', + ) + ) + violations += _exception_violations(rule, root, contract_id, contract) + violations += _ci_command_violations(rule, root, config_name) + return violations + + +def _ci_command_violations(rule: Rule, root: Path, config_name: str) -> list[Violation]: + """Require each CI job to run every contract, with failures failing the job. + + The registry's invocation check only looks for a substring, which an + added ``--contract`` or ``|| true`` would still contain. + """ + exact = re.compile( + rf"^(?:uv run .*\s)?lint-imports --config {re.escape(config_name)}$" + ) + violations = [] + for reference in rule.ci: + workflow = root / reference.workflow + if not workflow.is_file(): + continue + job = parse_jobs(workflow.read_text())[0].get(reference.job) + if job is None: + continue + violations.extend( + _violation( + rule, + f"job '{reference.job}' in {reference.workflow} runs Import Linter " + f"as '{command}', which may skip contracts or ignore failures", + f"run exactly 'lint-imports --config {config_name}', optionally " + "after 'uv run ...'", + ) + for command in (line.strip() for line in job.run_text.splitlines()) + if "lint-imports" in command and not exact.match(command) + ) + return violations + + +def unregistered_contracts(rules: Sequence[Rule], root: Path) -> list[Violation]: + """Return a hidden-rule violation for each contract in pyproject.toml no rule names. + + Import Linter also reads other files, but this repository keeps its + contracts in pyproject.toml, and CI passes it with --config. + """ + registered = { + rule.enforcer.get("contract") + for rule in rules + if rule.enforcer.kind is Kind.IMPORT_LINTER + and rule.enforcer.get("config") == "pyproject.toml" + } + violations = [] + for contract in contracts(root / "pyproject.toml"): + contract_id = contract.get("id") + if not contract_id: + violations.append( + Violation( + None, + "hidden-rule", + f"Import Linter contract '{contract.get('name', '')}' in " + "pyproject.toml has no id, so no rule can reference it", + "give it an id and add a [[rule]] with enforcer.kind = " + '"import-linter" that names it', + ) + ) + elif contract_id not in registered: + violations.append( + Violation( + None, + "hidden-rule", + f"Import Linter contract '{contract_id}' in pyproject.toml " + "has no rule", + 'add a [[rule]] with enforcer.kind = "import-linter" and ' + f'enforcer.contract = "{contract_id}"', + ) + ) + return violations diff --git a/scripts/rules/resolve.py b/scripts/rules/resolve.py index f2301f935..61392f9dc 100644 --- a/scripts/rules/resolve.py +++ b/scripts/rules/resolve.py @@ -19,6 +19,7 @@ matching_files, out_of_scope_files, ) +from scripts.rules.import_linter import resolve_import_linter from scripts.rules.model import CiRef, Kind, Rule, Violation from scripts.rules.workflows import JobBlock, parse_jobs @@ -369,7 +370,7 @@ def _resolve_workflow_job(rule: Rule, root: Path) -> list[Violation]: Kind.RUFF: _resolve_ruff, Kind.MYPY: _resolve_mypy, Kind.PYRIGHT: _resolve_file_field("config"), - Kind.IMPORT_LINTER: _resolve_file_field("config"), + Kind.IMPORT_LINTER: resolve_import_linter, Kind.WORKFLOW_JOB: _resolve_workflow_job, Kind.DOCS_GUARD: _resolve_file_field("doc"), Kind.LLM_REVIEW: _resolve_file_field("prompt"), diff --git a/scripts/rules/tests/test_import_linter.py b/scripts/rules/tests/test_import_linter.py new file mode 100644 index 000000000..25686554c --- /dev/null +++ b/scripts/rules/tests/test_import_linter.py @@ -0,0 +1,413 @@ +"""Tests that an Import Linter rule matches its contract and its baseline.""" + +from __future__ import annotations + +import dataclasses +import hashlib +import json +import textwrap +import tomllib +import unittest + +from scripts.rules.model import Baseline, CiRef, Enforcer, Kind, Rule, Status, Tier +from scripts.rules.resolve import resolve_enforcer +from scripts.rules.tests.support import Repo + +BASELINE_FILE = "rules/baselines/import-linter-pkg-pure.txt" +CONFIG = textwrap.dedent( + """ + [tool.importlinter] + root_package = "pkg" + + [[tool.importlinter.contracts]] + id = "pkg-pure" + name = "Pure modules import no database code" + type = "forbidden" + source_modules = ["pkg.pure"] + forbidden_modules = ["sqlalchemy"] + ignore_imports = [ + "pkg.models -> sqlalchemy", + "pkg.pure.legacy -> sqlalchemy", + ] + unmatched_ignore_imports_alerting = "error" + + [[tool.importlinter.contracts]] + id = "pkg-layers" + name = "Layers" + type = "layers" + layers = ["pkg.routes", "pkg.services"] + """ +) + + +def fingerprint(config: str, contract_id: str) -> str: + """Return the SHA-256 of a contract's definition, as the rules guard does. + + It covers the top-level [tool.importlinter] settings and every field of the + contract except ignore_imports, serialised as JSON with sorted keys. + """ + settings = tomllib.loads(config)["tool"]["importlinter"] + contract = next( + table for table in settings["contracts"] if table["id"] == contract_id + ) + definition = { + "settings": { + key: value for key, value in settings.items() if key != "contracts" + }, + "contract": { + key: value for key, value in contract.items() if key != "ignore_imports" + }, + } + canonical = json.dumps(definition, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def import_linter_rule( + contract: str, + *, + baseline: Baseline | None = None, + allowed: object = None, + sha256: str | None = None, +) -> Rule: + fields: dict[str, object] = {"config": "pyproject.toml", "contract": contract} + if allowed is not None: + fields["allowed_ignore_imports"] = allowed + if sha256 is None and contract not in ("", "pkg-missing"): + sha256 = fingerprint(CONFIG, contract) + if sha256 is not None: + fields["contract_sha256"] = sha256 + return Rule( + id="ex.r", + category="checks", + statement="A statement.", + rationale="docs/guide.md", + scope=("pkg/*.py",), + tier=Tier.MECHANICAL, + status=Status.RATCHETED if baseline else Status.ENFORCED, + autofix=False, + enforcer=Enforcer(Kind.IMPORT_LINTER, fields), + baseline=baseline, + ) + + +class TestImportLinterEnforcer(unittest.TestCase): + def setUp(self) -> None: + self.repo = Repo({"pyproject.toml": CONFIG}, render=False) + self.addCleanup(self.repo.__exit__) + self.repo.write( + BASELINE_FILE, + "# Tolerated existing violations. This list may only shrink.\n" + "pkg.pure.legacy -> sqlalchemy\n", + ) + + def messages(self, rule: Rule) -> list[str]: + return [str(violation) for violation in resolve_enforcer(rule, self.repo.root)] + + def test_a_contract_matching_its_baseline_and_allowed_imports_passes(self) -> None: + rule = import_linter_rule( + "pkg-pure", + baseline=Baseline(BASELINE_FILE, 1), + allowed=["pkg.models -> sqlalchemy"], + ) + + self.assertEqual(self.messages(rule), []) + + def test_a_contract_without_exceptions_passes_without_a_baseline(self) -> None: + self.assertEqual(self.messages(import_linter_rule("pkg-layers")), []) + + def test_the_contract_must_exist(self) -> None: + self.assertEqual( + self.messages(import_linter_rule("pkg-missing")), + [ + "[ex.r] enforcer: pyproject.toml has no Import Linter contract with " + "id 'pkg-missing'. Fix: add the contract to [tool.importlinter] or " + "fix enforcer.contract." + ], + ) + + def test_the_config_must_hold_an_import_linter_section(self) -> None: + self.repo.write("pyproject.toml", '[tool.mypy]\nfiles = ["pkg"]\n') + + self.assertEqual( + self.messages(import_linter_rule("pkg-layers")), + [ + "[ex.r] enforcer: pyproject.toml has no Import Linter contract with " + "id 'pkg-layers'. Fix: add the contract to [tool.importlinter] or " + "fix enforcer.contract." + ], + ) + + def test_an_ignored_import_missing_from_the_baseline_fails(self) -> None: + rule = import_linter_rule("pkg-pure", allowed=["pkg.models -> sqlalchemy"]) + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: contract 'pkg-pure' ignores imports that neither " + "the rule's baseline nor enforcer.allowed_ignore_imports lists: " + "pkg.pure.legacy -> sqlalchemy. Fix: fix the import instead, or " + "list it in the rule's baseline file under rules/baselines/." + ], + ) + + def test_a_baseline_entry_the_contract_does_not_ignore_fails(self) -> None: + self.repo.write( + BASELINE_FILE, + "pkg.pure.legacy -> sqlalchemy\npkg.pure.fixed -> sqlalchemy\n", + ) + rule = import_linter_rule( + "pkg-pure", + baseline=Baseline(BASELINE_FILE, 2), + allowed=["pkg.models -> sqlalchemy"], + ) + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: the rule's baseline and " + "enforcer.allowed_ignore_imports list imports that contract " + "'pkg-pure' does not ignore: pkg.pure.fixed -> sqlalchemy. " + "Fix: remove them from the baseline, or add them to the " + "contract's ignore_imports." + ], + ) + + def test_ignored_imports_need_unmatched_alerting_set_to_error(self) -> None: + self.repo.edit( + "pyproject.toml", 'unmatched_ignore_imports_alerting = "error"\n', "" + ) + rule = import_linter_rule( + "pkg-pure", + baseline=Baseline(BASELINE_FILE, 1), + allowed=["pkg.models -> sqlalchemy"], + sha256=fingerprint(self.repo.read("pyproject.toml"), "pkg-pure"), + ) + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: contract 'pkg-pure' has ignore_imports but does " + 'not set unmatched_ignore_imports_alerting = "error", so an ' + "exception that no longer matches would pass. Fix: set it on the " + "contract." + ], + ) + + def test_a_tolerated_exception_must_name_one_import(self) -> None: + self.repo.edit( + "pyproject.toml", + '"pkg.pure.legacy -> sqlalchemy",', + '"pkg.pure.* -> sqlalchemy",', + ) + self.repo.write(BASELINE_FILE, "pkg.pure.* -> sqlalchemy\n") + rule = import_linter_rule( + "pkg-pure", + baseline=Baseline(BASELINE_FILE, 1), + allowed=["pkg.models -> sqlalchemy"], + ) + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: contract 'pkg-pure' ignores " + "pkg.pure.* -> sqlalchemy, a wildcard that would also ignore " + "imports added later. Fix: list each import by its full module " + "names." + ], + ) + + def test_an_allowed_import_must_not_use_a_wildcard(self) -> None: + self.repo.edit( + "pyproject.toml", + '"pkg.models -> sqlalchemy",', + '"pkg.models.* -> sqlalchemy",', + ) + rule = import_linter_rule( + "pkg-pure", + baseline=Baseline(BASELINE_FILE, 1), + allowed=["pkg.models.* -> sqlalchemy"], + sha256=fingerprint(CONFIG, "pkg-pure"), + ) + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: contract 'pkg-pure' ignores " + "pkg.models.* -> sqlalchemy, a wildcard that would also ignore " + "imports added later. Fix: list each import by its full module " + "names." + ], + ) + + def test_a_changed_contract_fails_until_the_rule_pins_it_again(self) -> None: + self.repo.edit( + "pyproject.toml", + 'source_modules = ["pkg.pure"]', + 'source_modules = ["pkg.pure.validators"]', + ) + changed = fingerprint(self.repo.read("pyproject.toml"), "pkg-pure") + rule = import_linter_rule( + "pkg-pure", + baseline=Baseline(BASELINE_FILE, 1), + allowed=["pkg.models -> sqlalchemy"], + ) + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: contract 'pkg-pure' is not the definition the " + "rule pins (enforcer.contract_sha256 is " + f"{fingerprint(CONFIG, 'pkg-pure')}, the contract's is {changed}). " + "Fix: if the change is intended, set enforcer.contract_sha256 = " + f'"{changed}" in rules/registry.toml.' + ], + ) + + def test_a_changed_top_level_setting_changes_every_contract(self) -> None: + self.repo.edit( + "pyproject.toml", + 'root_package = "pkg"\n', + 'root_package = "pkg"\nexclude_type_checking_imports = true\n', + ) + + messages = self.messages(import_linter_rule("pkg-layers")) + + self.assertEqual(len(messages), 1) + self.assertIn("is not the definition the rule pins", messages[0]) + + def test_a_rule_must_pin_its_contract(self) -> None: + rule = import_linter_rule("pkg-layers", sha256="") + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: contract 'pkg-layers' is not the definition the " + "rule pins (enforcer.contract_sha256 is empty, the contract's is " + f"{fingerprint(CONFIG, 'pkg-layers')}). Fix: if the change is " + "intended, set enforcer.contract_sha256 = " + f'"{fingerprint(CONFIG, "pkg-layers")}" in rules/registry.toml.' + ], + ) + + def test_an_empty_contract_or_config_is_reported(self) -> None: + rule = import_linter_rule("", sha256="") + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: enforcer.config and enforcer.contract must name " + "the config file and the contract's id. Fix: set both, for " + 'example config = "pyproject.toml".' + ], + ) + + def test_ci_must_run_every_contract_from_the_named_config(self) -> None: + ci = (CiRef(".github/workflows/pr.yml", "quality-guards", "lint-imports"),) + for command, accepted in ( + ("uv run --frozen --extra dev lint-imports --config pyproject.toml", True), + ("lint-imports --config pyproject.toml", True), + ("lint-imports --config pyproject.toml --contract pkg-layers", False), + ("lint-imports --config pyproject.toml || true", False), + ("lint-imports", False), + ("lint-imports --config pyproject.toml.bak", False), + ): + with self.subTest(command=command): + self.repo.write( + ".github/workflows/pr.yml", + f"jobs:\n quality-guards:\n steps:\n - run: {command}\n", + ) + rule = dataclasses.replace(import_linter_rule("pkg-layers"), ci=ci) + messages = self.messages(rule) + expected = ( + [] + if accepted + else [ + "[ex.r] enforcer: job 'quality-guards' in " + ".github/workflows/pr.yml runs Import Linter as " + f"'{command}', which may skip contracts or ignore failures. " + "Fix: run exactly 'lint-imports --config pyproject.toml', " + "optionally after 'uv run ...'." + ] + ) + self.assertEqual(messages, expected) + + def test_a_config_that_is_not_toml_is_reported(self) -> None: + self.repo.write("setup.cfg", "[importlinter]\nroot_package = pkg\n") + + self.assertEqual( + self.messages( + dataclasses.replace( + import_linter_rule("pkg-layers"), + enforcer=Enforcer( + Kind.IMPORT_LINTER, + {"config": "setup.cfg", "contract": "pkg-layers"}, + ), + ) + ), + [ + "[ex.r] enforcer: setup.cfg is not a TOML file, so the rules " + "guard can't read its contracts. Fix: keep the Import Linter " + "contracts in [tool.importlinter] in pyproject.toml." + ], + ) + + def test_allowed_ignore_imports_must_be_a_list_of_strings(self) -> None: + rule = import_linter_rule( + "pkg-pure", + baseline=Baseline(BASELINE_FILE, 1), + allowed="pkg.models -> sqlalchemy", + ) + + self.assertEqual( + self.messages(rule), + [ + "[ex.r] enforcer: enforcer.allowed_ignore_imports must be a list " + "of imports written as Import Linter writes them, such as " + "'pkg.models -> sqlalchemy'. Fix: make it a list of strings." + ], + ) + + +class TestUnregisteredContracts(unittest.TestCase): + def test_a_contract_no_rule_references_is_a_hidden_rule(self) -> None: + # Reported in the order pyproject.toml lists the contracts. + with Repo({"pyproject.toml": CONFIG}) as repo: + hidden = [ + message for message in repo.messages() if "hidden-rule" in message + ] + + self.assertEqual( + hidden, + [ + "[registry] hidden-rule: Import Linter contract 'pkg-pure' in " + "pyproject.toml has no rule. Fix: add a [[rule]] with " + 'enforcer.kind = "import-linter" and enforcer.contract = ' + '"pkg-pure".', + "[registry] hidden-rule: Import Linter contract 'pkg-layers' in " + "pyproject.toml has no rule. Fix: add a [[rule]] with " + 'enforcer.kind = "import-linter" and enforcer.contract = ' + '"pkg-layers".', + ], + ) + + def test_a_contract_without_an_id_is_a_hidden_rule(self) -> None: + config = '[tool.importlinter]\nroot_package = "pkg"\n\n' + config += '[[tool.importlinter.contracts]]\nname = "Layers"\ntype = "layers"\n' + with Repo({"pyproject.toml": config}) as repo: + hidden = [ + message for message in repo.messages() if "hidden-rule" in message + ] + + self.assertEqual( + hidden, + [ + "[registry] hidden-rule: Import Linter contract 'Layers' in " + "pyproject.toml has no id, so no rule can reference it. Fix: give " + "it an id and add a [[rule]] with enforcer.kind = " + '"import-linter" that names it.' + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/rules/validate.py b/scripts/rules/validate.py index f4fefe347..966b967a3 100644 --- a/scripts/rules/validate.py +++ b/scripts/rules/validate.py @@ -21,7 +21,10 @@ Kind.RUFF: ({"mode": str}, {"codes": list, "pattern": str, "config": str}), Kind.MYPY: ({"paths": list}, {"config": str}), Kind.PYRIGHT: ({"config": str}, {"project": str}), - Kind.IMPORT_LINTER: ({"config": str, "contract": str}, {}), + Kind.IMPORT_LINTER: ( + {"config": str, "contract": str, "contract_sha256": str}, + {"allowed_ignore_imports": list}, + ), Kind.WORKFLOW_JOB: ({}, {"step": str}), Kind.DOCS_GUARD: ({"doc": str, "marker": str}, {}), Kind.LLM_REVIEW: ({"prompt": str, "model": str}, {}), diff --git a/tests/unit/v2/test_import_linter_contracts.py b/tests/unit/v2/test_import_linter_contracts.py new file mode 100644 index 000000000..71855fa0e --- /dev/null +++ b/tests/unit/v2/test_import_linter_contracts.py @@ -0,0 +1,278 @@ +"""Proofs for the v2 Import Linter contracts in pyproject.toml. + +Each proof writes a fixture package from ``rules/fixtures//``, then +runs the rule's real contract on it, with ``policyengine_api`` renamed to the +fixture package. The bad package must break the contract and the good one must +keep it. The contract keeps only the exceptions the rule allows by design +(``allowed_ignore_imports`` in rules/registry.toml), not its tolerated baseline. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[3] +FIXTURES = PROJECT_ROOT / "rules" / "fixtures" +FIXTURE_PACKAGE = "fixture_api" +APPLICATION_PACKAGE = re.compile(r"\bpolicyengine_api\b") +# How Import Linter's report names each import a broken contract forbids. +REPORTED_VIOLATION = re.compile(r"(\S+) is not allowed to import (\S+?):") +# Wide enough that the report never wraps a line. +REPORT_ENVIRONMENT = {**os.environ, "COLUMNS": "1000"} +RUN_IMPORT_LINTER = ( + "import sys; from importlinter.cli import lint_imports_command; " + "sys.exit(lint_imports_command())" +) + + +def _registry_rule(rule_id: str) -> dict: + registry = tomllib.loads((PROJECT_ROOT / "rules/registry.toml").read_text()) + return next(rule for rule in registry["rule"] if rule["id"] == rule_id) + + +def _renamed(value: object) -> object: + if isinstance(value, str): + return APPLICATION_PACKAGE.sub(FIXTURE_PACKAGE, value) + if isinstance(value, list): + return [_renamed(item) for item in value] + return value + + +def _toml_value(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return json.dumps(value) + if isinstance(value, list): + return "[" + ", ".join(_toml_value(item) for item in value) + "]" + msg = f"no TOML form for {value!r}" + raise TypeError(msg) + + +def _fixture_config(rule_id: str) -> tuple[str, str]: + """Return the rule's contract id and an Import Linter config for the fixture.""" + enforcer = _registry_rule(rule_id)["enforcer"] + pyproject = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text()) + settings = pyproject["tool"]["importlinter"] + contract = next( + contract + for contract in settings["contracts"] + if contract["id"] == enforcer["contract"] + ) + allowed = set(enforcer.get("allowed_ignore_imports", [])) + fixture_contract = { + key: _renamed(value) + for key, value in contract.items() + if key not in {"ignore_imports", "unmatched_ignore_imports_alerting"} + } + kept_exceptions = [ + entry for entry in contract.get("ignore_imports", []) if entry in allowed + ] + if kept_exceptions: + fixture_contract["ignore_imports"] = _renamed(kept_exceptions) + # A fixture need not contain every module an allowed exception names. + fixture_contract["unmatched_ignore_imports_alerting"] = "none" + top_level = { + key: _renamed(value) for key, value in settings.items() if key != "contracts" + } + lines = ["[tool.importlinter]"] + lines += [f"{key} = {_toml_value(value)}" for key, value in top_level.items()] + lines += ["", "[[tool.importlinter.contracts]]"] + lines += [ + f"{key} = {_toml_value(value)}" for key, value in fixture_contract.items() + ] + return contract["id"], "\n".join(lines) + "\n" + + +def _write_package(spec: Path, root: Path) -> None: + """Write each module the spec lists, with an __init__.py in every package.""" + modules = tomllib.loads(spec.read_text())["modules"] + for relative_path, source in modules.items(): + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source) + for package in path.parents: + if package == root: + break + (package / "__init__.py").touch() + + +def _lint_fixture(rule_id: str, fixture: str, root: Path) -> str: + """Run the rule's contract on a fixture package; return Import Linter's report.""" + contract_id, config = _fixture_config(rule_id) + _write_package(FIXTURES / rule_id / fixture, root) + (root / "importlinter.toml").write_text(config) + completed = subprocess.run( + [ + sys.executable, + "-c", + RUN_IMPORT_LINTER, + "--config", + "importlinter.toml", + "--contract", + contract_id, + "--no-cache", + ], + cwd=root, + capture_output=True, + text=True, + check=False, + timeout=120, + env=REPORT_ENVIRONMENT, + ) + return completed.stdout + completed.stderr + + +def _reported_violations(report: str) -> set[str]: + """Return each forbidden import the report names, as ``importer -> imported``.""" + flattened = " ".join(report.split()) + return { + f"{importer} -> {imported}" + for importer, imported in REPORTED_VIOLATION.findall(flattened) + } + + +def _assert_proof(rule_id: str, tmp_path: Path) -> None: + """Require the bad package to break the contract at exactly its expected imports. + + The bad package has one violation per source and forbidden module pattern + the rule's statement names, so narrowing the contract fails its proof. + """ + expected = set( + tomllib.loads((FIXTURES / rule_id / "bad_package.toml").read_text())[ + "expected_violations" + ] + ) + rejected = _lint_fixture(rule_id, "bad_package.toml", tmp_path / "bad") + accepted = _lint_fixture(rule_id, "good_package.toml", tmp_path / "good") + + assert "Contracts: 0 kept, 1 broken." in rejected, rejected + assert _reported_violations(rejected) == expected, rejected + assert "Contracts: 1 kept, 0 broken." in accepted, accepted + + +def test_rule_proof_import_layers(tmp_path: Path) -> None: + """Proof for rule api.v2.import-layers.""" + _assert_proof("api.v2.import-layers", tmp_path) + + +def test_rule_proof_pure_modules_no_database(tmp_path: Path) -> None: + """Proof for rule api.v2.pure-modules-no-database.""" + _assert_proof("api.v2.pure-modules-no-database", tmp_path) + + +def test_rule_proof_routes_import_no_database_access(tmp_path: Path) -> None: + """Proof for rule api.v2.routes-import-no-database-access.""" + _assert_proof("api.v2.routes-import-no-database-access", tmp_path) + + +def test_rule_proof_types_transformations_no_validators(tmp_path: Path) -> None: + """Proof for rule api.v2.types-transformations-no-validators.""" + _assert_proof("api.v2.types-transformations-no-validators", tmp_path) + + +def test_rule_proof_connectors_no_cross_calls(tmp_path: Path) -> None: + """Proof for rule api.v2.connectors-no-cross-calls.""" + _assert_proof("api.v2.connectors-no-cross-calls", tmp_path) + + +def test_the_repository_keeps_every_import_contract() -> None: + """Run the real contracts, so a release run catches PRs that combine badly. + + The quality-guards job runs the same command on every pull request; two + pull requests that each pass can still combine into a broken contract. + """ + completed = subprocess.run( + [ + sys.executable, + "-c", + RUN_IMPORT_LINTER, + "--config", + "pyproject.toml", + "--no-cache", + ], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + timeout=300, + env=REPORT_ENVIRONMENT, + ) + report = completed.stdout + completed.stderr + + assert completed.returncode == 0, report + assert " 0 broken." in report, report + + +def test_imports_made_only_under_type_checking_count(tmp_path: Path) -> None: + """Decided 2026-11-04: moving an import under TYPE_CHECKING can't pass.""" + report = _lint_fixture( + "api.v2.routes-import-no-database-access", + "type_checking_package.toml", + tmp_path, + ) + + assert "Contracts: 0 kept, 1 broken." in report, report + + +def _module_pattern(expression: str) -> re.Pattern[str]: + """Translate an Import Linter module expression into a regular expression. + + ``*`` stands for one module name and ``**`` for one or more. + """ + parts = [] + for name in expression.split("."): + if name == "**": + parts.append(r"[^.]+(?:\.[^.]+)*") + elif name == "*": + parts.append(r"[^.]+") + else: + parts.append(re.escape(name)) + return re.compile(r"\.".join(parts)) + + +def _contract_module_expressions(contract: dict) -> list[str]: + fields = ("layers", "source_modules", "forbidden_modules", "modules") + return [ + expression + for field in fields + for expression in contract.get(field, []) + if expression.startswith("policyengine_api.") + ] + + +def test_every_contract_module_expression_matches_a_module() -> None: + """A renamed or moved module would otherwise make a contract check nothing.""" + import grimp + + graph = grimp.build_graph("policyengine_api", cache_dir=None) + settings = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text())["tool"][ + "importlinter" + ] + + unmatched = [ + f"{contract['id']}: {expression}" + for contract in settings["contracts"] + for expression in _contract_module_expressions(contract) + if not any( + _module_pattern(expression).fullmatch(module) for module in graph.modules + ) + ] + + assert unmatched == [] + + +def test_the_module_expression_translation_follows_import_linter() -> None: + one_name = _module_pattern("policyengine_api.services.v2.*.types") + any_depth = _module_pattern("policyengine_api.**.types") + + assert one_name.fullmatch("policyengine_api.services.v2.policies.types") + assert not one_name.fullmatch("policyengine_api.services.v2.types") + assert not one_name.fullmatch("policyengine_api.services.v2.a.b.types") + assert any_depth.fullmatch("policyengine_api.services.v2.a.b.types") diff --git a/uv.lock b/uv.lock index 7961f55cc..19b02f1e3 100644 --- a/uv.lock +++ b/uv.lock @@ -1180,6 +1180,141 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, ] +[[package]] +name = "grimp" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/f4/a0313ef208458d099d5a68e250046cc576aa21538f5d82e32bc1ab76d146/grimp-3.18.tar.gz", hash = "sha256:581d3f0cd09983bbffa4507791baec8c4b56daf84a423c4e19b2b6f4888fd71d", size = 834024, upload-time = "2026-10-26T09:58:37.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/01/221badf12efa0b2046428fc3286c308ba7333176e81063cfb42ec0a1109f/grimp-3.18-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:fa55b7f9c61d6f3b541cb5902d7ac6ccfb96eaf3160043f8561e2f212991b74a", size = 2153354, upload-time = "2026-10-26T09:57:56.615Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/b3c3ff8f75a63611cdfc832216ac7f9ea0e0ee1c903748f7bf0b61196c29/grimp-3.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e8ebb6c2c15b1134b1ab5f8a93276eb49912745e7186abac77a550a981c4d44", size = 2110598, upload-time = "2026-10-26T09:57:46.737Z" }, + { url = "https://files.pythonhosted.org/packages/27/6a/2aba7fd6523325c8f5373af2186c52584ae06c71bb68a397e76909d65850/grimp-3.18-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90854aad6ab70ddcdb3717604af63906be196ec3cc3da0b0ed9b215c82b6a7a2", size = 2280511, upload-time = "2026-10-30T09:05:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/72/46/ec2a3ca503cdeb2cc56ee924f2eba5ea36eddd40d7240779f86f015cc371/grimp-3.18-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:80eb43bab85d0e45b6cdb02315bee1fdd76a131dae3b6491a2c9dacdd7390eeb", size = 2230817, upload-time = "2026-10-30T09:05:36.787Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/dccf4cf987f7a96219b2054d9e8049946c86f2c5a88eb61a945f94345cfc/grimp-3.18-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a3d88c8e9f005f14c1931c3f3a71fe54b4babbb3f27b54a353f2ce624ff0efef", size = 2374696, upload-time = "2026-10-30T09:06:03.401Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/435164fa90b6ad83f48edb6c9024684eb4c6f8965fb4f0b999efa81e0d17/grimp-3.18-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:48d1c17dd93c8a68beaf35dbb8f79da767b7a63b0ab86a5a02beafd0b54ae619", size = 2633313, upload-time = "2026-10-30T09:05:45.253Z" }, + { url = "https://files.pythonhosted.org/packages/ca/94/582945e7d3f5aea391dde02552c09681634186a39394f43e61d9cd4638a5/grimp-3.18-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c4a66143bd591902d13d0b3f052bfe025faa3864494ad4c7bfba1d85fba73024", size = 2364675, upload-time = "2026-10-30T09:05:54.276Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f8/2fac87e136ad2e2a0fcf1fa9485a7980865f741e988627e5f0502c3ae41a/grimp-3.18-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0171d8a516ed49d3dda228ec5e7a2ad64016d6f48ec6d15fc606173b39e18d1e", size = 2295679, upload-time = "2026-10-30T09:06:15.68Z" }, + { url = "https://files.pythonhosted.org/packages/64/34/dc55afae22c34d50fdea7eac05c9c10675777523391e4075ba0878da86db/grimp-3.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41d777c8b64a8d1e68522278d56b6857a021b10159d0c25e468fef0c0a82288e", size = 2280511, upload-time = "2026-10-26T09:56:49.582Z" }, + { url = "https://files.pythonhosted.org/packages/95/ea/38853fc6e86842785aed42a8dec01017e7b5ffe7f293e1200f26c0f262b7/grimp-3.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c759eac578bbef51285474e2c58dcbcb5bc2f7230080bb4b89bafce6d383006", size = 2230816, upload-time = "2026-10-26T09:57:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/9297a501633f5b23f03c9f65d0d065a23b993e9669f4170900d0855f71b3/grimp-3.18-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb8017c9577575e1b514318e46b3e6dc4c786f5fe2f0772998c791e794d74dea", size = 2374695, upload-time = "2026-10-26T09:57:26.005Z" }, + { url = "https://files.pythonhosted.org/packages/e2/57/9a8a3a069d1b76657d730fd052de2e82ff3fe72dfc943c0a1f3137c8939f/grimp-3.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:484ddda543a7d1d6f88dc4039a6b3f8662e67d052eaeea9e3d95ae617186c5fa", size = 2633313, upload-time = "2026-10-26T09:57:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/9b27d863ed20f4df6d5789582932bda92afa6585511da7e0c594d711232f/grimp-3.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b2815f6cc16bd6fa9a8e204e81c69bd88d6d90a55001fb874d55c645cf58956", size = 2364679, upload-time = "2026-10-26T09:57:18.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/a8/ac36868ad5f740e026495f83cc4017ef7f8db97cb53917737bf9929c7ae8/grimp-3.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28cef55938723bb391fbcf0fa5e0ddf8a8e62b71b475baf54a5a735d12df6c8e", size = 2295677, upload-time = "2026-10-26T09:57:36.432Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/188ebe78f13d5d2b168ea4468f9596d423cc6793614c8d5779cf92432bf8/grimp-3.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08b8950d933838e7a0e5cf2248eaceb9f4936ec50284811724319acc3d41b3b2", size = 2459914, upload-time = "2026-10-26T09:58:06.372Z" }, + { url = "https://files.pythonhosted.org/packages/62/23/e5a060d8c78665ba36dad338524738a464e9eb4a163bf22ebb7eb30ec950/grimp-3.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:55c5e86a133d3a48631e5307acb8c7184fa9a96d29a0338d7a0411a9adc99ffa", size = 2503313, upload-time = "2026-10-26T09:58:15.217Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ee/659b2df11442c8ce3ac0bc5970d99f6294309d620c5a5dc01f4aab67947a/grimp-3.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:80f7f1ef06fb4dfb3dcc85fbcc031843c33b0bcddd9b4543c734e3b9bbe68ec9", size = 2536905, upload-time = "2026-10-26T09:58:23.072Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1d/64ca536a7d7dd26ef7edd49c69dd0948cb7544a854da8c9ebfbe5b69e20d/grimp-3.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9c050be0ade2b9f3c59bf0b664ba28fb66b5cf8f770427400ce0a7ed5ddbd5d2", size = 2553732, upload-time = "2026-10-26T09:58:30.48Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c2/6ea1595304ba699a76f7f572891ac8f14f11c2b36f3a71b5cecfca2a651f/grimp-3.18-cp311-cp311-win32.whl", hash = "sha256:5655d875e40514ae4195a04eb96b6d9de188d6a520fc57e50a07d850e542b307", size = 1882019, upload-time = "2026-10-26T09:59:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/35/83/6f048967a919706487ddfe9d587e49e271e0d6b440ad6afd341ae2c3d8a8/grimp-3.18-cp311-cp311-win_amd64.whl", hash = "sha256:55650ec3bfdbc46cf673a3282b801c1b3799865c1769769621a77cfbfac762a8", size = 2010983, upload-time = "2026-10-26T09:58:49.969Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/5f2010ecc531a0345d4dc277f07335cb2729ab454a9725fca5c2febc728b/grimp-3.18-cp311-cp311-win_arm64.whl", hash = "sha256:a61173949e1dae14224f7084057caaf6ecf9c56cb39f73536aac094ff8cf0108", size = 1928954, upload-time = "2026-10-26T09:58:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/7094b0125310078cd37193b590925a6f7104f826e0ecf94a8d3ac75001c2/grimp-3.18-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:cad017a08dd7a7387b5208a640d4eee90a4152433080626aad09e6ce2d56a07d", size = 2153895, upload-time = "2026-10-26T09:57:57.693Z" }, + { url = "https://files.pythonhosted.org/packages/73/03/dd45288eacd34c97ce76e2596a865bcf9815525417057379affca3423063/grimp-3.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4482b0551ff5596c5b578ae6820fd32a8e0db278c314eb24e7cfffd84f20cf06", size = 2099041, upload-time = "2026-10-26T09:57:48.107Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/3c59b026e38290c2935a657fd72cb22daea41a2e38a485a63ae162522d06/grimp-3.18-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43d38ab816a9e5d3ed5e8448468e6ed61bccc491007bdafb10f1b2e433fcd215", size = 2276173, upload-time = "2026-10-30T09:05:26.777Z" }, + { url = "https://files.pythonhosted.org/packages/ba/15/f1aef5e8ca3a427108470281ee3a0185c9bca38277cc255060c81aa049d6/grimp-3.18-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:4d3bf444df87c4f3a2fe9a8d7ee95e6f28d37c961252821abc93ff872ee87097", size = 2224396, upload-time = "2026-10-30T09:05:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/7b72207aff83223fd081d41f335774f138973b8625e58508767a3245880a/grimp-3.18-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:26cec100e8af7e8c21bee1fb93c2c0e00ef468c5ceb42f03b4cd26f556e5ea67", size = 2365690, upload-time = "2026-10-30T09:06:04.795Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/922657cebff2fc8e25cac5f15165e63fc7880a33a6ebe7c59d0c3bc2f278/grimp-3.18-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c80ec28572574af7f867bc39b05a1768a295ffb3b73aa49ac6a188ae57b3c66d", size = 2629257, upload-time = "2026-10-30T09:05:46.886Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/960bb943b7b51ea5c036bee858b5e49416a6b98165ef01eae98a49f64576/grimp-3.18-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3b855e27874b633fa3a52e357b5804507fbd877ff097468197a2a463fd2c81b7", size = 2359483, upload-time = "2026-10-30T09:05:55.697Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cf/c133969021923097c0699f920721af4800f4d0540ec495596a3eb2c37e1f/grimp-3.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c046c18afd7123ef20abe3052b971c7645b1483dbde0b527ff0636f87077a77a", size = 2285072, upload-time = "2026-10-30T09:06:17.004Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/dc870213de5ac4360ab2433e888334fbdca8350bf7d2fdf455bdcd846706/grimp-3.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ca16afcf41957c89bf67970e08ffc69fbb339c1ebc84b6a728e2027914a0a9a", size = 2276176, upload-time = "2026-10-26T09:56:51.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5f/a1c80a1c0c2263b813c8c60ee4435c329d3e008872998e066104a1711f08/grimp-3.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:256a59bc8a8518dad521bcbd5d0d97398cb4e85304298a56950b504ce27a3818", size = 2224397, upload-time = "2026-10-26T09:57:02.294Z" }, + { url = "https://files.pythonhosted.org/packages/64/25/d958b14791a80a2eff35901a0a688070134b182c46cb7cf7503eece9cc4f/grimp-3.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760ae67db4a230e34e37a743facee2daab407176b1de8ad7a3e5fe87de1315b", size = 2365689, upload-time = "2026-10-26T09:57:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/87/5c/450ecbef5a761ac278822d7dd5f51474a06c8478677534ff5d20a805c532/grimp-3.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a86f60ce34e923e4a78de33621a1d0210eb0c2023d273330adf071937aec96f", size = 2629256, upload-time = "2026-10-26T09:57:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/32/fc/6c83230ded25d58b81cb51fdedbea24c6c3a83d30c05249113a1d628a70d/grimp-3.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01ce8ac303b53c8042bbb01ff1b68e7f30e26ba43c86f0708b83de1bffdb7fce", size = 2359486, upload-time = "2026-10-26T09:57:19.424Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b9/47e44696ed6be1863161ef6a5bd17c0403dba1bf403857461a549743c385/grimp-3.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89a1b57efd0b071ab7c30f1cb42db18255a7160f64ba800f8b15d854a6b4bd50", size = 2285073, upload-time = "2026-10-26T09:57:37.654Z" }, + { url = "https://files.pythonhosted.org/packages/67/ae/03bd637c48a7da87acf979f0f90554534a088390fa84f143fd55aed5bc15/grimp-3.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:95d9363c23f3762eca01ce786253f238e50aff481727315e46b8c733b7cd22d2", size = 2454616, upload-time = "2026-10-26T09:58:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/a8/bf/84bf9f4d58cfbf3bb7c956d5176a5c062e652d1fb2d68ee64c8d89bde4db/grimp-3.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c11915eeb8cb33fff0aff7e5c2bd6e76805ff05fee5791482bf00dfa8b5a5704", size = 2498452, upload-time = "2026-10-26T09:58:16.797Z" }, + { url = "https://files.pythonhosted.org/packages/ad/78/4a3976157e7a3eb8af8a22425ce1b228b0240fada3bac64e5073159c9593/grimp-3.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:43509137a39e6c6967f3dcc5203b677b789363521179796c026a3a918e3c4040", size = 2527787, upload-time = "2026-10-26T09:58:24.261Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b6/3ec487109565e047604001157e02d791d95643d0b0e11c707bd26447275e/grimp-3.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e4e8b25cefcd3ceccde8e2447144c7fbaa14647f6977401773b80e26212db15", size = 2540466, upload-time = "2026-10-26T09:58:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/8d/eee5f7c28d0d770fb780c3e3e69c143980cebfc3f847569ef5f639243999/grimp-3.18-cp312-cp312-win32.whl", hash = "sha256:d41e39388f960b8e2839a9749fc7163faedb8650d8cf6d5aed6067c5e0393757", size = 1878180, upload-time = "2026-10-26T09:59:01.595Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/f8e05cf2eb1c5d78838f8df411c26b3ae5bb8e187e1ae5f70104e21e04ed/grimp-3.18-cp312-cp312-win_amd64.whl", hash = "sha256:8bd7a38d3d7cf0844c1c786453726a8d35dee12e724b3ff8553f9cf38081b258", size = 2004645, upload-time = "2026-10-26T09:58:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fc/188985ed3d5cba25e56d4772f3b43ad37f8cb6246a577b2987870a4baad3/grimp-3.18-cp312-cp312-win_arm64.whl", hash = "sha256:eb85a21ea7fed7335b6fcc6adf7335fe904b7cef37243092104d4cf0380a4587", size = 1925154, upload-time = "2026-10-26T09:58:40.063Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9b/ec9b451fe327b295d8cd1073fc3e395d07dc6be9cdbf50ca58014df9296e/grimp-3.18-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3965804099c7d87ca910f5215dd8b85abcb8ed19c4a1f111626b756b377d7416", size = 2153480, upload-time = "2026-10-26T09:57:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/12/c4833b6feb84ef031626c62698ce1711898d0770208675faf0142cf53fef/grimp-3.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b791f9eadd965874682382214c4e3fd79b52476dea4e06b6043f4ecc3d6ef16", size = 2098697, upload-time = "2026-10-26T09:57:49.174Z" }, + { url = "https://files.pythonhosted.org/packages/6f/90/b6f79f177527e1dc6172c060358211bc6b923c49a262ad59497378f83dfd/grimp-3.18-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b3cdd4ab37d167faaed3fe4c4075e9598523d48c4e604ce36aec99147834a797", size = 2276129, upload-time = "2026-10-30T09:05:28.119Z" }, + { url = "https://files.pythonhosted.org/packages/b7/0c/56705713dbf5908df12731917ab712e3ebee1da1e234135dddcafd86ae9b/grimp-3.18-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:90a837d042a64500536bd99ee41cf04e1b16f2e4eb378b8750b9f9366bc8533e", size = 2224129, upload-time = "2026-10-30T09:05:39.547Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8b/eb7da3168db995e6d918870b6b827d5436e4e045af5980cfa973978fe943/grimp-3.18-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e20504afdd0faa2fac8941d4c72fad40863619748d96abe7073caa7cb4f13c2", size = 2365249, upload-time = "2026-10-30T09:06:06.736Z" }, + { url = "https://files.pythonhosted.org/packages/6b/88/2b9b197a4285165fd79c12abd3cbc8b5f865008230db74c31b5fb9247271/grimp-3.18-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:64a250970137e097ce7b97b23014448856846af83934b054423a515d1bf87c35", size = 2629330, upload-time = "2026-10-30T09:05:48.26Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7c/46dc1ed6e71b262e283aec37d24be1f258e99ae17ed906e6cc1da6c70d9b/grimp-3.18-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a089f692d22fe5f5dba1e5c5c2cbd0c9f4b7ed420e0f3ed6e8efd2fc109ca137", size = 2361092, upload-time = "2026-10-30T09:05:57.302Z" }, + { url = "https://files.pythonhosted.org/packages/07/e8/9f8e3ade34fc572767b4c5fb8105083c5ede67f8df93e608d11cfc7a587a/grimp-3.18-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4426c7c57ade9ae8e323765b37b4c785b6e2cb374038b42d6b36110a50c4af46", size = 2285344, upload-time = "2026-10-30T09:06:18.565Z" }, + { url = "https://files.pythonhosted.org/packages/bf/aa/77fe70a536ba9494e7e8c09a6d8071a511e40a5cd947fb772d248caeec58/grimp-3.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c59b458c6e07d90594b25076d6887847957c3c189e38a9c6a81cbefaeb13c6e", size = 2276130, upload-time = "2026-10-26T09:56:52.693Z" }, + { url = "https://files.pythonhosted.org/packages/5b/81/6feb1cc684fe656d06f17edfd999705d37d392744db2bcb28429e77ca5de/grimp-3.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d107902da5048aca9be26111b8379f50e02dbdad4dfe54775ec8a2cb365018ef", size = 2224129, upload-time = "2026-10-26T09:57:03.427Z" }, + { url = "https://files.pythonhosted.org/packages/1a/88/cfc843d0459af7923569b6e48275258be09e3b5796bc245fe353ccf43091/grimp-3.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edf249c111e5e6e9fcc1bea1dc3f3e6069fac18bddda9b65ec8434da883948ab", size = 2365248, upload-time = "2026-10-26T09:57:28.704Z" }, + { url = "https://files.pythonhosted.org/packages/d2/90/43564f395a62d42c012cba727b9d17f0c85e43e0e65e4648f7b4a3d13a87/grimp-3.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8785c2ddd195f684f9b0adae6d800bd2ca817822d7f5d4ee10f58abf1fb6ae25", size = 2629329, upload-time = "2026-10-26T09:57:12.899Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/9d2b8371ec6c55421c6d8973ec40704a932a47c032cb30a5dce3b24f2a29/grimp-3.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc8e05fb0014ee401ae9af5360cd367679e822dcf68ef4bd63aca2e3c3e2d47b", size = 2361096, upload-time = "2026-10-26T09:57:20.625Z" }, + { url = "https://files.pythonhosted.org/packages/42/ef/a9756cfacb3a4609d40081b40ffb25661c7310e408fe6a39b3397c98e642/grimp-3.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:035ea5851a421c46d77cd041c8d7a9be9e7a2a395c8e7b028a0f95fbb79322c0", size = 2285344, upload-time = "2026-10-26T09:57:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/ab/11/86cac944560fea76ab0351e41ca8a41df31cb4458fbffd638d4e594f8192/grimp-3.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea3ccde88a5dd09488607a6819bd74c4ab5feca04a0c326c048ed4d06959114b", size = 2454431, upload-time = "2026-10-26T09:58:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/63cd72ef9a3acb9e8e1589047bf5fa0939a8c54beb63a5139f5c2610d38a/grimp-3.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:515d69d7ca24d6d3ad09f9fe0c6a176165fc79948df0f9d75e5a1fbdb4a28f31", size = 2498089, upload-time = "2026-10-26T09:58:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/7a3416713861c5fdbe9687fde5c167442e745840d15031e6a59396d32e93/grimp-3.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:92d5bebf41e1506f72b80500a335538022f702a5565d64765ba0c46ed15d00bf", size = 2526912, upload-time = "2026-10-26T09:58:25.529Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/ff23929918fdae22cf692c4388e858e818d01eb6ec0ebf1df6010f99a025/grimp-3.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69256958d3b8b7a0298e028ee4a9cb40e0baf0b01b99eecbf6e1f57bdec89da0", size = 2541078, upload-time = "2026-10-26T09:58:33.412Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/cd18e2b7b7d3974365987984ca28bf0088d21d15da94eb63d37911fc39c7/grimp-3.18-cp313-cp313-win32.whl", hash = "sha256:22f1be5207d1244d90f502285c6ee0304a025633c381e44a796e6bcd41b13771", size = 1877463, upload-time = "2026-10-26T09:59:02.822Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/c83f4c643517d62535e3f3916d6f4b8b5d4422637275bd9d1c6f6d26e54e/grimp-3.18-cp313-cp313-win_amd64.whl", hash = "sha256:2aa75b562f134fab37d64b778b5152bef0be27549cdc2084feb5dae0329f3fff", size = 2004132, upload-time = "2026-10-26T09:58:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a4/438df0ddd0f02e3ce95625632558c63bdb6f75031e423314a649787b6554/grimp-3.18-cp313-cp313-win_arm64.whl", hash = "sha256:a2fe4fb3529b3e68e70cf1bb026effd129e6f3887f5236c6062b89680f9214bf", size = 1925031, upload-time = "2026-10-26T09:58:41.348Z" }, + { url = "https://files.pythonhosted.org/packages/8d/95/f1659bff361c15293321f9ab4ddf0c93c0a32c1c6d2e6e7df4d6bf12a8e4/grimp-3.18-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6e26c3020843ceeda2bb699ea09e27b6654f7823b51e779b9c607e204b34e1f4", size = 2152974, upload-time = "2026-10-26T09:58:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9a/f4aa9e284fb82874c4f588143557ae4f871ed52c6689a55c49968b6e3b44/grimp-3.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7bc0587c8622e1275e217e924c27e54420f7a06db88d133f4adad18a0298b040", size = 2099498, upload-time = "2026-10-26T09:57:50.493Z" }, + { url = "https://files.pythonhosted.org/packages/e0/89/ca91f3a223f9b1dce8aaf23a7b92a8afdb3c7bf6597348c793f3eef6e5d1/grimp-3.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5083d474794a20d8b3d53c2955d9cc6e590fe3d55a467ec6e4940d2d1413e5d3", size = 2276232, upload-time = "2026-10-30T09:05:29.668Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/b782fc977992e0460991e9bde35e15d40e41057427ded9e8612a87a00400/grimp-3.18-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e3e101de453ba3a5ed7e6776857e12e75913a71254779240b952b00fb99be327", size = 2224038, upload-time = "2026-10-30T09:05:40.995Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7e/d95e166c2aafc14a20bdcda3e251c197d411d765e674db7d3a48532b5e14/grimp-3.18-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d5ca964848d32b871a6b5755ec9e2c8499945268fb07065805616c2f0ccc7b94", size = 2365371, upload-time = "2026-10-30T09:06:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a3/c092a0c5458378962e11df965e6588743d9544019e1c0b375a2d144e2b7f/grimp-3.18-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:742661cf3af03b9cae83abbd47a86e3fee2e5241c9c0ca1506ee3a2d9d21c835", size = 2630405, upload-time = "2026-10-30T09:05:49.75Z" }, + { url = "https://files.pythonhosted.org/packages/41/bf/2f7fff6f1a7ed5209fdc42455c7aeb76d5e64017db1641cf30ee9e8c39dd/grimp-3.18-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cc513ef2584df912b7bc165a7a1d549cf8f6032e80fc11cf947ca9c2fcf3c5a2", size = 2359624, upload-time = "2026-10-30T09:05:58.832Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bd/defaf1fa6f19173b1b4bcf87311a9bc72d66e6e6cddd57cec8473fd4030e/grimp-3.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47ff6d75a2b8db5c0b98be66be74ff7d7ba6d603feca3b5cbe6684a849ffb08c", size = 2286960, upload-time = "2026-10-30T09:06:19.997Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/a96e7b9ecf3ffce2482d303663959a9f4d4ec33fceb667cc5071cd62a161/grimp-3.18-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1453b4b00c324cdaabb5c70f57d45a84882dd877883cefbda859fd66b400bcd6", size = 2276234, upload-time = "2026-10-26T09:56:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/a4/29/8bb9dccd6390ff78b6e49f6e073afe90c6add6f71e4f795e217ce0846fdb/grimp-3.18-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2611d37e446eee05e6621a28e4ae7b612899715cae637037404e7a0d91f327b", size = 2224037, upload-time = "2026-10-26T09:57:04.89Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/25bf0c6cd7c1fe4f77be0354a96337cee516a4b48bbfe48ca1745ec70cc6/grimp-3.18-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:438544f3e7aaed32ddc09d8a8f6685e67e7ce43f0931f7149426fb7b483a6df6", size = 2365372, upload-time = "2026-10-26T09:57:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/47/86/d581994d0c7f7ce454f017d1a8d2c5dcf6bf6ffbba9d6c111b182ab6f046/grimp-3.18-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00f68805578f97941da62ea6f77c1df18e7b9e405cdc673c1f93450594b832aa", size = 2630405, upload-time = "2026-10-26T09:57:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c6/85/356d045100d73d255aba31fa3941cfed6b01de940139c32c75f44797e499/grimp-3.18-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e639ff9e2fc9f0ad6c038f03746e61f671a1520eac1a8c0c7b35e2f047405e97", size = 2359629, upload-time = "2026-10-26T09:57:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/ac6badae3d5132d0df806e31b7a95910142af13bfbf4d03cb823c4e12f58/grimp-3.18-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca1231a495d685c91c4aabb28a515e7b57e10bc2a3e9b177d88182e038cdc0cc", size = 2286959, upload-time = "2026-10-26T09:57:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8a/55f5844c39881b15c8f768e551a03468a325300473e0f216bfd0eddff760/grimp-3.18-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e02c80058b07e63151a88d562cef78e128f6e3d3f0e82cbd4f41bfc4cd3016b0", size = 2455096, upload-time = "2026-10-26T09:58:10.971Z" }, + { url = "https://files.pythonhosted.org/packages/54/38/37b1dd9874cc49239fcc2df09e893b13a2a186b37d6d5b4141cc6691ce08/grimp-3.18-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:82e3eed4ce2488eacca6b32c57116ad2eeb9937320dadc1a8e75e1135afbe6ff", size = 2499283, upload-time = "2026-10-26T09:58:19.291Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/d41f8475adc086bc5fee2f83d94a02ced0e87e0e8a538eec13ded16e5cb4/grimp-3.18-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:026fb12839d4b6b7108b9684e234772f881026239945d94016eabe60a24ea405", size = 2526699, upload-time = "2026-10-26T09:58:26.628Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/b37963f96f69662fd76a8f9a85c40a19977f7fac198cc4d36927003b338a/grimp-3.18-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:15c97cd94795ebce57acd6190f25003d343e8cece486d9978df03b29dd53410c", size = 2542397, upload-time = "2026-10-26T09:58:34.983Z" }, + { url = "https://files.pythonhosted.org/packages/8c/08/e61e54a27f920702e74d7aae664626dadcbc7b603f6242cd7fbfcf67bb6a/grimp-3.18-cp314-cp314-win32.whl", hash = "sha256:3bfb79295c12fe96b9ec87590768c52eddde62382de1babc4c6e83f9deced86f", size = 1878320, upload-time = "2026-10-26T09:59:03.98Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/d29bd012cae0eb678af38928c69f547d2a2e069db98004ff9dd7b8e4ba1a/grimp-3.18-cp314-cp314-win_amd64.whl", hash = "sha256:103aa8031f3e08353716618f47f16ae9e91e7add2f64fff77d02cada62f04728", size = 2006784, upload-time = "2026-10-26T09:58:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/d07a1f733c75522985e733990884a341009676ef41b135c5141d7599d401/grimp-3.18-cp314-cp314-win_arm64.whl", hash = "sha256:932e992a798c4b69ad77957cc09517c22cd1aa3b07cc85aba41e14406cb1a42f", size = 1925674, upload-time = "2026-10-26T09:58:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7c/aa8d7bfe872f4bfba342ee0dae17eadd34dcc1e73b8822765f0528eea3e4/grimp-3.18-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:73633cf2c822f34a38dd77e34e015afd6fc489998abeab8b91a7149fc63c1a1b", size = 2153687, upload-time = "2026-10-26T09:58:01.827Z" }, + { url = "https://files.pythonhosted.org/packages/1c/73/613c1e57e3db9c81779e4e5f7e6bb2c69118da64be9a4aba69cc74401594/grimp-3.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d7418bc8b511a12e21099b43349588785254d3d38ecf0dac88aa927c91ebc47", size = 2099246, upload-time = "2026-10-26T09:57:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/8962a691c78fde0be777e67805e994944000551882dce617b03a99d5e02b/grimp-3.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:34090d38fba4223a6a9a5cc8f5c7523fdd9db7dfaef80dd90376d45242e59704", size = 2276732, upload-time = "2026-10-30T09:05:31.154Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/1c0ac6be3ea422ed8431bb392a0041c11300164761f2d2d3200e4f222f1d/grimp-3.18-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:3463911ead5015eaa91da3721b84c900cdcaec22d149133827a76ea86d798cb1", size = 2220324, upload-time = "2026-10-30T09:05:42.395Z" }, + { url = "https://files.pythonhosted.org/packages/09/00/a30fdaa8c0793db7dfc17590f341e1e82b30989602338205030d4e4394d0/grimp-3.18-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b989ad42e66d2ffb75dddfbc14bb980790fbbce6f58fdccc3df99cebf4661689", size = 2365815, upload-time = "2026-10-30T09:06:09.694Z" }, + { url = "https://files.pythonhosted.org/packages/2b/19/1965cf2d8d218f0d763872c32362cf5dc05eb5c192c7a5996089e41e048a/grimp-3.18-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4390a69445c586c1ad1c4aaab9e3ec44fcd3f662c04476dbb77b3b6dde5ed6ac", size = 2629781, upload-time = "2026-10-30T09:05:51.184Z" }, + { url = "https://files.pythonhosted.org/packages/26/eb/26a06c8eab6a252c8c1e0a3bbe3cf71ddd9b0e3c288c6ee474df635e3f3b/grimp-3.18-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a34f3414a8f944d1b30b958fe571f5ca07cd6fa8fd7dcffea89e995a6cd5ee0f", size = 2359278, upload-time = "2026-10-30T09:06:00.328Z" }, + { url = "https://files.pythonhosted.org/packages/cb/02/d9497138b299b45eebe856159605752be9304fd661ab87f507fdbc10fa2e/grimp-3.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98e62116c9a323f00b580b6d9a4aade24d047bdf37b3a4f5098098aa055063be", size = 2290843, upload-time = "2026-10-30T09:06:21.903Z" }, + { url = "https://files.pythonhosted.org/packages/4b/95/eedf8708e71345d3a2adea470c493ecb1bc368e295d01a02b7c1777cc2c8/grimp-3.18-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ecaf989c284fbd0abc8710b79565b5ac2165b9d74e77ced52463c576e5b00a0", size = 2276734, upload-time = "2026-10-26T09:56:55.162Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/8ee0455db564d983b0dd5875b709e360c68e54e454f7b14376f639b5ef27/grimp-3.18-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07abd5b67a0d2b8d1809cdea9f18d83c8a92525dc54b50726a30cfe89eb66d55", size = 2220324, upload-time = "2026-10-26T09:57:06.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/5720f66a65e084f6cb126f041454b1d6921ff3bc9ce1ffed559508c2621d/grimp-3.18-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a59c0727f35f765a93c20cc0141fbab5bad4ae58b5fb80c9b7ed4e7ac3b3554", size = 2365814, upload-time = "2026-10-26T09:57:31.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1a/37f276182e03521c6d4d0ecc51fdcdab92646e5d32e87d2f9afc735c4b90/grimp-3.18-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0040681a32586afc227016164ff3df7a20a628beec89b6ea61c2c0698a8dbfa7", size = 2629780, upload-time = "2026-10-26T09:57:15.476Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ff/cf80828297b3421d474c5d2597a5c5fd1a8e91f0e7ca632aec6f9a303fed/grimp-3.18-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb021eb3e3634d109aba8f4f06f702da1574de12eb9171fb115bcc77bca69f56", size = 2359284, upload-time = "2026-10-26T09:57:23.398Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/05f81aac348e8148230e1055f735f3adf7688daef7d16bfb37705e163749/grimp-3.18-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ba3160f9ee8a1c4c2be7ea7354930c0a8154a3eae543544d3a9acd098449b70", size = 2290843, upload-time = "2026-10-26T09:57:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/74108bbf404d4e302160c55f5f278767143a85953f98d82b2284cbc63903/grimp-3.18-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:848f21c262f5090fe048857f0a35999516607dd54c588474200a311d337fc4fb", size = 2454388, upload-time = "2026-10-26T09:58:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/3ed4ab7e7d3aef0ab7ce263ed8f51d1e8b92cb1345b99a2113e514d8aa76/grimp-3.18-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3b3f3d25fcb497c30f8c4d6ef076ddcd5214819ce7b15b5cd4a6038a3e2c4f51", size = 2494680, upload-time = "2026-10-26T09:58:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ec/9044aaa2f2f3e4b602f8e254517f872128fa528bcc73cc66a06abc2ddcfa/grimp-3.18-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8762c40283e9f24cfc58ccdb1b1416915b0c9e7abc20918850e5f690d740eb37", size = 2526535, upload-time = "2026-10-26T09:58:27.883Z" }, + { url = "https://files.pythonhosted.org/packages/f0/dd/e0a1f5e0d7f7356372a40a62714e344687c989b832f750236c9c22a546dc/grimp-3.18-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b855cffdb769994c54800286a72dfa3894d15728dc41eccb42315dce3e6f8232", size = 2547066, upload-time = "2026-10-26T09:58:36.185Z" }, + { url = "https://files.pythonhosted.org/packages/f6/7f/af487f83f07edf544a6c837412c895afe395fa8c5abe54346f9950f9325c/grimp-3.18-cp314-cp314t-win32.whl", hash = "sha256:0c18c304540366f4da39fa16dd4fdce4fcf1f82ae812c4fb686defd399db6851", size = 1875992, upload-time = "2026-10-26T09:59:05.068Z" }, + { url = "https://files.pythonhosted.org/packages/bf/40/4acad23429f1e79a9d3cb1f33f293f8ed465c1d269d61b34282913d77f54/grimp-3.18-cp314-cp314t-win_amd64.whl", hash = "sha256:0461322a58d7fe66485cc2ddbf725f8bb6c2f3b805826746e208d78e5e4b17d6", size = 2004022, upload-time = "2026-10-26T09:58:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d0/d0c2119662b549312166c8aac2e96028f5253075e60587d882e0aba4e673/grimp-3.18-cp314-cp314t-win_arm64.whl", hash = "sha256:a79cde5a2052c0c22dfd5ce0e3cb2fa0ef68625c480d779bd331f9d0bf3459f3", size = 1924204, upload-time = "2026-10-26T09:58:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/74/34/92e2e51eb0a832e56171196210793eb530d3d51a86814389ca4614237aca/grimp-3.18-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3a53760b5623c7a0a48caf5981355953f2c2a33c6d78593aad77f40c9070f526", size = 2153238, upload-time = "2026-10-26T09:58:03.035Z" }, + { url = "https://files.pythonhosted.org/packages/68/50/10fa8494dcaf0d644a1ec66ad20773686dd2143e4e54fdee6738d6815c9c/grimp-3.18-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:18b0901afede6f7f630d285da0f68ec462830550d6e7760ad04dd3cfe94830fa", size = 2100255, upload-time = "2026-10-26T09:57:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/06/cf/4bc257061f01663f7c960b051620052052dcce7e2f1bc3450b6e1b2de71c/grimp-3.18-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8e76d4e7df434abf383a9ec9aeffa20162b46944f0ba7a8854eaec604e97d5", size = 2276193, upload-time = "2026-10-30T09:05:32.518Z" }, + { url = "https://files.pythonhosted.org/packages/82/43/88f5be6b47d95c22419bbf9dd4d39b5a991df9155f7ca685f4c68c9239c4/grimp-3.18-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4f0f47e58d205decaac5b2189b0225ba7a2c30f631cd57525f705f9e5193b30e", size = 2367638, upload-time = "2026-10-30T09:06:11.145Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a8/a26b49bc3705f9ea06a4bb7f0d6d49d31c49b554bdc8cfd59af24ce4f9d9/grimp-3.18-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3586faa07635845a7d541844ea4969af365cebe9cfaa405e7a504f51a04a3b82", size = 2287563, upload-time = "2026-10-30T09:06:23.444Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/f10af5f3fad0f8c24373088c5e95354f43fdea5eb2b1af70f22cd102f6b8/grimp-3.18-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73d06cc1c29b3e96558e2f929a0253b4fc45e12a32d1b2dac0b950575fd7ff95", size = 2276196, upload-time = "2026-10-26T09:56:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/04/ee/e894a7721af42576295920eb4d1b01f043eccdeaf7b09e9c466da2ffa415/grimp-3.18-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:45dda7406194bd486b1f43674fbbdeaf5c6b66696647944652f4ef205aff1792", size = 2367636, upload-time = "2026-10-26T09:57:32.446Z" }, + { url = "https://files.pythonhosted.org/packages/0f/11/c59a63483cd45da04dad7efc6dfd0984f469c468d46f1ebbb63faabf1bda/grimp-3.18-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4647f81d393909e903b0cb4ce910b65c1f85c26bd20ea6addbd83c87eee5a1a7", size = 2287564, upload-time = "2026-10-26T09:57:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/82/18/aefacb820298617d6597a7f40d0763526c47d59f0dca424696f64118f413/grimp-3.18-cp315-cp315-win32.whl", hash = "sha256:fddfc9e1004238f5118389db8b4af28bb197846d2fb7f3d55000902aeb14e970", size = 1878390, upload-time = "2026-10-26T09:59:06.166Z" }, + { url = "https://files.pythonhosted.org/packages/46/82/b99517bc7aefe69f3ed19182836ee798a0624010f01f6557d78769a1e5fb/grimp-3.18-cp315-cp315-win_amd64.whl", hash = "sha256:9172763b79dfc1880a2833594d7b008b7afeff3236b2fa3c33ffd8f6cc6b3eae", size = 2006345, upload-time = "2026-10-26T09:58:56.153Z" }, + { url = "https://files.pythonhosted.org/packages/64/5c/d7fd4ad4b8efe237f3650a727815c81caa708731342d1d6582ee7fcf1286/grimp-3.18-cp315-cp315-win_arm64.whl", hash = "sha256:f52b5cce5bfa785f1e91c952124c8a264789acd25240a2478636db0c9b61dab3", size = 1925528, upload-time = "2026-10-26T09:58:45.978Z" }, + { url = "https://files.pythonhosted.org/packages/bb/81/18f6a2b1df6a856b8e2c65dd779a1487ac994da63d7712668a75ae0fd022/grimp-3.18-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:050332a3e09da40624d147d0b8521fad50a9d205ab228737965a388cd252eb56", size = 2154062, upload-time = "2026-10-26T09:58:04.114Z" }, + { url = "https://files.pythonhosted.org/packages/b1/52/206cc2c4255a8952b0b39b8d12f06c97629c4815a854bb535fbbeb1594f5/grimp-3.18-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:be75ccc563e7452949c7330ed06d8d128eaaff4433fdc09dde363ecd35552219", size = 2099675, upload-time = "2026-10-26T09:57:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ef/50/dede3953e96942c675afe3f30a1b8e94e43d44d091d9dff8bfba6e3e624b/grimp-3.18-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a578687f1a54c9b114199d56574306599ca08f1b6a95b81f4ef0c60a7aede475", size = 2277279, upload-time = "2026-10-30T09:05:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/00/4c/d0e9f6564e423a927b3b15170434d78ff4508a4b60776f36d81cc465f35d/grimp-3.18-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2506958d0b6fe01759cb633d39489bd09f1fb0ce4b9a9228d9ce9ac00863cca1", size = 2367202, upload-time = "2026-10-30T09:06:12.672Z" }, + { url = "https://files.pythonhosted.org/packages/09/f9/3322baf92a741cb64f684e9da55123b4c07c0faacfe0a1910e14b20e3025/grimp-3.18-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb12a7c3e3f0e5a60265e8264a71b35a5fb0db80fee55d4cc7a312e992fc1c36", size = 2291033, upload-time = "2026-10-30T09:06:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/03/82/da3cb63c8d17ae558078099ffaca7dc6db6bc865952fa10949eceb299e28/grimp-3.18-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d91b8b9eaf74953133b9516715bcbc274ebbcf925c8b8ce17ad5f0a433a8a75d", size = 2277276, upload-time = "2026-10-26T09:56:58.13Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/8011ad74d892b6f704951f0ebbccca13b23cce78134502cd260a2bf0cc61/grimp-3.18-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bf220bf26eb007010772601b1143262d0faeb2d6161191ba2684abd5ccf5b4b", size = 2367201, upload-time = "2026-10-26T09:57:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/fceecab7b017f2d9358e9585691f8af541a378091ee70af919775c74feb5/grimp-3.18-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d26b386fbb260be374bf8e4b5250ee608f4a0b0597c428b6ede3fdde6308b258", size = 2291034, upload-time = "2026-10-26T09:57:44.144Z" }, + { url = "https://files.pythonhosted.org/packages/75/6e/c76afc13396ffcb7dd0416bea190299bf5123e15c46476970e2a05322e8b/grimp-3.18-cp315-cp315t-win32.whl", hash = "sha256:a004d7cb708d784407d42073babb82e10dfe472652b4973e4706bb8ab36cd1c1", size = 1876352, upload-time = "2026-10-26T09:59:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/c2/35/8b43b86700207a3119c48e6941bddc53306d0180889bf302ae8e6103629e/grimp-3.18-cp315-cp315t-win_amd64.whl", hash = "sha256:982d1720ae596243a9582c264838498ddc920f3f0d0be24b3d91288c14e5ffad", size = 2004283, upload-time = "2026-10-26T09:58:57.731Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8a/5e99951f3878aad628067db1a75b637397aa012bf6c416a6d0c4fb8f99af/grimp-3.18-cp315-cp315t-win_arm64.whl", hash = "sha256:c0ec5074186acac4c6915b1557c12fd9594c7973a141c1f23edcdc6671fd341e", size = 1924538, upload-time = "2026-10-26T09:58:47.119Z" }, +] + [[package]] name = "grpc-google-iam-v1" version = "0.14.4" @@ -1456,6 +1591,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] +[[package]] +name = "import-linter" +version = "2.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/81/1bb414f8c8bcd024499e91f112d317c36a86bc264da1fd43f9797fc2b7b0/import_linter-2.15.tar.gz", hash = "sha256:1da912bea5e172a82a3ce617b5543f75cf64dc0d8f4d9b46c5578b68ccb81590", size = 1405973, upload-time = "2026-09-04T14:47:56.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/31/4571c20652a4002ed9e500046386f3731a77e23d95a608ae8a87f1fbb2da/import_linter-2.15-py3-none-any.whl", hash = "sha256:9aaf16a88ac1e99d5a464cd7f66b6a05f7060bfa761162e0ed441773a267ed3b", size = 640512, upload-time = "2026-09-04T14:47:54.878Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -2781,6 +2931,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "coverage" }, + { name = "import-linter" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-snapshot" }, @@ -2813,6 +2964,7 @@ requires-dist = [ { name = "google-cloud-secret-manager", specifier = ">=2,<3" }, { name = "gunicorn" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "import-linter", marker = "extra == 'dev'", specifier = ">=2.15,<3" }, { name = "markupsafe", specifier = ">=3,<4" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15,<2" },