tigrbl-core 0.1.0.dev1__tar.gz → 0.1.0.dev6__tar.gz

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.
Files changed (66) hide show
  1. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/PKG-INFO +7 -3
  2. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/README.md +2 -2
  3. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/pyproject.toml +5 -1
  4. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/__init__.py +31 -10
  5. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/alias_spec.py +1 -1
  6. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/app_spec.py +83 -4
  7. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/binding_spec.py +19 -19
  8. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/column_spec.py +183 -0
  9. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/engine_spec.py +78 -53
  10. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/field_spec.py +19 -2
  11. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/hook_spec.py +42 -0
  12. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/hook_types.py +34 -0
  13. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/io_spec.py +3 -2
  14. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/op_spec.py +452 -0
  15. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/op_utils.py +43 -0
  16. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/plugins.py +44 -0
  17. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/registry.py +38 -0
  18. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/request_spec.py +3 -1
  19. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/response_resolver.py +63 -0
  20. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/response_spec.py +4 -2
  21. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/router_spec.py +102 -0
  22. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/schema_spec.py +47 -0
  23. tigrbl_core-0.1.0.dev6/tigrbl_core/_spec/serde.py +164 -0
  24. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/session_spec.py +3 -15
  25. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/storage_spec.py +11 -4
  26. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/table_registry_spec.py +3 -1
  27. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/table_spec.py +53 -8
  28. tigrbl_core-0.1.0.dev6/tigrbl_core/config/__init__.py +6 -0
  29. tigrbl_core-0.1.0.dev6/tigrbl_core/config/constants.py +224 -0
  30. tigrbl_core-0.1.0.dev6/tigrbl_core/config/defaults.py +32 -0
  31. tigrbl_core-0.1.0.dev6/tigrbl_core/config/engine_traversal.py +102 -0
  32. tigrbl_core-0.1.0.dev6/tigrbl_core/config/resolver.py +276 -0
  33. tigrbl_core-0.1.0.dev6/tigrbl_core/core/__init__.py +7 -0
  34. tigrbl_core-0.1.0.dev6/tigrbl_core/op/__init__.py +17 -0
  35. tigrbl_core-0.1.0.dev6/tigrbl_core/op/canonical.py +39 -0
  36. tigrbl_core-0.1.0.dev6/tigrbl_core/op/collect.py +20 -0
  37. tigrbl_core-0.1.0.dev6/tigrbl_core/op/types.py +29 -0
  38. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/__init__.py +28 -0
  39. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/builder/__init__.py +17 -0
  40. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/builder/build_schema.py +307 -0
  41. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/builder/cache.py +24 -0
  42. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/builder/extras.py +85 -0
  43. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/builder/helpers.py +87 -0
  44. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/builder/list_params.py +117 -0
  45. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/builder/strip_parent_fields.py +70 -0
  46. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/get_schema.py +85 -0
  47. tigrbl_core-0.1.0.dev6/tigrbl_core/schema/utils.py +142 -0
  48. tigrbl_core-0.1.0.dev1/tigrbl_core/_spec/column_spec.py +0 -40
  49. tigrbl_core-0.1.0.dev1/tigrbl_core/_spec/hook_spec.py +0 -24
  50. tigrbl_core-0.1.0.dev1/tigrbl_core/_spec/op_spec.py +0 -110
  51. tigrbl_core-0.1.0.dev1/tigrbl_core/_spec/router_spec.py +0 -25
  52. tigrbl_core-0.1.0.dev1/tigrbl_core/_spec/schema_spec.py +0 -20
  53. tigrbl_core-0.1.0.dev1/tigrbl_core/_spec/shortcuts_spec.py +0 -8
  54. tigrbl_core-0.1.0.dev1/tigrbl_core/core/__init__.py +0 -47
  55. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/__init__.py +0 -43
  56. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/bulk.py +0 -167
  57. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/helpers/__init__.py +0 -76
  58. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/helpers/db.py +0 -91
  59. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/helpers/enum.py +0 -85
  60. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/helpers/filters.py +0 -161
  61. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/helpers/model.py +0 -136
  62. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/helpers/normalize.py +0 -98
  63. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/ops.py +0 -298
  64. tigrbl_core-0.1.0.dev1/tigrbl_core/core/crud/params.py +0 -50
  65. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/middleware_spec.py +0 -0
  66. {tigrbl_core-0.1.0.dev1 → tigrbl_core-0.1.0.dev6}/tigrbl_core/_spec/response_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tigrbl-core
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev6
4
4
  Summary: Core specifications and primitives for the Tigrbl framework.
