Skip to content

Commit a373eb2

Browse files
tercelclaude
andcommitted
refactor(registry): populate _module_meta in register/register_internal too
Cross-repo alignment with apcore-typescript: make python's manual register() and register_internal() entry points populate _module_meta via merge_module_metadata, just like _register_in_order (the discovery path). After this change every registered module — discovery, manual, internal — has a fully merged metadata dict in _module_meta, so get_definition() can read uniformly from `meta` without fallback. Changes: - Registry.register() — pre-compute merge_module_metadata(module, metadata or {}) outside the lock, store under the lock alongside _modules / _lowercase_map, and pop on on_load() rollback. - Registry.register_internal() — same pre-compute + store, with meta={} since the privileged sys-module path supplies no YAML. - Registry._register_in_order() — pass the instance (not the class) to merge_module_metadata, matching the new uniform contract. The instance picks up __init__-set attributes, and getattr falls through to class attrs anyway, so this is a strict superset of the prior class-only pass. - get_definition() — drop the `if "annotations" in meta else getattr(module, ...)` fallback chain. With the invariant above, meta always has all canonical keys, so reads collapse to plain meta.get(...) calls. Mirrors apcore-typescript Registry.getDefinition after its 64491b7 cleanup. merge_module_metadata robustness fixes (uncovered by the test suite when this change widened the set of inputs reaching it): - Add _coerce_code_annotations helper that narrows the raw `module.annotations` attribute to ModuleAnnotations | None. Real ModuleAnnotations instances pass through; dict-style annotations get filtered to canonical fields and re-constructed; everything else (None, MagicMock test stubs, custom objects) becomes None instead of crashing merge_annotations downstream. - Defensive isinstance() guards on every code-side fallback so a test stub returning a MagicMock for `description`/`name`/`tags`/ `version`/`metadata`/`documentation` does not propagate through the merged dict. - Accept either an instance or a class as the first parameter (renamed from `module_class` to `module` to reflect this). Tests: pytest 2252 pass / 2 xfail. pyright clean. ruff + black clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 106ee7f commit a373eb2

2 files changed

Lines changed: 89 additions & 27 deletions

File tree

src/apcore/registry/metadata.py

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
from __future__ import annotations
44

5+
import dataclasses
56
import logging
67
from pathlib import Path
78
from typing import Any
89

910
import yaml
1011

1112
from apcore.errors import ConfigError, ConfigNotFoundError
13+
from apcore.module import ModuleAnnotations
1214
from apcore.registry.types import DependencyInfo
1315
from apcore.schema.annotations import merge_annotations, merge_examples
1416

@@ -64,44 +66,76 @@ def parse_dependencies(deps_raw: list[dict[str, Any]]) -> list[DependencyInfo]:
6466
return result
6567

6668

67-
def merge_module_metadata(module_class: type, meta: dict[str, Any]) -> dict[str, Any]:
69+
def _coerce_code_annotations(raw: Any) -> ModuleAnnotations | None:
70+
"""Narrow a module's `annotations` attribute to ``ModuleAnnotations | None``.
71+
72+
Modules express annotations in three forms in the wild:
73+
74+
- ``ModuleAnnotations(...)`` instance — pass through.
75+
- ``dict[str, Any]`` (dict-style annotations) — filter to canonical
76+
field names and construct a ModuleAnnotations.
77+
- Anything else (None, MagicMock test stubs, custom objects) —
78+
return None so :func:`merge_annotations` does not crash on
79+
``getattr`` chasing nonexistent fields.
80+
"""
81+
if isinstance(raw, ModuleAnnotations):
82+
return raw
83+
if isinstance(raw, dict):
84+
valid = {f.name for f in dataclasses.fields(ModuleAnnotations)}
85+
return ModuleAnnotations(**{k: v for k, v in raw.items() if k in valid})
86+
return None
87+
88+
89+
def merge_module_metadata(module: Any, meta: dict[str, Any]) -> dict[str, Any]:
6890
"""Merge YAML metadata over code-level attributes per spec §4.13.
6991
92+
Accepts either a module instance OR a class. ``getattr`` resolves
93+
instance-level attributes first and then falls through to class
94+
attributes via Python's normal lookup chain, so passing the instance
95+
correctly handles modules that set their version/annotations/etc. in
96+
``__init__``.
97+
7098
Scalar fields (description, name, tags, version, documentation) follow
7199
"YAML wins, code is the fallback". The ``annotations`` field uses
72100
field-level merging via :func:`apcore.schema.merge_annotations` so a YAML
73101
annotation override does not blow away unrelated code-set flags. The
74102
``examples`` field uses :func:`apcore.schema.merge_examples` (YAML wins
75103
fully when present). The ``metadata`` field is a shallow dict merge.
76104
"""
77-
code_desc = getattr(module_class, "description", "")
78-
code_name = getattr(module_class, "name", None)
79-
code_tags = getattr(module_class, "tags", [])
80-
code_version = getattr(module_class, "version", "1.0.0")
81-
code_annotations = getattr(module_class, "annotations", None)
82-
code_examples = getattr(module_class, "examples", [])
83-
code_metadata = getattr(module_class, "metadata", {})
84-
code_docs = getattr(module_class, "documentation", None)
105+
code_desc = getattr(module, "description", "")
106+
code_name = getattr(module, "name", None)
107+
code_tags = getattr(module, "tags", [])
108+
code_version = getattr(module, "version", "1.0.0")
109+
code_annotations = _coerce_code_annotations(getattr(module, "annotations", None))
110+
code_examples = getattr(module, "examples", [])
111+
code_metadata = getattr(module, "metadata", {})
112+
code_docs = getattr(module, "documentation", None)
85113

