Skip to content

Type Checking (mypy)

This directory documents how OpenContracts' Python type-checking pipeline is wired up and how to graduate modules out of the initial baseline.

How it is wired

  • Configuration: mypy.ini at the repo root. (Pulled out of setup.cfg because the per-module baseline list below is large.)
  • python_version = 3.12 (matches the runtime — Docker images and CI both run 3.12.x). This must not be lowered below the runtime: mypy validates third-party stub syntax against this target, so a stale older value makes it reject modern stubs (e.g. numpy's PEP 695 type aliases) and abort before any project code is checked.
  • plugins = mypy_django_plugin.main, mypy_drf_plugin.main — Django- and DRF-aware type inference (models, querysets, serializers, etc.).
  • django_settings_module = config.settings.mypy — a thin wrapper around config.settings.test that supplies a dummy DATABASE_URL so mypy runs without needing an env var. The plugin introspects INSTALLED_APPS, model fields, reverse relations, etc. but never actually connects.
  • check_untyped_defs, warn_unused_ignores, warn_redundant_casts, and warn_unused_configs are on so the bar rises as modules graduate.
  • ignore_missing_imports = True — most ML/pipeline deps (pdfplumber, docling, sentence-transformers, …) ship no stubs.
  • Pre-commit hook: .pre-commit-config.yaml runs pre-commit/mirrors-mypy with stubs + the minimum Django runtime pinned in additional_dependencies so contributors don't need the full dev env installed locally. All deps are version-pinned — pre-commit autoupdate only bumps rev, so leaving stubs unpinned would let them drift independently on every weekly refresh.
  • CI: .github/workflows/backend.yml runs python -m mypy --config-file mypy.ini opencontractserver config as part of the linter job. The preceding Install dependencies step pip-installs requirements/local.txt, which pins mypy, django-stubs, and djangorestframework-stubs alongside the Django runtime — so the plugin has everything it needs in the runner env.

How to run mypy locally

The test container already has every runtime dep installed, which keeps the Django plugin happy.

docker compose -f test.yml run --rm django \
  python -m mypy --config-file mypy.ini opencontractserver config

Via pre-commit (isolated env)

pre-commit run mypy --all-files

Pre-commit builds its own virtualenv from the hook's additional_dependencies on first run (a few minutes) and caches it afterwards.

Via your own dev virtualenv

pip install -r requirements/local.txt
python -m mypy --config-file mypy.ini opencontractserver config

No DATABASE_URL env var is required — config.settings.mypy bakes in a dummy one for type-checking only.

Why there is a baseline

As of issue #1331 the codebase has ~7.2k pre-existing mypy errors across 357 files (see mypy_baseline.txt). Forcing all of those to be fixed in one go would block the rest of the remediation work.

What the baseline does (and does not) gate

mypy.ini lists every file that had an error at the time of the initial wire-up under its own [mypy-<module.path>] section with ignore_errors = True. There is no wildcard pattern covering opencontractserver.* or config.*.

That matters because:

  • New files ARE type-checked. A brand-new module at opencontractserver/new_feature/views.py is not in the baseline, so mypy will check it and the hook / CI will fail on any errors.
  • Refactoring an existing baselined file does not silently re-silence it. If a module is renamed or moved, its old [mypy-…] section becomes dead (warn_unused_configs flags this) and the new path is checked from scratch.
  • Existing baselined files are silenced. Any error inside opencontractserver/utils/storages.py (for example) is suppressed until that module is graduated.

Baseline shape (issue #1331)

Metric Value
Files with errors 357
Total error messages 7208
Top error code attr-defined (2403)
Next most common union-attr (355), arg-type (159), assignment (83), valid-type (82), misc (81)
Worst offender (file) opencontractserver/mcp/tests/test_mcp.py (319 errors)

The full breakdown lives in mypy_baseline.txt (sorted by file + line for stable diffs).

How to graduate a module out of the baseline

  1. Delete the module's section in mypy.ini. Example — to graduate opencontractserver/constants/annotations.py:

    # Before
    [mypy-opencontractserver.constants.annotations]
    ignore_errors = True
    
    # After
    (section removed)
    
  2. Run mypy and fix what surfaces:

    python -m mypy --config-file mypy.ini opencontractserver config
    
  3. Prune the corresponding lines from docs/typing/mypy_baseline.txt. This file is an advisory reference — it is not consumed by mypy itself, so it must be pruned manually. Reviewers: before approving a graduation PR, verify that:

  4. Every pruned entry references the module being graduated (no unrelated drive-by deletions).
  5. Every entry for the graduated module has been pruned (no leftover lines that would silently rot in the reference file).

  6. Commit with a message referencing the tracker issue, e.g. typing: graduate opencontractserver.constants.annotations (refs #1331).

Order we'd suggest graduating in

Pick small, leaf-ish modules first so the Django plugin has less surface to cover:

  1. opencontractserver/constants/* — pure Python constants, no imports.
  2. opencontractserver/utils/* helpers that don't touch models.
  3. config/settings/* — narrow and rarely changed.
  4. Apps with small models.py footprints (e.g. feedback, discovery).
  5. Work outwards into GraphQL types and views.

How to suppress a single error

Prefer fixing the error. When that isn't realistic in the current PR:

foo = something_mypy_doesnt_like()  # type: ignore[assignment]

Always pass the error code in brackets — warn_unused_ignores is on, so bare # type: ignore comments will themselves produce errors once the surrounding module graduates.

If a whole module can't be fixed yet but you want to leave a trail:

[mypy-opencontractserver.legacy_thing]
ignore_errors = True

…with a comment pointing at the tracker issue that will remove it.

Troubleshooting

  • Error constructing plugin instance of NewSemanalDjangoPlugin — the Django plugin imports config.settings.mypy, which transitively imports most of INSTALLED_APPS. Missing one of those apps (e.g. celery, channels, django-storages) is almost always the cause. Install the missing dep or run mypy from inside the test container.
  • Set the DATABASE_URL environment variable — you're pointing mypy at config.settings.test or .base instead of config.settings.mypy. Double-check django_settings_module in mypy.ini.
  • Library stubs not installed for "requests" — add types-requests to the hook's additional_dependencies (and to the dev env if you want the error to go away locally outside pre-commit).