cygnet-orm 1.0.0__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.
cygnet/__init__.py ADDED
@@ -0,0 +1,521 @@
1
+ """
2
+ CYGNET — The Littlest ORM.
3
+
4
+ Bring your own objects. Write real SQL.
5
+
6
+ This module is the public API surface. All user-facing functions are defined
7
+ here (or re-exported from submodules). Internal modules (meta, proxy,
8
+ executor, etc.) are not part of the public API.
9
+
10
+ Query verbs are UPPER_CASE (SELECT, INSERT, UPDATE, DELETE, TRUNCATE) to
11
+ mirror SQL and read naturally in Python: `await cygnet.SELECT(db).FROM(T)`.
12
+ The noqa: N802 suppressions silence PEP 8 naming complaints on these
13
+ intentionally-named functions.
14
+
15
+ This file also hosts cross-cutting helpers that don't belong inside a
16
+ specific builder module — `transaction` (savepoint-based nesting context
17
+ manager), `get` (PK fetch), `save` (insert-or-upsert dispatch), `create`
18
+ (insert-without-upsert), `follow` (FK traversal), and `TRUNCATE` (no
19
+ builder needed). These are kept here because they sit between the
20
+ builders and the executor: they coordinate across multiple builder
21
+ operations or sit outside the builder pattern entirely.
22
+
23
+ Naming convention recap for callers reading the export list:
24
+ - UPPER_CASE → SQL verbs / statement entry points
25
+ (SELECT, INSERT, UPDATE, DELETE, TRUNCATE)
26
+ - PascalCase → factories returning a proxy / decorator / class
27
+ (Table, Column, DBKey, AppKey, ForeignKey, table, CTE, Lateral,
28
+ RecursiveCTE)
29
+ - lower_case → helpers, sentinels, and convenience functions
30
+ (get, save, create, follow, transaction, lit, op, ops, is_null,
31
+ is_not_null, exists, not_exists, fn, cte, lateral, recursive_cte,
32
+ all)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ from typing import Any, cast
39
+
40
+ # Re-exports are grouped by role: annotations (used in model definitions),
41
+ # builders (returned by query-verb factories, not constructed directly by
42
+ # users), expression helpers (op/ops/is_null/is_not_null), and the `all`
43
+ # sentinel (see predicate.py — required for unrestricted UPDATE/DELETE).
44
+ # `cygnet.all` shadows Python's builtin `all` *inside this module only*;
45
+ # callers do `cygnet.all` which is unambiguous at the call site.
46
+ from .annotations import AppKey, Column, DBKey, ForeignKey, table
47
+ from .builders import DeleteBuilder, InsertBuilder, SelectBuilder, UpdateBuilder
48
+ from .cte import CTE, Lateral, RecursiveCTE, cte, lateral, recursive_cte
49
+ from .executor import Executor
50
+ from .expression import (
51
+ DBAdapter,
52
+ exists,
53
+ fn,
54
+ is_not_null,
55
+ is_null,
56
+ not_exists,
57
+ op,
58
+ ops,
59
+ )
60
+ from .predicate import Literal, all
61
+ from .proxy import ColumnProxy, TableProxy
62
+
63
+ __all__ = [
64
+ # Annotations
65
+ "DBKey",
66
+ "AppKey",
67
+ "Column",
68
+ "ForeignKey",
69
+ "table",
70
+ # Adapter contract
71
+ "DBAdapter",
72
+ # Table factory
73
+ "Table",
74
+ # Query verbs
75
+ "SELECT",
76
+ "INSERT",
77
+ "UPDATE",
78
+ "DELETE",
79
+ "TRUNCATE",
80
+ # Predicates
81
+ "all",
82
+ "lit",
83
+ "op",
84
+ "ops",
85
+ "is_null",
86
+ "is_not_null",
87
+ "exists",
88
+ "not_exists",
89
+ "fn",
90
+ # Convenience
91
+ "create",
92
+ "follow",
93
+ "get",
94
+ "save",
95
+ "transaction",
96
+ "flush_column_defaults",
97
+ # CTEs
98
+ "CTE",
99
+ "cte",
100
+ "RecursiveCTE",
101
+ "recursive_cte",
102
+ "Lateral",
103
+ "lateral",
104
+ ]
105
+
106
+
107
+ def Table[T](cls: type[T]) -> TableProxy[T]: # noqa: N802
108
+ """Create a table proxy from a dataclass.
109
+
110
+ Returns a cached singleton per class — cygnet.Table(X) is cygnet.Table(X).
111
+ The TableProxy is generic on the model type so that downstream APIs
112
+ (e.g. cygnet.get) can return the correct concrete type.
113
+ """
114
+ return TableProxy(cls)
115
+
116
+
117
+ # ── Query verb entry points ──────────────────────────────────────────────────
118
+ # These are thin wrappers that create builders. The db object is threaded
119
+ # through to the builder and eventually to the Executor, which calls
120
+ # db.execute() / db.execute_one(). Cygnet never imports a specific database
121
+ # driver — the db protocol is duck-typed (see tests/conftest.py:FakeDB for
122
+ # the minimal interface).
123
+ #
124
+ # Each builder is awaitable: `await SELECT(db).FROM(T)` triggers execution
125
+ # via the builder's __await__. Builders also expose `.sql()` for caller
126
+ # inspection without execution.
127
+ #
128
+ # UPDATE and DELETE refuse to run without an explicit .WHERE() — pass
129
+ # cygnet.all to act on every row intentionally. See predicate.py for the
130
+ # sentinel; the guard lives in the builder, not here.
131
+
132
+
133
+ def SELECT(db: DBAdapter, *columns: Any) -> SelectBuilder: # noqa: N802
134
+ return SelectBuilder(db, *columns)
135
+
136
+
137
+ def INSERT(db: DBAdapter) -> InsertBuilder: # noqa: N802
138
+ return InsertBuilder(db)
139
+
140
+
141
+ def UPDATE(db: DBAdapter) -> UpdateBuilder: # noqa: N802
142
+ return UpdateBuilder(db)
143
+
144
+
145
+ def DELETE(db: DBAdapter) -> DeleteBuilder: # noqa: N802
146
+ return DeleteBuilder(db)
147
+
148
+
149
+ async def TRUNCATE( # noqa: N802
150
+ db: DBAdapter, *tables: TableProxy[Any], cascade: bool = False
151
+ ) -> None:
152
+ """Truncate one or more tables. Use cascade=True to drop dependent rows.
153
+
154
+ Unlike SELECT/INSERT/UPDATE/DELETE, TRUNCATE has no builder — it's a
155
+ single statement with no clauses to chain, so a direct async function
156
+ is simpler.
157
+ """
158
+ # TRUNCATE doesn't go through the Executor: there are no rows to map
159
+ # back to objects and no params to bind, so this is a direct
160
+ # db.execute() with the empty-params list the protocol requires.
161
+ if not tables:
162
+ raise ValueError("TRUNCATE requires at least one table")
163
+ names = ", ".join(t._meta.table_name for t in tables)
164
+ sql = f"TRUNCATE TABLE {names}"
165
+ if cascade:
166
+ sql += " CASCADE"
167
+ await db.execute(sql, [])
168
+
169
+
170
+ def lit(sql: str) -> Literal:
171
+ """Create a raw SQL literal for use in any expression position.
172
+
173
+ The SQL is emitted verbatim — no escaping, no parameter substitution.
174
+ This is the escape hatch when Cygnet's expression API doesn't cover
175
+ your SQL construct. Use with care: the string is trusted.
176
+
177
+ Caveat for adapters that translate placeholder syntax: the reference
178
+ psycopg adapter (cygnet.psycopg_db.PsycopgDB) rewrites every ``$\\d+``
179
+ substring in the final SQL to psycopg's ``%s`` form — including any
180
+ such substrings inside a ``lit()`` payload. If you need a literal
181
+ ``$1`` string in a SQL fragment going through that adapter, write it
182
+ as ``'$' || '1'`` or similar. Custom adapters that don't translate
183
+ placeholders are unaffected.
184
+ """
185
+ return Literal(sql=sql)
186
+
187
+
188
+ def flush_column_defaults(db: DBAdapter | None = None) -> None:
189
+ """Evict cached column-DEFAULT introspection results.
190
+
191
+ Cygnet caches the set of columns carrying a non-NULL DEFAULT on
192
+ first INSERT against each (adapter, table) pair, then reuses it on
193
+ every subsequent INSERT — the round-trip to PG's catalog amortises
194
+ away. The cache is stable for the lifetime of the schema, but
195
+ adapters in long-running services (pooled connections, daemons)
196
+ typically outlive the schemas they were populated against.
197
+
198
+ After a migration that ALTERs a DEFAULT clause, call this function
199
+ so the next INSERT re-introspects. Otherwise Cygnet will keep
200
+ omitting columns whose DEFAULT was dropped (writing NULL where the
201
+ schema expected a value) or vice versa. Cygnet has no way to
202
+ detect migrations on its own.
203
+
204
+ With ``db=None``: clears every adapter's entries (covers "any
205
+ connection that goes through this process is post-migration").
206
+ With a specific adapter: evicts only that adapter — useful for
207
+ sharded / per-tenant migration patterns. Either form is a no-op
208
+ when the adapter has no cached entries.
209
+ """
210
+ Executor.flush_column_defaults(db)
211
+
212
+
213
+ # ── Convenience functions ────────────────────────────────────────────────────
214
+ # These wrap common patterns (get-by-PK, insert-without-upsert, upsert)
215
+ # so callers don't have to spell out the full builder chain for simple cases.
216
+ #
217
+ # The save / create / INSERT distinction is intentional and worth keeping
218
+ # straight when reading caller code:
219
+ # - INSERT(db).INTO(T).VALUES(...) — generic builder; user controls
220
+ # ON CONFLICT, RETURNING, etc. Most flexible, most verbose.
221
+ # - create(db, obj) — INSERT with no ON CONFLICT. Duplicates raise
222
+ # IntegrityError from the driver. PK populated on obj for DBKey.
223
+ # - save(db, obj) — INSERT ... ON CONFLICT DO UPDATE (upsert) when a
224
+ # PK is known; plain INSERT ... RETURNING when DBKey + PK is None.
225
+ # Idempotent; the default "persist this object" call.
226
+ # follow() and get() are read-side conveniences; they always SELECT and
227
+ # never mutate.
228
+
229
+
230
+ async def get[T](db: DBAdapter, table: TableProxy[T], **pk_kwargs: Any) -> T | None:
231
+ """Fetch a single object by primary key. Returns None if not found.
232
+
233
+ The pk kwarg name must match the Python attribute name (not the DB
234
+ column name): cygnet.get(db, T, id=1), not cygnet.get(db, T, user_id=1)
235
+ if the attr is `id` but the column is `user_id`.
236
+
237
+ Returns T | None thanks to the generic TableProxy[T] — callers get
238
+ the correct concrete type for the model under inspection.
239
+ """
240
+ meta = table._meta
241
+ # Defensive: TableMeta now enforces "exactly one PK" at introspection
242
+ # time, so this branch is unreachable through the public API. Kept as
243
+ # a clear error in case meta.pk is set None elsewhere in future work.
244
+ if meta.pk is None:
245
+ raise TypeError(f"{meta.cls.__name__} has no primary key")
246
+ # Validate the kwarg name explicitly so a wrong key (e.g. user_id when
247
+ # the attr is id) raises a TypeError naming both the model and the
248
+ # expected kwarg, rather than a bare KeyError on the attr name.
249
+ if meta.pk.attr_name not in pk_kwargs:
250
+ raise TypeError(
251
+ f"{meta.cls.__name__}.get() missing PK kwarg {meta.pk.attr_name!r}"
252
+ )
253
+ val = pk_kwargs[meta.pk.attr_name]
254
+ pred = getattr(table, meta.pk.attr_name) == val
255
+ results = await SELECT(db).FROM(table).WHERE(pred)
256
+ # SELECT.run_select returns list[Any] (each entry is an instance of the
257
+ # model class via _row_to_obj). Cast at the boundary so the public
258
+ # signature carries T | None without forcing internal SELECT machinery
259
+ # to be generic.
260
+ return cast("T | None", results[0] if results else None)
261
+
262
+
263
+ async def follow(db: DBAdapter, obj: Any, fk_column: Any) -> Any:
264
+ """Load the object that a foreign key points to.
265
+
266
+ Returns None if the FK value is None or no matching row exists.
267
+ Raises ValueError if fk_column is not a foreign key.
268
+ Raises TypeError if obj is not an instance of the FK column's table.
269
+ """
270
+ if not isinstance(fk_column, ColumnProxy):
271
+ raise ValueError(f"{fk_column!r} is not a column proxy")
272
+
273
+ # Order of checks below: type-validate fk_column → type-validate obj →
274
+ # check FK metadata → read FK value → null-short-circuit → SELECT.
275
+ # Validating obj before reading field metadata gives a clearer error
276
+ # for the common "passed the wrong instance" mistake.
277
+ field = fk_column._field
278
+ source_meta = fk_column._table._meta
279
+
280
+ if not isinstance(obj, source_meta.cls):
281
+ raise TypeError(
282
+ f"Expected {source_meta.cls.__name__}, got {type(obj).__name__}"
283
+ )
284
+
285
+ if field.foreign_key is None:
286
+ raise ValueError(
287
+ f"{source_meta.cls.__name__}.{field.attr_name} is not a foreign key"
288
+ )
289
+
290
+ fk_value = getattr(obj, field.attr_name)
291
+ if fk_value is None:
292
+ return None
293
+
294
+ target_proxy: TableProxy[Any] = TableProxy(field.foreign_key.target)
295
+ # FK validation in _introspect() guarantees the target has a PK.
296
+ assert target_proxy._meta.pk is not None
297
+ target_pk = target_proxy._meta.pk
298
+ return await get(db, target_proxy, **{target_pk.attr_name: fk_value})
299
+
300
+
301
+ async def create(db: DBAdapter, obj: Any) -> Any:
302
+ """
303
+ INSERT obj into its table. No ON CONFLICT — duplicates raise from the DB.
304
+
305
+ Returns the object with PK populated (for DBKey).
306
+ """
307
+ return await Executor(db).run_create(obj)
308
+
309
+
310
+ async def save(db: DBAdapter, obj: Any) -> None:
311
+ """
312
+ Persist obj to its table.
313
+
314
+ - DBKey + pk is None → INSERT ... RETURNING (pk populated on obj)
315
+ - DBKey + pk is set → INSERT ... ON CONFLICT DO UPDATE
316
+ - AppKey + pk is None → ValueError
317
+ - AppKey + pk is set → INSERT ... ON CONFLICT DO UPDATE
318
+
319
+ The DBKey/AppKey distinction drives the dispatch: a None PK on a DBKey
320
+ field means "the database will assign one" (safe to INSERT), but a None
321
+ PK on an AppKey field means the caller forgot to set it (error). Upsert
322
+ semantics (ON CONFLICT DO UPDATE) are the default whenever a PK is
323
+ present — save() is idempotent in that sense, unlike create().
324
+ """
325
+ await Executor(db).run_save(obj)
326
+
327
+
328
+ # State machine summary for transaction:
329
+ #
330
+ # db._in_transaction = False ──BEGIN──▶ db._in_transaction = True
331
+ # ▲ │
332
+ # └────COMMIT or ROLLBACK────────────┘
333
+ #
334
+ # A nested `async with cygnet.transaction(db)` finds the flag already
335
+ # True and issues SAVEPOINT spN / RELEASE / ROLLBACK TO instead of
336
+ # BEGIN / COMMIT / ROLLBACK. The flag is *not* a counter — nesting depth
337
+ # isn't tracked here; SAVEPOINTs nest naturally on the server side, and
338
+ # the unique-per-instance savepoint name (`sp_{id(self)}`) keeps the
339
+ # release pair matched.
340
+ #
341
+ # Why a bool and not a counter: a counter would require Cygnet to
342
+ # manage the depth explicitly, which doesn't add safety (the server
343
+ # rejects mismatched COMMITs anyway) but does add a failure mode where
344
+ # a counter and the server's view diverge after an unexpected exception.
345
+ # The flag-plus-savepoint approach is monotonic and self-healing.
346
+ #
347
+ # This is NOT task-local on purpose: Cygnet expects one db adapter
348
+ # instance per asyncio task. Sharing one PsycopgDB across concurrent
349
+ # tasks is now actively detected (S10): outermost __aenter__ captures
350
+ # `asyncio.current_task()` onto `db._transaction_task`, and nested
351
+ # __aenter__ verifies the same task is owning the transaction —
352
+ # cross-task nesting raises with a clear message instead of silently
353
+ # turning into a SAVEPOINT against another task's transaction. The
354
+ # guard is best-effort: it requires the outer layer to use
355
+ # cygnet.transaction (not an externally-managed BEGIN), and treats a
356
+ # None current_task (e.g., outside any task context) as "no claim".
357
+ # See PsycopgDB's docstring for the same warning at the adapter layer.
358
+ class transaction:
359
+ """
360
+ Async context manager for database transactions.
361
+
362
+ Outermost usage opens BEGIN/COMMIT.
363
+ Nested usage transparently promotes to SAVEPOINT/RELEASE.
364
+ Any exception triggers ROLLBACK or ROLLBACK TO SAVEPOINT.
365
+
366
+ Nesting detection relies on a `_in_transaction` flag on the db object.
367
+ This is a simple boolean, not a counter — nested transactions always
368
+ use SAVEPOINTs, and only the outermost context manager issues BEGIN/COMMIT.
369
+ The `__aenter__` returns the same db object (not a wrapper), so all
370
+ queries inside the block use the same connection.
371
+
372
+ Invariant: the db adapter is responsible for initializing
373
+ `_in_transaction` (usually False on a fresh connection). Cygnet toggles
374
+ it only on BEGIN/COMMIT/ROLLBACK at the outermost level; SAVEPOINT
375
+ operations leave it alone, which is what makes nesting work without a
376
+ counter. Concurrent use of the same db handle across tasks is not
377
+ supported — the flag is not task-local — but Cygnet actively detects
378
+ cross-task misuse (S10): the outermost ``__aenter__`` records the
379
+ owning ``asyncio.current_task()`` on the db, and a nested entry from
380
+ a different task raises ``RuntimeError`` rather than silently
381
+ SAVEPOINTing inside the other task's transaction.
382
+
383
+ Instance reuse: a single `transaction(db)` instance can be reused
384
+ across sequential `async with` blocks — `__aenter__` resets
385
+ `self._savepoint` so the prior nested savepoint name never leaks
386
+ into a subsequent outermost BEGIN/COMMIT. Concurrent re-entry of
387
+ the SAME transaction instance is unsupported (the `self._savepoint`
388
+ field would race); construct a fresh `transaction(db)` per task if
389
+ you need parallel transactional contexts on the same db handle —
390
+ and remember that the db adapter itself is also not task-local
391
+ (see the previous paragraph).
392
+
393
+ Usage::
394
+
395
+ async with cygnet.transaction(db) as tx:
396
+ await cygnet.INSERT(tx).INTO(AccountTable).VALUES(acc)
397
+ async with cygnet.transaction(tx) as tx2:
398
+ await cygnet.UPDATE(tx2).SET(LogTable, entry).WHERE(...)
399
+ """
400
+
401
+ def __init__(self, db: DBAdapter) -> None:
402
+ self._db = db
403
+ # Set to a savepoint name on __aenter__ when nested; stays None at
404
+ # the outermost level. Acts as the "am I the outermost?" signal
405
+ # in __aexit__ — see the branch there.
406
+ self._savepoint: str | None = None
407
+
408
+ async def __aenter__(self) -> Any:
409
+ # Reset _savepoint at every enter so a transaction instance can be
410
+ # reused across multiple `async with` blocks without a stale
411
+ # savepoint name leaking from a prior nested entry into a fresh
412
+ # BEGIN/COMMIT cycle.
413
+ self._savepoint = None
414
+ # S10: task-locality guard. asyncio.current_task() returns None
415
+ # outside any task context (rare for async code — needs to be
416
+ # awaited from somewhere), in which case we skip the check rather
417
+ # than fight the runtime. The captured-vs-current comparison
418
+ # uses `is`, not equality, because tasks have no meaningful
419
+ # __eq__ — identity is the right relation.
420
+ current_task = asyncio.current_task()
421
+ # getattr-with-default rather than direct access: adapters in the
422
+ # wild (or freshly-constructed fakes in tests) may not have set
423
+ # the attribute yet. Treat absence as "not in a transaction".
424
+ if getattr(self._db, "_in_transaction", False):
425
+ # Already in a transaction → SAVEPOINT path, but first verify
426
+ # we're in the same task that opened the outer transaction.
427
+ # If `owner` is None (older code populated _in_transaction
428
+ # without going through cygnet.transaction), be permissive:
429
+ # the user has opted out of the guard by managing transactions
430
+ # externally and we have no signal to compare against.
431
+ owner = getattr(self._db, "_transaction_task", None)
432
+ if (
433
+ owner is not None
434
+ and current_task is not None
435
+ and owner is not current_task
436
+ ):
437
+ raise RuntimeError(
438
+ "cygnet.transaction: nested entry from a different "
439
+ "asyncio task than the one that opened the outer "
440
+ "transaction — db adapters are not task-safe. Use "
441
+ "one db adapter (and connection) per task, or "
442
+ "serialise transactional access across tasks."
443
+ )
444
+ # id(self) produces a unique name per context manager instance,
445
+ # avoiding collisions even with deeply nested savepoints. The
446
+ # id is stable for the lifetime of `self`, which covers enter
447
+ # through exit — we need the same name in both halves to issue
448
+ # the matching RELEASE / ROLLBACK TO SAVEPOINT.
449
+ self._savepoint = f"sp_{id(self)}"
450
+ await self._db.execute(f"SAVEPOINT {self._savepoint}")
451
+ else:
452
+ # Outermost layer: open a real transaction and claim the flag.
453
+ # Ordering matters: BEGIN is sent first so a failing execute()
454
+ # leaves the flag at False and the next transaction() call
455
+ # opens cleanly instead of trying to SAVEPOINT against nothing.
456
+ await self._db.execute("BEGIN")
457
+ self._db._in_transaction = True
458
+ # Claim the task identity for the duration of the outermost
459
+ # transaction. Stored on the db (not self) because the
460
+ # `_in_transaction` state is on the db — keeping ownership
461
+ # next to the state it protects.
462
+ self._db._transaction_task = current_task
463
+ # Returns the original db, not self — the caller uses the same
464
+ # connection handle for queries inside the block.
465
+ return self._db
466
+
467
+ async def __aexit__(
468
+ self,
469
+ exc_type: type | None,
470
+ exc_val: BaseException | None,
471
+ exc_tb: Any,
472
+ ) -> None:
473
+ # Branch on "did __aenter__ open a savepoint?" rather than re-reading
474
+ # _in_transaction: the flag is True for both inner and outer layers
475
+ # while a nested transaction is active, so it can't distinguish them.
476
+ if self._savepoint:
477
+ # Nested layer: leave _in_transaction alone — the outermost
478
+ # context manager owns it. We do NOT swallow exc_type; returning
479
+ # None re-raises it after the savepoint is dealt with.
480
+ if exc_type:
481
+ # Roll the savepoint back, then RELEASE it (S33). ROLLBACK TO
482
+ # SAVEPOINT leaves the savepoint defined in PostgreSQL, so
483
+ # without the RELEASE it lingers on the savepoint stack for the
484
+ # rest of the outer transaction whenever the outer block
485
+ # catches the inner exception and continues. Both run inside a
486
+ # try so a failing savepoint command chains the original
487
+ # exception (B8) rather than silently replacing it: with no
488
+ # explicit `from`, the new error would propagate with only an
489
+ # implicit __context__, losing the user's real error as the
490
+ # __cause__ that tooling and `raise ... from` semantics expect.
491
+ try:
492
+ await self._db.execute(f"ROLLBACK TO SAVEPOINT {self._savepoint}")
493
+ await self._db.execute(f"RELEASE SAVEPOINT {self._savepoint}")
494
+ except Exception as savepoint_err:
495
+ raise savepoint_err from exc_val
496
+ else:
497
+ await self._db.execute(f"RELEASE SAVEPOINT {self._savepoint}")
498
+ else:
499
+ # The outermost context manager owns _in_transaction; reset it
500
+ # in a `finally` so a failure inside ROLLBACK/COMMIT itself
501
+ # doesn't strand the flag at True and silently turn the next
502
+ # transaction on this connection into a SAVEPOINT against a
503
+ # server-side-nonexistent transaction. The task-ownership
504
+ # claim is paired with the flag and cleared in the same
505
+ # finally block — sequential cross-task reuse needs both
506
+ # gone, not just _in_transaction.
507
+ try:
508
+ if exc_type:
509
+ # On the error path, a failing ROLLBACK must not silently
510
+ # replace the user's original exception (B8): chain it so
511
+ # the real error survives as __cause__. When ROLLBACK
512
+ # succeeds, returning None re-raises exc_val normally.
513
+ try:
514
+ await self._db.execute("ROLLBACK")
515
+ except Exception as rollback_err:
516
+ raise rollback_err from exc_val
517
+ else:
518
+ await self._db.execute("COMMIT")
519
+ finally:
520
+ self._db._in_transaction = False
521
+ self._db._transaction_task = None
cygnet/annotations.py ADDED
@@ -0,0 +1,86 @@
1
+ # annotations.py — Marker types carried inside Annotated[] type hints.
2
+ #
3
+ # These are passive metadata: they don't alter the dataclass at decoration
4
+ # time. Instead, meta.py reads them back via get_type_hints(include_extras=True)
5
+ # during introspection. Keeping them as plain frozen dataclasses (rather than
6
+ # enums or strings) lets us use isinstance() checks unambiguously, even when
7
+ # multiple annotation objects coexist in the same Annotated[] bracket.
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+
15
+ # Frozen so the two module-level singletons below (DBKey / AppKey) are
16
+ # hashable and safely shared across all dataclass annotations without
17
+ # mutation hazards. The class itself is intentionally underscore-prefixed:
18
+ # users never construct one directly — they reference the DBKey / AppKey
19
+ # singletons. Two PK fields on the same model is a configuration error,
20
+ # but a misconfigured user reusing the same singleton across fields would
21
+ # otherwise quietly succeed; meta._introspect's "more than one primary
22
+ # key" check catches that case.
23
+ @dataclass(frozen=True)
24
+ class _PrimaryKey:
25
+ # "db" means the database assigns the value (e.g., SERIAL / IDENTITY);
26
+ # "app" means the application must supply it before INSERT.
27
+ # This distinction drives two behaviours in executor.py:
28
+ # 1. DBKey fields with value None are omitted from INSERT column lists
29
+ # and the generated SQL includes RETURNING <pk>.
30
+ # 2. AppKey fields with value None raise at insert time — there's no
31
+ # server-side default to fall back on.
32
+ assigned_by: str
33
+
34
+
35
+ # Module-level singletons. Because _PrimaryKey is frozen, these are
36
+ # compared by value (==) in meta.py and executor.py, not by identity.
37
+ DBKey = _PrimaryKey(assigned_by="db")
38
+ AppKey = _PrimaryKey(assigned_by="app")
39
+
40
+
41
+ # Column-name override carrier. Frozen+hashable so the same _Column
42
+ # instance could in principle be shared, though in practice every
43
+ # Column("…") call produces a fresh instance — sharing has no measurable
44
+ # benefit and reads worse at the use site.
45
+ @dataclass(frozen=True)
46
+ class _Column:
47
+ # When None, the Python attribute name is used as the column name.
48
+ name: str | None = None
49
+
50
+
51
+ def Column(name: str) -> _Column: # noqa: N802
52
+ # Factory function so users write Column("col") rather than _Column("col").
53
+ # The leading underscore on the class signals that it's internal; only
54
+ # this function and the two PK singletons above are public API.
55
+ return _Column(name=name)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class _ForeignKey:
60
+ # The target dataclass whose primary key this field references.
61
+ # Resolved lazily by meta.py via get_type_hints(include_extras=True),
62
+ # so forward references and circular imports are not a problem.
63
+ target: type
64
+
65
+
66
+ def ForeignKey(target: type) -> _ForeignKey: # noqa: N802
67
+ # Factory function matching the Column() / DBKey / AppKey pattern.
68
+ # Users write ForeignKey(Customer), not _ForeignKey(target=Customer).
69
+ return _ForeignKey(target=target)
70
+
71
+
72
+ def table(name: str) -> Any:
73
+ """Class decorator: @cygnet.table("my_table_name")"""
74
+
75
+ # Stamps a dunder on the class that meta.py checks before falling back to
76
+ # the default naming convention (lowercase class name + "s"). The decorator
77
+ # returns the class unmodified — no wrapper, no metaclass — so dataclass
78
+ # semantics are fully preserved.
79
+ # Ordering note: @cygnet.table must be applied to a class that is already
80
+ # (or will be) a dataclass; the stamp survives @dataclass either way
81
+ # because @dataclass mutates the class in place rather than wrapping it.
82
+ def decorator(cls: Any) -> Any:
83
+ cls.__cygnet_table__ = name
84
+ return cls
85
+
86
+ return decorator