fix: unify mypy environments for local checks and CI (#1291)

The isolated pre-commit mypy hook previously omitted runtime type information that
make mypy used, hiding errors involving dependencies such as Pydantic and Pendulum.
Makefile, pre-commit and CI now run the same full-project typing policy in the
development environment defined by uv.lock.

- Use uv run --locked --exact --extra dev and the same mypy arguments for Makefile
  and the local hook. Check all of src and tests, including on configuration-only
  changes.
- Pin Python 3.13 for local development and the pre-commit CI job, and install the
  locked pre-commit version in CI.
- Disable incremental analysis because existing Pendulum cache state changes mypy 2.3.1
  diagnostics. Document the policy, the performance tradeoff and the existing typing debt.
- Add a regression test that exercises Makefile, the hook and the CI command in a
  temporary project, accepting valid dependency types and detecting deliberate
  Pydantic/Pendulum assignment errors.

Resolve the newly detected mypy diagnostics.

- Enable the numpydantic and Pydantic mypy plugins, retaining strict Pydantic
  constructor typing with init_typed = true. Validate raw/coercible payloads through model_validate.
- Propagate concrete record, provider and time-window types through generic collections,
  factories and lookup methods. Preserve runtime field inspection and generated time-window
  documentation.
- Align Pendulum annotations with actual factory/arithmetic results while retaining Pydantic
  validation adapters at runtime. Correct optional values, array boundaries, REST handlers
  and plotting interfaces.
- Add pinned scipy-stubs and types-psutil, update uv.lock, and supply the plugins' dependencies.
- Add runtime regression coverage for validated path defaults, normalized time-series metadata,
  generic field inspection, invalid timestamps and unsupported provider imports.

Runtime and compatibility details:

- Validate path defaults as Path objects while retaining raw string defaults needed by
  migration serialization with exclude_defaults.
- Normalize feed-in tariff lists and default charge rates to NumPy arrays; reject missing
  timestamps/uninitialized values explicitly. Importing into a provider without import support
  returns HTTP 400.
- Public JSON schemas and OpenAPI structure match main (excluding the generated version).

Signed-off-by: dr-dimitry

Signed-off-by: dr-dimitry
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
Co-authored-by: dr-dimitri <87113560+dr-dimitri@users.noreply.github.com>
Co-authored-by: Normann <github@koldrack.com>
This commit is contained in:
Bobby Noelte
2026-09-10 23:20:35 +02:00
committed by GitHub
co-authored by dr-dimitri Normann
parent 5b584cbb57
commit 1abdd345c4
114 changed files with 2217 additions and 1322 deletions
+16 -13
View File
@@ -81,7 +81,7 @@ class TestMergeModels:
def test_flat_override(self):
"""Top-level fields in update_dict override those in source, including None."""
source = SampleModel(name="Test", count=10, config={"threshold": 5})
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5}))
update = {"name": "Updated"}
result = merge_models(source, update)
@@ -91,7 +91,7 @@ class TestMergeModels:
def test_flat_override_with_none(self):
"""Update with None value should override source value."""
source = SampleModel(name="Test", count=10, config={"threshold": 5}, optional="keep me")
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5}, optional="keep me"))
update = {"optional": None}
result = merge_models(source, update)
@@ -99,7 +99,7 @@ class TestMergeModels:
def test_nested_override(self):
"""Nested fields in update_dict override nested fields in source, including None."""
source = SampleModel(name="Test", count=10, config={"threshold": 5, "enabled": True})
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5, "enabled": True}))
update = {"config": {"threshold": 99, "enabled": False}}
result = merge_models(source, update)
@@ -108,7 +108,7 @@ class TestMergeModels:
def test_nested_override_with_none(self):
"""Nested update with None should override nested source values."""
source = SampleModel(name="Test", count=10, config={"threshold": 5, "enabled": True})
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5, "enabled": True}))
update = {"config": {"threshold": None}}
result = merge_models(source, update)
@@ -117,7 +117,7 @@ class TestMergeModels:
def test_preserve_source_values(self):
"""Source values are preserved if not overridden in update_dict."""
source = SampleModel(name="Source", count=7, config={"threshold": 1})
source = SampleModel.model_validate(dict(name="Source", count=7, config={"threshold": 1}))
update: dict[str, Any] = {}
result = merge_models(source, update)
@@ -127,7 +127,7 @@ class TestMergeModels:
def test_update_extends_source(self):
"""Optional fields in update_dict are added to result."""
source = SampleModel(name="Test", count=10, config={"threshold": 5})
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5}))
update = {"optional": "new value"}
result = merge_models(source, update)
@@ -135,7 +135,7 @@ class TestMergeModels:
def test_update_extends_source_with_none(self):
"""Optional field with None in update_dict is added and overrides source."""
source = SampleModel(name="Test", count=10, config={"threshold": 5}, optional="value")
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5}, optional="value"))
update = {"optional": None}
result = merge_models(source, update)
@@ -143,7 +143,7 @@ class TestMergeModels:
def test_deep_merge_behavior(self):
"""Nested updates merge with source, overriding only specified subkeys."""
source = SampleModel(name="Model", count=3, config={"threshold": 1, "enabled": False})
source = SampleModel.model_validate(dict(name="Model", count=3, config={"threshold": 1, "enabled": False}))
update = {"config": {"enabled": True}}
result = merge_models(source, update)
@@ -152,7 +152,7 @@ class TestMergeModels:
def test_override_all(self):
"""All fields in update_dict override all fields in source, including None."""
source = SampleModel(name="Orig", count=1, config={"threshold": 10, "enabled": True})
source = SampleModel.model_validate(dict(name="Orig", count=1, config={"threshold": 10, "enabled": True}))
update = {
"name": "New",
"count": None,
@@ -376,7 +376,7 @@ class TestPydanticBaseModel:
def test_invalid_datetime_string(self):
with pytest.raises(ValueError):
PydanticTestModel(datetime_field="invalid_datetime")
PydanticTestModel.model_validate(dict(datetime_field="invalid_datetime"))
def test_iso8601_serialization(self):
dt = pendulum.datetime(2024, 12, 21, 15, 0, 0)
@@ -444,8 +444,11 @@ class TestPydanticDateTimeData:
"timestamps": ["2024-12-21T15:00:00+00:00"],
"values": [100],
}
model = PydanticDateTimeData(root=data)
assert pendulum.parse(model.root["timestamps"][0]) == pendulum.parse(
model = PydanticDateTimeData.model_validate(data)
timestamps = model.root["timestamps"]
assert isinstance(timestamps, list)
assert isinstance(timestamps[0], str)
assert pendulum.parse(timestamps[0]) == pendulum.parse(
"2024-12-21T15:00:00+00:00"
)
@@ -457,7 +460,7 @@ class TestPydanticDateTimeData:
with pytest.raises(
ValidationError, match="All lists in the dictionary must have the same length"
):
PydanticDateTimeData(root=data)
PydanticDateTimeData.model_validate(data)
class TestPydanticDateTimeDataFrame: