Genro Routes Architecture (current state)
This document is the source of truth for how routing, hierarchy, and plugins work after the recent refactors (no describe, single plugin store on routers). Diagrams use Mermaid.
Router and hierarchy
graph TD
RC[RoutingClass instance]
Router[Router (route property, one per instance)]
ChildRC[Child RoutingClass]
ChildRouter[Child Router]
RC -->|route property| Router
RC -->|attribute| ChildRC
ChildRC -->|route property| ChildRouter
RC -->|add_branches name+instance| ChildRC
Router -->|include(child.route, name)| ChildRouter
Router -->|nodes| M[Nodes tree]
Every
RoutingClassowns exactly oneRouter, created lazily on first access of the read-onlyrouteproperty. User code never instantiatesRouterdirectly; router options (description,prefix,default_entry) are set onself.routein__init__.Hierarchies are built via
add_branches(method onRoutingClass): the instance form{"name": alias, "instance": child}linkschild.routeinto the parent’s router under the alias eagerly, the factory form{"name": alias, "cls": Child}does so lazily at first traversal. Children are torn down viadetach_instance(method onRouter). See Branches below.Section(an emptyRoutingClass) provides pure grouping nodes without handlers:svc.add_branches({"name": "admin", "instance": Section("Admin area")}).default_entry(default:"index") specifies which handler to use for catch-all routing (best-match resolution).Introspection:
nodes()is the sole API; it returns router/instance, handlers (with metadata, doc, signature, plugins, params), children, andplugin_info.
Branches (declarative subtrees: factory, instance, alias)
add_branches is the single entry point for declaring a child subtree. Each
spec is one of three mutually exclusive forms (cls / instance / alias
cannot coexist). A factory spec is a self-describing subtree materialized
on demand: it lives in router._branches until built, then moves to
router._children. An instance spec attaches an already-built child
directly. Timing is derived from the form, not from a flag:
graph LR
Factory["factory spec {name, cls, params}"] -->|"first traversal (lazy)"| Child["child router in _children"]
Instance["instance spec {name, instance}"] -->|"immediately (eager)"| Child
Alias["alias spec {name, alias: path}"] -->|"path rewrite from root"| Target["target subtree"]
add_branches(spec | list | generator)accepts all three forms. A factory spec is stored — nothing is constructed at declaration; an instance spec is linked as a child at once.remove_branch(name)drops a spec (detaching the child if already materialized). Thebranchesproperty lists declared factory specs not yet built; instances (already children) do not appear.Factory (lazy): materialized at the first path traversal through their segment (
_find_candidate_node/router_at_path). Materialization (single point): constructcls(**params), wire_routing_parent,include()the child router (triggering plugin inheritance), then drop the spec. The spec is popped only after a successful build: a failing constructor is repeatable, never silently lost.Instance (eager): the caller built the instance, so it is wired immediately at declaration —
_routing_parentset, childinclude()d, plugin inheritance applied.paramsis rejected with an instance (ValueError); the instance must be aRoutingClass(TypeError); an instance already bound to another parent raisesValueError.Alias specs are transparent symlinks by absolute path from the tree root: navigation rewrites the path and resolves from the root, so the target’s whole subtree is served with the target’s plugins. Cycle-guarded (
ValueError); broken targets resolve tonot_found. A node reached through an alias reports the target’s path (realpath semantics).Introspection never builds:
nodes()shows lazy factory branches with their class-declared@routeleaves (scanned from the class MRO, no instance) and aliases as unresolved markers.nodes(_eager=True)expands everything;nodes(basepath=...)opens one subtree.node("@endpoint_id")skips non-traversed factory branches.
Plugin store
Single authoritative store per router: router._plugin_info (exposed via nodes).
classDiagram
class Router {
_plugin_info: dict
plug(name, **config)
}
class PluginInfo {
config: dict
locals: dict
}
Router --> "1" PluginInfo : plugin code key
Shape (reserved key _all_ for router level; each block has config + locals):
plugin_info
└── "<plugin_code>"
├── _all_
│ ├── config # router-level defaults
│ └── locals # runtime plugin-level
├── entry_name_1
│ ├── config # override per entry
│ └── locals # runtime per entry
└── entry_name_2
├── config
└── locals
Plugin lifecycle
plug("name", **config):instantiates plugin via registered class;
calls
configure(**config)which is validated by Pydantic;binds plugin to the router;
seeds
plugin_info[name].configfrom the provided config.
Inheritance (
_on_attached_to_parent): see Plugin Inheritance below.Detach of instances leaves plugin store on surviving routers untouched.
Plugin Inheritance
When a child is attached to a parent via add_branches (on RoutingClass) — at declaration for an instance spec, at first traversal for a factory spec — plugins may be inherited.
The inheritance behavior is delegated to the plugin via hooks, allowing each plugin to
decide how to handle parent-child relationships.
Inheritance Rules (Default Behavior)
Child does NOT have the plugin → plugin is inherited from parent:
A new plugin instance is created on the child (cloned from parent’s plugin class)
Plugin’s
on_attached_to_parent(parent_plugin)is calledDefault behavior: copy parent’s
_all_config to childon_decoreis applied to all child entries
Child already HAS the plugin → parent does NOT interfere:
Child keeps its own plugin instance and configuration
No config is copied from parent
The child made an explicit choice by having the plugin
Plugin Hooks for Inheritance
on_attached_to_parent(parent_plugin)
Called when a child router is attached to a parent that has this plugin. The child plugin can decide how to handle the parent’s configuration.
def on_attached_to_parent(self, parent_plugin: BasePlugin) -> None:
"""Handle attachment to a parent router with this plugin.
Default behavior: copy parent's _all_ config to child's _all_ config,
preserving any entry-specific config the child already has.
Override to customize inheritance behavior (e.g., AuthPlugin does
union of tags instead of replacement).
"""
Default implementation:
Copies parent’s
_all_config to child’s_all_configPreserves child’s entry-specific configurations (set via decorators)
Does NOT overwrite child’s
_all_if child already configured it
on_parent_config_changed(old_config, new_config)
Called when the parent router modifies its plugin configuration after attachment. The child plugin can decide whether to follow the change.
def on_parent_config_changed(
self,
old_config: dict[str, Any],
new_config: dict[str, Any]
) -> None:
"""Handle parent config change notification.
Default behavior:
- If child's config equals old_config (was aligned) → update to new_config
- If child's config differs from old_config (was customized) → ignore change
This preserves explicit child customizations while keeping "default"
children in sync with parent changes.
"""
Default implementation:
Compares child’s current
_all_config withold_configIf equal (child was following parent) → update to
new_configIf different (child made own choices) → ignore the change
Plugin API
BasePluginrequiresplugin_codeandplugin_descriptionclass attributes.configure(**config)method defines accepted parameters (validated by Pydantic).Use
_targetparameter to target specific entries:configure(_target="handler_name", ...)Use
_target="_all_"(default) for router-level config
configuration(method_name=None)method returns merged config (base + per-handler override).Plugins should read config at call time (no baked-in closures) so live updates apply without rebuild.
Plugin Inheritance Behavior
By default, BasePlugin.on_attached_to_parent() copies parent’s _all_ config to child
if the child only has default config ({"enabled": True}). This means:
LoggingPlugin, PydanticPlugin, ChannelPlugin: Inherit config from parent by default
AuthPlugin, EnvPlugin: Also inherit by default (they don’t override
on_attached_to_parent)
Note: Rule-based plugins like AuthPlugin and EnvPlugin inherit the plugin instance,
but each entry defines its own rule via decorators (auth_rule, env_requires).
Introspection data (nodes)
graph LR
R[Router] --> H[handlers]
R --> C[children]
R --> P[plugin_info]
H --> E1[entry -> {callable, metadata, doc, signature, return_type, response_schema, plugins, parameters, metadata_keys, extras}]
C --> Rchild[child router ...]
Filters (e.g.,
tagsvia AuthPlugin) apply to handlers and children; empty children pruned only when filters are active.plugin_infois included for routers; entries can mirror plugin info if desired, but the authoritative store is on the router.
Admin/CLI/UI implications
You can render a full tree (routers → children → handlers) with plugin config shown from
plugin_info.Updates can target router-level or per-entry config and take effect immediately if plugins read config live.
Locals are for plugin-owned runtime data; treat them as non-config state.
CLI transport adapter
The cli/ package is a built-in transport adapter that generates a click command tree from router introspection.
graph TD
RC[RoutingCli] --> CB[CliBuilder]
CB -->|nodes| R[Router.nodes]
CB --> G[click.Group root]
G --> C1[click.Command handler_a]
G --> C2[click.Command handler_b]
G --> SG[click.Group child_router]
SG --> C3[click.Command nested_handler]
CB --> PC[ParamConverter]
PC -->|signature + hints| CP[click.Option / click.Argument]
CB --> OF[OutputFormatter]
Data flow
RoutingCli(target)— accepts class or instance.CliBuilder.build()— readsinstance.route.nodes():Entries become root commands.
Child routers (attached instances) become nested
click.Groups.
For each entry,
ParamConverter.to_click_params(handler)mapsinspect.signature+get_type_hintsto click parameters.The command callback invokes the handler and pipes the result through
OutputFormatter.
Key design decisions
Not a plugin — the CLI invokes handlers; it does not alter their behavior.
Optional dependency —
clickis required only whengenro_routes.cliis imported (pip install genro-routes[cli]).Name normalization — Python
snake_casenames are converted tokebab-casefor CLI convention.Enum roundtrip —
click.Choicereturns strings; the callback converts them back to enum members before invoking the handler.Async support — coroutine handlers are detected via
inspect.iscoroutinefunctionand invoked withasyncio.run().