Skip to content

Enable mypy type-checking across the full icon4py codebase - #1382

Draft
msimberg wants to merge 179 commits into
C2SM:mainfrom
msimberg:type-checking-improvements
Draft

Enable mypy type-checking across the full icon4py codebase#1382
msimberg wants to merge 179 commits into
C2SM:mainfrom
msimberg:type-checking-improvements

Conversation

@msimberg

@msimberg msimberg commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Opened by an agent.

Enable mypy type-checking across the full icon4py codebase. Until now, most Python files were excluded from mypy through an opt-in whitelist, so type errors were only caught at runtime. This branch replaces that whitelist with an opt-out configuration, fixes the resulting type errors, and annotates the previously untyped test code.

The main pieces of work are: adding a small icon4py mypy plugin that treats vpfloat as float in type positions, mirroring the GT4Py plugin; enabling type-checking for the remaining source and test files in model/common, model/atmosphere, model/driver, model/standalone_driver, model/testing, tools, and bindings; fully annotating test functions; and replacing broad disable_error_code overrides with per-line # type: ignore[code] comments that include brief justifications. The only remaining module-level suppression is ignore_errors = true for icon4py.model.testing.serialbox, because serialbox4py is a C extension without type stubs.

A few design changes were required to expose and break circular imports that type-checking surfaced: initial_condition and related state helpers moved from model/common into model/standalone_driver, and read_experiment_config_from_fortran was renamed to read_config with a simplified signature. These are intentional and limited to what was needed to make the dependency graph type-safe.

All # type: ignore comments carry an error code and a short justification; there are no bare suppressions.

msimberg added 30 commits July 11, 2026 16:25
The icon4py mypy plugin registers a type-analyze hook for
icon4py.model.common.type_alias.vpfloat, resolving it to builtins.float
in type positions. This mirrors the existing GT4Py plugin's
blur_float_precision hook and eliminates the need for the
disable_error_code = [valid-type] override that was applied to
advection.stencils, diffusion.stencils, dycore.dycore_states, and
dycore.stencils.

The vpfloat variable in type_alias.py is a runtime-mutable global
(reassigned by set_precision() to switch between single and double
precision). While this works at runtime, mypy does not recognize it as
a valid type. The plugin resolves this without changing any runtime
behavior or touching any of the 695+ call sites that use vpfloat in
type positions.

Additionally fixes pre-existing valid-type errors in two dycore stencil
files that used gtx.Field[[dims.X, dims.Y], T] (bare list) instead of
gtx.Field[gtx.Dims[dims.X, dims.Y], T]. These were previously masked
by the valid-type override.

Removes two now-unused type: ignore[return-value] comments in the
solve_tridiagonal scan operators — the return type errors are resolved
by the vpfloat plugin.
Fix 4 pre-existing errors in type_alias.py:
- Change set_precision parameter from Literal['double','mixed','single']
  to str (the function already validates internally via match/case +
  ValueError; the only caller passes str from os.environ.get())
- Add type: ignore[assignment] on 3 lines where gtx.float32 is assigned
  to vpfloat/wpfloat. The GT4Py type plugin blurs float32/float64 to
  builtins.float in type annotations only, not in value positions.
  This is a consequence of the runtime precision-switching design
  (using type objects as values), which is preserved per design
  decision D1.
Adds 8 level-0 files (no unchecked internal dependencies) to the mypy
files list: dimension.py, exceptions.py, model_backends.py,
grid/geometry_config.py, math/projection.py,
math/stencils/generic_math_operations_array_ns.py,
metrics/compute_coeff_gradekin.py, metrics/compute_diffusion_metrics.py.

Fixes 9 type errors in model_backends.py:
- get_allocator: change return type from Backend to
  FieldBufferAllocatorProtocol (the function returns an allocator,
  not a Backend). When input is already a Backend, return
  backend.allocator instead of backend itself.
- is_cpu_backend/is_gpu_backend: use __gt_device_type__ instead of
  device_type (GT4Py stubs only expose __gt_device_type__ on
  FieldBufferAllocatorProtocol, not device_type).