5
5
  License-Expression: Apache-2.0
6
6
  Keywords: tigrbl,sdk,standards,framework
@@ -15,7 +15,11 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python
16
16
  Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Programming Language :: Python :: 3 :: Only
18
+ Requires-Dist: pydantic (>=2.10,<3)
19
+ Requires-Dist: pyyaml
18
20
  Requires-Dist: tigrbl-typing
21
+ Requires-Dist: tomli (>=2.0.1) ; python_version < "3.11"
22
+ Requires-Dist: tomli-w
19
23
  Description-Content-Type: text/markdown
20
24
 
21
25
  ![Tigrbl branding](https://github.com/swarmauri/swarmauri-sdk/blob/a170683ecda8ca1c4f912c966d4499649ffb8224/assets/tigrbl.brand.theme.svg)
@@ -26,9 +30,9 @@ Description-Content-Type: text/markdown
26
30
 
27
31
  ## Features
28
32
 
29
- - Modular package in the Tigrbl namespace.
33
+ - Core specification and primitive contracts for Tigrbl.
34
+ - Operational implementations now live in `tigrbl-ops-oltp`.
30
35
  - Supports Python 3.10 through 3.12.
31
- - Distributed as part of the swarmauri-sdk workspace.
32
36
 
33
37
  ## Installation
34
38
 
@@ -6,9 +6,9 @@
6
6
 
7
7
  ## Features
8
8
 
9
- - Modular package in the Tigrbl namespace.
9
+ - Core specification and primitive contracts for Tigrbl.
10
+ - Operational implementations now live in `tigrbl-ops-oltp`.
10
11
  - Supports Python 3.10 through 3.12.
11
- - Distributed as part of the swarmauri-sdk workspace.
12
12
 
13
13
  ## Installation
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tigrbl-core"
3
- version = "0.1.0.dev1"
3
+ version = "0.1.0.dev6"
4
4
  description = "Core specifications and primitives for the Tigrbl framework."
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
@@ -19,6 +19,10 @@ classifiers = [
19
19
  authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
20
20
  dependencies = [
21
21
  "tigrbl-typing",
22
+ "pydantic>=2.10,<3",
23
+ "pyyaml",
24
+ "tomli-w",
25
+ "tomli>=2.0.1; python_version < '3.11'",
22
26
  ]
23
27
  keywords = ["tigrbl", "sdk", "standards", "framework"]
24
28
 
@@ -11,53 +11,74 @@ from typing import Any
11
11
  _EXPORTS = {
12
12
  "AliasSpec": "alias_spec",
13
13
  "AppSpec": "app_spec",
14
- "Binding": "binding_spec",
15
- "BindingRegistry": "binding_spec",
16
14
  "BindingSpec": "binding_spec",
15
+ "BindingRegistrySpec": "binding_spec",
16
+ "TransportBindingSpec": "binding_spec",
17
17
  "HttpRestBindingSpec": "binding_spec",
18
18
  "HttpJsonRpcBindingSpec": "binding_spec",
19
19
  "WsBindingSpec": "binding_spec",
20
20
  "resolve_rest_nested_prefix": "binding_spec",
21
21
  "ColumnSpec": "column_spec",
22
22
  "EngineSpec": "engine_spec",
23
+ "EngineProviderSpec": "engine_spec",
23
24
  "FieldSpec": "field_spec",
25
+ "F": "field_spec",
24
26
  "HookSpec": "hook_spec",
25
27
  "IOSpec": "io_spec",
28
+ "IO": "io_spec",
26
29
  "MiddlewareSpec": "middleware_spec",
27
30
  "OpSpec": "op_spec",
31
+ "Arity": "op_spec",
32
+ "TargetOp": "op_spec",
33
+ "PersistPolicy": "op_spec",
28
34
  "PHASE": "op_spec",
29
35
  "PHASES": "op_spec",
36
+ "HookPhase": "hook_spec",
30
37
  "TemplateSpec": "response_spec",
31
38
  "ResponseSpec": "response_spec",
39
+ "resolve_response_spec": "response_resolver",
32
40
  "RequestSpec": "request_spec",
33
41
  "RouterSpec": "router_spec",
34
42
  "SchemaSpec": "schema_spec",
35
43
  "SchemaRef": "schema_spec",
44
+ "SchemaArg": "schema_spec",
36
45
  "SessionSpec": "session_spec",
37
46
  "session_spec": "session_spec",
38
47
  "tx_read_committed": "session_spec",
39
48
  "tx_repeatable_read": "session_spec",
40
49
  "tx_serializable": "session_spec",
41
50
  "readonly": "session_spec",
42
- "wrap_sessionmaker": "session_spec",
43
51
  "StorageSpec": "storage_spec",
52
+ "S": "storage_spec",
53
+ "StorageTransformSpec": "storage_spec",
44
54
  "StorageTransform": "storage_spec",
45
55
  "ForeignKeySpec": "storage_spec",
46
56
  "TableSpec": "table_spec",
47
57
  "TableRegistrySpec": "table_registry_spec",
48
- "F": "shortcuts_spec",
49
- "IO": "shortcuts_spec",
50
- "S": "shortcuts_spec",
51
- "makeColumn": "shortcuts_spec",
52
- "makeVirtualColumn": "shortcuts_spec",
53
- "acol": "shortcuts_spec",
54
- "vcol": "shortcuts_spec",
55
58
  }
56
59
 
57
60
  __all__ = list(_EXPORTS)
58
61
 
59
62
 
60
63
  def __getattr__(name: str) -> Any:
64
+ if name in {"PHASE", "PHASES"}:
65
+ from .hook_types import HookPhase, HookPhases
66
+
67
+ value = HookPhase if name == "PHASE" else HookPhases
68
+ globals()[name] = value
69
+ return value
70
+ if name in {"F", "IO", "S"}:
71
+ aliases = {
72
+ "F": ("field_spec", "FieldSpec"),
73
+ "IO": ("io_spec", "IOSpec"),
74
+ "S": ("storage_spec", "StorageSpec"),
75
+ }
76
+ module_name, attr = aliases[name]
77
+ module = import_module(f"{__name__}.{module_name}")
78
+ value = getattr(module, attr)
79
+ globals()[name] = value
80
+ return value
81
+
61
82
  module_name = _EXPORTS.get(name)
62
83
  if module_name is None:
63
84
  raise AttributeError(name)
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional
6
6
  from .._spec.op_spec import Arity, PersistPolicy
7
7
 
8
8
  if TYPE_CHECKING: # pragma: no cover
9
- from ..schema.types import SchemaArg
9
+ from .schema_spec import SchemaArg
10
10
 
11
11
 
12
12
  class AliasSpec(ABC):
@@ -1,14 +1,91 @@
1
1
  # pkgs/standards/tigrbl_core/tigrbl/_spec/app_spec.py
2
2
  from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
- from typing import Any, Callable, Optional, Sequence
4
+ from typing import Any, Callable, Iterable, Mapping, Optional, Sequence
5
5
 
6
6
  from .._spec.engine_spec import EngineCfg
7
7
  from .._spec.response_spec import ResponseSpec
8
+ from .serde import SerdeMixin
9
+
10
+
11
+ def _seqify(value: Any) -> tuple[Any, ...]:
12
+ """Normalize sequence-like inputs while treating scalars as a single item."""
13
+
14
+ if value is None:
15
+ return ()
16
+ if isinstance(value, tuple):
17
+ return value
18
+ if isinstance(value, (str, bytes, bytearray)):
19
+ return (value,)
20
+ if isinstance(value, Mapping):
21
+ return (value,)
22
+ if isinstance(value, Iterable):
23
+ return tuple(value)
24
+ return (value,)
25
+
26
+
27
+ def merge_seq_attr(
28
+ owner: type,
29
+ attr: str,
30
+ *,
31
+ include_inherited: bool = False,
32
+ reverse: bool = False,
33
+ dedupe: bool = True,
34
+ ) -> tuple[Any, ...]:
35
+ """Merge sequence-like class attributes over the MRO."""
36
+
37
+ values: list[Any] = []
38
+ seen_hashable: set[Any] = set()
39
+ mro = reversed(owner.__mro__) if reverse else owner.__mro__
40
+ for base in mro:
41
+ if include_inherited:
42
+ if not hasattr(base, attr):
43
+ continue
44
+ seq = getattr(base, attr) or ()
45
+ else:
46
+ seq = base.__dict__.get(attr, ()) or ()
47
+ for item in _seqify(seq):
48
+ if dedupe:
49
+ try:
50
+ if item in seen_hashable:
51
+ continue
52
+ seen_hashable.add(item)
53
+ except TypeError:
54
+ if any(item == existing for existing in values):
55
+ continue
56
+ values.append(item)
57
+ return tuple(values)
58
+
59
+
60
+ def normalize_app_spec(spec: "AppSpec") -> "AppSpec":
61
+ """Return a normalized app spec snapshot with stable sequence fields."""
62
+
63
+ routers = _seqify(spec.routers)
64
+ tables = _seqify(spec.tables)
65
+ ops = _seqify(spec.ops)
66
+
67
+ return AppSpec(
68
+ title=str(spec.title or "Tigrbl"),
69
+ description=spec.description,
70
+ version=str(spec.version or "0.1.0"),
71
+ engine=spec.engine,
72
+ routers=routers,
73
+ ops=ops,
74
+ tables=tables,
75
+ schemas=_seqify(spec.schemas),
76
+ hooks=_seqify(spec.hooks),
77
+ security_deps=_seqify(spec.security_deps),
78
+ deps=_seqify(spec.deps),
79
+ middlewares=_seqify(spec.middlewares),
80
+ response=spec.response,
81
+ jsonrpc_prefix=str(spec.jsonrpc_prefix or "/rpc"),
82
+ system_prefix=str(spec.system_prefix or "/system"),
83
+ lifespan=spec.lifespan,
84
+ )
8
85
 
9
86
 
10
87
  @dataclass(eq=False)
11
- class AppSpec:
88
+ class AppSpec(SerdeMixin):
12
89
  """
13
90
  Used to *produce an App subclass* via App.from_spec().
14
91
  """
@@ -43,9 +120,11 @@ class AppSpec:
43
120
  lifespan: Optional[Callable[..., Any]] = None
44
121
 
45
122
  @classmethod
46
- def collect(cls, app: type) -> "AppSpec":
47
- from ..mapping.spec_normalization import merge_seq_attr, normalize_app_spec
123
+ def from_dict(cls, payload: dict[str, Any]) -> "AppSpec":
124
+ return super().from_dict(payload)
48
125
 
126
+ @classmethod
127
+ def collect(cls, app: type) -> "AppSpec":
49
128
  sentinel = object()
50
129
  title: Any = sentinel
51
130
  version: Any = sentinel
@@ -1,60 +1,60 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
  from typing import Literal, Optional, Type, Union
5
5
 
6
6
  from ..config.constants import TIGRBL_NESTED_PATHS_ATTR
7
+ from .serde import SerdeMixin
7
8
 
8
9
 
9
10
  @dataclass(frozen=True, slots=True)
10
- class HttpRestBindingSpec:
11
+ class HttpRestBindingSpec(SerdeMixin):
11
12
  proto: Literal["http.rest", "https.rest"]
12
13
  methods: tuple[str, ...]
13
14
  path: str
14
15
 
15
16
 
16
17
  @dataclass(frozen=True, slots=True)
17
- class HttpJsonRpcBindingSpec:
18
+ class HttpJsonRpcBindingSpec(SerdeMixin):
18
19
  proto: Literal["http.jsonrpc", "https.jsonrpc"]
19
20
  rpc_method: str
20
21
 
21
22
 
22
23
  @dataclass(frozen=True, slots=True)
23
- class WsBindingSpec:
24
+ class WsBindingSpec(SerdeMixin):
24
25
  proto: Literal["ws", "wss"]
25
26
  path: str
26
27
  subprotocols: tuple[str, ...] = ()
27
28
 
28
29
 
29
- BindingSpec = Union[HttpRestBindingSpec, HttpJsonRpcBindingSpec, WsBindingSpec]
30
+ TransportBindingSpec = Union[
31
+ HttpRestBindingSpec,
32
+ HttpJsonRpcBindingSpec,
33
+ WsBindingSpec,
34
+ ]
30
35
 
31
36
 
32
37
  @dataclass(frozen=True, slots=True)
33
- class Binding:
38
+ class BindingSpec(SerdeMixin):
34
39
  """Named binding declaration used for registry composition."""
35
40
 
36
- """Named binding wrapper used by registries and planners."""
37
-
38
41
  name: str
39
- spec: BindingSpec
42
+ spec: TransportBindingSpec
40
43
 
41
44
 
42
45
  @dataclass(slots=True)
43
- class BindingRegistry:
46
+ class BindingRegistrySpec(SerdeMixin):
44
47
  """Simple in-memory registry for named transport bindings."""
45
48
 
46
- _bindings: dict[str, Binding]
47
-
48
- def __init__(self) -> None:
49
- self._bindings = {}
49
+ _bindings: dict[str, BindingSpec] = field(default_factory=dict)
50
50
 
51
- def register(self, binding: Binding) -> None:
51
+ def register(self, binding: BindingSpec) -> None:
52
52
  self._bindings[binding.name] = binding
53
53
 
54
- def get(self, name: str) -> Optional[Binding]:
54
+ def get(self, name: str) -> Optional[BindingSpec]:
55
55
  return self._bindings.get(name)
56
56
 
57
- def values(self) -> tuple[Binding, ...]:
57
+ def values(self) -> tuple[BindingSpec, ...]:
58
58
  return tuple(self._bindings.values())
59
59
 
60
60
 
@@ -68,11 +68,11 @@ def resolve_rest_nested_prefix(model: Type) -> Optional[str]:
68
68
 
69
69
 
70
70
  __all__ = [
71
- "Binding",
72
- "BindingRegistry",
73
71
  "BindingSpec",
72
+ "BindingRegistrySpec",
74
73
  "HttpJsonRpcBindingSpec",
75
74
  "HttpRestBindingSpec",
75
+ "TransportBindingSpec",
76
76
  "WsBindingSpec",
77
77
  "resolve_rest_nested_prefix",
78
78
  ]
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from functools import lru_cache
5
+ from types import SimpleNamespace
6
+ from typing import Any, Callable, Dict, Optional
7
+
8
+ from .._spec.field_spec import FieldSpec as F
9
+ from .._spec.io_spec import IOSpec as IO
10
+ from .._spec.storage_spec import StorageSpec as S
11
+ from .serde import SerdeMixin
12
+
13
+ logger = logging.getLogger("uvicorn")
14
+
15
+
16
+ class ColumnSpec(SerdeMixin):
17
+ """Aggregate configuration for a model attribute.
18
+
19
+ A :class:`ColumnSpec` brings together the three lower-level specs used by
20
+ Tigrbl's declarative column system:
21
+
22
+ - ``storage`` (:class:`~tigrbl._spec.storage_spec.StorageSpec`) controls
23
+ how the value is persisted in the database.
24
+ - ``field`` (:class:`~tigrbl._spec.field_spec.FieldSpec`) describes the
25
+ Python type and any schema metadata.
26
+ - ``io`` (:class:`~tigrbl._spec.io_spec.IOSpec`) governs inbound and
27
+ outbound API exposure.
28
+
29
+ Optional ``default_factory`` and ``read_producer`` callables allow for
30
+ programmatic defaults and virtual read-time values respectively.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ storage: S | None,
37
+ field: F | None = None,
38
+ io: IO | None = None,
39
+ default_factory: Optional[Callable[[dict], Any]] = None,
40
+ read_producer: Optional[Callable[[object, dict], Any]] = None,
41
+ ) -> None:
42
+ self.storage = storage
43
+ self.field = field if field is not None else F()
44
+ self.io = io if io is not None else IO()
45
+ self.default_factory = default_factory
46
+ self.read_producer = read_producer
47
+
48
+ # Default inbound/outbound verbs for columns lacking an explicit ColumnSpec.
49
+ #
50
+ # Without this, plain SQLAlchemy ``Column`` definitions are omitted from the
51
+ # collected spec map, causing downstream components to treat their values as
52
+ # unknown. By seeding such columns with a permissive IO spec we ensure they
53
+ # participate in canonical CRUD operations just like columns defined via
54
+ # ``acol``.
55
+ _DEFAULT_IO = IO(
56
+ in_verbs=("create", "update", "replace"),
57
+ out_verbs=("read", "list"),
58
+ mutable_verbs=("create", "update", "replace"),
59
+ )
60
+
61
+ @staticmethod
62
+ def _model_label(model: object) -> str:
63
+ return str(
64
+ getattr(model, "__name__", None)
65
+ or getattr(model, "name", None)
66
+ or type(model).__name__
67
+ )
68
+
69
+ @staticmethod
70
+ def _coerce_columns_iterable(columns: object) -> tuple[object, ...]:
71
+ """Normalize model/table column containers to an iterable tuple.
72
+
73
+ Some table classes expose ``columns`` as a ``SimpleNamespace`` of
74
+ ``ColumnSpec`` objects for convenience. Runtime collectors should treat
75
+ that namespace as a mapping and iterate over its values rather than
76
+ trying to iterate the namespace object directly.
77
+ """
78
+
79
+ if isinstance(columns, SimpleNamespace):
80
+ return tuple(columns.__dict__.values())
81
+ if isinstance(columns, dict):
82
+ return tuple(columns.values())
83
+ try:
84
+ return tuple(columns) # type: ignore[arg-type]
85
+ except TypeError:
86
+ return ()
87
+
88
+ @staticmethod
89
+ @lru_cache(maxsize=None)
90
+ def _mro_collect_columns_cached(
91
+ model: object, _cache_bust: int
92
+ ) -> Dict[str, "ColumnSpec"]:
93
+ """Collect ColumnSpecs declared on *model* and all mixins.
94
+
95
+ Iterates across the model's MRO so that mixin-defined columns are
96
+ included in the resulting mapping. Later definitions take precedence
97
+ over earlier ones in the MRO. Any table-backed columns lacking a spec
98
+ are populated with a default ColumnSpec so they participate in opviews
99
+ and schema generation.
100
+ """
101
+ logger.debug("Collecting columns for %s", ColumnSpec._model_label(model))
102
+ out: Dict[str, ColumnSpec] = {}
103
+ mro = getattr(model, "__mro__", ()) or ()
104
+ for base in reversed(mro):
105
+ mapping = getattr(base, "__tigrbl_colspecs__", None)
106
+ if isinstance(mapping, dict):
107
+ out.update(mapping)
108
+ mapping = getattr(base, "__tigrbl_cols__", None)
109
+ if isinstance(mapping, dict):
110
+ out.update(mapping)
111
+
112
+ cols = None
113
+ table = getattr(model, "__table__", None)
114
+ if table is not None:
115
+ cols = getattr(table, "columns", None)
116
+ elif hasattr(model, "columns"):
117
+ cols = getattr(model, "columns", None)
118
+
119
+ if cols is not None:
120
+ for col in ColumnSpec._coerce_columns_iterable(cols):
121
+ name = getattr(col, "key", None) or getattr(col, "name", None)
122
+ if not isinstance(name, str):
123
+ continue
124
+ out.setdefault(name, ColumnSpec(storage=S(), io=ColumnSpec._DEFAULT_IO))
125
+ else:
126
+ # Declarative models can be inspected before SQLAlchemy finishes
127
+ # materializing ``__table__``. In that transient state plain
128
+ # ``Column(...)`` declarations still exist on the class body and
129
+ # should participate in schema inference.
130
+ for base in reversed(mro):
131
+ for attr_name, value in vars(base).items():
132
+ if attr_name.startswith("_") or attr_name in out:
133
+ continue
134
+ if hasattr(value, "type") and hasattr(value, "nullable"):
135
+ out[attr_name] = ColumnSpec(
136
+ storage=S(), io=ColumnSpec._DEFAULT_IO
137
+ )
138
+
139
+ logger.debug(
140
+ "Collected %d columns for %s", len(out), ColumnSpec._model_label(model)
141
+ )
142
+ return out
143
+
144
+ @classmethod
145
+ def collect(
146
+ cls, model: object, *, _cache_bust: int | None = None
147
+ ) -> Dict[str, "ColumnSpec"]:
148
+ """Collect ColumnSpecs for *model* with topology-aware cache busting."""
149
+
150
+ if _cache_bust is None:
151
+ table = getattr(model, "__table__", None)
152
+ cols = getattr(table, "columns", None) if table is not None else None
153
+ col_count = (
154
+ len(cls._coerce_columns_iterable(cols)) if cols is not None else 0
155
+ )
156
+ _cache_bust = hash(
157
+ (
158
+ id(getattr(model, "__tigrbl_colspecs__", None)),
159
+ id(getattr(model, "__tigrbl_cols__", None)),
160
+ id(table),
161
+ col_count,
162
+ )
163
+ )
164
+ return cls._mro_collect_columns_cached(model, _cache_bust)
165
+
166
+ @classmethod
167
+ def mro_collect_columns(
168
+ cls, model: object, *, _cache_bust: int | None = None
169
+ ) -> Dict[str, "ColumnSpec"]:
170
+ """Backward-compatible alias for :meth:`collect`."""
171
+
172
+ return cls.collect(model, _cache_bust=_cache_bust)
173
+
174
+
175
+ def mro_collect_columns(
176
+ model: object, *, _cache_bust: int | None = None
177
+ ) -> Dict[str, ColumnSpec]:
178
+ return ColumnSpec.collect(model, _cache_bust=_cache_bust)
179
+
180
+
181
+ mro_collect_columns.cache_clear = ColumnSpec._mro_collect_columns_cached.cache_clear
182
+
183
+ __all__ = ["ColumnSpec", "mro_collect_columns"]