Skip to content

W3-9: replace Any in content_loader, type query_handler payloads - #87

Merged
vladmesh merged 2 commits into
mainfrom
claude/typing-any-sweep-m85yxc
Sep 29, 2026
Merged

vladmesh merged 2 commits into
mainfrom
claude/typing-any-sweep-m85yxc

Conversation

@vladmesh

@vladmesh vladmesh commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Card W3-9 (issue ff6cb95c128b1a6b57cb: any-to-object-sweep, dict-str-object-overuse). Based on main 2f79530. I checked both items against main before starting and neither was already fixed.

Root cause

content_loader/utils._read_yaml returned dict[str, Any], so every YAML value in the loader was Any and mypy checked none of the reads. query_handler.py built fixed-shape query answers as plain dict[str, object].

Fix

content_loader (0 Any left, was 43)

  • _read_yaml / _load_section now return dict[str, object]. A document whose top level is not a mapping now raises ValueError.
  • New helpers in utils.py narrow nested YAML values and raise ValueError that names the file or key: as_mapping, as_list, as_mapping_list, as_int.
  • as_int accepts the same scalar inputs that int() did.
  • Parse entry points (parse_npc, parse_player, parse_squad, parse_monster_template, parse_encounters, parse_lairs, deserialize_item, …) now take Mapping[str, object]. parse_items takes Sequence[Mapping[str, object]], and parse_attacks takes Sequence[object].
  • Pydantic fields class_features and modifiers now use object. I compared the dumped JSON Schemas of every registry entity type and every schema class before and after: they are byte-identical.
  • schema_gen.get_entity_schema → dict[str, object], with isinstance narrowing before it injects x-ref-type.
  • The manifest readers in manifest.py, assembly.py and service/commands_worldbuilder.py narrow layers / layers.<type> with as_mapping. commands_worldbuilder.py needed this because _read_yaml now returns object values.

query_handler

  • New TypedDicts replace the dict[str, object] return types: EntitySummary, EntityDetail (kind-specific keys are NotRequired), NpcDetail, CombatInfo, plus the nested EquippedWeaponInfo, InventoryEntryInfo, ResourcePoolInfo and ActivationTriggerInfo.
  • The dicts are built in the same order, so the keys and their JSON order on the wire do not change.
  • The query params: dict[str, object] stay as they are: their shape differs per query type, so they are not a fixed shape.

No new type: ignores were added. mypy --strict passes.

Behaviour changes (bad content only)

Valid content behaves exactly as before. Malformed content now fails earlier, with a ValueError that names the location:

  • A non-mapping top-level YAML document used to be returned silently and crashed later with AttributeError.
  • tags: medieval (a string instead of a list) in library metadata.yaml used to turn silently into ['m','e','d',…]. This was a small real bug.
    • Root cause: list(str).
    • Fix: as_list narrowing.
    • Catching test: test_library_tags_must_be_a_list.
  • A class_features value that is not a mapping used to raise AttributeError.
  • An NPC, squad or template entry that is not a mapping used to raise a pydantic ValidationError, which is itself a ValueError. It now raises a plain ValueError with the location in the message.
  • An unhashable ref: / base: value used to raise TypeError. It now raises the existing "unknown catalog entry" RuntimeError.
  • Library tags / requires_geography entries are now str()-coerced, which matches the declared list[str].

Tests

New tests in tests/unit/test_content_utils.py cover the narrowing helpers and the new error paths. I ran these 4 against main's src and confirmed they fail on main:

  • TestYamlHelpers::test_non_mapping_top_level_is_rejected
  • TestBadContentIsNamed::test_library_tags_must_be_a_list
  • TestBadContentIsNamed::test_npc_entry_must_be_a_mapping
  • TestBadContentIsNamed::test_class_features_block_must_be_a_mapping

The TestNarrowing helper tests test new code, so they fail on main only because the helpers cannot be imported there.

  • make check is green: 2957 backend tests pass, 353 frontend tests pass, and ruff and mypy are clean.
  • Integration: the environment has no Docker daemon, so I started the backend locally with the docker-compose.test.yml settings (DND_DICE_SEED=42, DND_CONTENT_DIR=tests/integration/content) and ran BACKEND_URL=http://localhost:8001 pytest tests/integration/. 184 passed.

Found but not fixed (out of scope / needs a decision)

  • core/models.Query.params is still dict[str, Any], and InnerSelf.to_dict returns dict[str, Any]. Both are outside content_loader.
  • creatures.py keeps its two existing # type: ignore[arg-type] on combat_position=tuple(...). Removing them needs a typed tuple[int, int] conversion in the schema, which is a design choice.
  • The grant_modifiers entries on ItemContent are still untyped dicts, and the manifest is read as raw YAML in 4 places (manifest.py, assembly.py ×2, commands_worldbuilder.py). Proper pydantic models (ModifierContent, ManifestContent) would give real validation, but would change the published JSON Schema and error types. I left that for a design decision.

…loads

content_loader: _read_yaml/_load_section now return dict[str, object];
nested YAML values are narrowed with new utils helpers (as_mapping,
as_list, as_mapping_list, as_int) that raise ValueError naming the
offending file/key. Parse entry points take Mapping[str, object];
pydantic schema dict fields use object (JSON Schema output unchanged).

query_handler: EntitySummary, EntityDetail, NpcDetail, CombatInfo and
their nested TypedDicts replace dict[str, object] for fixed-shape query
answers. Wire output is unchanged (same keys, same insertion order).
@vladmesh

Copy link
Copy Markdown
Owner Author

Independent review: PASS

  • What changed (card W3-9):
    • content_loader now has no Any: _read_yaml and _load_section return dict[str, object].
    • Nested values are narrowed with as_mapping, as_list, as_mapping_list and as_int, which raise a ValueError that names the file or key.
    • The fixed-shape dict[str, object] answers in query_handler are replaced with TypedDicts, built in the same key order, so the wire JSON does not change.
    • The PR states that the JSON Schema dumps are byte-identical. Integration CI, which exercises the worldbuilder and schema endpoints, is green.
  • Real bug fixed: tags: medieval in library metadata was silently split into characters by list(str).
  • Checks: local make check is green and required CI is green.
  • Regression check: I ran against main's src/ with only the new as_* helpers pasted in, so the test file imports. The behavioural tests fail there: non-mapping top level, tags as a string, NPC entry that is not a mapping (pydantic ValidationError instead of a named error), and a non-mapping class_features. They pass with the fix.
  • Non-blocking: malformed content now fails earlier, with different exception messages. Valid content behaves the same. The PR lists out-of-scope leftovers (Query.params: Any, the manifest has no pydantic model) as a design follow-up.

@vladmesh
vladmesh merged commit df7189e into main Sep 29, 2026
4 checks passed
@vladmesh
vladmesh deleted the claude/typing-any-sweep-m85yxc branch September 29, 2026 09:19
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.

1 participant