Skip to content

Commit 7a178e4

Browse files
vdavezclaude
andcommitted
chore(types): complete strict-mypy burn-down, make mypy a hard gate
Clears all ~24 strict-mypy errors in tango/shapes/ and flips lint.yml's mypy step off continue-on-error. Highlights: - Fix FieldSchema.nested_model annotation to `type | str | None` (it always accepted string model names; the old `type | None` was masking 60+ latent arg-type errors). Widen validate_data / _validate_field_spec model args to `type | str` accordingly. - Replace the lazy-init bool-flag pattern in ShapeParser with an _ensure_registry() helper so the registry narrows to non-None. - Remove two provably-dead `elif field_spec.is_wildcard:` branches (wildcards already `continue` at the top of the loop) and the now-orphaned _parse_nested_wildcard helper. Behavior-preserving. - Misc: cast Any->type/str returns, annotate field_type as Any, best_score as float, builtins.type disambiguation in FieldSchema. No runtime behavior change; 420 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6f7d071 commit 7a178e4

6 files changed

Lines changed: 63 additions & 126 deletions

File tree

.github/workflows/lint.yml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ name: Linting
33
# Lint gate runs on every PR and push to main.
44
#
55
# - ruff format + ruff check are HARD gates (block the PR).
6-
# - mypy is ADVISORY for now (continue-on-error): the package carries ~28
7-
# pre-existing type errors that predate CI enforcement. Tracked for burn-down
8-
# in makegov/tango-python; flip `continue-on-error` off once that's clear.
6+
# - mypy is a HARD gate: the package type-checks cleanly under strict mypy.
7+
# (The earlier ~28-error burn-down is complete.)
98
# - The SDK filter/shape conformance check needs the canonical manifest from the
109
# private makegov/tango repo, which requires a TANGO_API_REPO_ACCESS_TOKEN
1110
# secret the public CI does not have. The conformance job SKIPS cleanly when
@@ -42,8 +41,7 @@ jobs:
4241
- name: Lint with ruff
4342
run: uv run ruff check tango/
4443

45-
- name: Type check with mypy (advisory)
46-
continue-on-error: true
44+
- name: Type check with mypy
4745
run: uv run mypy tango/
4846

4947
conformance:

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
2026-06-02): `actions/checkout` v4→v6, `astral-sh/setup-uv` v4→v8.1.0
1313
(pinned exact — no floating `v8` major tag is published yet), and
1414
`codecov/codecov-action` v3→v5 (with the renamed `files:` input).
15+
- `mypy` is now a **hard gate** in `lint.yml` (no longer advisory). The
16+
`tango/` package type-checks cleanly under strict mypy.
17+
18+
### Changed
19+
- Completed the strict-`mypy` burn-down across `tango/shapes/` (parser,
20+
generator, factory, schema). All changes are type-annotation/typing
21+
corrections with no runtime behavior change, except:
22+
- `FieldSchema.nested_model` is now typed `type | str | None` (it always
23+
accepted string model names from the explicit schemas; the annotation was
24+
wrong). `ModelFactory.validate_data` and `ShapeParser._validate_field_spec`
25+
likewise accept `type | str` for the model argument.
26+
- Removed two dead `elif field_spec.is_wildcard:` branches (in
27+
`TypeGenerator.generate_type` and `ModelFactory.create_instance`) and the
28+
now-orphaned `_parse_nested_wildcard` helper. These were unreachable —
29+
wildcard field specs are fully handled by the top-of-loop branch that
30+
`continue`s before reaching them — so removal is behavior-preserving.
1531

1632
## [1.1.1] - 2026-05-29
1733

tango/shapes/factory.py

Lines changed: 12 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from collections.abc import Callable
2424
from datetime import date, datetime
2525
from decimal import Decimal
26-
from typing import Any
26+
from typing import Any, cast
2727

2828
from tango.exceptions import ModelInstantiationError
2929
from tango.shapes.generator import TypeGenerator
@@ -542,38 +542,6 @@ def create_instance(
542542
# Value is not a dict - might be a primitive or None
543543
result[result_field_name] = value
544544

545-
elif field_spec.is_wildcard:
546-
# Wildcard on nested field - use full model type
547-
# This is handled at the top level, but we need to handle it here too
548-
# for nested wildcards like recipient(*)
549-
if field_schema.nested_model:
550-
if field_schema.is_list:
551-
if isinstance(value, list):
552-
nested_instances = []
553-
for item in value:
554-
if isinstance(item, dict):
555-
# Parse all fields from the nested model
556-
nested_instance = self._parse_nested_wildcard(
557-
item, field_schema.nested_model
558-
)
559-
nested_instances.append(nested_instance)
560-
else:
561-
nested_instances.append(item)
562-
result[result_field_name] = nested_instances
563-
else:
564-
result[result_field_name] = value
565-
else:
566-
if isinstance(value, dict):
567-
nested_instance = self._parse_nested_wildcard(
568-
value, field_schema.nested_model
569-
)
570-
result[result_field_name] = nested_instance
571-
else:
572-
result[result_field_name] = value
573-
else:
574-
# Not a nested model, just use the value
575-
result[result_field_name] = value
576-
577545
else:
578546
# Simple field - parse using appropriate parser
579547
parsed_value = self._parse_field(
@@ -661,7 +629,7 @@ def _resolve_nested_model(self, nested_model: type | str) -> type:
661629
raise ModelInstantiationError(
662630
f"Could not resolve nested model '{nested_model}'"
663631
)
664-
return model_class
632+
return cast(type, model_class)
665633
except ImportError as err:
666634
raise ModelInstantiationError(
667635
f"Could not import models module to resolve '{nested_model}'"
@@ -704,41 +672,6 @@ def _create_nested_instance(
704672
# Recursively create nested instance
705673
return self.create_instance(data, nested_shape, resolved_model, nested_type)
706674

707-
def _parse_nested_wildcard(
708-
self, data: dict[str, Any], nested_model: type | str
709-
) -> dict[str, Any]:
710-
"""Parse nested object with wildcard (all fields)
711-
712-
Args:
713-
data: Nested object data
714-
nested_model: Model class or string name for the nested object
715-
716-
Returns:
717-
Dictionary with all parsed fields
718-
"""
719-
# Resolve nested model if it's a string
720-
resolved_model = self._resolve_nested_model(nested_model)
721-
722-
# Ensure model is registered
723-
if not self.schema_registry.is_registered(resolved_model):
724-
self.schema_registry.register(resolved_model)
725-
726-
# Get model schema
727-
model_schema = self.schema_registry.get_schema(resolved_model)
728-
729-
# Parse all fields
730-
result: dict[str, Any] = {}
731-
for field_name, value in data.items():
732-
if field_name in model_schema:
733-
field_schema = model_schema[field_name]
734-
parsed_value = self._parse_field(field_name, value, field_schema.type, field_schema)
735-
result[field_name] = parsed_value
736-
else:
737-
# Field not in schema, include as-is
738-
result[field_name] = value
739-
740-
return result
741-
742675
def _parse_field(self, field_name: str, value: Any, field_type: type, field_schema: Any) -> Any:
743676
"""Parse a single field value using appropriate parser
744677
@@ -778,7 +711,7 @@ def _parse_field(self, field_name: str, value: Any, field_type: type, field_sche
778711
return value
779712

780713
def validate_data(
781-
self, data: dict[str, Any], shape_spec: ShapeSpec, base_model: type
714+
self, data: dict[str, Any], shape_spec: ShapeSpec, base_model: type | str
782715
) -> list[str]:
783716
"""Validate that data matches the shape specification
784717
@@ -803,11 +736,15 @@ def validate_data(
803736
errors: list[str] = []
804737

805738
if not isinstance(data, dict):
806-
errors.append(f"Expected dictionary data, got {type(data).__name__}")
739+
errors.append( # type: ignore[unreachable]
740+
f"Expected dictionary data, got {type(data).__name__}"
741+
)
807742
return errors
808743

809-
# Ensure model is registered
810-
if not self.schema_registry.is_registered(base_model):
744+
# Ensure model is registered. String model names are expected to be
745+
# pre-registered (explicit schemas); only concrete classes can be
746+
# auto-registered via introspection.
747+
if isinstance(base_model, type) and not self.schema_registry.is_registered(base_model):
811748
self.schema_registry.register(base_model)
812749

813750
# Get model schema
@@ -826,9 +763,8 @@ def validate_data(
826763

827764
# Check if field exists in schema
828765
if field_spec.name not in model_schema:
829-
errors.append(
830-
f"Field '{field_spec.name}' does not exist in {base_model.__name__} schema"
831-
)
766+
model_name = base_model.__name__ if isinstance(base_model, type) else base_model
767+
errors.append(f"Field '{field_spec.name}' does not exist in {model_name} schema")
832768
continue
833769

834770
field_schema = model_schema[field_spec.name]

tango/shapes/generator.py

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import logging
2121
import threading
2222
from collections import OrderedDict
23-
from typing import Any, get_args, get_origin, get_type_hints
23+
from typing import Any, cast, get_args, get_origin, get_type_hints
2424

2525
from tango.exceptions import TypeGenerationError
2626
from tango.shapes.models import ShapeSpec
@@ -250,7 +250,10 @@ def generate_type(
250250

251251
field_schema = model_schema[field_spec.name]
252252

253-
# Determine field type
253+
# Determine field type. The value is a heterogeneous mix of type
254+
# objects, parameterized generics (list[...]), and union objects,
255+
# so it is intentionally typed as Any.
256+
field_type: Any
254257
if field_spec.nested_fields:
255258
# Generate nested type
256259
if not field_schema.nested_model:
@@ -275,25 +278,7 @@ def generate_type(
275278

276279
# Handle optional types
277280
if field_schema.is_optional:
278-
field_type = field_type | None # type: ignore
279-
280-
annotations[field_name] = field_type
281-
282-
elif field_spec.is_wildcard:
283-
# Wildcard on nested field - use full model type
284-
if field_schema.nested_model:
285-
# Resolve nested model if it's a string
286-
field_type = self._resolve_nested_model(field_schema.nested_model)
287-
else:
288-
field_type = field_schema.type
289-
290-
# Handle list types
291-
if field_schema.is_list:
292-
field_type = list[field_type] # type: ignore
293-
294-
# Handle optional types
295-
if field_schema.is_optional:
296-
field_type = field_type | None # type: ignore
281+
field_type = field_type | None
297282

298283
annotations[field_name] = field_type
299284

@@ -303,11 +288,11 @@ def generate_type(
303288

304289
# Handle list types
305290
if field_schema.is_list:
306-
field_type = list[field_type] # type: ignore
291+
field_type = list[field_type]
307292

308293
# Handle optional types
309294
if field_schema.is_optional:
310-
field_type = field_type | None # type: ignore
295+
field_type = field_type | None
311296

312297
annotations[field_name] = field_type
313298

@@ -329,7 +314,7 @@ def generate_type(
329314
field_type = field_schema.type
330315
# Handle optional types
331316
if field_schema.is_optional:
332-
field_type = field_type | None # type: ignore
317+
field_type = field_type | None
333318
annotations[auto_field] = field_type
334319

335320
# Create TypedDict dynamically
@@ -414,7 +399,7 @@ def _resolve_nested_model(self, nested_model: type | str) -> type:
414399
model_class = getattr(models, nested_model, None)
415400
if model_class is None:
416401
raise TypeGenerationError(f"Could not resolve nested model '{nested_model}'")
417-
return model_class
402+
return cast(type, model_class)
418403
except ImportError as err:
419404
raise TypeGenerationError(
420405
f"Could not import models module to resolve '{nested_model}'"
@@ -555,7 +540,7 @@ def _format_type_annotation(self, type_annotation: Any) -> str:
555540

556541
# Handle basic types
557542
if hasattr(type_annotation, "__name__"):
558-
type_name = type_annotation.__name__
543+
type_name = str(type_annotation.__name__)
559544
else:
560545
type_name = str(type_annotation)
561546

@@ -576,7 +561,7 @@ def _format_type_annotation(self, type_annotation: Any) -> str:
576561
if args:
577562
formatted_args = [self._format_type_annotation(arg) for arg in args]
578563
return f"{origin.__name__}[{', '.join(formatted_args)}]"
579-
return origin.__name__
564+
return str(origin.__name__)
580565

581566
return type_name
582567

tango/shapes/parser.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def _suggest_field_correction(invalid_field: str, valid_fields: list[str]) -> st
110110

111111
# Check for common prefix
112112
best_match = None
113-
best_score = 0
113+
best_score = 0.0
114114

115115
for field in valid_fields:
116116
# Count common prefix length
@@ -167,6 +167,13 @@ def __init__(self, cache_enabled: bool = True, schema_registry: SchemaRegistry |
167167
self._schema_registry = schema_registry
168168
self._schema_registry_initialized = schema_registry is not None
169169

170+
def _ensure_registry(self) -> SchemaRegistry:
171+
"""Return the schema registry, lazily creating it on first use."""
172+
if self._schema_registry is None:
173+
self._schema_registry = SchemaRegistry()
174+
self._schema_registry_initialized = True
175+
return self._schema_registry
176+
170177
def parse(self, shape: str) -> ShapeSpec:
171178
"""Parse a shape string into a ShapeSpec
172179
@@ -544,25 +551,22 @@ def validate(self, shape_spec: ShapeSpec, model_class: type) -> None:
544551
>>> spec = parser.parse("invalid_field")
545552
>>> parser.validate(spec, Contract) # Raises ShapeValidationError
546553
"""
547-
# Lazy initialize schema registry
548-
if not self._schema_registry_initialized:
549-
self._schema_registry = SchemaRegistry()
550-
self._schema_registry_initialized = True
554+
registry = self._ensure_registry()
551555

552556
# Ensure model is registered
553-
if not self._schema_registry.is_registered(model_class):
554-
self._schema_registry.register(model_class)
557+
if not registry.is_registered(model_class):
558+
registry.register(model_class)
555559

556560
# Validate each field
557561
for field_spec in shape_spec.fields:
558562
self._validate_field_spec(field_spec, model_class)
559563

560-
def _validate_field_spec(self, field_spec: FieldSpec, model_class: type) -> None:
564+
def _validate_field_spec(self, field_spec: FieldSpec, model_class: type | str) -> None:
561565
"""Validate a single field specification against a model
562566
563567
Args:
564568
field_spec: Field specification to validate
565-
model_class: Model class to validate against
569+
model_class: Model class (or registered model name) to validate against
566570
567571
Raises:
568572
ShapeValidationError: If field is invalid
@@ -571,20 +575,17 @@ def _validate_field_spec(self, field_spec: FieldSpec, model_class: type) -> None
571575
if field_spec.is_wildcard:
572576
return
573577

574-
# Lazy initialize schema registry if needed
575-
if not self._schema_registry_initialized:
576-
self._schema_registry = SchemaRegistry()
577-
self._schema_registry_initialized = True
578+
registry = self._ensure_registry()
578579

579580
# Validate field exists in model
580581
try:
581-
field_schema = self._schema_registry.validate_field(model_class, field_spec.name)
582+
field_schema = registry.validate_field(model_class, field_spec.name)
582583
except ShapeValidationError as e:
583584
# Enhance error message with suggestions
584585
model_name = (
585586
model_class.__name__ if hasattr(model_class, "__name__") else str(model_class)
586587
)
587-
model_schema = self._schema_registry.get_schema(model_class)
588+
model_schema = registry.get_schema(model_class)
588589
valid_fields = list(model_schema.keys())
589590

590591
error_msg = f"Field '{field_spec.name}' does not exist in {model_name}."
@@ -630,7 +631,7 @@ def _validate_field_spec(self, field_spec: FieldSpec, model_class: type) -> None
630631
error_msg += "\n\nNested selections are only valid for object fields like 'recipient', 'agency', 'location', etc."
631632

632633
# Find some nested fields as examples
633-
model_schema = self._schema_registry.get_schema(model_class)
634+
model_schema = registry.get_schema(model_class)
634635
nested_examples = [
635636
name for name, schema in model_schema.items() if schema.nested_model
636637
]

tango/shapes/schema.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
list indicators independently of the dataclass definitions.
99
"""
1010

11+
import builtins
1112
from dataclasses import dataclass
1213
from typing import Any, get_args, get_origin, get_type_hints
1314

@@ -33,10 +34,10 @@ class FieldSchema:
3334
"""
3435

3536
name: str
36-
type: type
37+
type: builtins.type
3738
is_optional: bool
3839
is_list: bool
39-
nested_model: type | None = None
40+
nested_model: builtins.type | str | None = None
4041

4142
def __repr__(self) -> str:
4243
"""String representation for debugging"""

0 commit comments

Comments
 (0)