tigrbl 0.0.1.dev1__py3-none-any.whl → 0.3.0.dev3__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.
- tigrbl/README.md +94 -0
- tigrbl/__init__.py +139 -14
- tigrbl/api/__init__.py +6 -0
- tigrbl/api/_api.py +72 -0
- tigrbl/api/api_spec.py +30 -0
- tigrbl/api/mro_collect.py +43 -0
- tigrbl/api/shortcuts.py +56 -0
- tigrbl/api/tigrbl_api.py +286 -0
- tigrbl/app/__init__.py +0 -0
- tigrbl/app/_app.py +61 -0
- tigrbl/app/app_spec.py +42 -0
- tigrbl/app/mro_collect.py +67 -0
- tigrbl/app/shortcuts.py +65 -0
- tigrbl/app/tigrbl_app.py +314 -0
- tigrbl/bindings/__init__.py +73 -0
- tigrbl/bindings/api/__init__.py +12 -0
- tigrbl/bindings/api/common.py +109 -0
- tigrbl/bindings/api/include.py +256 -0
- tigrbl/bindings/api/resource_proxy.py +149 -0
- tigrbl/bindings/api/rpc.py +111 -0
- tigrbl/bindings/columns.py +49 -0
- tigrbl/bindings/handlers/__init__.py +11 -0
- tigrbl/bindings/handlers/builder.py +119 -0
- tigrbl/bindings/handlers/ctx.py +74 -0
- tigrbl/bindings/handlers/identifiers.py +228 -0
- tigrbl/bindings/handlers/namespaces.py +51 -0
- tigrbl/bindings/handlers/steps.py +276 -0
- tigrbl/bindings/hooks.py +311 -0
- tigrbl/bindings/model.py +194 -0
- tigrbl/bindings/model_helpers.py +139 -0
- tigrbl/bindings/model_registry.py +77 -0
- tigrbl/bindings/rest/__init__.py +7 -0
- tigrbl/bindings/rest/attach.py +34 -0
- tigrbl/bindings/rest/collection.py +265 -0
- tigrbl/bindings/rest/common.py +116 -0
- tigrbl/bindings/rest/fastapi.py +76 -0
- tigrbl/bindings/rest/helpers.py +119 -0
- tigrbl/bindings/rest/io.py +317 -0
- tigrbl/bindings/rest/member.py +367 -0
- tigrbl/bindings/rest/router.py +292 -0
- tigrbl/bindings/rest/routing.py +133 -0
- tigrbl/bindings/rpc.py +364 -0
- tigrbl/bindings/schemas/__init__.py +11 -0
- tigrbl/bindings/schemas/builder.py +348 -0
- tigrbl/bindings/schemas/defaults.py +260 -0
- tigrbl/bindings/schemas/utils.py +193 -0
- tigrbl/column/README.md +62 -0
- tigrbl/column/__init__.py +72 -0
- tigrbl/column/_column.py +96 -0
- tigrbl/column/column_spec.py +40 -0
- tigrbl/column/field_spec.py +31 -0
- tigrbl/column/infer/__init__.py +25 -0
- tigrbl/column/infer/core.py +92 -0
- tigrbl/column/infer/jsonhints.py +44 -0
- tigrbl/column/infer/planning.py +133 -0
- tigrbl/column/infer/types.py +102 -0
- tigrbl/column/infer/utils.py +59 -0
- tigrbl/column/io_spec.py +133 -0
- tigrbl/column/mro_collect.py +59 -0
- tigrbl/column/shortcuts.py +89 -0
- tigrbl/column/storage_spec.py +65 -0
- tigrbl/config/__init__.py +19 -0
- tigrbl/config/constants.py +224 -0
- tigrbl/config/defaults.py +29 -0
- tigrbl/config/resolver.py +295 -0
- tigrbl/core/__init__.py +47 -0
- tigrbl/core/crud/__init__.py +36 -0
- tigrbl/core/crud/bulk.py +168 -0
- tigrbl/core/crud/helpers/__init__.py +76 -0
- tigrbl/core/crud/helpers/db.py +92 -0
- tigrbl/core/crud/helpers/enum.py +86 -0
- tigrbl/core/crud/helpers/filters.py +162 -0
- tigrbl/core/crud/helpers/model.py +123 -0
- tigrbl/core/crud/helpers/normalize.py +99 -0
- tigrbl/core/crud/ops.py +235 -0
- tigrbl/ddl/__init__.py +344 -0
- tigrbl/decorators.py +17 -0
- tigrbl/deps/__init__.py +20 -0
- tigrbl/deps/fastapi.py +45 -0
- tigrbl/deps/favicon.svg +4 -0
- tigrbl/deps/jinja.py +27 -0
- tigrbl/deps/pydantic.py +10 -0
- tigrbl/deps/sqlalchemy.py +94 -0
- tigrbl/deps/starlette.py +36 -0
- tigrbl/engine/__init__.py +26 -0
- tigrbl/engine/_engine.py +130 -0
- tigrbl/engine/bind.py +33 -0
- tigrbl/engine/builders.py +236 -0
- tigrbl/engine/collect.py +111 -0
- tigrbl/engine/decorators.py +108 -0
- tigrbl/engine/engine_spec.py +261 -0
- tigrbl/engine/resolver.py +224 -0
- tigrbl/engine/shortcuts.py +216 -0
- tigrbl/hook/__init__.py +21 -0
- tigrbl/hook/_hook.py +22 -0
- tigrbl/hook/decorators.py +28 -0
- tigrbl/hook/hook_spec.py +24 -0
- tigrbl/hook/mro_collect.py +98 -0
- tigrbl/hook/shortcuts.py +44 -0
- tigrbl/hook/types.py +76 -0
- tigrbl/op/__init__.py +50 -0
- tigrbl/op/_op.py +31 -0
- tigrbl/op/canonical.py +31 -0
- tigrbl/op/collect.py +11 -0
- tigrbl/op/decorators.py +238 -0
- tigrbl/op/model_registry.py +301 -0
- tigrbl/op/mro_collect.py +99 -0
- tigrbl/op/resolver.py +216 -0
- tigrbl/op/types.py +136 -0
- tigrbl/orm/__init__.py +1 -0
- tigrbl/orm/mixins/_RowBound.py +83 -0
- tigrbl/orm/mixins/__init__.py +95 -0
- tigrbl/orm/mixins/bootstrappable.py +113 -0
- tigrbl/orm/mixins/bound.py +47 -0
- tigrbl/orm/mixins/edges.py +40 -0
- tigrbl/orm/mixins/fields.py +165 -0
- tigrbl/orm/mixins/hierarchy.py +54 -0
- tigrbl/orm/mixins/key_digest.py +44 -0
- tigrbl/orm/mixins/lifecycle.py +115 -0
- tigrbl/orm/mixins/locks.py +51 -0
- tigrbl/orm/mixins/markers.py +16 -0
- tigrbl/orm/mixins/operations.py +57 -0
- tigrbl/orm/mixins/ownable.py +337 -0
- tigrbl/orm/mixins/principals.py +98 -0
- tigrbl/orm/mixins/tenant_bound.py +301 -0
- tigrbl/orm/mixins/upsertable.py +111 -0
- tigrbl/orm/mixins/utils.py +49 -0
- tigrbl/orm/tables/__init__.py +72 -0
- tigrbl/orm/tables/_base.py +8 -0
- tigrbl/orm/tables/audit.py +56 -0
- tigrbl/orm/tables/client.py +25 -0
- tigrbl/orm/tables/group.py +29 -0
- tigrbl/orm/tables/org.py +30 -0
- tigrbl/orm/tables/rbac.py +76 -0
- tigrbl/orm/tables/status.py +106 -0
- tigrbl/orm/tables/tenant.py +22 -0
- tigrbl/orm/tables/user.py +39 -0
- tigrbl/response/README.md +34 -0
- tigrbl/response/__init__.py +33 -0
- tigrbl/response/bind.py +12 -0
- tigrbl/response/decorators.py +37 -0
- tigrbl/response/resolver.py +83 -0
- tigrbl/response/shortcuts.py +144 -0
- tigrbl/response/types.py +49 -0
- tigrbl/rest/__init__.py +27 -0
- tigrbl/runtime/README.md +129 -0
- tigrbl/runtime/__init__.py +20 -0
- tigrbl/runtime/atoms/__init__.py +102 -0
- tigrbl/runtime/atoms/emit/__init__.py +42 -0
- tigrbl/runtime/atoms/emit/paired_post.py +158 -0
- tigrbl/runtime/atoms/emit/paired_pre.py +106 -0
- tigrbl/runtime/atoms/emit/readtime_alias.py +120 -0
- tigrbl/runtime/atoms/out/__init__.py +38 -0
- tigrbl/runtime/atoms/out/masking.py +135 -0
- tigrbl/runtime/atoms/refresh/__init__.py +38 -0
- tigrbl/runtime/atoms/refresh/demand.py +130 -0
- tigrbl/runtime/atoms/resolve/__init__.py +40 -0
- tigrbl/runtime/atoms/resolve/assemble.py +167 -0
- tigrbl/runtime/atoms/resolve/paired_gen.py +147 -0
- tigrbl/runtime/atoms/response/__init__.py +17 -0
- tigrbl/runtime/atoms/response/negotiate.py +30 -0
- tigrbl/runtime/atoms/response/negotiation.py +43 -0
- tigrbl/runtime/atoms/response/render.py +36 -0
- tigrbl/runtime/atoms/response/renderer.py +116 -0
- tigrbl/runtime/atoms/response/template.py +44 -0
- tigrbl/runtime/atoms/response/templates.py +88 -0
- tigrbl/runtime/atoms/schema/__init__.py +40 -0
- tigrbl/runtime/atoms/schema/collect_in.py +21 -0
- tigrbl/runtime/atoms/schema/collect_out.py +21 -0
- tigrbl/runtime/atoms/storage/__init__.py +38 -0
- tigrbl/runtime/atoms/storage/to_stored.py +167 -0
- tigrbl/runtime/atoms/wire/__init__.py +45 -0
- tigrbl/runtime/atoms/wire/build_in.py +166 -0
- tigrbl/runtime/atoms/wire/build_out.py +87 -0
- tigrbl/runtime/atoms/wire/dump.py +206 -0
- tigrbl/runtime/atoms/wire/validate_in.py +227 -0
- tigrbl/runtime/context.py +206 -0
- tigrbl/runtime/errors/__init__.py +61 -0
- tigrbl/runtime/errors/converters.py +214 -0
- tigrbl/runtime/errors/exceptions.py +124 -0
- tigrbl/runtime/errors/mappings.py +71 -0
- tigrbl/runtime/errors/utils.py +150 -0
- tigrbl/runtime/events.py +209 -0
- tigrbl/runtime/executor/__init__.py +6 -0
- tigrbl/runtime/executor/guards.py +132 -0
- tigrbl/runtime/executor/helpers.py +88 -0
- tigrbl/runtime/executor/invoke.py +150 -0
- tigrbl/runtime/executor/types.py +84 -0
- tigrbl/runtime/kernel.py +628 -0
- tigrbl/runtime/labels.py +353 -0
- tigrbl/runtime/opview.py +87 -0
- tigrbl/runtime/ordering.py +256 -0
- tigrbl/runtime/system.py +279 -0
- tigrbl/runtime/trace.py +330 -0
- tigrbl/schema/__init__.py +38 -0
- tigrbl/schema/_schema.py +27 -0
- tigrbl/schema/builder/__init__.py +17 -0
- tigrbl/schema/builder/build_schema.py +209 -0
- tigrbl/schema/builder/cache.py +24 -0
- tigrbl/schema/builder/compat.py +16 -0
- tigrbl/schema/builder/extras.py +85 -0
- tigrbl/schema/builder/helpers.py +51 -0
- tigrbl/schema/builder/list_params.py +117 -0
- tigrbl/schema/builder/strip_parent_fields.py +70 -0
- tigrbl/schema/collect.py +55 -0
- tigrbl/schema/decorators.py +68 -0
- tigrbl/schema/get_schema.py +86 -0
- tigrbl/schema/schema_spec.py +20 -0
- tigrbl/schema/shortcuts.py +42 -0
- tigrbl/schema/types.py +34 -0
- tigrbl/schema/utils.py +143 -0
- tigrbl/shortcuts.py +22 -0
- tigrbl/specs.py +44 -0
- tigrbl/system/__init__.py +12 -0
- tigrbl/system/diagnostics/__init__.py +24 -0
- tigrbl/system/diagnostics/compat.py +31 -0
- tigrbl/system/diagnostics/healthz.py +41 -0
- tigrbl/system/diagnostics/hookz.py +51 -0
- tigrbl/system/diagnostics/kernelz.py +20 -0
- tigrbl/system/diagnostics/methodz.py +43 -0
- tigrbl/system/diagnostics/router.py +73 -0
- tigrbl/system/diagnostics/utils.py +43 -0
- tigrbl/table/__init__.py +9 -0
- tigrbl/table/_base.py +237 -0
- tigrbl/table/_table.py +54 -0
- tigrbl/table/mro_collect.py +69 -0
- tigrbl/table/shortcuts.py +57 -0
- tigrbl/table/table_spec.py +28 -0
- tigrbl/transport/__init__.py +74 -0
- tigrbl/transport/jsonrpc/__init__.py +19 -0
- tigrbl/transport/jsonrpc/dispatcher.py +352 -0
- tigrbl/transport/jsonrpc/helpers.py +115 -0
- tigrbl/transport/jsonrpc/models.py +41 -0
- tigrbl/transport/rest/__init__.py +25 -0
- tigrbl/transport/rest/aggregator.py +132 -0
- tigrbl/types/__init__.py +174 -0
- tigrbl/types/allow_anon_provider.py +19 -0
- tigrbl/types/authn_abc.py +30 -0
- tigrbl/types/nested_path_provider.py +22 -0
- tigrbl/types/op.py +35 -0
- tigrbl/types/op_config_provider.py +17 -0
- tigrbl/types/op_verb_alias_provider.py +33 -0
- tigrbl/types/request_extras_provider.py +22 -0
- tigrbl/types/response_extras_provider.py +22 -0
- tigrbl/types/table_config_provider.py +13 -0
- tigrbl-0.3.0.dev3.dist-info/LICENSE +201 -0
- tigrbl-0.3.0.dev3.dist-info/METADATA +501 -0
- tigrbl-0.3.0.dev3.dist-info/RECORD +249 -0
- tigrbl/ExampleAgent.py +0 -1
- tigrbl-0.0.1.dev1.dist-info/METADATA +0 -18
- tigrbl-0.0.1.dev1.dist-info/RECORD +0 -5
- {tigrbl-0.0.1.dev1.dist-info → tigrbl-0.3.0.dev3.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ...specs import ColumnSpec, F, S, acol
|
|
4
|
+
from ...specs.storage_spec import ForeignKeySpec
|
|
5
|
+
from ...types import PgUUID, UUID, declarative_mixin, declared_attr, uuid4, Mapped
|
|
6
|
+
|
|
7
|
+
from .utils import _infer_schema, uuid_example, CRUD_IO, RO_IO
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@declarative_mixin
|
|
11
|
+
class GUIDPk:
|
|
12
|
+
"""Universal surrogate primary key."""
|
|
13
|
+
|
|
14
|
+
id: Mapped[UUID] = acol(
|
|
15
|
+
spec=ColumnSpec(
|
|
16
|
+
storage=S(
|
|
17
|
+
type_=PgUUID(as_uuid=True),
|
|
18
|
+
primary_key=True,
|
|
19
|
+
default=uuid4,
|
|
20
|
+
),
|
|
21
|
+
field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
|
|
22
|
+
io=RO_IO,
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@declarative_mixin
|
|
28
|
+
class TenantColumn:
|
|
29
|
+
"""Adds ``tenant_id`` with a schema-qualified FK to ``<schema>.tenants.id``."""
|
|
30
|
+
|
|
31
|
+
@declared_attr
|
|
32
|
+
def tenant_id(cls) -> Mapped[UUID]:
|
|
33
|
+
schema = getattr(cls, "__tenant_table_schema__", None) or _infer_schema(cls)
|
|
34
|
+
spec = ColumnSpec(
|
|
35
|
+
storage=S(
|
|
36
|
+
type_=PgUUID(as_uuid=True),
|
|
37
|
+
fk=ForeignKeySpec(target=f"{schema}.tenants.id"),
|
|
38
|
+
nullable=False,
|
|
39
|
+
index=True,
|
|
40
|
+
),
|
|
41
|
+
field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
|
|
42
|
+
io=CRUD_IO,
|
|
43
|
+
)
|
|
44
|
+
return acol(spec=spec)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@declarative_mixin
|
|
48
|
+
class UserColumn:
|
|
49
|
+
"""Adds ``user_id`` with a schema-qualified FK to ``<schema>.users.id``."""
|
|
50
|
+
|
|
51
|
+
@declared_attr
|
|
52
|
+
def user_id(cls) -> Mapped[UUID]:
|
|
53
|
+
schema = getattr(cls, "__user_table_schema__", None) or _infer_schema(cls)
|
|
54
|
+
spec = ColumnSpec(
|
|
55
|
+
storage=S(
|
|
56
|
+
type_=PgUUID(as_uuid=True),
|
|
57
|
+
fk=ForeignKeySpec(target=f"{schema}.users.id"),
|
|
58
|
+
nullable=False,
|
|
59
|
+
index=True,
|
|
60
|
+
),
|
|
61
|
+
field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
|
|
62
|
+
io=CRUD_IO,
|
|
63
|
+
)
|
|
64
|
+
return acol(spec=spec)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@declarative_mixin
|
|
68
|
+
class OrgColumn:
|
|
69
|
+
"""Adds ``org_id`` with a schema-qualified FK to ``<schema>.orgs.id``."""
|
|
70
|
+
|
|
71
|
+
@declared_attr
|
|
72
|
+
def org_id(cls) -> Mapped[UUID]:
|
|
73
|
+
schema = getattr(cls, "__org_table_schema__", None) or _infer_schema(cls)
|
|
74
|
+
spec = ColumnSpec(
|
|
75
|
+
storage=S(
|
|
76
|
+
type_=PgUUID(as_uuid=True),
|
|
77
|
+
fk=ForeignKeySpec(target=f"{schema}.orgs.id"),
|
|
78
|
+
nullable=False,
|
|
79
|
+
index=True,
|
|
80
|
+
),
|
|
81
|
+
field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
|
|
82
|
+
io=CRUD_IO,
|
|
83
|
+
)
|
|
84
|
+
return acol(spec=spec)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@declarative_mixin
|
|
88
|
+
class Principal:
|
|
89
|
+
__abstract__ = True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"GUIDPk",
|
|
94
|
+
"TenantColumn",
|
|
95
|
+
"UserColumn",
|
|
96
|
+
"OrgColumn",
|
|
97
|
+
"Principal",
|
|
98
|
+
]
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Tenant scoping mixin for Tigrbl v3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from ._RowBound import _RowBound
|
|
11
|
+
from ...specs import acol
|
|
12
|
+
from ...config.constants import (
|
|
13
|
+
TIGRBL_TENANT_POLICY_ATTR,
|
|
14
|
+
CTX_AUTH_KEY,
|
|
15
|
+
CTX_TENANT_ID_KEY,
|
|
16
|
+
)
|
|
17
|
+
from ...runtime.errors import create_standardized_error
|
|
18
|
+
from ...specs import ColumnSpec, F, IO, S
|
|
19
|
+
from ...specs.storage_spec import ForeignKeySpec
|
|
20
|
+
from ...types import Mapped, PgUUID, declared_attr
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TenantPolicy(str, Enum):
|
|
27
|
+
CLIENT_SET = "client" # client may supply tenant_id on create/update
|
|
28
|
+
DEFAULT_TO_CTX = "default" # server fills tenant_id on create; immutable
|
|
29
|
+
STRICT_SERVER = "strict" # server forces tenant_id and forbids changes
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _infer_schema(cls, default: str = "public") -> str:
|
|
33
|
+
args = getattr(cls, "__table_args__", None)
|
|
34
|
+
if not args:
|
|
35
|
+
return default
|
|
36
|
+
if isinstance(args, dict):
|
|
37
|
+
return args.get("schema", default)
|
|
38
|
+
if isinstance(args, (tuple, list)):
|
|
39
|
+
for elem in args:
|
|
40
|
+
if isinstance(elem, dict) and "schema" in elem:
|
|
41
|
+
return elem["schema"]
|
|
42
|
+
return default
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_missing(value) -> bool:
|
|
46
|
+
"""Treat None or empty strings as 'not provided'."""
|
|
47
|
+
return value is None or (isinstance(value, str) and not value.strip())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize_uuid(val):
|
|
51
|
+
if isinstance(val, UUID):
|
|
52
|
+
return val
|
|
53
|
+
if isinstance(val, str):
|
|
54
|
+
try:
|
|
55
|
+
return UUID(val)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return val # let model validation surface the error
|
|
58
|
+
return val
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TenantBound(_RowBound):
|
|
62
|
+
"""
|
|
63
|
+
Plug-and-play tenant isolation.
|
|
64
|
+
|
|
65
|
+
• tenant_id column is defined per subclass (declared_attr) so the schema
|
|
66
|
+
builder sees the right flags before caching.
|
|
67
|
+
• _RowBound’s read/list filters work because we implement `is_visible`.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
__tigrbl_tenant_policy__: TenantPolicy = TenantPolicy.CLIENT_SET
|
|
71
|
+
|
|
72
|
+
# ────────────────────────────────────────────────────────────────────
|
|
73
|
+
# tenant_id column (Schema-Aware; PgUUID(as_uuid=True))
|
|
74
|
+
# -------------------------------------------------------------------
|
|
75
|
+
@declared_attr
|
|
76
|
+
def tenant_id(cls) -> Mapped[UUID]:
|
|
77
|
+
pol = getattr(cls, TIGRBL_TENANT_POLICY_ATTR, TenantPolicy.CLIENT_SET)
|
|
78
|
+
schema = _infer_schema(cls, default="public")
|
|
79
|
+
|
|
80
|
+
in_verbs = (
|
|
81
|
+
("create", "update", "replace")
|
|
82
|
+
if pol == TenantPolicy.CLIENT_SET
|
|
83
|
+
else ("create",)
|
|
84
|
+
)
|
|
85
|
+
io = IO(
|
|
86
|
+
in_verbs=in_verbs,
|
|
87
|
+
out_verbs=("read", "list"),
|
|
88
|
+
mutable_verbs=in_verbs,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
spec = ColumnSpec(
|
|
92
|
+
storage=S(
|
|
93
|
+
type_=PgUUID(as_uuid=True),
|
|
94
|
+
fk=ForeignKeySpec(target=f"{schema}.tenants.id"),
|
|
95
|
+
nullable=False,
|
|
96
|
+
index=True,
|
|
97
|
+
),
|
|
98
|
+
field=F(py_type=UUID),
|
|
99
|
+
io=io,
|
|
100
|
+
)
|
|
101
|
+
return acol(spec=spec)
|
|
102
|
+
|
|
103
|
+
@declared_attr
|
|
104
|
+
def __tablename__(cls):
|
|
105
|
+
return cls.__name__.lower()
|
|
106
|
+
|
|
107
|
+
# -------------------------------------------------------------------
|
|
108
|
+
# Row-level visibility for _RowBound
|
|
109
|
+
# -------------------------------------------------------------------
|
|
110
|
+
@staticmethod
|
|
111
|
+
def is_visible(obj, ctx) -> bool:
|
|
112
|
+
return getattr(obj, "tenant_id", None) == _ctx_tenant_id(ctx)
|
|
113
|
+
|
|
114
|
+
# -------------------------------------------------------------------
|
|
115
|
+
# Runtime hooks
|
|
116
|
+
# -------------------------------------------------------------------
|
|
117
|
+
def __init_subclass__(cls, **kw):
|
|
118
|
+
super().__init_subclass__(**kw)
|
|
119
|
+
cls._install_tenant_bound_hooks()
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def _install_tenant_bound_hooks(cls) -> None:
|
|
123
|
+
pol = getattr(cls, "__tigrbl_tenant_policy__", TenantPolicy.CLIENT_SET)
|
|
124
|
+
|
|
125
|
+
def _err(code: int, msg: str) -> None:
|
|
126
|
+
http_exc, _, _ = create_standardized_error(code, message=msg)
|
|
127
|
+
raise http_exc
|
|
128
|
+
|
|
129
|
+
def _before_create(ctx: dict[str, Any]) -> None:
|
|
130
|
+
env = (
|
|
131
|
+
ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
|
|
132
|
+
) or {}
|
|
133
|
+
params = (
|
|
134
|
+
(
|
|
135
|
+
env.get("params")
|
|
136
|
+
if isinstance(env, dict)
|
|
137
|
+
else getattr(env, "params", None)
|
|
138
|
+
)
|
|
139
|
+
or (
|
|
140
|
+
ctx.get("payload")
|
|
141
|
+
if isinstance(ctx, dict)
|
|
142
|
+
else getattr(ctx, "payload", None)
|
|
143
|
+
)
|
|
144
|
+
or {}
|
|
145
|
+
)
|
|
146
|
+
if hasattr(params, "model_dump"):
|
|
147
|
+
params = params.model_dump()
|
|
148
|
+
|
|
149
|
+
tenant_id = _ctx_tenant_id(ctx)
|
|
150
|
+
provided = params.get("tenant_id")
|
|
151
|
+
missing = _is_missing(provided)
|
|
152
|
+
|
|
153
|
+
if pol == TenantPolicy.STRICT_SERVER:
|
|
154
|
+
if tenant_id is None:
|
|
155
|
+
_err(400, "tenant_id is required.")
|
|
156
|
+
if not missing:
|
|
157
|
+
if _normalize_uuid(provided) != _normalize_uuid(tenant_id):
|
|
158
|
+
_err(400, "tenant_id mismatch.")
|
|
159
|
+
_err(400, "tenant_id is server-assigned.")
|
|
160
|
+
params["tenant_id"] = tenant_id
|
|
161
|
+
elif pol == TenantPolicy.DEFAULT_TO_CTX:
|
|
162
|
+
if missing and tenant_id is not None:
|
|
163
|
+
params["tenant_id"] = tenant_id
|
|
164
|
+
elif not missing:
|
|
165
|
+
params["tenant_id"] = _normalize_uuid(provided)
|
|
166
|
+
else: # CLIENT_SET
|
|
167
|
+
if not missing:
|
|
168
|
+
params["tenant_id"] = _normalize_uuid(provided)
|
|
169
|
+
|
|
170
|
+
env_attr = (
|
|
171
|
+
ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
|
|
172
|
+
)
|
|
173
|
+
if env_attr is not None:
|
|
174
|
+
if isinstance(env_attr, dict):
|
|
175
|
+
env_attr["params"] = params
|
|
176
|
+
else:
|
|
177
|
+
env_attr.params = params
|
|
178
|
+
if isinstance(ctx, dict):
|
|
179
|
+
ctx["payload"] = params
|
|
180
|
+
else:
|
|
181
|
+
setattr(ctx, "payload", params)
|
|
182
|
+
|
|
183
|
+
def _before_update(ctx: dict[str, Any]) -> None:
|
|
184
|
+
env = (
|
|
185
|
+
ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
|
|
186
|
+
) or {}
|
|
187
|
+
params = (
|
|
188
|
+
(
|
|
189
|
+
env.get("params")
|
|
190
|
+
if isinstance(env, dict)
|
|
191
|
+
else getattr(env, "params", None)
|
|
192
|
+
)
|
|
193
|
+
or (
|
|
194
|
+
ctx.get("payload")
|
|
195
|
+
if isinstance(ctx, dict)
|
|
196
|
+
else getattr(ctx, "payload", None)
|
|
197
|
+
)
|
|
198
|
+
or {}
|
|
199
|
+
)
|
|
200
|
+
if hasattr(params, "model_dump"):
|
|
201
|
+
params = params.model_dump()
|
|
202
|
+
|
|
203
|
+
if "tenant_id" not in params:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if _is_missing(params.get("tenant_id")):
|
|
207
|
+
params.pop("tenant_id", None)
|
|
208
|
+
env_attr = (
|
|
209
|
+
ctx.get("env")
|
|
210
|
+
if isinstance(ctx, dict)
|
|
211
|
+
else getattr(ctx, "env", None)
|
|
212
|
+
)
|
|
213
|
+
if env_attr is not None:
|
|
214
|
+
if isinstance(env_attr, dict):
|
|
215
|
+
env_attr["params"] = params
|
|
216
|
+
else:
|
|
217
|
+
env_attr.params = params
|
|
218
|
+
if isinstance(ctx, dict):
|
|
219
|
+
ctx["payload"] = params
|
|
220
|
+
else:
|
|
221
|
+
setattr(ctx, "payload", params)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if pol != TenantPolicy.CLIENT_SET:
|
|
225
|
+
_err(400, "tenant_id is immutable.")
|
|
226
|
+
|
|
227
|
+
new_val = _normalize_uuid(params["tenant_id"])
|
|
228
|
+
tenant_id = _ctx_tenant_id(ctx)
|
|
229
|
+
is_admin = bool(ctx.get("is_admin"))
|
|
230
|
+
|
|
231
|
+
if not is_admin and tenant_id is not None and new_val != tenant_id:
|
|
232
|
+
_err(403, "Cannot switch tenant context.")
|
|
233
|
+
|
|
234
|
+
params["tenant_id"] = new_val
|
|
235
|
+
env_attr = (
|
|
236
|
+
ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
|
|
237
|
+
)
|
|
238
|
+
if env_attr is not None:
|
|
239
|
+
if isinstance(env_attr, dict):
|
|
240
|
+
env_attr["params"] = params
|
|
241
|
+
else:
|
|
242
|
+
env_attr.params = params
|
|
243
|
+
if isinstance(ctx, dict):
|
|
244
|
+
ctx["payload"] = params
|
|
245
|
+
else:
|
|
246
|
+
setattr(ctx, "payload", params)
|
|
247
|
+
|
|
248
|
+
hooks = {**getattr(cls, "__tigrbl_hooks__", {})}
|
|
249
|
+
|
|
250
|
+
def _append(alias: str, phase: str, fn) -> None:
|
|
251
|
+
phase_map = hooks.get(alias) or {}
|
|
252
|
+
lst = list(phase_map.get(phase) or [])
|
|
253
|
+
if fn not in lst:
|
|
254
|
+
lst.append(fn)
|
|
255
|
+
phase_map[phase] = tuple(lst)
|
|
256
|
+
hooks[alias] = phase_map
|
|
257
|
+
|
|
258
|
+
_append("create", "PRE_TX_BEGIN", _before_create)
|
|
259
|
+
_append("update", "PRE_TX_BEGIN", _before_update)
|
|
260
|
+
|
|
261
|
+
setattr(cls, "__tigrbl_hooks__", hooks)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _ctx_tenant_id(ctx: Mapping[str, Any]) -> Any | None:
|
|
265
|
+
"""Best-effort extraction of tenant_id from ctx."""
|
|
266
|
+
t = (
|
|
267
|
+
ctx.get(CTX_TENANT_ID_KEY)
|
|
268
|
+
if isinstance(ctx, dict)
|
|
269
|
+
else getattr(ctx, CTX_TENANT_ID_KEY, None)
|
|
270
|
+
)
|
|
271
|
+
if t:
|
|
272
|
+
return _normalize_uuid(t)
|
|
273
|
+
|
|
274
|
+
auth = (
|
|
275
|
+
ctx.get(CTX_AUTH_KEY)
|
|
276
|
+
if isinstance(ctx, dict)
|
|
277
|
+
else getattr(ctx, CTX_AUTH_KEY, None)
|
|
278
|
+
) or {}
|
|
279
|
+
t = auth.get(CTX_TENANT_ID_KEY)
|
|
280
|
+
if t:
|
|
281
|
+
return _normalize_uuid(t)
|
|
282
|
+
|
|
283
|
+
inj = (
|
|
284
|
+
ctx.get("injected_fields")
|
|
285
|
+
if isinstance(ctx, dict)
|
|
286
|
+
else getattr(ctx, "injected_fields", None)
|
|
287
|
+
) or {}
|
|
288
|
+
t = inj.get(CTX_TENANT_ID_KEY)
|
|
289
|
+
if t:
|
|
290
|
+
return _normalize_uuid(t)
|
|
291
|
+
|
|
292
|
+
ac = (
|
|
293
|
+
ctx.get("auth_context")
|
|
294
|
+
if isinstance(ctx, dict)
|
|
295
|
+
else getattr(ctx, "auth_context", None)
|
|
296
|
+
) or {}
|
|
297
|
+
t = ac.get(CTX_TENANT_ID_KEY)
|
|
298
|
+
if t:
|
|
299
|
+
return _normalize_uuid(t)
|
|
300
|
+
|
|
301
|
+
return None
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# tigrbl/v3/mixins/upsertable.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Mapping, Sequence, Optional, Tuple
|
|
5
|
+
from sqlalchemy import and_, inspect as sa_inspect
|
|
6
|
+
from tigrbl.types import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Upsertable:
|
|
10
|
+
"""
|
|
11
|
+
Hybrid upsert:
|
|
12
|
+
• If __upsert_keys__ is set and fully present -> decide by those keys
|
|
13
|
+
• Else if all PK parts are present -> decide by PK
|
|
14
|
+
• Else -> no rewrite
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__upsert_keys__: Sequence[str] | None = None # optional natural key list
|
|
18
|
+
|
|
19
|
+
def __init_subclass__(cls, **kw):
|
|
20
|
+
super().__init_subclass__(**kw)
|
|
21
|
+
cls._install_upsertable_hooks()
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def _install_upsertable_hooks(cls) -> None:
|
|
25
|
+
hooks = {**getattr(cls, "__tigrbl_hooks__", {})}
|
|
26
|
+
|
|
27
|
+
def _append(alias: str, phase: str, fn) -> None:
|
|
28
|
+
phase_map = hooks.get(alias) or {}
|
|
29
|
+
lst = list(phase_map.get(phase) or [])
|
|
30
|
+
if fn not in lst:
|
|
31
|
+
lst.append(fn)
|
|
32
|
+
phase_map[phase] = tuple(lst)
|
|
33
|
+
hooks[alias] = phase_map
|
|
34
|
+
|
|
35
|
+
for op in ("create", "update", "replace"):
|
|
36
|
+
_append(op, "PRE_TX_BEGIN", cls._make_upsert_rewrite_hook(op))
|
|
37
|
+
|
|
38
|
+
setattr(cls, "__tigrbl_hooks__", hooks)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def _make_upsert_rewrite_hook(cls, verb: str):
|
|
42
|
+
tab = "".join(w.title() for w in cls.__tablename__.split("_"))
|
|
43
|
+
|
|
44
|
+
async def _rewrite(ctx: Mapping[str, Any]) -> None:
|
|
45
|
+
params = ctx["env"].params if ctx.get("env") else {}
|
|
46
|
+
if hasattr(params, "model_dump"):
|
|
47
|
+
params = params.model_dump()
|
|
48
|
+
if not isinstance(params, Mapping):
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
db: Session = ctx["db"]
|
|
52
|
+
mapper = sa_inspect(cls)
|
|
53
|
+
|
|
54
|
+
# 1) Try natural keys if declared
|
|
55
|
+
key_names: Sequence[str] | None = cls.__upsert_keys__
|
|
56
|
+
if key_names:
|
|
57
|
+
kv = _extract_values(params, key_names)
|
|
58
|
+
if kv is not None:
|
|
59
|
+
exists = _exists_by_names(cls, db, key_names, kv)
|
|
60
|
+
_rewrite_by_existence(ctx, tab, verb, exists)
|
|
61
|
+
return # done
|
|
62
|
+
|
|
63
|
+
# 2) Fall back to PKs if fully present
|
|
64
|
+
pk_cols = tuple(mapper.primary_key)
|
|
65
|
+
pk_names = tuple(c.key for c in pk_cols)
|
|
66
|
+
kv = _extract_values(params, pk_names)
|
|
67
|
+
if kv is None:
|
|
68
|
+
return # not enough info → no rewrite
|
|
69
|
+
|
|
70
|
+
exists = _exists_by_pk(cls, db, pk_cols, kv)
|
|
71
|
+
_rewrite_by_existence(ctx, tab, verb, exists)
|
|
72
|
+
|
|
73
|
+
return _rewrite
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _extract_values(
|
|
77
|
+
p: Mapping[str, Any], names: Sequence[str]
|
|
78
|
+
) -> Optional[Tuple[Any, ...]]:
|
|
79
|
+
vals = []
|
|
80
|
+
for n in names:
|
|
81
|
+
v = p.get(n)
|
|
82
|
+
if v is None:
|
|
83
|
+
return None
|
|
84
|
+
vals.append(v)
|
|
85
|
+
return tuple(vals)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _exists_by_names(
|
|
89
|
+
model, db: Session, names: Sequence[str], vals: Tuple[Any, ...]
|
|
90
|
+
) -> bool:
|
|
91
|
+
q = db.query(model)
|
|
92
|
+
for n, v in zip(names, vals):
|
|
93
|
+
q = q.filter(getattr(model, n) == v)
|
|
94
|
+
return db.query(q.exists()).scalar() is True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _exists_by_pk(model, db: Session, pk_cols, pk_vals: Tuple[Any, ...]) -> bool:
|
|
98
|
+
if len(pk_cols) == 1:
|
|
99
|
+
# fast path
|
|
100
|
+
return db.get(model, pk_vals[0]) is not None
|
|
101
|
+
conds = [getattr(model, c.key) == v for c, v in zip(pk_cols, pk_vals)]
|
|
102
|
+
return db.query(db.query(model).filter(and_(*conds)).exists()).scalar() is True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _rewrite_by_existence(ctx, tab: str, verb: str, exists: bool) -> None:
|
|
106
|
+
if verb == "create" and exists:
|
|
107
|
+
ctx["env"].method = f"{tab}.update"
|
|
108
|
+
elif verb == "update" and not exists:
|
|
109
|
+
ctx["env"].method = f"{tab}.create"
|
|
110
|
+
elif verb == "replace":
|
|
111
|
+
ctx["env"].method = f"{tab}.update" if exists else f"{tab}.create"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
|
|
5
|
+
from ...specs import IO
|
|
6
|
+
from ...types import UUID
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def tzutcnow() -> dt.datetime:
|
|
10
|
+
"""Return an aware UTC ``datetime``."""
|
|
11
|
+
return dt.datetime.now(dt.timezone.utc)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tzutcnow_plus_day() -> dt.datetime:
|
|
15
|
+
"""Return an aware UTC ``datetime`` one day in the future."""
|
|
16
|
+
return tzutcnow() + dt.timedelta(days=1)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _infer_schema(cls, default: str = "public") -> str:
|
|
20
|
+
"""Extract schema from ``__table_args__`` in dict or tuple/list form."""
|
|
21
|
+
args = getattr(cls, "__table_args__", None)
|
|
22
|
+
if not args:
|
|
23
|
+
return default
|
|
24
|
+
if isinstance(args, dict):
|
|
25
|
+
return args.get("schema", default)
|
|
26
|
+
if isinstance(args, (tuple, list)):
|
|
27
|
+
for elem in args:
|
|
28
|
+
if isinstance(elem, dict) and "schema" in elem:
|
|
29
|
+
return elem["schema"]
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
uuid_example = UUID("00000000-dead-beef-cafe-000000000000")
|
|
34
|
+
|
|
35
|
+
CRUD_IN = ("create", "update", "replace")
|
|
36
|
+
CRUD_OUT = ("read", "list")
|
|
37
|
+
CRUD_IO = IO(in_verbs=CRUD_IN, out_verbs=CRUD_OUT, mutable_verbs=CRUD_IN)
|
|
38
|
+
RO_IO = IO(out_verbs=CRUD_OUT)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"tzutcnow",
|
|
42
|
+
"tzutcnow_plus_day",
|
|
43
|
+
"_infer_schema",
|
|
44
|
+
"uuid_example",
|
|
45
|
+
"CRUD_IN",
|
|
46
|
+
"CRUD_OUT",
|
|
47
|
+
"CRUD_IO",
|
|
48
|
+
"RO_IO",
|
|
49
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Public façade for all table classes.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
from tigrbl.orm.tables import (
|
|
6
|
+
Tenant,
|
|
7
|
+
User,
|
|
8
|
+
Group,
|
|
9
|
+
Role,
|
|
10
|
+
)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
from ._base import Base
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Tenant",
|
|
19
|
+
"Client",
|
|
20
|
+
"User",
|
|
21
|
+
"Group",
|
|
22
|
+
"Org",
|
|
23
|
+
"Role",
|
|
24
|
+
"RolePerm",
|
|
25
|
+
"RoleGrant",
|
|
26
|
+
"Status",
|
|
27
|
+
"StatusEnum",
|
|
28
|
+
"Change",
|
|
29
|
+
"Base",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------ #
|
|
33
|
+
# Lazy attribute loader (PEP 562). Keeps import graphs light-weight.
|
|
34
|
+
# ------------------------------------------------------------------ #
|
|
35
|
+
_module_map = {
|
|
36
|
+
"Tenant": f"{__name__}.tenant",
|
|
37
|
+
"Client": f"{__name__}.client",
|
|
38
|
+
"User": f"{__name__}.user",
|
|
39
|
+
"Group": f"{__name__}.group",
|
|
40
|
+
"Org": f"{__name__}.org",
|
|
41
|
+
"Role": f"{__name__}.rbac",
|
|
42
|
+
"RolePerm": f"{__name__}.rbac",
|
|
43
|
+
"RoleGrant": f"{__name__}.rbac",
|
|
44
|
+
"Status": f"{__name__}.status",
|
|
45
|
+
"StatusEnum": f"{__name__}.status",
|
|
46
|
+
"Change": f"{__name__}.audit",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def __getattr__(name: str) -> Any: # noqa: D401
|
|
51
|
+
"""Dynamically import `tenant`, `user`, or `group` on first use."""
|
|
52
|
+
if name not in _module_map:
|
|
53
|
+
raise AttributeError(name)
|
|
54
|
+
module = importlib.import_module(_module_map[name])
|
|
55
|
+
obj = getattr(module, name)
|
|
56
|
+
globals()[name] = obj # cache for future look-ups
|
|
57
|
+
return obj
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------ #
|
|
61
|
+
# Static typing support – imported eagerly only during type checking.
|
|
62
|
+
# ------------------------------------------------------------------ #
|
|
63
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
64
|
+
from ._base import Base
|
|
65
|
+
from .tenant import Tenant
|
|
66
|
+
from .client import Client
|
|
67
|
+
from .user import User
|
|
68
|
+
from .group import Group
|
|
69
|
+
from .org import Org
|
|
70
|
+
from .rbac import Role, RoleGrant, RolePerm
|
|
71
|
+
from .status import Status, StatusEnum
|
|
72
|
+
from .audit import Change
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# tigrbl/tables/audit.py
|
|
2
|
+
import datetime as dt
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from . import Base
|
|
6
|
+
from ..mixins import GUIDPk, Timestamped
|
|
7
|
+
from ...specs import IO, F, acol, S
|
|
8
|
+
from ...types import DateTime, Integer, String, PgUUID, Mapped
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Change(Base, GUIDPk, Timestamped):
|
|
12
|
+
__tablename__ = "changes"
|
|
13
|
+
|
|
14
|
+
seq: Mapped[int] = acol(
|
|
15
|
+
storage=S(Integer, primary_key=True),
|
|
16
|
+
field=F(),
|
|
17
|
+
io=IO(out_verbs=("read", "list")),
|
|
18
|
+
)
|
|
19
|
+
at: Mapped[dt.datetime] = acol(
|
|
20
|
+
storage=S(DateTime, default=dt.datetime.utcnow),
|
|
21
|
+
field=F(),
|
|
22
|
+
io=IO(out_verbs=("read", "list")),
|
|
23
|
+
)
|
|
24
|
+
actor_id: Mapped[UUID | None] = acol(
|
|
25
|
+
storage=S(PgUUID, nullable=True),
|
|
26
|
+
field=F(),
|
|
27
|
+
io=IO(out_verbs=("read", "list")),
|
|
28
|
+
)
|
|
29
|
+
table_name: Mapped[str] = acol(
|
|
30
|
+
storage=S(String),
|
|
31
|
+
field=F(),
|
|
32
|
+
io=IO(out_verbs=("read", "list")),
|
|
33
|
+
)
|
|
34
|
+
row_id: Mapped[UUID | None] = acol(
|
|
35
|
+
storage=S(PgUUID, nullable=True),
|
|
36
|
+
field=F(),
|
|
37
|
+
io=IO(out_verbs=("read", "list")),
|
|
38
|
+
)
|
|
39
|
+
action: Mapped[str] = acol(
|
|
40
|
+
storage=S(String),
|
|
41
|
+
field=F(),
|
|
42
|
+
io=IO(out_verbs=("read", "list")),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["Change"]
|
|
47
|
+
|
|
48
|
+
for _name in list(globals()):
|
|
49
|
+
if _name not in __all__ and not _name.startswith("__"):
|
|
50
|
+
del globals()[_name]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __dir__():
|
|
54
|
+
"""Tighten ``dir()`` output for interactive sessions."""
|
|
55
|
+
|
|
56
|
+
return sorted(__all__)
|