- make_custom_gtfn_backend: change return type from Backend to
  GTFNBackendFactory (the function returns a factory, not a Backend).
- make_custom_gtfn_backend/make_custom_dace_backend: type **kwargs
  instead of untyped **_.

Follow-on fix in standalone_driver.py: update _allocator property type
from gtx.typing.Backend to FieldBufferAllocatorProtocol to match the
new return type of get_allocator.
Both files are level-1 (depend only on type_alias.py and dimension.py,
both already checked) and require no source changes.
Fix 2 errors:
- attrs: MutableMapping[str, ...] is invalid (Ellipsis not a valid
  type argument); change to MutableMapping[str, Any]
- Add missing return type annotation -> xa.DataArray
Add missing type annotation for e2c parameter (data_alloc.NDArray).
Fix 5 errors:
- Add type: ignore[override] on Zone.value property (intentional
  override of Enum.value to return combined name_level string)
- Add type annotation for get_last_nudging parameter and return type
- Remove 3 stale type: ignore comments (name-defined, attr-defined)
  that are no longer needed
Fix 6 errors:
- Remove stale type: ignore[return-value] on line 99 (no longer needed
  after model_backends.py fixes)
- Add explicit Any type annotation for backend_factory to prevent
  DeviceType being inferred as callable
- Add type: ignore[arg-type] on **variants passed to compile() and
  **constant_args/**horizontal_sizes/**vertical_sizes passed to
  functools.partial(). GT4Py Program.compile and __call__ use dynamic
  **kwargs that mypy cannot verify against strict method signatures.

Also removes unused import gt_coredefs from states/model.py (ruff).
@msimberg

msimberg commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the outstanding review comments in commits b74c39b through d7aa7ca.

Fixed:

  • RC9/RC50: compute_lsq_coeffs now accepts None for domain_length/domain_height since fix least-squares namelist parameter lsq_dim_stencil #1379 is merged; removed related ignores in test_advection.py (b74c39b).
  • RC13/RC48: Tightened savepoint and data-provider types from Any to concrete serialbox.* types (3b6f8d6).
  • RC17/RC51/RC52: Tightened parameter types in quadrature stencil tests (32d5851).
  • RC49: Replaced unary minus with np.negative in test_compute_barycentric_backtrajectory_alt.py (32d5851).
  • RC19/RC54: Improved type: ignore comments and narrowed log_dbg type in tracer_advection utils (d7c369d).
  • RC20/RC21: Tightened types in diffusion tests (964f490).
  • RC22/RC24/RC29/RC30: Added clearer comments/asserts for runtime-only shapes, runtime dtype, negative-test strings, and torus parametrization (d7c369d).
  • RC26/RC25: Clarified mypy excludes in pyproject.toml and rephrased tach.toml comment (d7aa7ca).

Already in place / not applicable:

  • RC1/RC2/RC3: field.ndarray is already passed to data_alloc.array_namespace; on_gpu is preserved.
  • RC4: import-not-found ignore for viztracer is already present.
  • RC5/RC6: --isolated is already present in the mypy pre-commit hook.
  • RC28: model/driver/tests/driver/fixtures.py no longer exists after the driver package removal.

Discussion answers:

  • RC7/RC8: .ndarray fixes the type error; on_gpu is still needed for backend selection.
  • RC10: The ignores come from the fixture being typed as base_grid.Grid, not IconGrid.
  • RC11/RC12: Other compute_lsq_coeffs callers already pass concrete floats; remaining base_grid.Grid ignores are the same fixture-type issue.
  • RC14/RC16: **kwargs: Any stays in the generic stencil-test harness; per-test types would add boilerplate without real safety.
  • RC15: The unary-minus misc error is specific to numpy stubs under Python 3.12.
  • RC23: Keeping the Callable[..., None] comment; it documents the side-effect-only intent.
  • RC27/RC56: The alias at serialbox.py:804 lets dycore code use the descriptive name; test_solve_nonhydro.py:2121 runs when datatests are selected.
  • RC55: Not changing savepoint get to scalar; it would force union narrowing at every caller without improving type-checking (serialbox.py is under ignore_errors).

🤖 Posted by an agent on behalf of @msimberg

msimberg added 10 commits August 5, 2026 21:58
- Remove now-unused NDArrayObject __setitem__ ignores in initial_condition
  modules (GT4Py 1.2.0 no longer emits index errors there).
- Replace per-line ghex/viztracer import ignores with module-level
  ignore_missing_imports overrides in pyproject.toml, matching the existing
  dace pattern. This makes type-checking consistent whether the optional
  distributed/profiling dependencies are installed.
@msimberg

msimberg commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

cscs-ci run default

@msimberg

msimberg commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

cscs-ci run default

@msimberg

msimberg commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

cscs-ci run default

from types import TracebackType

import viztracer # type: ignore[import-not-found]
import viztracer # optional profiling dependency, not in typing env

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it doesn't need to be ignored anymore, I'd remove the comment.

Suggested change
import viztracer # optional profiling dependency, not in typing env
import viztracer

Comment on lines -34 to +56
grid_functionality = collections.defaultdict(dict)
grid_functionality: collections.defaultdict[str, dict[str, Any]] = collections.defaultdict(dict)


def get_grid_for_experiment(experiment: test_defs.Experiment, backend: gtx_typing.Backend):
def get_grid_for_experiment(experiment: test_defs.Experiment, backend: gtx_typing.Backend) -> Any:
return _get_or_initialize(experiment, backend, "grid")


def get_edge_geometry_for_experiment(experiment: test_defs.Experiment, backend: gtx_typing.Backend):
def get_edge_geometry_for_experiment(
experiment: test_defs.Experiment, backend: gtx_typing.Backend
) -> Any:
return _get_or_initialize(experiment, backend, "edge_geometry")


def get_cell_geometry_for_experiment(experiment: test_defs.Experiment, backend: gtx_typing.Backend):
def get_cell_geometry_for_experiment(
experiment: test_defs.Experiment, backend: gtx_typing.Backend
) -> Any:
return _get_or_initialize(experiment, backend, "cell_geometry")


def _get_or_initialize(experiment: test_defs.Experiment, backend: gtx_typing.Backend, name: str):
def _get_or_initialize(
experiment: test_defs.Experiment, backend: gtx_typing.Backend, name: str
) -> Any:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these return types not be more tight than Any? If yes, change them.

Comment on lines -188 to +211
theta_v_at_cells_on_half_levels=None,
perturbed_exner_at_cells_on_model_levels=None,
rho_at_cells_on_half_levels=None,
exner_tendency_due_to_slow_physics=None,
grf_tend_rho=None,
grf_tend_thv=None,
grf_tend_w=None,
mass_flux_at_edges_on_model_levels=None,
normal_wind_tendency_due_to_slow_physics_process=None,
grf_tend_vn=None,
theta_v_at_cells_on_half_levels=data_alloc.zero_field(
icon_grid, dims.CellDim, dims.KDim, extend={dims.KDim: 1}, allocator=backend
),
perturbed_exner_at_cells_on_model_levels=data_alloc.zero_field(
icon_grid, dims.CellDim, dims.KDim, allocator=backend
),
rho_at_cells_on_half_levels=data_alloc.zero_field(
icon_grid, dims.CellDim, dims.KDim, extend={dims.KDim: 1}, allocator=backend
),
exner_tendency_due_to_slow_physics=data_alloc.zero_field(
icon_grid, dims.CellDim, dims.KDim, allocator=backend
),
grf_tend_rho=data_alloc.zero_field(icon_grid, dims.CellDim, dims.KDim, allocator=backend),
grf_tend_thv=data_alloc.zero_field(icon_grid, dims.CellDim, dims.KDim, allocator=backend),
grf_tend_w=data_alloc.zero_field(
icon_grid, dims.CellDim, dims.KDim, extend={dims.KDim: 1}, allocator=backend
),
mass_flux_at_edges_on_model_levels=data_alloc.zero_field(
icon_grid, dims.EdgeDim, dims.KDim, allocator=backend
),
normal_wind_tendency_due_to_slow_physics_process=data_alloc.zero_field(
icon_grid, dims.EdgeDim, dims.KDim, allocator=backend
),
grf_tend_vn=data_alloc.zero_field(icon_grid, dims.EdgeDim, dims.KDim, allocator=backend),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, why are these allocated now instead of being None? It might be the right thing to do, but can you explain the change and why it's not unnecessary allocations?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DiagnosticStateNonHydro and PrognosticState constructors require typed gtx.Field values. None would violate their annotations and need # type: ignore[arg-type] suppressions. Zero fields satisfy the types and act as neutral test values.\n\n🤖 Posted by an agent on behalf of @msimberg

Comment on lines -358 to +405
theta_v=None,
rho=None,
exner=None,
theta_v=data_alloc.zero_field(icon_grid, dims.CellDim, dims.KDim, allocator=backend),
rho=data_alloc.zero_field(icon_grid, dims.CellDim, dims.KDim, allocator=backend),
exner=data_alloc.zero_field(icon_grid, dims.CellDim, dims.KDim, allocator=backend),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, and just above: why are these now allocated to zero fields?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reason: the state constructors require non-optional Field values per their annotations. data_alloc.zero_field gives a typed neutral value instead of None with a suppression.\n\n🤖 Posted by an agent on behalf of @msimberg

Comment on lines -86 to +124
virtual_temperature=None,
virtual_temperature=virtual_temperature,
pressure=entry_savepoint.pressure(),
pressure_ifc=None,
u=None,
v=None,
pressure_ifc=pressure_ifc,
u=u,
v=v,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why these fields aren't None anymore?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrognosticState requires non-optional vn, w, exner, and theta_v fields under the current annotations. The previous None values were only accepted via # type: ignore[arg-type]. Using data_alloc.zero_field satisfies the types without suppression.\n\n🤖 Posted by an agent on behalf of @msimberg

@msimberg

Copy link
Copy Markdown
Contributor Author

cscs-ci run default

@msimberg

Copy link
Copy Markdown
Contributor Author

cscs-ci run default

🤖 Posted by an agent on behalf of @msimberg

@github-actions

Copy link
Copy Markdown

When developing, you can test your changes on CSCS CI before merge with the default pipeline: cscs-ci run default. This will run a default subset of tests.

You can pass options to override pipeline variables, for example:

  • cscs-ci run default;BACKENDS=gtfn_cpu;LEVELS=unit
  • cscs-ci run default;MODEL_SUBPACKAGES=common:driver;SESSIONS=model
    Avoid running the pipeline for all tests when you are developing.

Available options are:

  • SESSIONS: model, model_mpi, or tools (correspond to nox sessions)
  • MODEL_SUBSETS: datatest, basic, or stencils (correspond to nox session selections)
  • MODEL_SUBPACKAGES: subpackages for non-MPI tests (last component, e.g. diffusion, driver)
  • MODEL_MPI_SUBPACKAGES: subpackages for MPI tests (as above)
  • BACKENDS: backends
  • GRIDS: grids for stencil tests (simple, icon_regional, or icon_global)
  • LEVELS: testing level for non-stencil tests (unit or integration)

For each option, all can be used as a shorthand for all possible values of that variable, e.g. LEVELS=all.

See scripts/python/generate_ci_pipeline.py and noxfile.py for available values for each option.

The all pipeline can be run with cscs-ci run all. This will run all icon4py tests in CSCS CI which can be expensive. This pipeline runs on a schedule on main, and can be run when extensive validation is needed (e.g. before releases).

Merging

Once your PR is approved and ready for merging, add it to the merge queue. The merge CSCS CI pipeline will run automatically on the merge-queue branch and must pass before the PR is merged. A dummy merge check will be triggered on the PR itself since it's required to add a PR to the merge queue.

Optional Tests

To run benchmarks you can use:

  • cscs-ci run benchmark-bencher

For more detailed information please look at CI in the EXCLAIM universe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants