tigrbl-core 0.4.2.dev3__py3-none-any.whl → 0.4.3.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,8 @@ _EXPORTS = {
29
29
  "HttpStreamBindingSpec": "binding_spec",
30
30
  "SseBindingSpec": "binding_spec",
31
31
  "WebTransportBindingSpec": "binding_spec",
32
+ "WELL_KNOWN_PREFIX": "well_known_spec",
33
+ "WellKnownResourceSpec": "well_known_spec",
32
34
  "WsBindingSpec": "binding_spec",
33
35
  "resolve_rest_nested_prefix": "binding_spec",
34
36
  "ColumnSpec": "column_spec",
@@ -80,6 +82,8 @@ _EXPORTS = {
80
82
  "validate_app_framing_for_binding": "binding_spec",
81
83
  "validate_binding_profile_exchange": "binding_spec",
82
84
  "validate_path_binding": "path_spec",
85
+ "normalize_well_known_name": "well_known_spec",
86
+ "well_known_path": "well_known_spec",
83
87
  "PHASE": "op_spec",
84
88
  "PHASES": "op_spec",
85
89
  "HookPhase": "hook_spec",
@@ -106,7 +110,39 @@ _EXPORTS = {
106
110
  "TypeAdapter": "datatypes",
107
111
  "TypeRegistry": "datatypes",
108
112
  "TableSpec": "table_spec",
113
+ "BindingToken": "table_profile_bindings",
114
+ "LoweredBinding": "table_profile_bindings",
115
+ "lower_binding_tokens_for_ops": "table_profile_bindings",
116
+ "lower_default_bindings_for_op": "table_profile_bindings",
117
+ "lower_table_profile_bindings": "table_profile_bindings",
118
+ "TableProfileSpec": "table_profile_spec",
119
+ "BuiltinTableProfile": "table_profile_spec",
120
+ "BUILTIN_TABLE_PROFILE_DEFINITIONS": "table_profile_spec",
121
+ "BUILTIN_TABLE_PROFILE_KINDS": "table_profile_spec",
122
+ "TableProfileError": "table_profile_spec",
123
+ "TableProfileBindingFamily": "table_profile_spec",
124
+ "TableProfileRole": "table_profile_spec",
125
+ "PLAIN_TABLE_PROFILE": "table_profile_spec",
126
+ "CRUD_TABLE_PROFILE": "table_profile_spec",
127
+ "REALTIME_TABLE_PROFILE": "table_profile_spec",
128
+ "coerce_table_profile": "table_profile_spec",
129
+ "get_builtin_table_profile_definition": "table_profile_spec",
130
+ "get_table_profile": "table_profile_spec",
131
+ "iter_builtin_table_profile_definitions": "table_profile_spec",
132
+ "make_builtin_table_profile": "table_profile_spec",
133
+ "register_table_profile": "table_profile_spec",
109
134
  "TableRegistrySpec": "table_registry_spec",
135
+ "BINDING_STACK_PROJECTIONS": "transport_stack",
136
+ "BindingStackError": "transport_stack",
137
+ "BindingStackProjection": "transport_stack",
138
+ "binding_stack_maturity": "transport_stack",
139
+ "classify_binding_stack": "transport_stack",
140
+ "compose_h3_binding_projections": "transport_stack",
141
+ "require_binding_stack": "transport_stack",
142
+ "ExposureDecision": "exposure_policy",
143
+ "ExposurePolicyError": "exposure_policy",
144
+ "exposed_surfaces": "exposure_policy",
145
+ "resolve_exposure_policy": "exposure_policy",
110
146
  }
111
147
 
112
148
  __all__ = list(_EXPORTS)
@@ -2,10 +2,12 @@
2
2
  from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Any, Callable, Optional, Sequence
5
+ import warnings
5
6
 
6
7
  from .._spec.engine_spec import EngineCfg, EngineSpec
7
8
  from .._spec.monotone import as_tuple, merge_mro_sequence_attr
8
9
  from .._spec.response_spec import ResponseSpec
10
+ from .._spec.well_known_spec import WellKnownResourceSpec
9
11
  from .serde import SerdeMixin
10
12
 
11
13
 
@@ -45,12 +47,15 @@ def normalize_app_spec(spec: "AppSpec") -> "AppSpec":
45
47
  title=str(spec.title or "Tigrbl"),
46
48
  description=spec.description,
47
49
  version=str(spec.version or "0.1.0"),
48
- execution_backend=str(getattr(spec, "execution_backend", None) or "auto"),
50
+ execution_backend=normalize_execution_backend(
51
+ getattr(spec, "execution_backend", None),
52
+ ),
49
53
  engine=spec.engine,
50
54
  engine_name=spec.engine_name,
51
55
  engines=_seqify(spec.engines),
52
56
  routers=routers,
53
57
  ops=ops,
58
+ well_known=_seqify(spec.well_known),
54
59
  tables=tables,
55
60
  schemas=_seqify(spec.schemas),
56
61
  hooks=_seqify(spec.hooks),
@@ -64,6 +69,23 @@ def normalize_app_spec(spec: "AppSpec") -> "AppSpec":
64
69
  )
65
70
 
66
71
 
72
+ def normalize_execution_backend(value: Any) -> str:
73
+ lowered = str(value or "auto").strip().lower()
74
+ if not lowered:
75
+ return "auto"
76
+ if lowered == "rust":
77
+ warnings.warn(
78
+ "AppSpec.execution_backend='rust' is deprecated; "
79
+ "Tigrbl runtime execution is Python-only.",
80
+ DeprecationWarning,
81
+ stacklevel=2,
82
+ )
83
+ return "python"
84
+ if lowered in {"auto", "python"}:
85
+ return lowered
86
+ raise ValueError(f"unsupported execution backend: {value!r}")
87
+
88
+
67
89
  @dataclass(eq=False)
68
90
  class AppSpec(SerdeMixin):
69
91
  """
@@ -83,6 +105,7 @@ class AppSpec(SerdeMixin):
83
105
 
84
106
  # NEW: orchestration/topology knobs
85
107
  ops: Sequence[Any] = field(default_factory=tuple) # op descriptors or specs
108
+ well_known: Sequence[WellKnownResourceSpec] = field(default_factory=tuple)
86
109
  tables: Sequence[Any] = field(default_factory=tuple) # table refs owned by app
87
110
  schemas: Sequence[Any] = field(default_factory=tuple) # schema classes/defs
88
111
  hooks: Sequence[Callable[..., Any]] = field(default_factory=tuple)
@@ -103,9 +126,11 @@ class AppSpec(SerdeMixin):
103
126
  lifespan: Optional[Callable[..., Any]] = None
104
127
 
105
128
  def __post_init__(self) -> None:
129
+ self.execution_backend = normalize_execution_backend(self.execution_backend)
106
130
  self.engines = _seqify(self.engines)
107
131
  self.routers = _seqify(self.routers)
108
132
  self.ops = _seqify(self.ops)
133
+ self.well_known = _seqify(self.well_known)
109
134
  self.tables = _seqify(self.tables)
110
135
  self.schemas = _seqify(self.schemas)
111
136
  self.hooks = _seqify(self.hooks)
@@ -113,6 +138,14 @@ class AppSpec(SerdeMixin):
113
138
  self.deps = _seqify(self.deps)
114
139
  self.middlewares = _seqify(self.middlewares)
115
140
 
141
+ for resource in self.well_known:
142
+ if isinstance(resource, str):
143
+ raise TypeError("AppSpec.well_known entries must be nested specs, not strings.")
144
+ if not isinstance(resource, WellKnownResourceSpec):
145
+ raise TypeError(
146
+ "AppSpec.well_known entries must be WellKnownResourceSpec; "
147
+ f"got {type(resource).__name__}."
148
+ )
116
149
  validate_engine_inventory(self.engines)
117
150
  validate_engine_name_binding(
118
151
  self.engine_name,
@@ -172,6 +205,8 @@ class AppSpec(SerdeMixin):
172
205
 
173
206
  @classmethod
174
207
  def from_dict(cls, payload: dict[str, Any]) -> "AppSpec":
208
+ if "routes" in payload:
209
+ raise ValueError("AppSpec does not accept 'routes'; use path-owned specs.")
175
210
  return super().from_dict(payload)
176
211
 
177
212
  @classmethod
@@ -255,6 +290,7 @@ class AppSpec(SerdeMixin):
255
290
  or ()
256
291
  ),
257
292
  ops=tuple(merge_seq_attr(app, "OPS") or ()),
293
+ well_known=tuple(merge_seq_attr(app, "WELL_KNOWN") or ()),
258
294
  tables=tuple(merge_seq_attr(app, "TABLES") or ()),
259
295
  schemas=tuple(merge_seq_attr(app, "SCHEMAS") or ()),
260
296
  hooks=tuple(merge_seq_attr(app, "HOOKS") or ()),
@@ -277,6 +313,7 @@ class AppSpec(SerdeMixin):
277
313
  engines=tuple(spec.engines or ()),
278
314
  routers=tuple(spec.routers or ()),
279
315
  ops=tuple(spec.ops or ()),
316
+ well_known=tuple(spec.well_known or ()),
280
317
  tables=tuple(spec.tables or ()),
281
318
  schemas=tuple(spec.schemas or ()),
282
319
  hooks=tuple(spec.hooks or ()),
@@ -28,6 +28,7 @@ Framing = Literal[
28
28
  "binary",
29
29
  "bytes",
30
30
  "webtransport",
31
+ "multipart/form-data",
31
32
  ]
32
33
  BindingProfile = Literal[
33
34
  "rest",
@@ -52,16 +53,16 @@ WebTransportLane = Literal[
52
53
  ]
53
54
 
54
55
  APP_LEVEL_FRAMING_SUPPORT: dict[str, tuple[str, ...]] = {
55
- "http.rest": ("json",),
56
- "https.rest": ("json",),
56
+ "http.rest": ("json", "multipart/form-data"),
57
+ "https.rest": ("json", "multipart/form-data"),
57
58
  "http.jsonrpc": ("jsonrpc",),
58
59
  "https.jsonrpc": ("jsonrpc",),
59
60
  "http.stream": ("stream", "bytes", "binary", "text", "json", "ndjson"),
60
61
  "https.stream": ("stream", "bytes", "binary", "text", "json", "ndjson"),
61
62
  "http.sse": ("sse",),
62
63
  "https.sse": ("sse",),
63
- "ws": ("text", "bytes", "binary", "json", "jsonrpc"),
64
- "wss": ("text", "bytes", "binary", "json", "jsonrpc"),
64
+ "ws": ("text", "bytes", "binary", "json", "jsonrpc", "ndjson"),
65
+ "wss": ("text", "bytes", "binary", "json", "jsonrpc", "ndjson"),
65
66
  "webtransport": ("webtransport",),
66
67
  }
67
68
 
@@ -70,8 +71,8 @@ BINDING_PROFILE_EXCHANGE_SUPPORT: dict[str, tuple[str, ...]] = {
70
71
  "https.rest": ("request_response",),
71
72
  "http.jsonrpc": ("request_response",),
72
73
  "https.jsonrpc": ("request_response",),
73
- "http.stream": ("server_stream",),
74
- "https.stream": ("server_stream",),
74
+ "http.stream": ("server_stream", "client_stream"),
75
+ "https.stream": ("server_stream", "client_stream"),
75
76
  "http.sse": ("server_stream",),
76
77
  "https.sse": ("server_stream",),
77
78
  "ws": ("bidirectional_stream",),
@@ -154,17 +155,16 @@ def validate_app_framing_for_binding(
154
155
  allowed = APP_LEVEL_FRAMING_SUPPORT.get(binding_kind)
155
156
  if allowed is None:
156
157
  raise ValueError(f"unsupported binding kind {binding_kind!r}")
157
- if selected == "ndjson" and binding_kind in {"ws", "wss"}:
158
- raise ValueError("WebSocket ndjson framing is unsupported and must fail closed")
159
158
  if selected not in allowed:
160
159
  raise ValueError(
161
160
  f"unsupported app-level framing {selected!r} for binding {binding_kind!r}"
162
161
  )
163
- if selected == "jsonrpc" and binding_kind in {"ws", "wss"}:
162
+ if selected in {"jsonrpc", "ndjson"} and binding_kind in {"ws", "wss"}:
164
163
  lowered = tuple(str(item).lower() for item in subprotocols)
165
- if "jsonrpc" not in lowered:
164
+ if selected not in lowered:
166
165
  raise ValueError(
167
- "WebSocket jsonrpc framing requires subprotocols to include 'jsonrpc'."
166
+ f"WebSocket {selected} framing requires subprotocols to include "
167
+ f"'{selected}'."
168
168
  )
169
169
  if selected == "ndjson" and "jsonrpc" in binding_kind:
170
170
  raise ValueError("ndjson is not a JSON-RPC framing substitute")
@@ -583,6 +583,23 @@ def project_binding_runtime_metadata(binding: TransportBindingSpec) -> dict[str,
583
583
  if isinstance(binding, WebTransportBindingSpec):
584
584
  metadata["lane"] = binding.lane or webtransport_lane_for_profile(binding.profile)
585
585
  metadata["inner_framing"] = inner_framing
586
+ if metadata["lane"] == "bidi_stream":
587
+ metadata["direction"] = "bidirectional"
588
+ elif metadata["lane"] == "unidi_client_stream":
589
+ metadata["stream_initiator"] = "client"
590
+ metadata["direction"] = "client_to_server"
591
+ elif metadata["lane"] == "unidi_server_stream":
592
+ metadata["stream_initiator"] = "server"
593
+ metadata["direction"] = "server_to_client"
594
+ elif family == "stream" and proto in {"http.stream", "https.stream"}:
595
+ if exchange == "client_stream":
596
+ metadata["carrier_kind"] = "http_request_body"
597
+ metadata["stream_initiator"] = "client"
598
+ metadata["direction"] = "client_to_server"
599
+ elif exchange == "server_stream":
600
+ metadata["carrier_kind"] = "http_response_body"
601
+ metadata["stream_initiator"] = "server"
602
+ metadata["direction"] = "server_to_client"
586
603
  return metadata
587
604
 
588
605
 
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+
6
+ class InvalidHookPhaseError(ValueError):
7
+ """Raised when a hook phase is not one of the supported declarative phases."""
8
+
9
+ def __init__(self, *, phase: str, allowed_phases: Iterable[str]):
10
+ self.phase = phase
11
+ self.allowed_phases = tuple(allowed_phases)
12
+ options = ", ".join(self.allowed_phases)
13
+ super().__init__(f"Invalid hook phase '{phase}'. Valid phases are: {options}.")
14
+
15
+
16
+ __all__ = ["InvalidHookPhaseError"]
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from .binding_spec import canonical_binding_kind
8
+
9
+
10
+ DOCS_SURFACES = frozenset({"openapi", "openrpc"})
11
+ RUNTIME_SURFACES = frozenset({"http", "jsonrpc", "stream", "sse", "websocket", "webtransport"})
12
+ SUPPORTED_BINDING_KINDS = frozenset(
13
+ {
14
+ "http.rest",
15
+ "http.jsonrpc",
16
+ "http.stream",
17
+ "http.sse",
18
+ "ws",
19
+ "webtransport",
20
+ }
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ExposureDecision:
26
+ docs: bool
27
+ runtime: bool
28
+ docs_source: str
29
+ runtime_source: str
30
+
31
+
32
+ class ExposurePolicyError(ValueError):
33
+ pass
34
+
35
+
36
+ def resolve_exposure_policy(
37
+ *,
38
+ docs_exposure: str = "default",
39
+ runtime_exposure: str = "none",
40
+ binding: Any | None = None,
41
+ ) -> ExposureDecision:
42
+ docs = _enabled(docs_exposure, default=True)
43
+ runtime = _enabled(runtime_exposure, default=False)
44
+ if runtime:
45
+ if binding is None:
46
+ raise ExposurePolicyError("runtime exposure requires declared transport binding")
47
+ _require_supported_binding(binding)
48
+ if docs and binding is not None:
49
+ _require_supported_binding(binding)
50
+ return ExposureDecision(
51
+ docs=docs,
52
+ runtime=runtime,
53
+ docs_source=str(docs_exposure),
54
+ runtime_source=str(runtime_exposure),
55
+ )
56
+
57
+
58
+ def exposed_surfaces(decision: ExposureDecision, binding: Any | None = None) -> tuple[str, ...]:
59
+ surfaces: list[str] = []
60
+ if decision.docs:
61
+ surfaces.extend(("openapi", "openrpc"))
62
+ if decision.runtime:
63
+ if binding is None:
64
+ raise ExposurePolicyError("runtime exposure requires declared transport binding")
65
+ surfaces.append(_runtime_surface(binding))
66
+ return tuple(surfaces)
67
+
68
+
69
+ def _enabled(value: str, *, default: bool) -> bool:
70
+ token = str(value or "default").strip().lower()
71
+ if token == "default":
72
+ return default
73
+ if token in {"enabled", "on", "true"}:
74
+ return True
75
+ if token in {"disabled", "none", "off", "false"}:
76
+ return False
77
+ raise ExposurePolicyError(f"unsupported exposure value: {value!r}")
78
+
79
+
80
+ def _require_supported_binding(binding: Any) -> str:
81
+ kind = _binding_kind(binding)
82
+ if kind not in SUPPORTED_BINDING_KINDS:
83
+ raise ExposurePolicyError(f"unsupported transport binding: {kind}")
84
+ return kind
85
+
86
+
87
+ def _runtime_surface(binding: Any) -> str:
88
+ kind = _require_supported_binding(binding)
89
+ return {
90
+ "http.rest": "http",
91
+ "http.jsonrpc": "jsonrpc",
92
+ "http.stream": "stream",
93
+ "http.sse": "sse",
94
+ "ws": "websocket",
95
+ "webtransport": "webtransport",
96
+ }[kind]
97
+
98
+
99
+ def _binding_kind(binding: Any) -> str:
100
+ if isinstance(binding, Mapping):
101
+ return str(binding.get("kind") or binding.get("proto") or "")
102
+ return canonical_binding_kind(binding)
103
+
104
+
105
+ __all__ = [
106
+ "DOCS_SURFACES",
107
+ "RUNTIME_SURFACES",
108
+ "ExposureDecision",
109
+ "ExposurePolicyError",
110
+ "exposed_surfaces",
111
+ "resolve_exposure_policy",
112
+ ]
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from enum import Enum
4
4
  from typing import Any, Awaitable, Callable, Tuple
5
5
 
6
- from tigrbl_typing.phases import normalize_phase
6
+ from tigrbl_typing.phases import canonicalize_phase_input
7
7
 
8
8
 
9
9
  class HookPhase(str, Enum):
@@ -30,7 +30,7 @@ class HookPhase(str, Enum):
30
30
 
31
31
  @classmethod
32
32
  def _missing_(cls, value: object):
33
- normalized = normalize_phase(str(value)) if value is not None else None
33
+ normalized = canonicalize_phase_input(str(value)) if value is not None else None
34
34
  if normalized != value:
35
35
  return cls(normalized)
36
36
  return None
@@ -206,6 +206,15 @@ def _as_specs(value: Any, table: type) -> List["OpSpec"]:
206
206
 
207
207
 
208
208
  def _generate_canonical(table: type) -> List["OpSpec"]:
209
+ profile = getattr(table, "TABLE_PROFILE", None)
210
+ if profile is not None:
211
+ try:
212
+ from tigrbl_core._spec.table_profile_spec import coerce_table_profile
213
+
214
+ return list(coerce_table_profile(profile).bind_table(table).ops)
215
+ except Exception:
216
+ raise
217
+
209
218
  from tigrbl_core.op.canonical import should_wire_canonical
210
219
 
211
220
  specs: List[OpSpec] = []
@@ -15,6 +15,7 @@ from .binding_spec import (
15
15
  from .op_spec import OpSpec
16
16
  from .serde import SerdeMixin
17
17
  from .table_spec import TableSpec
18
+ from .well_known_spec import WellKnownResourceSpec
18
19
 
19
20
  PathKind = Literal[
20
21
  "resource",
@@ -31,6 +32,7 @@ PathKind = Literal[
31
32
  "docs-uix",
32
33
  "static",
33
34
  "mount",
35
+ "well-known",
34
36
  ]
35
37
 
36
38
 
@@ -48,6 +50,7 @@ class PathSpec(SerdeMixin):
48
50
  docs_uix: Any | None = None
49
51
  static: Any | None = None
50
52
  mount: Any | None = None
53
+ well_known: WellKnownResourceSpec | None = None
51
54
 
52
55
  def __post_init__(self) -> None:
53
56
  if not isinstance(self.path, str) or not self.path.startswith("/"):
@@ -67,7 +70,13 @@ class PathSpec(SerdeMixin):
67
70
  if "routes" in payload:
68
71
  raise ValueError("PathSpec does not accept 'routes'; use path-owned specs.")
69
72
  if "engine" in payload or "engine_name" in payload:
70
- raise ValueError("PathSpec does not own engines; bind engines at app, router, table, or op scope.")
73
+ rejected = ", ".join(
74
+ field for field in ("engine", "engine_name") if field in payload
75
+ )
76
+ raise ValueError(
77
+ "PathSpec does not own engines; rejected "
78
+ f"{rejected}; bind engines at app, router, table, or op scope."
79
+ )
71
80
  return super().from_dict(payload)
72
81
 
73
82
  def binding_path(self, binding: TransportBindingSpec) -> str:
@@ -134,6 +143,7 @@ class PathSpec(SerdeMixin):
134
143
  "docs-uix",
135
144
  "static",
136
145
  "mount",
146
+ "well-known",
137
147
  }
138
148
  if self.kind not in valid:
139
149
  raise ValueError(f"PathSpec.kind must be a canonical path kind; got {self.kind!r}.")
@@ -144,6 +154,7 @@ class PathSpec(SerdeMixin):
144
154
  "docs-uix": ("docs_uix", self.docs_uix),
145
155
  "static": ("static", self.static),
146
156
  "mount": ("mount", self.mount),
157
+ "well-known": ("well_known", self.well_known),
147
158
  }
148
159
  if self.kind in payload_fields:
149
160
  field_name, value = payload_fields[self.kind]
@@ -155,11 +166,16 @@ class PathSpec(SerdeMixin):
155
166
  ("docs_uix", self.docs_uix),
156
167
  ("static", self.static),
157
168
  ("mount", self.mount),
169
+ ("well_known", self.well_known),
158
170
  ):
159
171
  if value is not None:
160
172
  raise ValueError(
161
173
  f"PathSpec.{field_name} is only valid on its matching canonical path kind."
162
174
  )
175
+ if self.well_known is not None and not isinstance(
176
+ self.well_known, WellKnownResourceSpec
177
+ ):
178
+ raise TypeError("PathSpec.well_known must be WellKnownResourceSpec.")
163
179
 
164
180
  def _validate_tables(self) -> None:
165
181
  for table in self.tables:
@@ -10,6 +10,7 @@ from .._spec.path_spec import PathSpec
10
10
  from .._spec.response_spec import ResponseSpec
11
11
  from .._spec.schema_spec import SchemaSpec
12
12
  from .._spec.table_spec import TableSpec
13
+ from .._spec.well_known_spec import WellKnownResourceSpec
13
14
  from .serde import SerdeMixin
14
15
 
15
16
 
@@ -29,10 +30,13 @@ class RouterSpec(SerdeMixin):
29
30
  deps: Sequence[Callable[..., Any]] = field(default_factory=tuple)
30
31
  middlewares: Sequence[Any] = field(default_factory=tuple)
31
32
  response: Optional[ResponseSpec] = None
33
+ well_known: Sequence[WellKnownResourceSpec] = field(default_factory=tuple)
32
34
  paths: Sequence[PathSpec] = field(default_factory=tuple)
33
35
  tables: Sequence[Any] = field(default_factory=tuple)
34
36
 
35
37
  def __post_init__(self) -> None:
38
+ self.well_known = tuple(self.well_known or ())
39
+ self._validate_nested_specs("well_known", self.well_known, (WellKnownResourceSpec,))
36
40
  self._validate_nested_specs("paths", self.paths, (PathSpec,))
37
41
  self._validate_nested_specs("tables", self.tables, (TableSpec,))
38
42
  self._validate_nested_specs("ops", self.ops, (OpSpec,))
@@ -44,6 +48,8 @@ class RouterSpec(SerdeMixin):
44
48
  raise ValueError(
45
49
  "RouterSpec does not accept 'models'; use 'tables' with nested TableSpec values."
46
50
  )
51
+ if "routes" in payload:
52
+ raise ValueError("RouterSpec does not accept 'routes'; use path-owned specs.")
47
53
  return super().from_dict(payload)
48
54
 
49
55
  @staticmethod
@@ -102,6 +108,7 @@ class RouterSpec(SerdeMixin):
102
108
  engine_name=engine_name,
103
109
  tags=merge_seq_attr(router, "TAGS", include_inherited=True),
104
110
  ops=merge_seq_attr(router, "OPS", include_inherited=True),
111
+ well_known=merge_seq_attr(router, "WELL_KNOWN", include_inherited=True),
105
112
  paths=merge_seq_attr(router, "PATHS", include_inherited=True),
106
113
  tables=merge_seq_attr(router, "TABLES", include_inherited=True),
107
114
  schemas=merge_seq_attr(router, "SCHEMAS", include_inherited=True),