86114
yaml_metadata = meta.get("metadata", {})
87115
merged_metadata = {**(code_metadata or {}), **(yaml_metadata or {})}
88116

89117
yaml_annotations = meta.get("annotations")
90-
merged_annotations: Any
118+
merged_annotations: ModuleAnnotations | None
91119
if yaml_annotations is None and code_annotations is None:
92120
merged_annotations = None
93121
else:
94122
merged_annotations = merge_annotations(yaml_annotations, code_annotations)
95123

124+
# Defend against unusable code_examples (e.g. MagicMock test stubs):
125+
# only forward when it is actually a list, otherwise treat as absent.
126+
safe_code_examples = code_examples if isinstance(code_examples, list) else None
127+
96128
return {
97-
"description": meta.get("description") or code_desc,
98-
"name": meta.get("name") or code_name,
99-
"tags": meta.get("tags") if meta.get("tags") is not None else (code_tags or []),
100-
"version": meta.get("version") or code_version,
129+
"description": meta.get("description") or (code_desc if isinstance(code_desc, str) else ""),
130+
"name": meta.get("name") or (code_name if isinstance(code_name, (str, type(None))) else None),
131+
"tags": (
132+
meta.get("tags") if meta.get("tags") is not None else (code_tags if isinstance(code_tags, list) else [])
133+
),
134+
"version": meta.get("version") or (code_version if isinstance(code_version, str) else "1.0.0"),
101135
"annotations": merged_annotations,
102-
"examples": merge_examples(meta.get("examples"), code_examples or None),
103-
"metadata": merged_metadata,
104-
"documentation": meta.get("documentation") or code_docs,
136+
"examples": merge_examples(meta.get("examples"), safe_code_examples),
137+
"metadata": merged_metadata if isinstance(merged_metadata, dict) else {},
138+
"documentation": meta.get("documentation") or (code_docs if isinstance(code_docs, str) else None),
105139
}
106140

107141

