tigrbl-orm 0.1.0.dev1__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.
@@ -0,0 +1 @@
1
+ """ORM utilities containing SQLAlchemy tables and mixins."""
@@ -0,0 +1,83 @@
1
+ # tigrbl/v3/mixins/_RowBound.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Mapping, Sequence
5
+
6
+ from ...runtime.status import HTTP_ERROR_MESSAGES, create_standardized_error
7
+
8
+
9
+ class _RowBound:
10
+ """
11
+ Base mix-in for row-level visibility.
12
+
13
+ Concrete subclasses **must** override:
14
+
15
+ @staticmethod
16
+ def is_visible(obj, ctx) -> bool
17
+
18
+ Hooks are wired only if the subclass actually provides an implementation.
19
+ """
20
+
21
+ # ────────────────────────────────────────────────────────────────────
22
+ # Tigrbl bootstrap
23
+ # -------------------------------------------------------------------
24
+ def __init_subclass__(cls, **kw):
25
+ super().__init_subclass__(**kw)
26
+ cls._install_rowbound_hooks()
27
+
28
+ @classmethod
29
+ def _install_rowbound_hooks(cls) -> None:
30
+ # Skip abstract helpers or unmapped mix-ins
31
+ if cls.is_visible is _RowBound.is_visible:
32
+ return
33
+ if not hasattr(cls, "__table__"):
34
+ return
35
+
36
+ hook = cls._make_row_visibility_hook()
37
+ hooks_attr = getattr(cls, "__tigrbl_hooks__", {})
38
+ hooks = {**hooks_attr} if isinstance(hooks_attr, dict) else {}
39
+
40
+ def _append(alias: str, phase: str, fn) -> None:
41
+ phase_map = hooks.get(alias) or {}
42
+ lst = list(phase_map.get(phase) or [])
43
+ if fn not in lst:
44
+ lst.append(fn)
45
+ phase_map[phase] = tuple(lst)
46
+ hooks[alias] = phase_map
47
+
48
+ for op in ("read", "list"):
49
+ _append(op, "POST_HANDLER", hook)
50
+
51
+ setattr(cls, "__tigrbl_hooks__", hooks)
52
+
53
+ # ────────────────────────────────────────────────────────────────────
54
+ # Per-request hook
55
+ # -------------------------------------------------------------------
56
+ @classmethod
57
+ def _make_row_visibility_hook(cls):
58
+ def _row_visibility_hook(ctx: Mapping[str, Any]) -> None:
59
+ if "result" not in ctx: # nothing to filter
60
+ return
61
+
62
+ res = ctx["result"]
63
+
64
+ # LIST → keep only visible rows
65
+ if isinstance(res, Sequence):
66
+ ctx["result"] = [row for row in res if cls.is_visible(row, ctx)]
67
+ return
68
+
69
+ # READ → invisible row → pretend 404
70
+ if not cls.is_visible(res, ctx):
71
+ http_exc, _, _ = create_standardized_error(
72
+ 404, message=HTTP_ERROR_MESSAGES[404]
73
+ )
74
+ raise http_exc
75
+
76
+ return _row_visibility_hook
77
+
78
+ # -------------------------------------------------------------------
79
+ # Must be overridden
80
+ # -------------------------------------------------------------------
81
+ @staticmethod
82
+ def is_visible(obj, ctx) -> bool: # pragma: no cover
83
+ raise NotImplementedError
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from .bootstrappable import Bootstrappable
4
+ from .upsertable import Upsertable
5
+ from .ownable import Ownable, OwnerPolicy
6
+ from .tenant_bound import TenantBound, TenantPolicy
7
+ from .key_digest import KeyDigest
8
+
9
+ from .utils import (
10
+ tzutcnow,
11
+ tzutcnow_plus_day,
12
+ _infer_schema,
13
+ uuid_example,
14
+ CRUD_IN,
15
+ CRUD_OUT,
16
+ CRUD_IO,
17
+ RO_IO,
18
+ )
19
+ from .principals import GUIDPk, TenantColumn, UserColumn, OrgColumn, Principal
20
+ from .bound import OwnerBound, UserBound
21
+ from .lifecycle import (
22
+ Created,
23
+ LastUsed,
24
+ Timestamped,
25
+ ActiveToggle,
26
+ SoftDelete,
27
+ Versioned,
28
+ )
29
+ from .hierarchy import Contained, TreeNode
30
+ from .edges import RelationEdge, MaskableEdge, TaggableEdge
31
+ from .markers import AsyncCapable, Audited
32
+ from .locks import RowLock, SoftLock
33
+ from .operations import BulkCapable, Replaceable, Mergeable, Streamable
34
+ from .fields import (
35
+ Slugged,
36
+ StatusColumn,
37
+ ValidityWindow,
38
+ Monetary,
39
+ ExtRef,
40
+ MetaJSON,
41
+ BlobRef,
42
+ SearchVector,
43
+ )
44
+
45
+ __all__ = [
46
+ "Bootstrappable",
47
+ "Upsertable",
48
+ "Ownable",
49
+ "OwnerPolicy",
50
+ "TenantBound",
51
+ "TenantPolicy",
52
+ "KeyDigest",
53
+ "tzutcnow",
54
+ "tzutcnow_plus_day",
55
+ "_infer_schema",
56
+ "uuid_example",
57
+ "CRUD_IN",
58
+ "CRUD_OUT",
59
+ "CRUD_IO",
60
+ "RO_IO",
61
+ "GUIDPk",
62
+ "TenantColumn",
63
+ "UserColumn",
64
+ "OrgColumn",
65
+ "Principal",
66
+ "OwnerBound",
67
+ "UserBound",
68
+ "Created",
69
+ "LastUsed",
70
+ "Timestamped",
71
+ "ActiveToggle",
72
+ "SoftDelete",
73
+ "Versioned",
74
+ "Contained",
75
+ "TreeNode",
76
+ "RelationEdge",
77
+ "MaskableEdge",
78
+ "TaggableEdge",
79
+ "AsyncCapable",
80
+ "Audited",
81
+ "RowLock",
82
+ "SoftLock",
83
+ "BulkCapable",
84
+ "Replaceable",
85
+ "Mergeable",
86
+ "Streamable",
87
+ "Slugged",
88
+ "StatusColumn",
89
+ "ValidityWindow",
90
+ "Monetary",
91
+ "ExtRef",
92
+ "MetaJSON",
93
+ "BlobRef",
94
+ "SearchVector",
95
+ ]
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, ClassVar, Iterable
4
+ import logging
5
+ import sqlalchemy as sa
6
+ from sqlalchemy import inspect as sa_inspect
7
+ from sqlalchemy.exc import IntegrityError
8
+
9
+ from ...types import Session, event
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class Bootstrappable:
15
+ """
16
+ Seed DEFAULT_ROWS for *this mapped class only* with zero magic.
17
+
18
+ Rules:
19
+ - Insert ONLY keys present on this class's mapped columns.
20
+ - No auto defaults, no timestamp injection, no unique probing.
21
+ - Idempotency only if ALL primary key columns are present in the row.
22
+ - Listener is attached to cls.__table__ (no cross-class/global effects).
23
+ """
24
+
25
+ DEFAULT_ROWS: ClassVar[list[dict[str, Any]]] = []
26
+
27
+ def __init_subclass__(cls, **kw):
28
+ super().__init_subclass__(**kw)
29
+ event.listen(
30
+ cls.__table__, "after_create", cls._after_create_insert_default_rows
31
+ )
32
+
33
+ @classmethod
34
+ def _after_create_insert_default_rows(cls, target, connection, **_):
35
+ if not getattr(cls, "DEFAULT_ROWS", None):
36
+ return
37
+ from sqlalchemy.orm import sessionmaker
38
+
39
+ SessionLocal = sessionmaker(bind=connection, future=True)
40
+ db: Session = SessionLocal()
41
+ try:
42
+ cls._insert_rows(db, cls.DEFAULT_ROWS)
43
+ db.commit()
44
+ except Exception as e:
45
+ db.rollback()
46
+ log.warning(
47
+ "Bootstrappable seed failed for %s: %s",
48
+ cls.__name__,
49
+ repr(e),
50
+ exc_info=True,
51
+ )
52
+ finally:
53
+ db.close()
54
+
55
+ @classmethod
56
+ def ensure_bootstrapped(
57
+ cls, db: Session, rows: Iterable[dict[str, Any]] | None = None
58
+ ) -> None:
59
+ rows = cls.DEFAULT_ROWS if rows is None else list(rows)
60
+ if rows:
61
+ cls._insert_rows(db, rows)
62
+
63
+ @classmethod
64
+ def _insert_rows(cls, db: Session, rows: Iterable[dict[str, Any]]) -> None:
65
+ mapper = sa_inspect(cls)
66
+
67
+ # --- pick an insertable target (avoid boolean eval + avoid JOINs) ---
68
+ local = mapper.local_table
69
+ table = local if local is not None else mapper.persist_selectable
70
+ if not hasattr(table, "insert"): # e.g., persist_selectable is a JOIN
71
+ table = mapper.local_table
72
+
73
+ col_keys = {c.key for c in mapper.columns}
74
+ pk_cols = list(table.primary_key.columns) if table.primary_key else []
75
+ pk_keys = {c.key for c in pk_cols}
76
+
77
+ def clean(r: dict[str, Any]) -> dict[str, Any]:
78
+ # keep only columns mapped on THIS class
79
+ return {k: r[k] for k in r.keys() & col_keys}
80
+
81
+ payloads = [clean(r) for r in rows if r]
82
+ if not payloads:
83
+ return
84
+
85
+ # Idempotent path if all PK columns are provided (per row)
86
+ can_upsert = bool(pk_cols) and all(pk_keys <= set(p.keys()) for p in payloads)
87
+
88
+ dialect = db.get_bind().dialect.name
89
+
90
+ if can_upsert and dialect == "postgresql":
91
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
92
+
93
+ stmt = (
94
+ pg_insert(table)
95
+ .values(payloads)
96
+ .on_conflict_do_nothing(index_elements=[c.name for c in pk_cols])
97
+ )
98
+ db.execute(stmt)
99
+ return
100
+
101
+ if can_upsert and dialect == "sqlite":
102
+ # Best-effort idempotency for SQLite
103
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
104
+
105
+ db.execute(sqlite_insert(table).values(payloads).prefix_with("OR IGNORE"))
106
+ return
107
+
108
+ # Fallback: plain inserts; swallow duplicate races
109
+ for p in payloads:
110
+ try:
111
+ db.execute(sa.insert(table).values(**p))
112
+ except IntegrityError:
113
+ db.rollback() # treat as already present
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from ...config.constants import CTX_AUTH_KEY, CTX_USER_ID_KEY
4
+ from ...specs import ColumnSpec, F, S, acol
5
+ from ..._spec.storage_spec import ForeignKeySpec
6
+ from ...types import PgUUID, UUID, Mapped
7
+
8
+ from .utils import uuid_example, CRUD_IO
9
+
10
+
11
+ class OwnerBound:
12
+ owner_id: Mapped[UUID] = acol(
13
+ spec=ColumnSpec(
14
+ storage=S(
15
+ type_=PgUUID(as_uuid=True),
16
+ fk=ForeignKeySpec(target="users.id"),
17
+ ),
18
+ field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
19
+ io=CRUD_IO,
20
+ )
21
+ )
22
+
23
+ @classmethod
24
+ def filter_for_ctx(cls, q, ctx):
25
+ auto_fields = ctx.get(CTX_AUTH_KEY, {})
26
+ return q.filter(cls.owner_id == auto_fields.get(CTX_USER_ID_KEY))
27
+
28
+
29
+ class UserBound:
30
+ user_id: Mapped[UUID] = acol(
31
+ spec=ColumnSpec(
32
+ storage=S(
33
+ type_=PgUUID(as_uuid=True),
34
+ fk=ForeignKeySpec(target="users.id"),
35
+ ),
36
+ field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
37
+ io=CRUD_IO,
38
+ )
39
+ )
40
+
41
+ @classmethod
42
+ def filter_for_ctx(cls, q, ctx):
43
+ auto_fields = ctx.get(CTX_AUTH_KEY, {})
44
+ return q.filter(cls.user_id == auto_fields.get(CTX_USER_ID_KEY))
45
+
46
+
47
+ __all__ = ["OwnerBound", "UserBound"]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from ...specs import ColumnSpec, F, S, acol
4
+ from ...types import Integer, String, declarative_mixin, Mapped
5
+
6
+ from .utils import CRUD_IO
7
+
8
+
9
+ @declarative_mixin
10
+ class RelationEdge:
11
+ """Marker: row itself is an association—no extra columns required."""
12
+
13
+ pass
14
+
15
+
16
+ @declarative_mixin
17
+ class MaskableEdge:
18
+ """Edge row with bitmap of verbs/roles."""
19
+
20
+ mask: Mapped[int] = acol(
21
+ spec=ColumnSpec(
22
+ storage=S(type_=Integer, nullable=False),
23
+ field=F(py_type=int),
24
+ io=CRUD_IO,
25
+ )
26
+ )
27
+
28
+
29
+ @declarative_mixin
30
+ class TaggableEdge:
31
+ tag: Mapped[str] = acol(
32
+ spec=ColumnSpec(
33
+ storage=S(type_=String, nullable=False),
34
+ field=F(py_type=str),
35
+ io=CRUD_IO,
36
+ )
37
+ )
38
+
39
+
40
+ __all__ = ["RelationEdge", "MaskableEdge", "TaggableEdge"]
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ from decimal import Decimal
5
+
6
+ from ...specs import ColumnSpec, F, IO, S, acol
7
+ from ...types import (
8
+ TZDateTime,
9
+ PgUUID,
10
+ String,
11
+ SAEnum,
12
+ Numeric,
13
+ JSONB,
14
+ TSVECTOR,
15
+ UUID,
16
+ Index,
17
+ declarative_mixin,
18
+ declared_attr,
19
+ Mapped,
20
+ )
21
+
22
+ from .utils import tzutcnow, tzutcnow_plus_day, CRUD_IO
23
+
24
+
25
+ @declarative_mixin
26
+ class Slugged:
27
+ slug: Mapped[str] = acol(
28
+ spec=ColumnSpec(
29
+ storage=S(type_=String, unique=True, nullable=False),
30
+ field=F(py_type=str, constraints={"max_length": 120}),
31
+ io=CRUD_IO,
32
+ )
33
+ )
34
+
35
+
36
+ @declarative_mixin
37
+ class StatusColumn:
38
+ status: Mapped[str] = acol(
39
+ spec=ColumnSpec(
40
+ storage=S(
41
+ type_=SAEnum(
42
+ "queued",
43
+ "waiting",
44
+ "input_required",
45
+ "auth_required",
46
+ "approved",
47
+ "rejected",
48
+ "dispatched",
49
+ "running",
50
+ "paused",
51
+ "success",
52
+ "failed",
53
+ "cancelled",
54
+ name="status_enum",
55
+ ),
56
+ default="waiting",
57
+ nullable=False,
58
+ ),
59
+ field=F(py_type=str),
60
+ io=CRUD_IO,
61
+ )
62
+ )
63
+
64
+
65
+ @declarative_mixin
66
+ class ValidityWindow:
67
+ valid_from: Mapped[dt.datetime] = acol(
68
+ spec=ColumnSpec(
69
+ storage=S(type_=TZDateTime, default=tzutcnow, nullable=False),
70
+ field=F(py_type=dt.datetime),
71
+ io=CRUD_IO,
72
+ )
73
+ )
74
+ valid_to: Mapped[dt.datetime | None] = acol(
75
+ spec=ColumnSpec(
76
+ storage=S(type_=TZDateTime, default=tzutcnow_plus_day),
77
+ field=F(py_type=dt.datetime),
78
+ io=CRUD_IO,
79
+ )
80
+ )
81
+
82
+
83
+ @declarative_mixin
84
+ class Monetary:
85
+ amount: Mapped[Decimal] = acol(
86
+ spec=ColumnSpec(
87
+ storage=S(type_=Numeric(18, 2), nullable=False),
88
+ field=F(py_type=Decimal),
89
+ io=CRUD_IO,
90
+ )
91
+ )
92
+ currency: Mapped[str] = acol(
93
+ spec=ColumnSpec(
94
+ storage=S(type_=String, default="USD", nullable=False),
95
+ field=F(py_type=str, constraints={"max_length": 3}),
96
+ io=CRUD_IO,
97
+ )
98
+ )
99
+
100
+
101
+ @declarative_mixin
102
+ class ExtRef:
103
+ external_id: Mapped[str] = acol(
104
+ spec=ColumnSpec(
105
+ storage=S(type_=String),
106
+ field=F(py_type=str),
107
+ io=CRUD_IO,
108
+ )
109
+ )
110
+ provider: Mapped[str] = acol(
111
+ spec=ColumnSpec(
112
+ storage=S(type_=String),
113
+ field=F(py_type=str),
114
+ io=CRUD_IO,
115
+ )
116
+ )
117
+
118
+
119
+ @declarative_mixin
120
+ class MetaJSON:
121
+ meta: Mapped[dict] = acol(
122
+ spec=ColumnSpec(
123
+ storage=S(type_=JSONB, default=dict),
124
+ field=F(py_type=dict),
125
+ io=CRUD_IO,
126
+ )
127
+ )
128
+
129
+
130
+ @declarative_mixin
131
+ class BlobRef:
132
+ blob_id: Mapped[UUID | None] = acol(
133
+ spec=ColumnSpec(
134
+ storage=S(type_=PgUUID(as_uuid=True)),
135
+ field=F(py_type=UUID),
136
+ io=CRUD_IO,
137
+ )
138
+ )
139
+
140
+
141
+ @declarative_mixin
142
+ class SearchVector:
143
+ tsv: Mapped[str] = acol(
144
+ spec=ColumnSpec(
145
+ storage=S(type_=TSVECTOR),
146
+ field=F(py_type=str),
147
+ io=IO(),
148
+ )
149
+ )
150
+
151
+ @declared_attr
152
+ def __table_args__(cls):
153
+ return (Index(f"ix_{cls.__tablename__}_tsv", "tsv"),)
154
+
155
+
156
+ __all__ = [
157
+ "Slugged",
158
+ "StatusColumn",
159
+ "ValidityWindow",
160
+ "Monetary",
161
+ "ExtRef",
162
+ "MetaJSON",
163
+ "BlobRef",
164
+ "SearchVector",
165
+ ]
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from ...specs import ColumnSpec, F, S, acol
4
+ from ..._spec.storage_spec import ForeignKeySpec
5
+ from ...types import PgUUID, UUID, String, declarative_mixin, declared_attr, Mapped
6
+
7
+ from .utils import CRUD_IO
8
+
9
+
10
+ @declarative_mixin
11
+ class Contained:
12
+ @declared_attr
13
+ def parent_id(cls) -> Mapped[UUID]:
14
+ if not hasattr(cls, "parent_table"):
15
+ raise AttributeError("subclass must set parent_table")
16
+ spec = ColumnSpec(
17
+ storage=S(
18
+ type_=PgUUID(as_uuid=True),
19
+ fk=ForeignKeySpec(target=f"{cls.parent_table}.id"),
20
+ nullable=False,
21
+ ),
22
+ field=F(py_type=UUID),
23
+ io=CRUD_IO,
24
+ )
25
+ return acol(spec=spec)
26
+
27
+
28
+ @declarative_mixin
29
+ class TreeNode:
30
+ """Self-nesting hierarchy."""
31
+
32
+ @declared_attr
33
+ def parent_id(cls) -> Mapped[UUID | None]:
34
+ spec = ColumnSpec(
35
+ storage=S(
36
+ type_=PgUUID(as_uuid=True),
37
+ fk=ForeignKeySpec(target=f"{cls.__tablename__}.id"),
38
+ nullable=True,
39
+ ),
40
+ field=F(py_type=UUID),
41
+ io=CRUD_IO,
42
+ )
43
+ return acol(spec=spec)
44
+
45
+ path: Mapped[str] = acol(
46
+ spec=ColumnSpec(
47
+ storage=S(type_=String),
48
+ field=F(py_type=str),
49
+ io=CRUD_IO,
50
+ )
51
+ )
52
+
53
+
54
+ __all__ = ["Contained", "TreeNode"]
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from hashlib import sha256
4
+ from secrets import token_urlsafe
5
+
6
+ from ..._spec.io_spec import Pair
7
+ from ...specs import F, IO, S, acol
8
+ from ...types import Mapped, String, declarative_mixin
9
+
10
+
11
+ @declarative_mixin
12
+ class KeyDigest:
13
+ """Provides hashed API key storage with helpers."""
14
+
15
+ @staticmethod
16
+ def _generate_pair(_ctx: dict | None = None) -> Pair:
17
+ """Generate a raw/stored pair for API keys."""
18
+ raw = token_urlsafe(32)
19
+ return Pair(raw=raw, stored=sha256(raw.encode()).hexdigest())
20
+
21
+ digest: Mapped[str] = acol(
22
+ storage=S(String, nullable=False, unique=True, index=True),
23
+ field=F(constraints={"max_length": 64}),
24
+ io=IO(out_verbs=("read", "list", "create")).paired(
25
+ make=_generate_pair,
26
+ alias="api_key",
27
+ verbs=("create",),
28
+ emit="post_refresh",
29
+ alias_field=F(py_type=str), # include alias in the response schema
30
+ mask_last=None, # set an int if you want masking
31
+ ),
32
+ )
33
+
34
+ @staticmethod
35
+ def digest_of(value: str) -> str:
36
+ return sha256(value.encode()).hexdigest()
37
+
38
+ @property
39
+ def raw_key(self) -> str: # pragma: no cover - write-only
40
+ raise AttributeError("raw_key is write-only")
41
+
42
+ @raw_key.setter
43
+ def raw_key(self, value: str) -> None:
44
+ self.digest = self.digest_of(value)