genro-routes — Code Reading Guide
Version: 0.22.0 Status: 🔴 DA REVISIONARE — Documento non ancora approvato
This guide accompanies a first-time reader of the source code. For each module it explains what it does, how it works and above all why it is built that way when the pattern is not obvious.
Recommended reading order: follow the chapter numbering. Each chapter assumes you understood the previous ones.
Table of Contents
1. The big idea
genro-routes is a transport-agnostic routing engine.
In Flask/FastAPI/Django routes are tied to HTTP: verbs, URL patterns, status
codes. Here routes are named operations (list_orders, create_user)
registered on object instances. The protocol (HTTP, WebSocket, MCP,
Telegram, CLI) is a separate adapter living in another package (e.g.
genro-asgi).
┌──────────────────────────────┐ ┌────────────────────────┐
│ genro-routes │ │ Transport adapter │
│ - registers handlers │ │ (genro-asgi, etc.) │
│ - organizes hierarchies │ │ - maps HTTP → node() │
│ - applies plugins │ │ - handles I/O │
│ - exposes introspection │ │ - serializes results │
└──────────────────────────────┘ └────────────────────────┘
Two fundamental consequences:
Routers live on instances, not as global singletons. Every
MyService()creates its own router with its own plugins. No shared state.One class, one router. Every
RoutingClassowns exactly oneRouter, created lazily and exposed as the read-only propertyroute. Hierarchy and multiple “surfaces” are expressed by composing instances (add_branches), never by adding routers to a class.
2. File map
src/genro_routes/
├── __init__.py ← public API + plugin auto-import
├── exceptions.py ← 4 exceptions (NotFound, NotAuthorized, ...)
├── core/
│ ├── __init__.py ← re-export aggregator
│ ├── router_interface.py ← ABC with 2 methods: node() and nodes()
│ ├── decorators.py ← @route — pure marker, zero side effects
│ ├── context.py ← RoutingContext — extensible container with parent chain
│ ├── base_router.py ← ~1050 lines — core: binding, resolution, introspection
│ ├── router_node.py ← callable wrapper returned by node()
│ ├── router.py ← extends BaseRouter with plugins and middleware
│ └── routing.py ← RoutingClass mixin + Section + _RoutingProxy
├── cli/
│ ├── __init__.py ← RoutingCli — public API of the CLI adapter
│ ├── _builder.py ← CliBuilder — builds a click tree from nodes()
│ ├── _type_map.py ← ParamConverter — Python types → click
│ └── _formatters.py ← OutputFormatter — JSON / table / raw
└── plugins/
├── __init__.py ← docstring only, no imports
├── _base_plugin.py ← MethodEntry + BasePlugin + _wrap_configure
├── logging.py ← LoggingPlugin — timing and logging
├── pydantic.py ← PydanticPlugin — input validation + response schema
├── auth.py ← AuthPlugin — RBAC with tag matching
├── env.py ← EnvPlugin + CapabilitiesSet — dynamic feature flags
└── channel.py ← ChannelPlugin — transport-channel filtering
Size: ~4,700 lines of source code in total.
3. The @route decorator — markers, not mutations
File: core/decorators.py (78 lines)
@route()
def list_orders(self):
...
@route(name="detail", auth="admin")
def handle_detail(self, order_id: int):
...
What it does
Appends a dict to the list func._route_decorator_kw. It touches no router,
imports no heavy module, and returns the function unchanged.
Why it is surprising
In Flask @app.route("/path") immediately mutates the global router. Here the
decorator runs at class definition time (import time), when the router
instance does not exist yet.
Why it is built this way
The router is instance-scoped. Class MyService can be instantiated N times,
each with its own router. The decorator is shared at class level (via the MRO),
so it cannot talk to any specific instance. The solution: annotate the function
and let the router discover the markers later (lazy binding).
Details worth noting
Keyword-only:
route()takes no positional arguments. There is no router selector — all markers belong to the class’s single router.Payload keys:
namebecomesentry_name(explicit logical name),endpoint_idenables reverse lookup vianode("@endpoint_id")andget_url(). Any extra**kwargs(e.g.auth="admin",logging_before=False) are copied verbatim into the payload and later dispatched to plugins.Stackable: multiple
@routeon the same function create multiple markers → the same function is registered under multiple entry names (aliases).
4. RouterInterface — the minimal contract
File: core/router_interface.py (83 lines)
An ABC with only two abstract methods:
Method |
Purpose |
|---|---|
|
Resolve a path → callable RouterNode |
|
Introspection: return the tree of entries and child routers |
Any object implementing this interface can be used where a router is expected
(duck typing). This lets external packages such as genro-asgi create
router-compatible objects without depending on the BaseRouter implementation.
5. BaseRouter — the routing core
File: core/base_router.py (~1050 lines)
This is the most important class. It implements all the routing with no plugin logic. Router (the subclass) only adds plugins and middleware.
5.1 Constructor and slots
BaseRouter(owner, *, prefix=None, description=None,
default_entry="index", get_default_handler=None,
get_kwargs=None)
owneris required and must be aRoutingClassinstance (ValueErrorifNone,TypeErrorotherwise). Routers are bound to that instance and never re-bound.The router’s
nameis always"route"— users never pass a name.At the end of
__init__the router registers itself unconditionally:self.instance._register_router(self). RoutingClass stores it in a single private slot (see chapter 7).
__slots__ = (
"instance", # owner: the RoutingClass instance
"name", # always "route"
"prefix", # prefix to strip (e.g. "handle_")
"description", # human-readable description
"default_entry", # fallback entry (default: "index")
"__entries_raw", # dict name → MethodEntry (⚠️ name-mangled!)
"_children", # dict alias → child router
"_get_defaults", # default kwargs (get_kwargs / get_default_handler)
"_bound", # lazy-binding flag
)
⚠️ Pattern: name mangling of __entries_raw
The attribute __entries_raw (double underscore) undergoes Python name
mangling → it becomes _BaseRouter__entries_raw. This is intentional: it
protects the attribute from accidental access by consumers. In router.py you
will see the explicit access:
for entry in self._BaseRouter__entries_raw.values():
This is needed when Router wants the raw data without triggering lazy binding
(the _entries property would call _bind()).
5.2 Lazy binding
@property
def _entries(self):
if not self._bound:
self._bind()
return self.__entries_raw
The first time anything accesses _entries (via node(), nodes(), or any
operation), _bind() runs and executes add_entry("*").
add_entry("*") triggers _register_marked(), which iterates
_iter_marked_methods():
Walks the MRO of
type(self.instance)(derived classes first, so overrides win)For each class in the MRO, scans
__dict__looking for plain functions carrying the_route_decorator_kwattributeDeduplicates by method name (MRO priority) and by function identity (so a class-level alias of the same function is not registered twice)
Registers every marker as an entry — all markers belong to this router (one router per class, so there is no per-router filtering)
Why lazy: the order of router and plugin setup inside __init__ does not
matter. Plugins can be added after the router is created. Everything resolves
at first use.
5.3 Handler registration
add_entry(target) accepts:
Target type |
Behavior |
|---|---|
|
Discover all |
Comma string |
Register each name as a separate entry |
Plain string |
Look up |
Callable |
Register directly as entry |
List/tuple/set |
Iterate and register each one |
Plugin option dispatch: keywords containing _ are analyzed. If the part
before the underscore is a known plugin name, the keyword is grouped as a
plugin option. E.g.:
@route(auth_rule="admin", logging_before=False, meta_category="users")
becomes:
plugin_options = {"auth": {"rule": "admin"}, "logging": {"before": False}}core_options = {"meta": {"category": "users"}}
Shorthand: if the keyword without underscore is a known plugin name and
that plugin declares plugin_default_param, it is mapped to the default
parameter. So auth="admin" is equivalent to auth_rule="admin".
5.4 Path resolution — _find_candidate_node(path)
This is the routing algorithm. No regex, no URL patterns.
Path: "orders/create"
│ │
│ └─ looked up in the child router's entries
└─ looked up in the current router's _children
Step by step:
Empty path → return the router’s
default_entrySplit on
/→["orders", "create"]For each segment:
If it is an entry → found; remaining segments become
partialIf it is a child router → navigate into the child and continue
If navigation runs out without an entry → use the
default_entryof the last router reached, with the leftover segments aspartial
The partial segments are then mapped to the handler’s positional parameters
via signature inspection (see RouterNode).
Example:
router.node("users/get_user/42")
→ navigates into child "users"
→ finds entry "get_user"
→ partial = ["42"]
→ "42" is mapped to the first positional parameter of get_user()
Reverse lookup: a path starting with @ is an endpoint_id lookup:
node("@invoice.detail") searches the whole tree recursively for the entry
registered with @route(endpoint_id="invoice.detail"). The companion
get_url(path_or_id, **kwargs) builds a URL path, appending keyword values
that match the handler’s positional parameters as path segments.
5.5 Children — include() and detach_instance()
include(source, name=...) links a source into this router:
Router source:
self._children[alias] = source. If the source’s owner has no parent yet, this is the primary attachment: plugin inheritance is triggered via_on_attached_to_parentand_routing_parentis set on the owner. Subsequent includes of the same router are secondary links — navigational shortcuts only, no inheritance, no parent change.RouterNode source: creates an entry alias — the same
MethodEntryis stored under a new name in this router. No copy;_rebuild_handlersskips aliases (entry.router is not self) so the wrapping pipeline of the source router stays in charge.
detach_instance(child) removes every alias whose router belongs to the child
instance and clears child._routing_parent.
User code normally calls neither directly: RoutingClass.add_branches
(chapter 7) sets the parent relation and delegates the link to include().
5.6 Introspection — nodes()
nodes() builds a nested dict of the whole routing tree. It supports:
Parameter |
Effect |
|---|---|
|
Navigate and return only that subtree |
|
Child routers stay as references (not expanded) |
|
Filter entries by regex |
|
Include blocked entries with the reason |
|
Plugin filters (e.g. |
The tree is dialect-neutral: OpenAPI/MCP output is produced by a transport adapter (e.g. genro-asgi) that reads nodes(), not by a mode parameter.
5.7 Hooks for subclasses
BaseRouter defines four no-op hooks that Router overrides:
Hook |
When it runs |
What Router does |
|---|---|---|
|
Handler rebuild |
Builds the middleware pipeline |
|
After registration |
Applies plugin config and |
|
Primary |
Inherits the parent’s plugins |
|
During nodes() |
Adds plugin info for introspection |
6. RouterNode — the callable wrapper
File: core/router_node.py (240 lines)
RouterNode is what router.node("path") returns. It is a callable object:
node()() invokes the handler.
What it does
Receives the router, the entry name (optional) and the
partialsegmentsResolves the entry (from the name or from
default_entry)Maps the partial segments to the handler’s positional parameters (
_assign_partial)When called (
__call__), merges the partials with the explicit args and invokesentry.handler
It also exposes endpoint_id, doc and metadata (the meta_* kwargs from
the decorator) as read-only properties.
Path → parameter mapping (_assign_partial)
# Handler: def get_user(self, user_id, detail=None): ...
# Path: "get_user/42/full"
# partial: ["42", "full"]
# → partial_kwargs = {"user_id": "42", "detail": "full"}
Inspection happens via inspect.signature. If there are extra segments and the
function has no *args, the node is invalid (NotFound).
Precedence: path > kwargs
filtered_kwargs = {k: v for k, v in kwargs.items() if k not in self._partial_kwargs}
Values extracted from the path win over those passed as keywords.
Exception mapping
Every RouterNode has an _exceptions dict mapping error codes to exception
classes. The caller can customize them:
node = router.node("action", errors={"not_found": MyHTTPNotFound})
If the node has error set, __call__ raises the mapped exception.
7. RoutingClass — the mixin that ties everything together
File: core/routing.py (518 lines)
This mixin is the bridge between user classes and routers. Users of
genro-routes inherit from RoutingClass.
7.1 __slots__ — state isolation
__slots__ = (
"__routing_proxy__",
"__genro_routes_router__",
"_routing_parent",
"_ctx",
"_capabilities",
)
All framework attributes live in dedicated slots → they do not pollute the
user’s namespace. __routing_proxy__ and __genro_routes_router__ have
deliberately long names to avoid collisions.
7.2 The route property — one router per instance
@property
def route(self) -> Router:
router = getattr(self, _ROUTER_ATTR_NAME, None)
if router is None:
router = Router(self)
return router
The router is created lazily on first access and stored in the
__genro_routes_router__ slot by _register_router (called by the Router
constructor). Users never call Router(...) directly; configuration happens on
the existing router in __init__ (binding is lazy, so this is race-free):
class MyService(RoutingClass):
def __init__(self):
self.route.description = "My service API"
self.route.plug("logging")
_register_router raises ValueError if a different router is already
registered — the one-router invariant is enforced, not just conventional.
A class with no plugins and no options needs no __init__ at all.
7.3 __setattr__ — child auto-detach ⚠️
def __setattr__(self, name, value):
current = self._get_current_routing_attr(name)
if current is not None:
self._auto_detach_child(current)
object.__setattr__(self, name, value)
This is an important side effect: every attribute assignment on a RoutingClass goes through this logic. If the previous attribute value was a child RoutingClass bound to this parent, it is automatically detached from the router.
Why: it avoids memory leaks and orphan routers. If you do
parent.child = OtherChild(), the old child is removed from the hierarchy
without a manual detach_instance() call.
Note: object.__setattr__ is used in several places to bypass this
custom __setattr__ when the auto-detach check is not wanted (e.g. during
internal initialization).
7.4 add_branches — hierarchical composition
add_branches is a method of RoutingClass (not of Router or the proxy).
Each branch is a dict; two forms are accepted:
# instance form — eager: attach an already-built child instance now
self.add_branches({"name": "sales", "instance": child})
# factory form — lazy: the child is created on first access
self.add_branches({"name": "sales", "cls": SalesService, "params": {...}})
The instance form sets child._routing_parent = self and delegates the routing
link to self.route.include(child.route, name=name) (which triggers plugin
inheritance on the primary attachment). The factory form records the class and
params and materializes the child lazily on first resolution of the branch.
detach_instance stays on Router.
7.5 Section — the grouping node
svc.add_branches({"name": "admin", "instance": Section("Admin area")})
Section is a minimal concrete RoutingClass carrying an empty router. It is
used to build intermediate levels of a routing tree (namespacing, dynamically
discovered hierarchies) without defining a dedicated class. Multiple surfaces
(api + admin) are separate RoutingClass instances composed by attach.
7.6 routing property — the proxy
@property
def routing(self):
proxy = getattr(self, "__routing_proxy__", None)
if proxy is None:
proxy = _RoutingProxy(self)
setattr(self, "__routing_proxy__", proxy)
return proxy
Returns a cached _RoutingProxy. The proxy groups the management operations
without polluting the class namespace:
Proxy method |
Purpose |
|---|---|
|
Plugin configuration via target syntax |
|
Introspection: returns the router description dict |
|
Delegates to the owner’s |
For navigation and introspection use the router directly: route.node(path)
resolves (and executes) a path, route.nodes(basepath=...) inspects and opens
a subtree.
Target syntax for configure: "plugin/selector"
svc.routing.configure("logging/_all_", before=False)
svc.routing.configure("auth/admin_*", rule="admin")
The selector supports glob patterns (fnmatchcase). Child routers belong to
child instances: configure them through the child’s own routing proxy.
7.7 ctx property — slot + parent chain
The context (RoutingContext) is set by the adapter (ASGI, etc.) in the
instance’s _ctx slot. Reading walks up the _routing_parent chain:
@property
def ctx(self):
result = getattr(self, "_ctx", None)
if result is not None:
return result
parent = getattr(self, "_routing_parent", None)
if parent is not None:
return parent.ctx
return None
@ctx.setter
def ctx(self, value):
object.__setattr__(self, "_ctx", value)
Layering (server → app → request) is handled by the parent chain inside
RoutingContext, not by RoutingClass:
server_ctx = RoutingContext()
server_ctx.server = server
app_ctx = RoutingContext(parent=server_ctx)
app_ctx.app = app
ctx = RoutingContext(parent=app_ctx)
ctx.db = db_connection
svc.ctx = ctx
# svc.ctx.db → local
# svc.ctx.server → walks the parent chain up to server_ctx
Concurrency isolation (ContextVar for async, threading.local for threads) is the adapter’s responsibility, not genro-routes’.
8. Router — BaseRouter + plugin pipeline
File: core/router.py (551 lines)
Router extends BaseRouter adding:
The global plugin registry (
_PLUGIN_REGISTRY— the only global state)Per-router plugin instances
The middleware pipeline
Plugin inheritance across hierarchies
8.1 Global registry
_PLUGIN_REGISTRY: dict[str, type[BasePlugin]] = {}
Plugins self-register at the end of their module:
# In auth.py, last line:
Router.register_plugin(AuthPlugin)
The import happens in __init__.py:
for _plugin in ("logging", "pydantic", "auth", "env", "channel"):
import_module(f"{__name__}.plugins.{_plugin}")
8.2 plug(name) — attaching a plugin
self.route.plug("logging").plug("auth")
plug() is chainable (returns self). It creates a _PluginSpec,
instantiates the plugin, and adds it to the internal structures. If the router
is already bound, on_decore is applied to existing entries and handlers are
rebuilt.
8.3 __getattr__ — fluent plugin access
def __getattr__(self, name):
plugin = self._plugins_by_name.get(name)
if plugin is None:
raise AttributeError(...)
return plugin
This enables router.logging.configure(before=False). Plugins become virtual
attributes of the router.
8.4 Middleware pipeline — _wrap_handler
wrapped = call_next # = entry.func (the original method)
for plugin in reversed(self._plugins):
plugin_call = plugin.wrap_handler(self, entry, wrapped)
wrapped = self._create_wrapper(plugin, entry, plugin_call, wrapped)
The last attached plugin is closest to the real handler. The first one is the outermost (first to run, last to complete).
The wrapper checks is_plugin_enabled at runtime: if the plugin is
disabled, it jumps straight to next_handler. This allows enabling/disabling
plugins without rebuilding the chain.
8.5 Plugin inheritance — _on_attached_to_parent
When a child router is attached to a parent (primary include()):
For each parent plugin not present in the child: a new instance of the same plugin class is created and added to the child
For each parent plugin already present in the child:
on_attached_to_parent(parent_plugin)is called so the plugin decides how to handle inheritance
The _inherited_from set (parent ids) prevents inheriting twice from the same
parent; _plugin_children tracks children for configuration-change
propagation.
8.6 is_plugin_enabled — 5-level cascade
Resolution order (first match wins):
entry locals (runtime override via
set_plugin_enabled)entry config (static via
configure(_target=handler))global locals (runtime override via
set_plugin_enabled("_all_"))global config (static via
configure())default:
True
The config/locals separation allows runtime overrides that can be set and removed independently of the static configuration.
9. The plugin system
File: plugins/_base_plugin.py (381 lines)
9.1 MethodEntry — the record of a handler
@dataclass
class MethodEntry:
name: str # logical name (e.g. "list_orders")
func: Callable # the original bound method
router: Any # the router that owns it
plugins: list[str] # names of the applied plugins
metadata: dict[str, Any] # metadata (plugin_config, meta_*, ...)
handler: Callable = None # the method after middleware wrapping
endpoint_id: str | None = None # optional globally unique id (reverse lookup)
handler starts equal to func and is replaced by the middleware pipeline
when plugins build the wrappers.
The signature property returns inspect.signature(func), computed on first
access and cached. RouterNode.__call__ binds the call arguments against it
before invoking the handler, so a call that does not fit the signature raises
the signature_error class instead of reaching the handler; _assign_partial
reads it to place the unresolved path segments.
9.2 BasePlugin — the contract
Every plugin inherits from BasePlugin and defines:
Class attributes (required):
plugin_code = "auth" # unique identifier
plugin_description = "..." # human-readable description
plugin_default_param = "rule" # shorthand parameter (optional)
Overridable hooks:
Hook |
When |
Purpose |
|---|---|---|
|
Configuration |
Declare the schema via the signature |
|
Handler registration |
Analyze/transform the entry |
|
Middleware build |
Return a wrapper callable |
|
|
Decide accessibility |
|
|
Provide introspection metadata |
|
Primary attachment |
Handle config inheritance |
|
Parent changes config |
Decide whether to follow or ignore |
9.3 __init_subclass__ — the most elegant pattern ⚠️
class BasePlugin:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if "configure" in cls.__dict__:
cls.configure = _wrap_configure(cls.__dict__["configure"])
Every subclass defining configure() gets it automatically wrapped by
_wrap_configure. The plugin author writes:
class AuthPlugin(BasePlugin):
def configure(self, *, rule: str = "", enabled: bool = True,
_target: str = "_all_", flags: str | None = None):
pass # The body is literally empty!
The parameter signature IS the configuration schema.
_wrap_configure adds:
Pydantic validation via
@validate_callon the original signatureflagsparsing (string"enabled,before:off"→ boolean dict)Multiple-target handling (comma-separated)
Automatic persistence in the router’s
_plugin_infostore
Why: it removes all configuration boilerplate. Zero validation code, zero persistence code, zero serialization code. The plugin developer only declares the accepted parameters and their types.
9.4 Configuration store
Configuration lives in Router._plugin_info:
{
"auth": {
"_all_": {"config": {"rule": "user"}, "locals": {}},
"admin_action": {"config": {"rule": "admin"}, "locals": {}}
}
}
_all_ is the global configuration; specific entries override it.
9.5 Inheritance: “follow if aligned, ignore if customized”
When the parent changes its config, _notify_children calls
on_parent_config_changed(old, new) on each child. The default implementation:
If the child’s config was equal to the parent’s old config → update to the new one
If the child’s config had been customized → ignore
This avoids both the rigidity of forced inheritance and the chaos of total independence. The cascade propagates recursively to grandchildren but stops where the config was customized.
10. The built-in plugins
10.1 LoggingPlugin (plugins/logging.py, 175 lines)
Hook: wrap_handler
Adds logging with timing to every handler call. The logged closure reads the
configuration at runtime (not at wrap time), allowing dynamic toggles.
configure(enabled=True, before=True, after=True, log=True, print=False)
Note: print as a parameter name shadows the builtin — intentional (it carries
the # noqa: A002 comment).
10.2 PydanticPlugin (plugins/pydantic.py, 247 lines)
Hooks: on_decore, wrap_handler, entry_metadata
In on_decore (at registration):
Inspects signature and type hints
Creates a dynamic Pydantic model for the input parameters
Generates the JSON response schema from the return type hint
In wrap_handler (at runtime):
Validates only the parameters with a type hint (untyped ones pass through)
The
disabledcheck happens on every call, not at wrap time
Note: the configuration parameter is disabled (negative), not enabled.
Different from the other plugins.
10.3 AuthPlugin (plugins/auth.py, 170 lines)
Hook: deny_reason
Implements RBAC with expressive rules:
Rule |
Meaning |
|---|---|
|
Requires the “admin” tag |
|
OR: either one suffices |
|
AND: both required |
|
NOT: must not have “guest” |
|
Combination |
401 vs 403 distinction:
Entry with a rule but no tags provided →
"not_authenticated"(401)Tags provided but not matching →
"not_authorized"(403)
Recursion over RouterInterface: if the entry is a router (not a handler), it iterates over all its entries and children. If at least one is accessible, the router is visible.
10.4 EnvPlugin + CapabilitiesSet (plugins/env.py, 307 lines)
Hook: deny_reason
Filters entries based on the capabilities available in the system. Unlike AuthPlugin (who you are), EnvPlugin is about what is available (is Redis up? is Stripe configured?).
CapabilitiesSet is the base class for defining dynamic capabilities:
class MyCapabilities(CapabilitiesSet):
@capability
def redis(self):
return self._redis_client.ping()
@capability
def stripe(self):
return self._stripe_key is not None
The pattern is surprising: CapabilitiesSet.__iter__ uses dir(self) to
discover the methods marked with @capability, calls them at runtime, and
yields the name only if they return True. There is no cache: every iteration
re-evaluates the capabilities.
Accumulation in the hierarchy: capabilities add up walking the
_routing_parent chain (BaseRouter.current_capabilities). Parent has
“redis”, child has “pyjwt” → an entry requiring redis&pyjwt is satisfied.
10.5 ChannelPlugin (plugins/channel.py, 143 lines)
Hook: deny_reason
Filters entries based on the transport channel (mcp, rest, bot_*, etc.).
@route(channel="mcp,bot_.*")
def mcp_and_bots_only(self): ...
Default closed: without channel configuration, the entry is not available on any channel
Patterns are full-match regexes (
re.fullmatch)"*"is a special case (wildcard, everything open)
10.6 OpenAPI (out of core)
OpenAPI translation is not part of genro-routes. The routing core only exposes
the dialect-neutral introspection tree: each entry carries a result block
({schema, media_type}) and a params block, built by the PydanticPlugin from
return-type and parameter annotations. A transport adapter such as genro-asgi
reads that tree and produces the OpenAPI (or MCP) document — HTTP-method
inference, tags, security and request/response schemas all live there, not here.
11. Exceptions
File: exceptions.py (73 lines)
Four exceptions, all with a selector: str attribute:
Exception |
Typical HTTP code |
When |
|---|---|---|
|
404 |
Path not resolved |
|
401 |
Credentials required, not provided |
|
403 |
Credentials provided but insufficient |
|
501 |
Missing capability or unsupported channel |
The selector has the format "router_name:path" (e.g. "route:admin/create").
12. Non-standard patterns — reasoned recap
12.1 @route as pure marker
Mainstream pattern |
genro-routes pattern |
Reason |
|---|---|---|
|
|
Instance-scoped router, no singleton |
12.2 Lazy binding
Mainstream pattern |
genro-routes pattern |
Reason |
|---|---|---|
Explicit registration or at decorator time |
Binding at first |
Setup order irrelevant |
12.3 __init_subclass__ on BasePlugin
Mainstream pattern |
genro-routes pattern |
Reason |
|---|---|---|
Metaclass or ABC with concrete methods |
|
The signature = the schema, zero boilerplate |
12.4 __setattr__ on RoutingClass
Mainstream pattern |
genro-routes pattern |
Reason |
|---|---|---|
Explicit |
Auto-detach on attribute reassignment |
Implicit GC of the hierarchy |
12.5 __getattr__ on Router
Mainstream pattern |
genro-routes pattern |
Reason |
|---|---|---|
|
|
Natural syntax |
12.6 object.__setattr__ scattered through the code
Used to bypass RoutingClass’s custom __setattr__ when setting internal
attributes that must not trigger the auto-detach. You will see it in:
add_branches, _register_router, the ctx and capabilities setters,
_RoutingProxy.__init__, and in base_router.py inside _include_router and
detach_instance (which set/clear _routing_parent on the owner).
12.7 safe_is_instance with a string
safe_is_instance(obj, "genro_routes.core.routing.RoutingClass")
Type check via the full class name instead of a direct import. It breaks the
circular dependencies between routing.py, base_router.py and router.py,
which reference each other.
12.8 Intentional name mangling (__entries_raw)
Double-underscore attribute for protection. Router explicitly accesses
self._BaseRouter__entries_raw when it needs raw access without triggering
lazy binding.
12.9 Operations-first, not REST
REST |
genro-routes |
Reason |
|---|---|---|
|
|
The protocol is a transport detail |
Explicit HTTP verb |
HTTP method inferred from the signature |
Transport-agnostic |
12.10 CapabilitiesSet with dir() and dynamic calls
A set whose content is computed on every iteration by calling the methods
marked @capability. There is no equivalent in web frameworks. The pattern
allows capabilities that change at runtime (Redis goes down, a feature gets
activated, etc.).
13. The CLI adapter
Package: cli/ (4 files, ~375 lines)
The CLI adapter is a transport adapter like genro-asgi, but for the command line. Given a RoutingClass, it automatically generates a complete click interface with help and typed parameters.
from genro_routes.cli import RoutingCli
cli = RoutingCli(MyService)
cli.run()
Internal architecture
The flow is: RoutingCli → CliBuilder → click.Group tree.
RoutingCli(__init__.py) — accepts a class or an instance. If it receives a class it instantiates it with no arguments. Delegates toCliBuilder.CliBuilder(_builder.py) — callsinstance.route.nodes()and walks the output recursively:Entries become commands on the root group
Child routers (
routersin nodes) become nested sub-groupsNames are converted from
snake_casetokebab-case(CLI convention)
ParamConverter(_type_map.py) — mapsinspect.Parameter+ type hints to click parameters:No default →
click.Argument(positional)With default →
click.Option(--name)bool→ flag (--verbose/--no-verbose)Literal/Enum→click.Choicelist[X]→multiple=Truedict/ complex types → JSON string
OutputFormatter(_formatters.py) — formats the handler’s return value:auto(plain str, JSON for dict/list),json,table(rich if available),raw.
CLI design choices
Not a plugin: the CLI does not add behavior to handlers, it invokes them. It is an external adapter like genro-asgi.
click as optional dependency:
pip install genro-routes[cli]. Importinggenro_routes.clifails withImportErrorif click is not installed.Enum roundtrip: click
Choicereturns strings. The callback converts strings back into Enum members before invoking the handler.Async handlers: detected with
inspect.iscoroutinefunction, invoked withasyncio.run().
14. Quick glossary
Term |
Meaning |
|---|---|
Entry |
A handler registered with a logical name in a router |
Router |
Container of entries and child routers, bound to an instance |
RouterNode |
Callable wrapper returned by |
RoutingClass |
Mixin for classes exposing a router ( |
Section |
Empty RoutingClass used as a grouping node |
Plugin |
Component adding behavior (logging, auth, …) |
Marker |
|
Lazy binding |
The router discovers markers at first use |
Partial |
Unresolved path segments, passed as arguments |
endpoint_id |
Globally unique entry id for reverse lookup ( |
Plugin store |
|
CapabilitiesSet |
Set of dynamic feature flags evaluated at runtime |
Transport adapter |
External package mapping a protocol to |
RoutingCli |
Built-in CLI adapter generating click commands from |
Recommended source reading order:
core/decorators.py— 78 lines, the conceptual entry pointcore/router_interface.py— 83 lines, the contractplugins/_base_plugin.py— 381 lines, MethodEntry and BasePlugincore/base_router.py— ~1050 lines, the core (read the first 500)core/router_node.py— 240 lines, how a handler gets invokedcore/routing.py— 518 lines, the mixin, Section and the proxycore/router.py— 551 lines, the plugin pipelineThe plugins, in any order
cli/__init__.py→cli/_builder.py— the CLI adapter
For the tests, start from:
test_router_basic.py— basic usagetest_node_resolution.py— how path resolution workstest_auth_plugin.py— the authorization systemtest_env_plugin.py— dynamic capabilitiestest_cli.py— the CLI adapter