src/apcore/registry/registry.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,10 @@ def _register_in_order(
473473
logger.error("Failed to instantiate module '%s': %s", mod_id, e)
474474
continue
475475

476-
merged_meta = merge_module_metadata(cls, meta)
476+
# Pass the instance (not the class) so __init__-set attributes
477+
# are picked up; getattr falls through to class attrs anyway.
478+
# Aligned with manual register() / register_internal().
479+
merged_meta = merge_module_metadata(module, meta)
477480
with self._lock:
478481
self._modules[mod_id] = module
479482
self._module_meta[mod_id] = merged_meta
@@ -539,6 +542,17 @@ def register(
539542

540543
is_versioned = version is not None
541544

545+
# Pre-compute the merged metadata view OUTSIDE the lock so the
546+
# critical section stays minimal. merge_module_metadata is pure
547+
# (no I/O, no shared state). Pass the *instance* so getattr()
548+
# picks up instance-level attribute overrides (e.g. modules that
549+
# set self.version in __init__) and still falls back to class
550+
# attributes via Python's normal lookup chain. This populates
551+
# _module_meta even for the manual register() path, matching
552+
# apcore-typescript Registry.register() so get_definition()
553+
# never has to fall back to the raw module object.
554+
merged_meta = merge_module_metadata(module, metadata or {})
555+
542556
with self._lock:
543557
# For explicit versioned registration, skip conflict check when
544558
# the module_id already exists (we allow multiple versions).
@@ -568,6 +582,7 @@ def register(
568582
latest = self._versioned_modules.get_latest(module_id)
569583
if latest is not None:
570584
self._modules[module_id] = latest
585+
self._module_meta[module_id] = merged_meta
571586
self._lowercase_map[module_id.lower()] = module_id
572587

573588
# Call on_load if available
@@ -580,6 +595,7 @@ def register(
580595
self._versioned_meta.remove(module_id, effective_version)
581596
if not self._versioned_modules.has(module_id):
582597
self._modules.pop(module_id, None)
598+
self._module_meta.pop(module_id, None)
583599
raise
584600

585601
self._trigger_event("register", module_id, module)
@@ -728,19 +744,23 @@ def get_definition(self, module_id: str, version_hint: str | None = None) -> Mod
728744
if deprecation:
729745
sunset_date = deprecation.get("sunset_date")
730746

731-
# `meta` is populated by merge_module_metadata at registration time and
732-
# already implements spec §4.13 (YAML > code > defaults). Read from it
733-
# directly so YAML annotations and examples are honored.
747+
# INVARIANT: every registration site (`register`, `register_internal`,
748+
# `_register_in_order`) populates `_module_meta` via
749+
# `merge_module_metadata`, so `meta` always contains the full set of
750+
# canonical keys including the merged `annotations` slot. Read all
751+
# merged-meta fields straight from it. Schemas come straight from
752+
# the module instance because they are not part of the merged
753+
# metadata payload. Aligned with apcore-typescript Registry.getDefinition.
734754
return ModuleDescriptor(
735755
module_id=module_id,
736-
name=meta.get("name") or getattr(module, "name", None),
737-
description=meta.get("description") or getattr(module, "description", ""),
738-
documentation=meta.get("documentation") or getattr(module, "documentation", None),
756+
name=meta.get("name"),
757+
description=meta.get("description") or "",
758+
documentation=meta.get("documentation"),
739759
input_schema=input_json,
740760
output_schema=output_json,
741-
version=meta.get("version") or getattr(module, "version", "1.0.0"),
742-
tags=list(meta.get("tags") or getattr(module, "tags", None) or []),
743-
annotations=meta.get("annotations") if "annotations" in meta else getattr(module, "annotations", None),
761+
version=meta.get("version") or "1.0.0",
762+
tags=list(meta.get("tags") or []),
763+
annotations=meta.get("annotations"),
744764
examples=list(meta.get("examples") or []),
745765
metadata=effective_metadata,
746766
sunset_date=sunset_date,
@@ -1127,13 +1147,21 @@ def register_internal(self, module_id: str, module: Any) -> None:
11271147
_validate_module_id(module_id, allow_reserved=True)
11281148

11291149
_ensure_schema_adapter(module)
1150+
1151+
# Mirror register(): populate _module_meta via the same merge path
1152+
# so get_definition() reads merged metadata uniformly regardless of
1153+
# which registration entry point was used. Pass the instance so
1154+
# __init__-set attributes are picked up.
1155+
merged_meta = merge_module_metadata(module, {})
1156+
11301157
with self._lock:
11311158
if module_id in self._modules:
11321159
# Aligned with apcore-typescript / apcore-rust and the canonical
11331160
# message produced by detect_id_conflicts for the public
11341161
# `register()` path.
11351162
raise InvalidInputError(message=f"Module ID '{module_id}' is already registered")
11361163
self._modules[module_id] = module
1164+
self._module_meta[module_id] = merged_meta
11371165
self._lowercase_map[module_id.lower()] = module_id
11381166
self._trigger_event("register", module_id, module)
11391167

0 commit comments

Comments
 (0)