Registry Introspection¶
Cosalette's introspection system lets you inspect all registered devices, telemetry, commands, and adapters at any point after registration — before the app even starts running.
Why Introspect?¶
A cosalette app assembles its registrations declaratively via decorators:
@app.telemetry("temp", interval=30, publish=Every(seconds=60) | OnChange())
async def temp() -> dict[str, object]:
return {"celsius": read_sensor()}
But once an app has dozens of registrations across multiple files, it becomes hard to answer simple questions:
- What devices are registered?
- What interval does
tempuse? Is it deferred? - Which telemetry uses a
SaveOnPublishpolicy? - What adapters are wired up?
build_registry_snapshot() answers all of these programmatically.
The Snapshot¶
build_registry_snapshot(app) returns a plain Python dict that is
fully JSON-serializable — no custom encoders needed:
import cosalette
from cosalette import build_registry_snapshot
app = cosalette.App(name="mybridge", version="1.0.0")
# ... register devices, telemetry, commands, adapters ...
snapshot = build_registry_snapshot(app)
The returned dict has this structure:
{
"app": {
"name": "mybridge",
"version": "1.0.0",
"description": "IoT-to-MQTT bridge",
},
"devices": [ ... ],
"telemetry": [ ... ],
"commands": [ ... ],
"streams": [ ... ],
"periodic": [ ... ],
"adapters": [ ... ],
}
Telemetry Entries¶
Each telemetry entry captures the full configuration:
{
"name": "temp",
"type": "telemetry",
"func": "sensors.temp",
"interval": 30.0, # (1)!
"strategy": "AnyStrategy(Every(seconds=60.0), OnChange())", # (2)!
"persist": "SaveOnPublish()", # (3)!
"group": "sensors", # (4)!
"is_root": False,
"has_init": False,
"dependencies": [["store", "DeviceStore"]], # (5)!
}
- Concrete float, or
"<deferred>"if the interval is a settings-derived callable - Strategy
repr()— composites are shown recursively - Persist policy
repr(), ornullif not set - Coalescing group name, or
null - Injected parameters as
[param_name, type_name]pairs
Deferred Intervals¶
Intervals can be a concrete float or a callable that resolves from settings at runtime (see ADR-020):
# Concrete — shows as 30.0
@app.telemetry("temp", interval=30.0)
# Deferred — shows as "<deferred>"
@app.telemetry("temp", interval=lambda s: s.sensor_interval)
Before the app runs, deferred intervals cannot be resolved because settings
haven't been validated yet. The snapshot shows "<deferred>" as a clear
indicator.
Device and Command Entries¶
# Device entry
{"name": "motor", "type": "device", "func": "devices.motor",
"is_root": False, "has_init": True,
"dependencies": [["ctx", "DeviceContext"]]}
# Command entry
{"name": "valve", "type": "command", "func": "handlers.valve",
"mqtt_params": ["payload", "topic"], "is_root": False,
"has_init": False, "dependencies": []}
Stream and Periodic Entries¶
# Stream entry
{"name": "receiver", "type": "stream", "func": "streams.receiver",
"enabled": True, "is_root": False, "maxsize": 0,
"backpressure": "drop_newest",
"summary": "Read sensor frames from the serial port",
"state_model": "FrameState", # (1)!
"behavior": ["decodes LaCrosse frames"],
"effects": ["publishes per-sensor state"],
"dependencies": [["ctx", "DeviceContext"]]}
# Periodic entry
{"name": "cache-refresh", "type": "periodic", "func": "tasks.refresh_cache",
"interval": 60.0, "enabled": True, "has_init": False,
"summary": "Refresh the upstream cache",
"behavior": ["evicts stale entries"], # (2)!
"dependencies": [["cache", "CachePort"]]}
- Class name of the declared
state_model, ornull. On streams this is runtime load-bearing — it validates everyctx.publish_state()payload (see Validated Published State). - Periodic tasks carry no
state_model,payload_model, oreffects: they have no MQTT presence by design (ADR-041).
Periodic tasks never appear in the generated AsyncAPI document — they have no MQTT
presence by design (ADR-041), so the
snapshot is the only place their contract metadata surfaces. Streams now emit an
AsyncAPI state channel (x-cosalette-archetype: stream) as of
ADR-054, reversing
ADR-045's original exclusion;
the registry snapshot additionally carries stream-only fields that AsyncAPI does not
(maxsize, backpressure, dependencies).
Adapter Entries¶
Adapter impl and dry_run fields show:
- Class name for type-based registration
- Import string for lazy registration (e.g.,
"mypackage.adapters:MyAdapter") - Qualified name for callable factories
Use Cases¶
| Use case | How |
|---|---|
| Agent consumption | The cosalette_inspect_app MCP tool returns the snapshot as JSON via format_registry_json() (see _mcp/_introspect_tools.py) |
| Programmatic/scripted use | Call build_registry_snapshot(app) directly, then format_registry_table()/format_registry_json() (see Formatting below) |
| Test assertions | Verify registration correctness in integration tests |
No CLI flag renders the registry snapshot
--show-devices and --show-devices-json (see CLI Reference)
render the AsyncAPI document (app.asyncapi()), not the registry
snapshot described on this page — despite the name, they don't call
build_registry_snapshot(). There is currently no cosalette CLI flag
that prints the registry snapshot; the closest equivalent is the
cosalette_inspect_app MCP tool above, or calling
build_registry_snapshot() yourself.
Formatting¶
Two convenience functions turn a snapshot into display-ready output:
from cosalette import build_registry_snapshot, format_registry_table, format_registry_json
snapshot = build_registry_snapshot(app)
# Human-readable table
print(format_registry_table(snapshot))
# Indented JSON via orjson (same formatter the cosalette_inspect_app MCP tool uses)
print(format_registry_json(snapshot))
format_registry_table groups registrations by type (devices, telemetry,
commands, streams, periodic, adapters), omitting empty sections. Booleans are
rendered as ✓/— and missing values as —.
format_registry_json delegates to orjson with two-space indentation,
consistent with ADR-021.
Introspection Accessors¶
build_registry_snapshot() returns a flattened, serialised view. When you need the
live registration objects — with their full typed metadata — rather than a
serialised snapshot, the App exposes read-only accessor properties. Most are shared
with Router through a common mixin, so the same code works against either.
Registration collections¶
The sequence accessors return immutable point-in-time tuple snapshots, so
registry internals cannot be mutated through them. .adapters returns a live,
immutable MappingProxyType view (entries added later remain visible through it):
| Accessor | Return type | App | Router |
|---|---|---|---|
.devices |
Sequence[DeviceRegistration] |
✓ | ✓ |
.telemetry_registrations |
Sequence[TelemetryRegistration] |
✓ | ✓ |
.commands |
Sequence[CommandRegistration] |
✓ | ✓ |
.periodic_registrations |
Sequence[PeriodicRegistration] |
✓ | ✓ |
.stream_registrations |
Sequence[StreamRegistration] |
✓ | ✓ |
.state_factories |
tuple[StateRegistration, ...] |
✓ | — |
.adapters |
Mapping[type, ...] |
✓ | ✓ |
The _registrations suffix on telemetry_registrations and periodic_registrations
avoids shadowing the @app.telemetry / @app.periodic decorators.
.root_names returns a frozenset[str] of the names of root-level registrations
(is_root) across the device, telemetry, command, and stream archetypes. Root entities
occupy the app namespace with no device segment, so by the ADR-058 contract they never
appear in a schema's device_names; tooling that compares registrations against schema
device names (e.g. cosalette schema check) excludes them to avoid a spurious EXTRA.
# Assert registration metadata directly, without a snapshot
reg = next(r for r in app.commands if r.name == "valve")
assert reg.payload_model is ValveCommand
assert reg.state_model is ValveState
Name and configuration accessors¶
| Accessor | Return type | App | Router | Description |
|---|---|---|---|---|
.registered_names |
frozenset[str] |
✓ | ✓ | Every registered device/telemetry/command/periodic/stream name |
.settings_class |
type[Settings] |
✓ | — | The concrete Settings subclass, available before startup |
.store |
Store \| None |
✓ | — | Configured store backend (or None when explicitly opted out) |
.store_is_default |
bool |
✓ | — | True when the store was auto-resolved by the framework |
.has_dynamic_entities |
bool |
✓ | — | True when the app's entity set can vary between runs |
.retained_cleanup |
bool \| None |
✓ | — | Explicit ADR-048 cleanup override (True/False), or None for auto |
registered_names answers "is this name taken?"; the collection accessors expose the
metadata behind each name. All of these are stable public API — useful for structural
wiring tests, code generators, and diagnostics that reason about an app before it
starts.
Design Notes¶
The introspection module reads the App's internal registries directly.
It produces a read-only snapshot — no mutations, no side effects. The
output uses repr() on strategies and persist policies, which means
adding a custom strategy only requires implementing __repr__ for it
to appear correctly in snapshots.
Open/Closed Principle
New strategy or policy classes automatically work with introspection
as long as they implement __repr__. No changes to the introspection
module are needed — the system is open for extension, closed for
modification.