Skip to content

API Reference

This section is the map to Arvel’s public surface area. The pages below are auto-generated from the source code using mkdocstrings (Google-style docstrings, members in source order). When you upgrade Arvel or pin a new minor release, regenerate the docs so signatures and narrative descriptions stay aligned with what actually ships.

Use these modules as entry points: they group the framework by concern—data layer, HTTP stack, bootstrap and providers, auth, caching, events, queues, validation, and the testing helpers. Subpackages expand into more specific types as you drill in.

Core framework

arvel.data — ORM models, repositories, query builder, relationships, transactions, observers, and migrations-facing utilities. This is where typed Mapped columns and the repository pattern live.

arvel.http — Routing resources, controllers, requests, responses, and HTTP-facing glue that sits alongside FastAPI.

arvel.foundation — Application bootstrap, service container, providers, and lifecycle hooks that wire the framework together.

Cross-cutting services

arvel.auth — Guards, user resolution, and authentication plumbing you integrate with your identity strategy.

arvel.cache — Cache contracts and drivers; swap implementations per environment without changing call sites.

arvel.events — Domain events, dispatch, and listener registration for decoupled reactions inside the app.

arvel.queue — Job types, dispatch, and queue contracts for async and background work.

arvel.validation — Validation rules and helpers that align with Arvel’s request and DTO patterns.

Testing

arvel.testingTestClient, TestResponse, DatabaseTestCase, ModelFactory, FactoryBuilder, and fakes for cache, mail, queue, storage, locks, media, notifications, events, and broadcasting.


arvel.data

Arvel data layer.

ORM, repositories, transactions, observers, migrations, seeders, views, relationships, result types, collections, scopes, casts, soft deletes, and polymorphic relationships.

Imports are lazy — submodules are loaded on first attribute access to keep import arvel.data fast.

Caster

Bases: Protocol

Protocol for custom cast classes.

Source code in src/arvel/data/casts.py
36
37
38
39
40
41
42
43
44
45
46
@runtime_checkable
class Caster(Protocol):
    """Protocol for custom cast classes."""

    def get(self, value: Any, attr_name: str, model: object) -> Any:
        """Transform the database value into a Python value."""
        ...

    def set(self, value: Any, attr_name: str, model: object) -> Any:
        """Transform the Python value into a database value."""
        ...

get(value, attr_name, model)

Transform the database value into a Python value.

Source code in src/arvel/data/casts.py
40
41
42
def get(self, value: Any, attr_name: str, model: object) -> Any:
    """Transform the database value into a Python value."""
    ...

set(value, attr_name, model)

Transform the Python value into a database value.

Source code in src/arvel/data/casts.py
44
45
46
def set(self, value: Any, attr_name: str, model: object) -> Any:
    """Transform the Python value into a database value."""
    ...

EncryptedCaster

Cast via the framework's EncrypterContract.

The encrypter is resolved lazily at first use to avoid circular imports.

Source code in src/arvel/data/casts.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
class EncryptedCaster:
    """Cast via the framework's EncrypterContract.

    The encrypter is resolved lazily at first use to avoid circular imports.
    """

    def __init__(self, encrypter: _Encrypter | None = None) -> None:
        self._encrypter = encrypter

    def _get_encrypter(self) -> _Encrypter:
        if self._encrypter is not None:
            return self._encrypter
        msg = (
            "EncryptedCaster requires an encrypter instance. "
            "Pass it via __cast_encrypter__ on the model or configure the DI container."
        )
        raise RuntimeError(msg)

    def get(self, value: Any, attr_name: str, model: object) -> Any:
        if value is None:
            return None
        encrypter = self._resolve_encrypter(model)
        return encrypter.decrypt(value)

    def set(self, value: Any, attr_name: str, model: object) -> Any:
        if value is None:
            return None
        encrypter = self._resolve_encrypter(model)
        return encrypter.encrypt(value)

    def _resolve_encrypter(self, model: object) -> _Encrypter:
        model_encrypter = getattr(model, "__cast_encrypter__", None)
        if model_encrypter is not None:
            return model_encrypter  # type: ignore[return-value]
        if self._encrypter is not None:
            return self._encrypter
        return self._get_encrypter()

EnumCaster

Cast between enum members and their values.

Source code in src/arvel/data/casts.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
class EnumCaster:
    """Cast between enum members and their values."""

    def __init__(self, enum_cls: type[enum.Enum]) -> None:
        self._enum_cls = enum_cls

    def get(self, value: Any, attr_name: str, model: object) -> Any:
        if value is None:
            return None
        if isinstance(value, self._enum_cls):
            return value
        return self._enum_cls(value)

    def set(self, value: Any, attr_name: str, model: object) -> Any:
        if value is None:
            return None
        if isinstance(value, self._enum_cls):
            return value.value
        return value

JsonCaster

Cast between Python dicts/lists and JSON strings.

Source code in src/arvel/data/casts.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class JsonCaster:
    """Cast between Python dicts/lists and JSON strings."""

    def get(self, value: Any, attr_name: str, model: object) -> Any:
        if value is None:
            return None
        if isinstance(value, (dict, list)):
            return value
        return json.loads(value)

    def set(self, value: Any, attr_name: str, model: object) -> Any:
        if value is None:
            return None
        if isinstance(value, str):
            return value
        return json.dumps(value)

ArvelCollection

Bases: list[T]

List subclass with chainable data-manipulation helpers.

Behaves exactly like a built-in list for iteration, indexing, len(), truthiness, and serialization — but adds fluent methods inspired by Laravel's Collection.

Source code in src/arvel/data/collection.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
class ArvelCollection(list[T]):
    """List subclass with chainable data-manipulation helpers.

    Behaves exactly like a built-in ``list`` for iteration, indexing,
    ``len()``, truthiness, and serialization — but adds fluent methods
    inspired by Laravel's ``Collection``.
    """

    # ------------------------------------------------------------------
    # Element access
    # ------------------------------------------------------------------

    def first(self, default: T | None = None) -> T | None:
        """Return the first item, or *default* if the collection is empty."""
        return self[0] if self else default

    def last(self, default: T | None = None) -> T | None:
        """Return the last item, or *default* if the collection is empty."""
        return self[-1] if self else default

    # ------------------------------------------------------------------
    # Transformation
    # ------------------------------------------------------------------

    def map(self, fn: Callable[[T], R]) -> ArvelCollection[R]:
        """Apply *fn* to each item and return a new collection."""
        return ArvelCollection(fn(item) for item in self)

    def flat_map(self, fn: Callable[[T], list[R]]) -> ArvelCollection[R]:
        """Map then flatten one level."""
        result: list[R] = []
        for item in self:
            result.extend(fn(item))
        return ArvelCollection(result)

    def filter(self, fn: Callable[[T], bool] | None = None) -> ArvelCollection[T]:
        """Keep items where *fn* returns truthy, or all truthy items if *fn* is None."""
        if fn is None:
            return ArvelCollection(item for item in self if item)
        return ArvelCollection(item for item in self if fn(item))

    def reject(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
        """Remove items where *fn* returns truthy."""
        return ArvelCollection(item for item in self if not fn(item))

    def each(self, fn: Callable[[T], object]) -> ArvelCollection[T]:
        """Call *fn* on each item for side effects; return self."""
        for item in self:
            fn(item)
        return self

    # ------------------------------------------------------------------
    # Extraction
    # ------------------------------------------------------------------

    def pluck(self, key: str) -> ArvelCollection[Any]:
        """Extract a single attribute or dict key from each item."""
        return ArvelCollection(_extract(item, key) for item in self)

    def values(self) -> ArvelCollection[T]:
        """Return a re-indexed copy (removes gaps after filtering)."""
        return ArvelCollection(self)

    # ------------------------------------------------------------------
    # Filtering / searching
    # ------------------------------------------------------------------

    def where(self, key: str, value: Any) -> ArvelCollection[T]:
        """Keep items where ``item.key == value`` (or ``item[key] == value``)."""
        return ArvelCollection(item for item in self if _extract(item, key, _SENTINEL) == value)

    def where_in(self, key: str, values: list[Any] | set[Any]) -> ArvelCollection[T]:
        """Keep items where ``item.key`` is in *values*."""
        value_set = set(values)
        return ArvelCollection(item for item in self if _extract(item, key, _SENTINEL) in value_set)

    def first_where(self, key: str, value: Any) -> T | None:
        """Return the first item matching ``key == value``, or None."""
        for item in self:
            if _extract(item, key, _SENTINEL) == value:
                return item
        return None

    def contains(self, fn_or_value: Callable[[T], bool] | Any) -> bool:
        """Check if *any* item satisfies *fn* or equals *value*."""
        if callable(fn_or_value):
            return any(fn_or_value(item) for item in self)
        return fn_or_value in self

    # ------------------------------------------------------------------
    # Grouping / chunking
    # ------------------------------------------------------------------

    def group_by(self, key: str | Callable[[T], Hashable]) -> dict[Any, ArvelCollection[T]]:
        """Group items by a key attribute name or callable."""
        result: dict[Any, ArvelCollection[T]] = {}
        if isinstance(key, str):
            for item in self:
                result.setdefault(_extract(item, key), ArvelCollection()).append(item)
        else:
            for item in self:
                result.setdefault(key(item), ArvelCollection()).append(item)
        return result

    def chunk(self, size: int) -> ArvelCollection[ArvelCollection[T]]:
        """Split into sub-collections of at most *size* items."""
        if size < 1:
            msg = "chunk size must be >= 1"
            raise ValueError(msg)
        it: Iterator[T] = iter(self)
        chunks: list[ArvelCollection[T]] = []
        while True:
            batch = ArvelCollection(islice(it, size))
            if not batch:
                break
            chunks.append(batch)
        return ArvelCollection(chunks)

    # ------------------------------------------------------------------
    # Ordering / uniqueness
    # ------------------------------------------------------------------

    def sort_by(
        self,
        key: str | Callable[[T], Any],
        *,
        descending: bool = False,
    ) -> ArvelCollection[T]:
        """Return a new collection sorted by *key*."""
        if isinstance(key, str):
            attr_name = key

            def sort_fn(item: T) -> Any:
                return _extract(item, attr_name)
        else:
            sort_fn = key
        return ArvelCollection(
            sorted(self, key=sort_fn, reverse=descending),
        )

    def unique(self, key: str | Callable[[T], Hashable] | None = None) -> ArvelCollection[T]:
        """Remove duplicates, preserving order.

        If *key* is given, uniqueness is determined by the key value.
        """
        seen: set[Any] = set()
        result: list[T] = []
        if key is None:
            for item in self:
                identity: Any = id(item) if not isinstance(item, Hashable) else item
                if identity not in seen:
                    seen.add(identity)
                    result.append(item)
        elif isinstance(key, str):
            for item in self:
                identity = _extract(item, key)
                if identity not in seen:
                    seen.add(identity)
                    result.append(item)
        else:
            for item in self:
                identity = key(item)
                if identity not in seen:
                    seen.add(identity)
                    result.append(item)
        return ArvelCollection(result)

    def reverse(self) -> ArvelCollection[T]:  # type: ignore[override]  # ty: ignore[invalid-method-override]  # Intentional: non-mutating API
        """Return a new reversed collection (non-mutating).

        Intentionally overrides ``list.reverse()`` to return a new
        collection instead of mutating in place, matching the immutable
        API style of all other ArvelCollection methods.
        """
        return ArvelCollection(reversed(self))

    # ------------------------------------------------------------------
    # Aggregation
    # ------------------------------------------------------------------

    def count(self) -> int:  # type: ignore[override]  # ty: ignore[invalid-method-override]  # Intentional: Laravel-style count()
        """Return the number of items (alias for ``len()``).

        Intentionally overrides ``list.count(value)`` to return the
        collection length without requiring a value argument, matching
        Laravel's Collection.count() API.
        """
        return len(self)

    def sum(self, key: str | Callable[[T], Any] | None = None) -> Any:
        """Sum item values, optionally by attribute or callable."""
        if key is None:
            return builtins.sum(self)
        if isinstance(key, str):
            return builtins.sum(_extract(item, key, 0) for item in self)
        return builtins.sum(key(item) for item in self)

    def avg(self, key: str | Callable[[T], Any] | None = None) -> float:
        """Return the average value."""
        if not self:
            return 0.0
        total = self.sum(key)
        return total / len(self)

    def min(self, key: str | Callable[[T], Any] | None = None) -> Any:
        """Return the minimum value."""
        if not self:
            return None
        if key is None:
            return builtins.min(self)
        if isinstance(key, str):
            return builtins.min(_extract(item, key, None) for item in self)
        return builtins.min(key(item) for item in self)

    def max(self, key: str | Callable[[T], Any] | None = None) -> Any:
        """Return the maximum value."""
        if not self:
            return None
        if key is None:
            return builtins.max(self)
        if isinstance(key, str):
            return builtins.max(_extract(item, key, None) for item in self)
        return builtins.max(key(item) for item in self)

    # ------------------------------------------------------------------
    # Slicing
    # ------------------------------------------------------------------

    def take(self, n: int) -> ArvelCollection[T]:
        """Take the first *n* items (negative *n* takes from the end)."""
        if n < 0:
            return ArvelCollection(self[n:])
        return ArvelCollection(self[:n])

    def skip(self, n: int) -> ArvelCollection[T]:
        """Skip the first *n* items."""
        return ArvelCollection(self[n:])

    def slice(self, offset: int, length: int | None = None) -> ArvelCollection[T]:
        """Extract a portion starting at *offset*."""
        if length is None:
            return ArvelCollection(self[offset:])
        return ArvelCollection(self[offset : offset + length])

    def nth(self, step: int, offset: int = 0) -> ArvelCollection[T]:
        """Return every *step*-th item, starting at *offset*."""
        return ArvelCollection(self[offset::step])

    def for_page(self, page: int, per_page: int) -> ArvelCollection[T]:
        """Return a page of results (1-indexed)."""
        start = (page - 1) * per_page
        return ArvelCollection(self[start : start + per_page])

    def take_while(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
        """Take items while *fn* returns truthy."""
        result: list[T] = []
        for item in self:
            if not fn(item):
                break
            result.append(item)
        return ArvelCollection(result)

    def take_until(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
        """Take items until *fn* returns truthy."""
        result: list[T] = []
        for item in self:
            if fn(item):
                break
            result.append(item)
        return ArvelCollection(result)

    def skip_while(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
        """Skip items while *fn* returns truthy."""
        skipping = True
        result: list[T] = []
        for item in self:
            if skipping and fn(item):
                continue
            skipping = False
            result.append(item)
        return ArvelCollection(result)

    def skip_until(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
        """Skip items until *fn* returns truthy."""
        skipping = True
        result: list[T] = []
        for item in self:
            if skipping:
                if fn(item):
                    skipping = False
                    result.append(item)
                continue
            result.append(item)
        return ArvelCollection(result)

    # ------------------------------------------------------------------
    # Set operations
    # ------------------------------------------------------------------

    def merge(self, other: Iterable[T]) -> ArvelCollection[T]:
        """Combine with another iterable."""
        return ArvelCollection([*self, *other])

    def concat(self, other: Iterable[T]) -> ArvelCollection[T]:
        """Alias for ``merge``."""
        return self.merge(other)

    def diff(self, other: Iterable[Any]) -> ArvelCollection[T]:
        """Items in this collection but not in *other*."""
        other_set = set(other)
        return ArvelCollection(item for item in self if item not in other_set)

    def intersect(self, other: Iterable[Any]) -> ArvelCollection[T]:
        """Items present in both this collection and *other*."""
        other_set = set(other)
        return ArvelCollection(item for item in self if item in other_set)

    def zip(self, other: Iterable[R]) -> ArvelCollection[tuple[T, R]]:
        """Pair items with another iterable."""
        return ArvelCollection(builtins.zip(self, other, strict=False))

    # ------------------------------------------------------------------
    # Partitioning / splitting
    # ------------------------------------------------------------------

    def partition(self, fn: Callable[[T], bool]) -> tuple[ArvelCollection[T], ArvelCollection[T]]:
        """Split into two collections: items passing *fn* and items failing."""
        passing: list[T] = []
        failing: list[T] = []
        for item in self:
            (passing if fn(item) else failing).append(item)
        return ArvelCollection(passing), ArvelCollection(failing)

    def split(self, n: int) -> ArvelCollection[ArvelCollection[T]]:
        """Split into *n* groups as evenly as possible."""
        if n < 1:
            msg = "split count must be >= 1"
            raise ValueError(msg)
        k, m = divmod(len(self), n)
        groups: list[ArvelCollection[T]] = []
        start = 0
        for i in range(n):
            end = start + k + (1 if i < m else 0)
            groups.append(ArvelCollection(self[start:end]))
            start = end
        return ArvelCollection(groups)

    def sliding(self, size: int, step: int = 1) -> ArvelCollection[ArvelCollection[T]]:
        """Return a sliding window of *size* items, advancing by *step*."""
        if size < 1:
            msg = "window size must be >= 1"
            raise ValueError(msg)
        windows: list[ArvelCollection[T]] = []
        for i in range(0, len(self) - size + 1, step):
            windows.append(ArvelCollection(self[i : i + size]))
        return ArvelCollection(windows)

    # ------------------------------------------------------------------
    # Flattening
    # ------------------------------------------------------------------

    def flatten(self, depth: int = -1) -> ArvelCollection[Any]:
        """Flatten nested iterables (excluding strings/dicts).

        *depth=-1* flattens fully; *depth=1* flattens one level.
        """
        return ArvelCollection(_flatten_iter(self, depth))

    def collapse(self) -> ArvelCollection[Any]:
        """Flatten one level of nesting."""
        return self.flatten(depth=1)

    # ------------------------------------------------------------------
    # Conditional flow
    # ------------------------------------------------------------------

    def pipe(self, fn: Callable[[ArvelCollection[T]], R]) -> R:
        """Pass the entire collection through *fn* and return the result."""
        return fn(self)

    def tap(self, fn: Callable[[ArvelCollection[T]], object]) -> ArvelCollection[T]:
        """Call *fn* with the collection for side effects, return self."""
        fn(self)
        return self

    def when(
        self,
        condition: bool,
        fn: Callable[[ArvelCollection[T]], ArvelCollection[T]],
        default: Callable[[ArvelCollection[T]], ArvelCollection[T]] | None = None,
    ) -> ArvelCollection[T]:
        """Apply *fn* if *condition* is truthy, otherwise apply *default*."""
        if condition:
            return fn(self)
        if default is not None:
            return default(self)
        return self

    def unless(
        self,
        condition: bool,
        fn: Callable[[ArvelCollection[T]], ArvelCollection[T]],
        default: Callable[[ArvelCollection[T]], ArvelCollection[T]] | None = None,
    ) -> ArvelCollection[T]:
        """Apply *fn* if *condition* is falsy, otherwise apply *default*."""
        if not condition:
            return fn(self)
        if default is not None:
            return default(self)
        return self

    # ------------------------------------------------------------------
    # Reduction / folding
    # ------------------------------------------------------------------

    def reduce(self, fn: Callable[[R, T], R], initial: R) -> R:
        """Fold left over the collection."""
        return _reduce(fn, self, initial)

    def every(self, fn: Callable[[T], bool]) -> bool:
        """Return ``True`` if all items satisfy *fn*."""
        return all(fn(item) for item in self)

    def some(self, fn: Callable[[T], bool]) -> bool:
        """Return ``True`` if any item satisfies *fn*. Alias for callable contains."""
        return any(fn(item) for item in self)

    # ------------------------------------------------------------------
    # Searching
    # ------------------------------------------------------------------

    @overload
    def search(self, value: Callable[[T], bool]) -> int | None: ...
    @overload
    def search(self, value: T) -> int | None: ...
    def search(self, value: T | Callable[[T], bool]) -> int | None:
        """Return the index of the first matching item, or ``None``.

        Accepts a value or a callable predicate.
        """
        if callable(value):
            predicate: Callable[[T], bool] = value  # type: ignore[assignment]  # ty: ignore[invalid-assignment]  # overloads guarantee Callable[[T], bool]
            for i, item in enumerate(self):
                if predicate(item):
                    return i
            return None
        try:
            return self.index(value)
        except ValueError:
            return None

    def sole(self, fn: Callable[[T], bool] | None = None) -> T:
        """Return the only item matching *fn*, or the only item if *fn* is None.

        Raises ``ValueError`` if zero or more than one match.
        """
        if fn is None:
            if len(self) != 1:
                msg = f"Expected exactly 1 item, got {len(self)}"
                raise ValueError(msg)
            return self[0]
        matches = [item for item in self if fn(item)]
        if len(matches) != 1:
            msg = f"Expected exactly 1 matching item, got {len(matches)}"
            raise ValueError(msg)
        return matches[0]

    # ------------------------------------------------------------------
    # String
    # ------------------------------------------------------------------

    def implode(self, glue: str, key: str | None = None) -> str:
        """Join items into a string, optionally plucking *key* first."""
        if key is not None:
            return glue.join(str(_extract(item, key, "")) for item in self)
        return glue.join(str(item) for item in self)

    # ------------------------------------------------------------------
    # Randomness
    # ------------------------------------------------------------------

    def random(self, count: int = 1) -> T | ArvelCollection[T]:
        """Return *count* random items. Returns a single item when count=1."""
        if not self:
            msg = "Cannot get random items from an empty collection"
            raise ValueError(msg)
        if count == 1:
            return _random.choice(self)  # noqa: S311 — not cryptographic
        return ArvelCollection(
            _random.sample(self, min(count, len(self))),
        )

    def shuffle(self) -> ArvelCollection[T]:
        """Return a new collection with items in random order."""
        items = list(self)
        _random.shuffle(items)
        return ArvelCollection(items)

    # ------------------------------------------------------------------
    # Extended aggregation
    # ------------------------------------------------------------------

    def median(self, key: str | Callable[[T], Any] | None = None) -> float:
        """Return the statistical median."""
        values = self._numeric_values(key)
        if not values:
            return 0.0
        return _statistics.median(values)

    def mode(self, key: str | Callable[[T], Any] | None = None) -> ArvelCollection[Any]:
        """Return the most common value(s)."""
        values = self._numeric_values(key)
        if not values:
            return ArvelCollection()
        counter = Counter(values)
        max_count = max(counter.values())
        return ArvelCollection(v for v, c in counter.items() if c == max_count)

    def count_by(self, fn: Callable[[T], Any] | str | None = None) -> dict[Any, int]:
        """Count occurrences grouped by the return value of *fn* or attribute *key*."""
        counter: dict[Any, int] = {}
        if fn is None:
            for item in self:
                counter[item] = counter.get(item, 0) + 1
        elif isinstance(fn, str):
            for item in self:
                k = _extract(item, fn)
                counter[k] = counter.get(k, 0) + 1
        else:
            for item in self:
                k = fn(item)
                counter[k] = counter.get(k, 0) + 1
        return counter

    def duplicates(self, key: str | Callable[[T], Any] | None = None) -> ArvelCollection[T]:
        """Return items that appear more than once."""
        identity_fn = _make_identity_fn(key)
        seen: dict[Any, int] = {}
        for item in self:
            identity = identity_fn(item)
            seen[identity] = seen.get(identity, 0) + 1
        dup_keys = {k for k, v in seen.items() if v > 1}
        result: list[T] = []
        added: set[Any] = set()
        for item in self:
            identity = identity_fn(item)
            if identity in dup_keys and identity not in added:
                result.append(item)
                added.add(identity)
        return ArvelCollection(result)

    # ------------------------------------------------------------------
    # Mutation (returns new collections for immutability by default)
    # ------------------------------------------------------------------

    def prepend(self, item: T) -> ArvelCollection[T]:
        """Return a new collection with *item* at the front."""
        return ArvelCollection([item, *self])

    def push(self, *items: T) -> ArvelCollection[T]:
        """Return a new collection with *items* appended."""
        return ArvelCollection([*self, *items])

    def pop_item(self) -> tuple[T, ArvelCollection[T]]:
        """Return the last item and a new collection without it.

        Named ``pop_item`` to avoid shadowing ``list.pop``.
        """
        if not self:
            msg = "Cannot pop from an empty collection"
            raise ValueError(msg)
        return self[-1], ArvelCollection(self[:-1])

    def shift(self) -> tuple[T, ArvelCollection[T]]:
        """Return the first item and a new collection without it."""
        if not self:
            msg = "Cannot shift from an empty collection"
            raise ValueError(msg)
        return self[0], ArvelCollection(self[1:])

    # ------------------------------------------------------------------
    # Niche utilities
    # ------------------------------------------------------------------

    def ensure(self, *types: type) -> ArvelCollection[T]:
        """Verify every item is an instance of one of *types*.

        Returns self if all items match, raises ``TypeError`` otherwise.
        """
        for item in self:
            if not isinstance(item, types):
                expected = " | ".join(t.__name__ for t in types)
                msg = f"Expected {expected}, got {type(item).__name__}"
                raise TypeError(msg)
        return self

    def percentage(self, fn: Callable[[T], bool], precision: int = 2) -> float:
        """Return the percentage of items satisfying *fn*."""
        if not self:
            return 0.0
        count = builtins.sum(1 for item in self if fn(item))
        return round(count / len(self) * 100, precision)

    def multiply(self, n: int) -> ArvelCollection[T]:
        """Repeat the collection *n* times."""
        return ArvelCollection(list(self) * n)

    def after(self, fn_or_value: Callable[[T], bool] | Any) -> T | None:
        """Return the item after the first match, or ``None``."""
        it = iter(self)
        if callable(fn_or_value):
            for item in it:
                if fn_or_value(item):
                    return next(it, None)
        else:
            for item in it:
                if item == fn_or_value:
                    return next(it, None)
        return None

    def before(self, fn_or_value: Callable[[T], bool] | Any) -> T | None:
        """Return the item before the first match, or ``None``."""
        prev: T | None = None
        if callable(fn_or_value):
            for item in self:
                if fn_or_value(item):
                    return prev
                prev = item
        else:
            for item in self:
                if item == fn_or_value:
                    return prev
                prev = item
        return None

    def select(self, *keys: str) -> ArvelCollection[dict[str, Any]]:
        """Pick only the given *keys* from each item (dict or object)."""

        def _pick(item: Any) -> dict[str, Any]:
            if isinstance(item, dict):
                return {k: item.get(k) for k in keys}
            return {k: getattr(item, k, None) for k in keys}

        return ArvelCollection(_pick(item) for item in self)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _numeric_values(self, key: str | Callable[[T], Any] | None = None) -> list[float]:
        """Extract numeric values for statistical methods."""
        values: list[float] = []
        if key is None:

            def extractor(item: T) -> Any:
                return item
        elif isinstance(key, str):
            attr = key

            def extractor(item: T) -> Any:
                return _extract(item, attr)
        else:
            extractor = key
        for item in self:
            v: Any = extractor(item)
            if v is not None:
                with contextlib.suppress(TypeError, ValueError):
                    values.append(float(v))
        return values

    # ------------------------------------------------------------------
    # Conversion
    # ------------------------------------------------------------------

    def to_list(self) -> list[T]:
        """Return a plain Python list."""
        return list(self)

    def to_dict(self, key: str) -> dict[Any, T]:
        """Key items by an attribute, returning a dict."""
        result: dict[Any, T] = {}
        for item in self:
            result[_extract(item, key)] = item
        return result

    def is_empty(self) -> bool:
        return len(self) == 0

    def is_not_empty(self) -> bool:
        return len(self) > 0

    # ------------------------------------------------------------------
    # Serialization
    # ------------------------------------------------------------------

    def _serialize_item(self, item: Any) -> Any:
        """Convert a single item to a JSON-safe representation."""
        dump = getattr(item, "model_dump", None)
        if callable(dump):
            return dump()
        return item

    def to_json(self, *, indent: int = 2) -> str:
        """Serialize the collection to a JSON string.

        Calls ``model_dump()`` on Pydantic/ArvelModel items so the
        output is always plain dicts that ``json.dumps`` can handle.
        """
        serialized = [self._serialize_item(item) for item in self]
        return json.dumps(serialized, indent=indent, default=str)

    def __str__(self) -> str:
        return self.to_json()

    def __repr__(self) -> str:
        return f"ArvelCollection({list.__repr__(self)})"

first(default=None)

Return the first item, or default if the collection is empty.

Source code in src/arvel/data/collection.py
40
41
42
def first(self, default: T | None = None) -> T | None:
    """Return the first item, or *default* if the collection is empty."""
    return self[0] if self else default

last(default=None)

Return the last item, or default if the collection is empty.

Source code in src/arvel/data/collection.py
44
45
46
def last(self, default: T | None = None) -> T | None:
    """Return the last item, or *default* if the collection is empty."""
    return self[-1] if self else default

map(fn)

Apply fn to each item and return a new collection.

Source code in src/arvel/data/collection.py
52
53
54
def map(self, fn: Callable[[T], R]) -> ArvelCollection[R]:
    """Apply *fn* to each item and return a new collection."""
    return ArvelCollection(fn(item) for item in self)

flat_map(fn)

Map then flatten one level.

Source code in src/arvel/data/collection.py
56
57
58
59
60
61
def flat_map(self, fn: Callable[[T], list[R]]) -> ArvelCollection[R]:
    """Map then flatten one level."""
    result: list[R] = []
    for item in self:
        result.extend(fn(item))
    return ArvelCollection(result)

filter(fn=None)

Keep items where fn returns truthy, or all truthy items if fn is None.

Source code in src/arvel/data/collection.py
63
64
65
66
67
def filter(self, fn: Callable[[T], bool] | None = None) -> ArvelCollection[T]:
    """Keep items where *fn* returns truthy, or all truthy items if *fn* is None."""
    if fn is None:
        return ArvelCollection(item for item in self if item)
    return ArvelCollection(item for item in self if fn(item))

reject(fn)

Remove items where fn returns truthy.

Source code in src/arvel/data/collection.py
69
70
71
def reject(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
    """Remove items where *fn* returns truthy."""
    return ArvelCollection(item for item in self if not fn(item))

each(fn)

Call fn on each item for side effects; return self.

Source code in src/arvel/data/collection.py
73
74
75
76
77
def each(self, fn: Callable[[T], object]) -> ArvelCollection[T]:
    """Call *fn* on each item for side effects; return self."""
    for item in self:
        fn(item)
    return self

pluck(key)

Extract a single attribute or dict key from each item.

Source code in src/arvel/data/collection.py
83
84
85
def pluck(self, key: str) -> ArvelCollection[Any]:
    """Extract a single attribute or dict key from each item."""
    return ArvelCollection(_extract(item, key) for item in self)

values()

Return a re-indexed copy (removes gaps after filtering).

Source code in src/arvel/data/collection.py
87
88
89
def values(self) -> ArvelCollection[T]:
    """Return a re-indexed copy (removes gaps after filtering)."""
    return ArvelCollection(self)

where(key, value)

Keep items where item.key == value (or item[key] == value).

Source code in src/arvel/data/collection.py
95
96
97
def where(self, key: str, value: Any) -> ArvelCollection[T]:
    """Keep items where ``item.key == value`` (or ``item[key] == value``)."""
    return ArvelCollection(item for item in self if _extract(item, key, _SENTINEL) == value)

where_in(key, values)

Keep items where item.key is in values.

Source code in src/arvel/data/collection.py
 99
100
101
102
def where_in(self, key: str, values: list[Any] | set[Any]) -> ArvelCollection[T]:
    """Keep items where ``item.key`` is in *values*."""
    value_set = set(values)
    return ArvelCollection(item for item in self if _extract(item, key, _SENTINEL) in value_set)

first_where(key, value)

Return the first item matching key == value, or None.

Source code in src/arvel/data/collection.py
104
105
106
107
108
109
def first_where(self, key: str, value: Any) -> T | None:
    """Return the first item matching ``key == value``, or None."""
    for item in self:
        if _extract(item, key, _SENTINEL) == value:
            return item
    return None

contains(fn_or_value)

Check if any item satisfies fn or equals value.

Source code in src/arvel/data/collection.py
111
112
113
114
115
def contains(self, fn_or_value: Callable[[T], bool] | Any) -> bool:
    """Check if *any* item satisfies *fn* or equals *value*."""
    if callable(fn_or_value):
        return any(fn_or_value(item) for item in self)
    return fn_or_value in self

group_by(key)

Group items by a key attribute name or callable.

Source code in src/arvel/data/collection.py
121
122
123
124
125
126
127
128
129
130
def group_by(self, key: str | Callable[[T], Hashable]) -> dict[Any, ArvelCollection[T]]:
    """Group items by a key attribute name or callable."""
    result: dict[Any, ArvelCollection[T]] = {}
    if isinstance(key, str):
        for item in self:
            result.setdefault(_extract(item, key), ArvelCollection()).append(item)
    else:
        for item in self:
            result.setdefault(key(item), ArvelCollection()).append(item)
    return result

chunk(size)

Split into sub-collections of at most size items.

Source code in src/arvel/data/collection.py
132
133
134
135
136
137
138
139
140
141
142
143
144
def chunk(self, size: int) -> ArvelCollection[ArvelCollection[T]]:
    """Split into sub-collections of at most *size* items."""
    if size < 1:
        msg = "chunk size must be >= 1"
        raise ValueError(msg)
    it: Iterator[T] = iter(self)
    chunks: list[ArvelCollection[T]] = []
    while True:
        batch = ArvelCollection(islice(it, size))
        if not batch:
            break
        chunks.append(batch)
    return ArvelCollection(chunks)

sort_by(key, *, descending=False)

Return a new collection sorted by key.

Source code in src/arvel/data/collection.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def sort_by(
    self,
    key: str | Callable[[T], Any],
    *,
    descending: bool = False,
) -> ArvelCollection[T]:
    """Return a new collection sorted by *key*."""
    if isinstance(key, str):
        attr_name = key

        def sort_fn(item: T) -> Any:
            return _extract(item, attr_name)
    else:
        sort_fn = key
    return ArvelCollection(
        sorted(self, key=sort_fn, reverse=descending),
    )

unique(key=None)

Remove duplicates, preserving order.

If key is given, uniqueness is determined by the key value.

Source code in src/arvel/data/collection.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def unique(self, key: str | Callable[[T], Hashable] | None = None) -> ArvelCollection[T]:
    """Remove duplicates, preserving order.

    If *key* is given, uniqueness is determined by the key value.
    """
    seen: set[Any] = set()
    result: list[T] = []
    if key is None:
        for item in self:
            identity: Any = id(item) if not isinstance(item, Hashable) else item
            if identity not in seen:
                seen.add(identity)
                result.append(item)
    elif isinstance(key, str):
        for item in self:
            identity = _extract(item, key)
            if identity not in seen:
                seen.add(identity)
                result.append(item)
    else:
        for item in self:
            identity = key(item)
            if identity not in seen:
                seen.add(identity)
                result.append(item)
    return ArvelCollection(result)

reverse()

Return a new reversed collection (non-mutating).

Intentionally overrides list.reverse() to return a new collection instead of mutating in place, matching the immutable API style of all other ArvelCollection methods.

Source code in src/arvel/data/collection.py
195
196
197
198
199
200
201
202
def reverse(self) -> ArvelCollection[T]:  # type: ignore[override]  # ty: ignore[invalid-method-override]  # Intentional: non-mutating API
    """Return a new reversed collection (non-mutating).

    Intentionally overrides ``list.reverse()`` to return a new
    collection instead of mutating in place, matching the immutable
    API style of all other ArvelCollection methods.
    """
    return ArvelCollection(reversed(self))

count()

Return the number of items (alias for len()).

Intentionally overrides list.count(value) to return the collection length without requiring a value argument, matching Laravel's Collection.count() API.

Source code in src/arvel/data/collection.py
208
209
210
211
212
213
214
215
def count(self) -> int:  # type: ignore[override]  # ty: ignore[invalid-method-override]  # Intentional: Laravel-style count()
    """Return the number of items (alias for ``len()``).

    Intentionally overrides ``list.count(value)`` to return the
    collection length without requiring a value argument, matching
    Laravel's Collection.count() API.
    """
    return len(self)

sum(key=None)

Sum item values, optionally by attribute or callable.

Source code in src/arvel/data/collection.py
217
218
219
220
221
222
223
def sum(self, key: str | Callable[[T], Any] | None = None) -> Any:
    """Sum item values, optionally by attribute or callable."""
    if key is None:
        return builtins.sum(self)
    if isinstance(key, str):
        return builtins.sum(_extract(item, key, 0) for item in self)
    return builtins.sum(key(item) for item in self)

avg(key=None)

Return the average value.

Source code in src/arvel/data/collection.py
225
226
227
228
229
230
def avg(self, key: str | Callable[[T], Any] | None = None) -> float:
    """Return the average value."""
    if not self:
        return 0.0
    total = self.sum(key)
    return total / len(self)

min(key=None)

Return the minimum value.

Source code in src/arvel/data/collection.py
232
233
234
235
236
237
238
239
240
def min(self, key: str | Callable[[T], Any] | None = None) -> Any:
    """Return the minimum value."""
    if not self:
        return None
    if key is None:
        return builtins.min(self)
    if isinstance(key, str):
        return builtins.min(_extract(item, key, None) for item in self)
    return builtins.min(key(item) for item in self)

max(key=None)

Return the maximum value.

Source code in src/arvel/data/collection.py
242
243
244
245
246
247
248
249
250
def max(self, key: str | Callable[[T], Any] | None = None) -> Any:
    """Return the maximum value."""
    if not self:
        return None
    if key is None:
        return builtins.max(self)
    if isinstance(key, str):
        return builtins.max(_extract(item, key, None) for item in self)
    return builtins.max(key(item) for item in self)

take(n)

Take the first n items (negative n takes from the end).

Source code in src/arvel/data/collection.py
256
257
258
259
260
def take(self, n: int) -> ArvelCollection[T]:
    """Take the first *n* items (negative *n* takes from the end)."""
    if n < 0:
        return ArvelCollection(self[n:])
    return ArvelCollection(self[:n])

skip(n)

Skip the first n items.

Source code in src/arvel/data/collection.py
262
263
264
def skip(self, n: int) -> ArvelCollection[T]:
    """Skip the first *n* items."""
    return ArvelCollection(self[n:])

slice(offset, length=None)

Extract a portion starting at offset.

Source code in src/arvel/data/collection.py
266
267
268
269
270
def slice(self, offset: int, length: int | None = None) -> ArvelCollection[T]:
    """Extract a portion starting at *offset*."""
    if length is None:
        return ArvelCollection(self[offset:])
    return ArvelCollection(self[offset : offset + length])

nth(step, offset=0)

Return every step-th item, starting at offset.

Source code in src/arvel/data/collection.py
272
273
274
def nth(self, step: int, offset: int = 0) -> ArvelCollection[T]:
    """Return every *step*-th item, starting at *offset*."""
    return ArvelCollection(self[offset::step])

for_page(page, per_page)

Return a page of results (1-indexed).

Source code in src/arvel/data/collection.py
276
277
278
279
def for_page(self, page: int, per_page: int) -> ArvelCollection[T]:
    """Return a page of results (1-indexed)."""
    start = (page - 1) * per_page
    return ArvelCollection(self[start : start + per_page])

take_while(fn)

Take items while fn returns truthy.

Source code in src/arvel/data/collection.py
281
282
283
284
285
286
287
288
def take_while(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
    """Take items while *fn* returns truthy."""
    result: list[T] = []
    for item in self:
        if not fn(item):
            break
        result.append(item)
    return ArvelCollection(result)

take_until(fn)

Take items until fn returns truthy.

Source code in src/arvel/data/collection.py
290
291
292
293
294
295
296
297
def take_until(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
    """Take items until *fn* returns truthy."""
    result: list[T] = []
    for item in self:
        if fn(item):
            break
        result.append(item)
    return ArvelCollection(result)

skip_while(fn)

Skip items while fn returns truthy.

Source code in src/arvel/data/collection.py
299
300
301
302
303
304
305
306
307
308
def skip_while(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
    """Skip items while *fn* returns truthy."""
    skipping = True
    result: list[T] = []
    for item in self:
        if skipping and fn(item):
            continue
        skipping = False
        result.append(item)
    return ArvelCollection(result)

skip_until(fn)

Skip items until fn returns truthy.

Source code in src/arvel/data/collection.py
310
311
312
313
314
315
316
317
318
319
320
321
def skip_until(self, fn: Callable[[T], bool]) -> ArvelCollection[T]:
    """Skip items until *fn* returns truthy."""
    skipping = True
    result: list[T] = []
    for item in self:
        if skipping:
            if fn(item):
                skipping = False
                result.append(item)
            continue
        result.append(item)
    return ArvelCollection(result)

merge(other)

Combine with another iterable.

Source code in src/arvel/data/collection.py
327
328
329
def merge(self, other: Iterable[T]) -> ArvelCollection[T]:
    """Combine with another iterable."""
    return ArvelCollection([*self, *other])

concat(other)

Alias for merge.

Source code in src/arvel/data/collection.py
331
332
333
def concat(self, other: Iterable[T]) -> ArvelCollection[T]:
    """Alias for ``merge``."""
    return self.merge(other)

diff(other)

Items in this collection but not in other.

Source code in src/arvel/data/collection.py
335
336
337
338
def diff(self, other: Iterable[Any]) -> ArvelCollection[T]:
    """Items in this collection but not in *other*."""
    other_set = set(other)
    return ArvelCollection(item for item in self if item not in other_set)

intersect(other)

Items present in both this collection and other.

Source code in src/arvel/data/collection.py
340
341
342
343
def intersect(self, other: Iterable[Any]) -> ArvelCollection[T]:
    """Items present in both this collection and *other*."""
    other_set = set(other)
    return ArvelCollection(item for item in self if item in other_set)

zip(other)

Pair items with another iterable.

Source code in src/arvel/data/collection.py
345
346
347
def zip(self, other: Iterable[R]) -> ArvelCollection[tuple[T, R]]:
    """Pair items with another iterable."""
    return ArvelCollection(builtins.zip(self, other, strict=False))

partition(fn)

Split into two collections: items passing fn and items failing.

Source code in src/arvel/data/collection.py
353
354
355
356
357
358
359
def partition(self, fn: Callable[[T], bool]) -> tuple[ArvelCollection[T], ArvelCollection[T]]:
    """Split into two collections: items passing *fn* and items failing."""
    passing: list[T] = []
    failing: list[T] = []
    for item in self:
        (passing if fn(item) else failing).append(item)
    return ArvelCollection(passing), ArvelCollection(failing)

split(n)

Split into n groups as evenly as possible.

Source code in src/arvel/data/collection.py
361
362
363
364
365
366
367
368
369
370
371
372
373
def split(self, n: int) -> ArvelCollection[ArvelCollection[T]]:
    """Split into *n* groups as evenly as possible."""
    if n < 1:
        msg = "split count must be >= 1"
        raise ValueError(msg)
    k, m = divmod(len(self), n)
    groups: list[ArvelCollection[T]] = []
    start = 0
    for i in range(n):
        end = start + k + (1 if i < m else 0)
        groups.append(ArvelCollection(self[start:end]))
        start = end
    return ArvelCollection(groups)

sliding(size, step=1)

Return a sliding window of size items, advancing by step.

Source code in src/arvel/data/collection.py
375
376
377
378
379
380
381
382
383
def sliding(self, size: int, step: int = 1) -> ArvelCollection[ArvelCollection[T]]:
    """Return a sliding window of *size* items, advancing by *step*."""
    if size < 1:
        msg = "window size must be >= 1"
        raise ValueError(msg)
    windows: list[ArvelCollection[T]] = []
    for i in range(0, len(self) - size + 1, step):
        windows.append(ArvelCollection(self[i : i + size]))
    return ArvelCollection(windows)

flatten(depth=-1)

Flatten nested iterables (excluding strings/dicts).

depth=-1 flattens fully; depth=1 flattens one level.

Source code in src/arvel/data/collection.py
389
390
391
392
393
394
def flatten(self, depth: int = -1) -> ArvelCollection[Any]:
    """Flatten nested iterables (excluding strings/dicts).

    *depth=-1* flattens fully; *depth=1* flattens one level.
    """
    return ArvelCollection(_flatten_iter(self, depth))

collapse()

Flatten one level of nesting.

Source code in src/arvel/data/collection.py
396
397
398
def collapse(self) -> ArvelCollection[Any]:
    """Flatten one level of nesting."""
    return self.flatten(depth=1)

pipe(fn)

Pass the entire collection through fn and return the result.

Source code in src/arvel/data/collection.py
404
405
406
def pipe(self, fn: Callable[[ArvelCollection[T]], R]) -> R:
    """Pass the entire collection through *fn* and return the result."""
    return fn(self)

tap(fn)

Call fn with the collection for side effects, return self.

Source code in src/arvel/data/collection.py
408
409
410
411
def tap(self, fn: Callable[[ArvelCollection[T]], object]) -> ArvelCollection[T]:
    """Call *fn* with the collection for side effects, return self."""
    fn(self)
    return self

when(condition, fn, default=None)

Apply fn if condition is truthy, otherwise apply default.

Source code in src/arvel/data/collection.py
413
414
415
416
417
418
419
420
421
422
423
424
def when(
    self,
    condition: bool,
    fn: Callable[[ArvelCollection[T]], ArvelCollection[T]],
    default: Callable[[ArvelCollection[T]], ArvelCollection[T]] | None = None,
) -> ArvelCollection[T]:
    """Apply *fn* if *condition* is truthy, otherwise apply *default*."""
    if condition:
        return fn(self)
    if default is not None:
        return default(self)
    return self

unless(condition, fn, default=None)

Apply fn if condition is falsy, otherwise apply default.

Source code in src/arvel/data/collection.py
426
427
428
429
430
431
432
433
434
435
436
437
def unless(
    self,
    condition: bool,
    fn: Callable[[ArvelCollection[T]], ArvelCollection[T]],
    default: Callable[[ArvelCollection[T]], ArvelCollection[T]] | None = None,
) -> ArvelCollection[T]:
    """Apply *fn* if *condition* is falsy, otherwise apply *default*."""
    if not condition:
        return fn(self)
    if default is not None:
        return default(self)
    return self

reduce(fn, initial)

Fold left over the collection.

Source code in src/arvel/data/collection.py
443
444
445
def reduce(self, fn: Callable[[R, T], R], initial: R) -> R:
    """Fold left over the collection."""
    return _reduce(fn, self, initial)

every(fn)

Return True if all items satisfy fn.

Source code in src/arvel/data/collection.py
447
448
449
def every(self, fn: Callable[[T], bool]) -> bool:
    """Return ``True`` if all items satisfy *fn*."""
    return all(fn(item) for item in self)

some(fn)

Return True if any item satisfies fn. Alias for callable contains.

Source code in src/arvel/data/collection.py
451
452
453
def some(self, fn: Callable[[T], bool]) -> bool:
    """Return ``True`` if any item satisfies *fn*. Alias for callable contains."""
    return any(fn(item) for item in self)

search(value)

search(value: Callable[[T], bool]) -> int | None
search(value: T) -> int | None

Return the index of the first matching item, or None.

Accepts a value or a callable predicate.

Source code in src/arvel/data/collection.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def search(self, value: T | Callable[[T], bool]) -> int | None:
    """Return the index of the first matching item, or ``None``.

    Accepts a value or a callable predicate.
    """
    if callable(value):
        predicate: Callable[[T], bool] = value  # type: ignore[assignment]  # ty: ignore[invalid-assignment]  # overloads guarantee Callable[[T], bool]
        for i, item in enumerate(self):
            if predicate(item):
                return i
        return None
    try:
        return self.index(value)
    except ValueError:
        return None

sole(fn=None)

Return the only item matching fn, or the only item if fn is None.

Raises ValueError if zero or more than one match.

Source code in src/arvel/data/collection.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
def sole(self, fn: Callable[[T], bool] | None = None) -> T:
    """Return the only item matching *fn*, or the only item if *fn* is None.

    Raises ``ValueError`` if zero or more than one match.
    """
    if fn is None:
        if len(self) != 1:
            msg = f"Expected exactly 1 item, got {len(self)}"
            raise ValueError(msg)
        return self[0]
    matches = [item for item in self if fn(item)]
    if len(matches) != 1:
        msg = f"Expected exactly 1 matching item, got {len(matches)}"
        raise ValueError(msg)
    return matches[0]

implode(glue, key=None)

Join items into a string, optionally plucking key first.

Source code in src/arvel/data/collection.py
499
500
501
502
503
def implode(self, glue: str, key: str | None = None) -> str:
    """Join items into a string, optionally plucking *key* first."""
    if key is not None:
        return glue.join(str(_extract(item, key, "")) for item in self)
    return glue.join(str(item) for item in self)

random(count=1)

Return count random items. Returns a single item when count=1.

Source code in src/arvel/data/collection.py
509
510
511
512
513
514
515
516
517
518
def random(self, count: int = 1) -> T | ArvelCollection[T]:
    """Return *count* random items. Returns a single item when count=1."""
    if not self:
        msg = "Cannot get random items from an empty collection"
        raise ValueError(msg)
    if count == 1:
        return _random.choice(self)  # noqa: S311 — not cryptographic
    return ArvelCollection(
        _random.sample(self, min(count, len(self))),
    )

shuffle()

Return a new collection with items in random order.

Source code in src/arvel/data/collection.py
520
521
522
523
524
def shuffle(self) -> ArvelCollection[T]:
    """Return a new collection with items in random order."""
    items = list(self)
    _random.shuffle(items)
    return ArvelCollection(items)

median(key=None)

Return the statistical median.

Source code in src/arvel/data/collection.py
530
531
532
533
534
535
def median(self, key: str | Callable[[T], Any] | None = None) -> float:
    """Return the statistical median."""
    values = self._numeric_values(key)
    if not values:
        return 0.0
    return _statistics.median(values)

mode(key=None)

Return the most common value(s).

Source code in src/arvel/data/collection.py
537
538
539
540
541
542
543
544
def mode(self, key: str | Callable[[T], Any] | None = None) -> ArvelCollection[Any]:
    """Return the most common value(s)."""
    values = self._numeric_values(key)
    if not values:
        return ArvelCollection()
    counter = Counter(values)
    max_count = max(counter.values())
    return ArvelCollection(v for v, c in counter.items() if c == max_count)

count_by(fn=None)

Count occurrences grouped by the return value of fn or attribute key.

Source code in src/arvel/data/collection.py
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
def count_by(self, fn: Callable[[T], Any] | str | None = None) -> dict[Any, int]:
    """Count occurrences grouped by the return value of *fn* or attribute *key*."""
    counter: dict[Any, int] = {}
    if fn is None:
        for item in self:
            counter[item] = counter.get(item, 0) + 1
    elif isinstance(fn, str):
        for item in self:
            k = _extract(item, fn)
            counter[k] = counter.get(k, 0) + 1
    else:
        for item in self:
            k = fn(item)
            counter[k] = counter.get(k, 0) + 1
    return counter

duplicates(key=None)

Return items that appear more than once.

Source code in src/arvel/data/collection.py
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
def duplicates(self, key: str | Callable[[T], Any] | None = None) -> ArvelCollection[T]:
    """Return items that appear more than once."""
    identity_fn = _make_identity_fn(key)
    seen: dict[Any, int] = {}
    for item in self:
        identity = identity_fn(item)
        seen[identity] = seen.get(identity, 0) + 1
    dup_keys = {k for k, v in seen.items() if v > 1}
    result: list[T] = []
    added: set[Any] = set()
    for item in self:
        identity = identity_fn(item)
        if identity in dup_keys and identity not in added:
            result.append(item)
            added.add(identity)
    return ArvelCollection(result)

prepend(item)

Return a new collection with item at the front.

Source code in src/arvel/data/collection.py
583
584
585
def prepend(self, item: T) -> ArvelCollection[T]:
    """Return a new collection with *item* at the front."""
    return ArvelCollection([item, *self])

push(*items)

Return a new collection with items appended.

Source code in src/arvel/data/collection.py
587
588
589
def push(self, *items: T) -> ArvelCollection[T]:
    """Return a new collection with *items* appended."""
    return ArvelCollection([*self, *items])

pop_item()

Return the last item and a new collection without it.

Named pop_item to avoid shadowing list.pop.

Source code in src/arvel/data/collection.py
591
592
593
594
595
596
597
598
599
def pop_item(self) -> tuple[T, ArvelCollection[T]]:
    """Return the last item and a new collection without it.

    Named ``pop_item`` to avoid shadowing ``list.pop``.
    """
    if not self:
        msg = "Cannot pop from an empty collection"
        raise ValueError(msg)
    return self[-1], ArvelCollection(self[:-1])

shift()

Return the first item and a new collection without it.

Source code in src/arvel/data/collection.py
601
602
603
604
605
606
def shift(self) -> tuple[T, ArvelCollection[T]]:
    """Return the first item and a new collection without it."""
    if not self:
        msg = "Cannot shift from an empty collection"
        raise ValueError(msg)
    return self[0], ArvelCollection(self[1:])

ensure(*types)

Verify every item is an instance of one of types.

Returns self if all items match, raises TypeError otherwise.

Source code in src/arvel/data/collection.py
612
613
614
615
616
617
618
619
620
621
622
def ensure(self, *types: type) -> ArvelCollection[T]:
    """Verify every item is an instance of one of *types*.

    Returns self if all items match, raises ``TypeError`` otherwise.
    """
    for item in self:
        if not isinstance(item, types):
            expected = " | ".join(t.__name__ for t in types)
            msg = f"Expected {expected}, got {type(item).__name__}"
            raise TypeError(msg)
    return self

percentage(fn, precision=2)

Return the percentage of items satisfying fn.

Source code in src/arvel/data/collection.py
624
625
626
627
628
629
def percentage(self, fn: Callable[[T], bool], precision: int = 2) -> float:
    """Return the percentage of items satisfying *fn*."""
    if not self:
        return 0.0
    count = builtins.sum(1 for item in self if fn(item))
    return round(count / len(self) * 100, precision)

multiply(n)

Repeat the collection n times.

Source code in src/arvel/data/collection.py
631
632
633
def multiply(self, n: int) -> ArvelCollection[T]:
    """Repeat the collection *n* times."""
    return ArvelCollection(list(self) * n)

after(fn_or_value)

Return the item after the first match, or None.

Source code in src/arvel/data/collection.py
635
636
637
638
639
640
641
642
643
644
645
646
def after(self, fn_or_value: Callable[[T], bool] | Any) -> T | None:
    """Return the item after the first match, or ``None``."""
    it = iter(self)
    if callable(fn_or_value):
        for item in it:
            if fn_or_value(item):
                return next(it, None)
    else:
        for item in it:
            if item == fn_or_value:
                return next(it, None)
    return None

before(fn_or_value)

Return the item before the first match, or None.

Source code in src/arvel/data/collection.py
648
649
650
651
652
653
654
655
656
657
658
659
660
661
def before(self, fn_or_value: Callable[[T], bool] | Any) -> T | None:
    """Return the item before the first match, or ``None``."""
    prev: T | None = None
    if callable(fn_or_value):
        for item in self:
            if fn_or_value(item):
                return prev
            prev = item
    else:
        for item in self:
            if item == fn_or_value:
                return prev
            prev = item
    return None

select(*keys)

Pick only the given keys from each item (dict or object).

Source code in src/arvel/data/collection.py
663
664
665
666
667
668
669
670
671
def select(self, *keys: str) -> ArvelCollection[dict[str, Any]]:
    """Pick only the given *keys* from each item (dict or object)."""

    def _pick(item: Any) -> dict[str, Any]:
        if isinstance(item, dict):
            return {k: item.get(k) for k in keys}
        return {k: getattr(item, k, None) for k in keys}

    return ArvelCollection(_pick(item) for item in self)

to_list()

Return a plain Python list.

Source code in src/arvel/data/collection.py
702
703
704
def to_list(self) -> list[T]:
    """Return a plain Python list."""
    return list(self)

to_dict(key)

Key items by an attribute, returning a dict.

Source code in src/arvel/data/collection.py
706
707
708
709
710
711
def to_dict(self, key: str) -> dict[Any, T]:
    """Key items by an attribute, returning a dict."""
    result: dict[Any, T] = {}
    for item in self:
        result[_extract(item, key)] = item
    return result

to_json(*, indent=2)

Serialize the collection to a JSON string.

Calls model_dump() on Pydantic/ArvelModel items so the output is always plain dicts that json.dumps can handle.

Source code in src/arvel/data/collection.py
730
731
732
733
734
735
736
737
def to_json(self, *, indent: int = 2) -> str:
    """Serialize the collection to a JSON string.

    Calls ``model_dump()`` on Pydantic/ArvelModel items so the
    output is always plain dicts that ``json.dumps`` can handle.
    """
    serialized = [self._serialize_item(item) for item in self]
    return json.dumps(serialized, indent=indent, default=str)

DatabaseSettings

Bases: ModuleSettings

Database connection and pool configuration.

All fields are prefixed with DB_ in environment variables.

Pool defaults follow SA recommended practices: - pool_pre_ping=True detects stale connections from DB restarts - pool_recycle=3600 prevents long-lived connections from going stale - expire_on_commit=False prevents lazy-load errors after commit in async

Source code in src/arvel/data/config.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class DatabaseSettings(ModuleSettings):
    """Database connection and pool configuration.

    All fields are prefixed with DB_ in environment variables.

    Pool defaults follow SA recommended practices:
    - ``pool_pre_ping=True`` detects stale connections from DB restarts
    - ``pool_recycle=3600`` prevents long-lived connections from going stale
    - ``expire_on_commit=False`` prevents lazy-load errors after commit in async
    """

    model_config = SettingsConfigDict(
        env_prefix="DB_",
        extra="ignore",
    )

    config_name: ClassVar[str] = "database"

    db_url: str | None = Field(default=None, validation_alias="DB_URL")
    driver: Literal["sqlite", "pgsql", "postgres", "postgresql", "mysql"] = "sqlite"
    host: str = "127.0.0.1"
    port: int = 5432
    database: str = "database/database.sqlite"
    username: str = "arvel"
    password: SecretStr = SecretStr("")
    echo: bool = False
    pool_size: int = 10
    pool_max_overflow: int = 5
    pool_timeout: int = 30
    pool_recycle: int = 3600
    pool_pre_ping: bool = True
    expire_on_commit: bool = False

    @property
    def url(self) -> str:
        """Resolve DB URL from DB_URL or structured DB_* values."""
        if self.db_url:
            return self.db_url

        if self.driver == "sqlite":
            db_path = Path(self.database)
            if db_path.is_absolute():
                return f"sqlite+aiosqlite:///{db_path}"
            normalized = db_path.as_posix().lstrip("./")
            return f"sqlite+aiosqlite:///{normalized}"

        if self.driver in {"pgsql", "postgres", "postgresql"}:
            dialect = "postgresql+asyncpg"
        else:
            dialect = "mysql+aiomysql"

        raw_password = self.password.get_secret_value()
        credentials = self.username if not raw_password else f"{self.username}:{raw_password}"
        return f"{dialect}://{credentials}@{self.host}:{self.port}/{self.database}"

url property

Resolve DB URL from DB_URL or structured DB_* values.

ConfigurationError

Bases: DataError

Raised when a model or repository is misconfigured.

Examples: setting both __fillable__ and __guarded__, missing required config, or conflicting options.

Source code in src/arvel/data/exceptions.py
 96
 97
 98
 99
100
101
class ConfigurationError(DataError):
    """Raised when a model or repository is misconfigured.

    Examples: setting both ``__fillable__`` and ``__guarded__``,
    missing required config, or conflicting options.
    """

CreationAbortedError

Bases: DataError

Raised when an observer vetoes record creation.

Attributes:

Name Type Description
model_name

The model class name.

observer_name

The observer that vetoed.

Source code in src/arvel/data/exceptions.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class CreationAbortedError(DataError):
    """Raised when an observer vetoes record creation.

    Attributes:
        model_name: The model class name.
        observer_name: The observer that vetoed.
    """

    def __init__(
        self,
        message: str,
        *,
        model_name: str,
        observer_name: str = "",
    ) -> None:
        super().__init__(message)
        self.model_name = model_name
        self.observer_name = observer_name

DataError

Bases: ArvelError

Base for all data layer exceptions.

Source code in src/arvel/data/exceptions.py
12
13
class DataError(ArvelError):
    """Base for all data layer exceptions."""

DeletionAbortedError

Bases: DataError

Raised when an observer vetoes a record deletion.

Attributes:

Name Type Description
model_name

The model class name.

observer_name

The observer that vetoed.

Source code in src/arvel/data/exceptions.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
class DeletionAbortedError(DataError):
    """Raised when an observer vetoes a record deletion.

    Attributes:
        model_name: The model class name.
        observer_name: The observer that vetoed.
    """

    def __init__(
        self,
        message: str,
        *,
        model_name: str,
        observer_name: str = "",
    ) -> None:
        super().__init__(message)
        self.model_name = model_name
        self.observer_name = observer_name

IntegrityError

Bases: DataError

Raised when a database constraint is violated.

Wraps SA's IntegrityError with a human-readable message.

Source code in src/arvel/data/exceptions.py
124
125
126
127
128
class IntegrityError(DataError):
    """Raised when a database constraint is violated.

    Wraps SA's IntegrityError with a human-readable message.
    """

MassAssignmentError

Bases: DataError

Raised in strict mode when a guarded field is assigned.

Attributes:

Name Type Description
model_name

The model class name.

field_name

The field that was blocked.

Source code in src/arvel/data/exceptions.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
class MassAssignmentError(DataError):
    """Raised in strict mode when a guarded field is assigned.

    Attributes:
        model_name: The model class name.
        field_name: The field that was blocked.
    """

    def __init__(
        self,
        message: str,
        *,
        model_name: str,
        field_name: str,
    ) -> None:
        super().__init__(message)
        self.model_name = model_name
        self.field_name = field_name

NotFoundError

Bases: DataError

Raised when a record is not found by ID.

Attributes:

Name Type Description
model_name

The model class name.

record_id

The ID that was searched for.

Source code in src/arvel/data/exceptions.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class NotFoundError(DataError):
    """Raised when a record is not found by ID.

    Attributes:
        model_name: The model class name.
        record_id: The ID that was searched for.
    """

    def __init__(
        self,
        message: str,
        *,
        model_name: str,
        record_id: int | str,
    ) -> None:
        super().__init__(message)
        self.model_name = model_name
        self.record_id = record_id

TransactionError

Bases: DataError

Raised when a database transaction fails.

Source code in src/arvel/data/exceptions.py
131
132
class TransactionError(DataError):
    """Raised when a database transaction fails."""

UpdateAbortedError

Bases: DataError

Raised when an observer vetoes a record update.

Attributes:

Name Type Description
model_name

The model class name.

observer_name

The observer that vetoed.

Source code in src/arvel/data/exceptions.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class UpdateAbortedError(DataError):
    """Raised when an observer vetoes a record update.

    Attributes:
        model_name: The model class name.
        observer_name: The observer that vetoed.
    """

    def __init__(
        self,
        message: str,
        *,
        model_name: str,
        observer_name: str = "",
    ) -> None:
        super().__init__(message)
        self.model_name = model_name
        self.observer_name = observer_name

MaterializedView

Bases: ABC

Abstract base for materialized views.

Subclass, set __viewname__, and implement query_definition()::

class ActiveUserStats(MaterializedView):
    __viewname__ = "active_user_stats"

    @classmethod
    def query_definition(cls) -> Select:
        return select(User.id, func.count(Post.id)).join(...).group_by(User.id)
Source code in src/arvel/data/materialized_view.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MaterializedView(ABC):
    """Abstract base for materialized views.

    Subclass, set ``__viewname__``, and implement ``query_definition()``::

        class ActiveUserStats(MaterializedView):
            __viewname__ = "active_user_stats"

            @classmethod
            def query_definition(cls) -> Select:
                return select(User.id, func.count(Post.id)).join(...).group_by(User.id)
    """

    __viewname__: ClassVar[str]
    readonly: ClassVar[bool] = True

    @classmethod
    @abstractmethod
    def query_definition(cls) -> Any:
        """Return a SQLAlchemy select() defining the view."""
        ...

query_definition() abstractmethod classmethod

Return a SQLAlchemy select() defining the view.

Source code in src/arvel/data/materialized_view.py
29
30
31
32
33
@classmethod
@abstractmethod
def query_definition(cls) -> Any:
    """Return a SQLAlchemy select() defining the view."""
    ...

ViewRegistry

Registry for materialized view classes.

Source code in src/arvel/data/materialized_view.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class ViewRegistry:
    """Registry for materialized view classes."""

    def __init__(self) -> None:
        self._views: dict[str, type[MaterializedView]] = {}

    def register(self, view_cls: type[MaterializedView]) -> None:
        name = view_cls.__viewname__
        if not name.replace("_", "").isalnum():
            msg = f"Invalid view name: {name!r} (must be alphanumeric + underscores)"
            raise ValueError(msg)
        self._views[name] = view_cls

    def all(self) -> list[type[MaterializedView]]:
        return list(self._views.values())

    def get(self, name: str) -> type[MaterializedView] | None:
        return self._views.get(name)

    async def refresh(self, name: str, *, db_url: str) -> dict[str, Any]:
        """Refresh a single materialized view by name.

        On SQLite this is a no-op that returns a status dict.
        On PostgreSQL this executes REFRESH MATERIALIZED VIEW.
        """
        view_cls = self.get(name)
        if view_cls is None:
            msg = f"View '{name}' not found in registry"
            raise KeyError(msg)

        is_pg = "postgresql" in db_url or "asyncpg" in db_url

        if is_pg:
            from sqlalchemy import text
            from sqlalchemy.ext.asyncio import create_async_engine

            engine = create_async_engine(db_url, echo=False)
            try:
                async with engine.connect() as conn:
                    await conn.execute(text(f"REFRESH MATERIALIZED VIEW {view_cls.__viewname__}"))
                    await conn.commit()
            finally:
                await engine.dispose()

        return {"view": name, "refreshed": is_pg, "status": "ok"}

    async def refresh_all(self, *, db_url: str) -> list[dict[str, Any]]:
        """Refresh all registered views."""
        results = []
        for name in self._views:
            result = await self.refresh(name, db_url=db_url)
            results.append(result)
        return results

refresh(name, *, db_url) async

Refresh a single materialized view by name.

On SQLite this is a no-op that returns a status dict. On PostgreSQL this executes REFRESH MATERIALIZED VIEW.

Source code in src/arvel/data/materialized_view.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
async def refresh(self, name: str, *, db_url: str) -> dict[str, Any]:
    """Refresh a single materialized view by name.

    On SQLite this is a no-op that returns a status dict.
    On PostgreSQL this executes REFRESH MATERIALIZED VIEW.
    """
    view_cls = self.get(name)
    if view_cls is None:
        msg = f"View '{name}' not found in registry"
        raise KeyError(msg)

    is_pg = "postgresql" in db_url or "asyncpg" in db_url

    if is_pg:
        from sqlalchemy import text
        from sqlalchemy.ext.asyncio import create_async_engine

        engine = create_async_engine(db_url, echo=False)
        try:
            async with engine.connect() as conn:
                await conn.execute(text(f"REFRESH MATERIALIZED VIEW {view_cls.__viewname__}"))
                await conn.commit()
        finally:
            await engine.dispose()

    return {"view": name, "refreshed": is_pg, "status": "ok"}

refresh_all(*, db_url) async

Refresh all registered views.

Source code in src/arvel/data/materialized_view.py
82
83
84
85
86
87
88
async def refresh_all(self, *, db_url: str) -> list[dict[str, Any]]:
    """Refresh all registered views."""
    results = []
    for name in self._views:
        result = await self.refresh(name, db_url=db_url)
        results.append(result)
    return results

MigrationRunner

Programmatic Alembic migration runner.

Wraps alembic.command operations behind an async-friendly interface. Uses ArvelModel.metadata for auto-generation and reads DB URL from the caller (typically DatabaseSettings).

The original DB URL (async or sync dialect) is passed through to run_alembic_env() which dispatches the correct engine strategy.

Source code in src/arvel/data/migrations.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
class MigrationRunner:
    """Programmatic Alembic migration runner.

    Wraps alembic.command operations behind an async-friendly interface.
    Uses ArvelModel.metadata for auto-generation and reads DB URL from
    the caller (typically DatabaseSettings).

    The original DB URL (async or sync dialect) is passed through to
    ``run_alembic_env()`` which dispatches the correct engine strategy.
    """

    def __init__(self, *, db_url: str, migrations_dir: str) -> None:
        self._db_url = db_url
        self._migrations_dir = Path(migrations_dir)
        self._is_sqlite = "sqlite" in db_url
        self._ensure_env()

    @property
    def target_metadata(self) -> MetaData:
        return ArvelModel.metadata

    def _build_config(self) -> AlembicConfig:
        cfg = AlembicConfig()
        cfg.set_main_option("script_location", str(self._migrations_dir))
        cfg.set_main_option("sqlalchemy.url", self._db_url)
        return cfg

    def _ensure_env(self) -> None:
        """Write env.py if it doesn't exist."""
        self._migrations_dir.mkdir(parents=True, exist_ok=True)
        (self._migrations_dir / "versions").mkdir(exist_ok=True)

        env_path = self._migrations_dir / "env.py"
        if not env_path.exists():
            env_path.write_text(_ENV_TEMPLATE)

    def _sync_versions(self) -> None:
        """Mirror root-level migration files into ``versions/`` for Alembic.

        Laravel convention puts migration files directly in the migrations
        directory. Alembic expects them in a ``versions/`` subdirectory.
        This method symlinks root-level migration .py files into ``versions/``
        so both conventions coexist.
        """
        versions_dir = self._migrations_dir / "versions"
        versions_dir.mkdir(exist_ok=True)

        for existing in versions_dir.iterdir():
            if existing.is_symlink():
                existing.unlink()

        for f in _discover_migrations(self._migrations_dir):
            link = versions_dir / f.name
            if not link.exists():
                link.symlink_to(f.resolve())

    def generate(self, message: str) -> str:
        """Generate a new migration file from a Jinja template."""
        from datetime import UTC, datetime

        from arvel.cli.templates.engine import render_template

        timestamp = datetime.now(UTC).strftime("%Y_%m_%d_%H%M%S")
        slug = message.lower().replace(" ", "_")
        filename = f"{timestamp}_{slug}.py"

        table_name = self._extract_table_name(message)

        content = render_template(
            "migration.py.j2",
            {"message": message, "table_name": table_name},
        )

        filepath = self._migrations_dir / filename
        filepath.write_text(content)
        return str(filepath)

    @staticmethod
    def _extract_table_name(message: str) -> str:
        """Derive table name from migration message.

        'create_users_table' -> 'users'
        'create users table' -> 'users'
        'add_posts_table' -> 'posts'
        """
        normalized = message.lower().replace(" ", "_")
        normalized = normalized.removeprefix("create_")
        normalized = normalized.removeprefix("add_")
        normalized = normalized.removesuffix("_table")
        return normalized

    async def upgrade(
        self,
        revision: str = "head",
        *,
        environment: str = "development",
        force: bool = False,
    ) -> None:
        """Run pending migrations up to revision."""
        self._check_production(environment, force)
        self._sync_versions()
        inject_revisions(self._migrations_dir)
        cfg = self._build_config()
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, alembic_cmd.upgrade, cfg, revision)

    async def downgrade(
        self,
        steps: int = 1,
        *,
        environment: str = "development",
        force: bool = False,
    ) -> None:
        """Rollback N migration steps. Silently succeeds if there are no migrations to rollback."""
        self._check_production(environment, force)
        self._sync_versions()
        inject_revisions(self._migrations_dir)
        cfg = self._build_config()
        target = f"-{steps}"
        try:
            loop = asyncio.get_running_loop()
            await loop.run_in_executor(None, alembic_cmd.downgrade, cfg, target)
        except CommandError:
            pass

    async def fresh(
        self,
        *,
        environment: str = "development",
        force: bool = False,
    ) -> None:
        """Drop **all** tables then re-run all migrations from scratch.

        Unlike ``downgrade`` (which replays downgrade scripts), this method
        uses ``metadata.drop_all()`` to unconditionally drop every known
        table — including the Alembic version table — so the database is
        truly empty before ``upgrade`` runs.  This matches Laravel's
        ``migrate:fresh`` semantics.
        """
        self._check_production(environment, force)
        self._sync_versions()
        inject_revisions(self._migrations_dir)

        await self._drop_all_tables()

        cfg = self._build_config()
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, alembic_cmd.upgrade, cfg, "head")

    async def _drop_all_tables(self) -> None:
        """Drop every table in the database via live introspection.

        Reflects the actual schema from the database rather than relying on
        ``ArvelModel.metadata`` (which may be empty if models haven't been
        imported).  Also drops the Alembic version tracking table so
        ``upgrade`` starts fresh.
        """
        from sqlalchemy import MetaData, text

        def _reflect_and_drop(connection: Connection) -> None:
            reflected = MetaData()
            reflected.reflect(bind=connection)
            reflected.drop_all(bind=connection)
            connection.execute(text("DROP TABLE IF EXISTS alembic_version"))

        if _is_async_url(self._db_url):
            from sqlalchemy.ext.asyncio import create_async_engine

            engine = create_async_engine(self._db_url)
            async with engine.begin() as conn:
                await conn.run_sync(_reflect_and_drop)
            await engine.dispose()
        else:
            from sqlalchemy import create_engine

            engine = create_engine(self._db_url)
            with engine.begin() as conn:
                _reflect_and_drop(conn)
            engine.dispose()

    async def status(self) -> list[MigrationStatusEntry]:
        """Return a list of migration entries with applied/pending status."""
        self._sync_versions()
        inject_revisions(self._migrations_dir)
        cfg = self._build_config()
        script_dir = ScriptDirectory.from_config(cfg)
        result: list[MigrationStatusEntry] = []
        for sc in script_dir.walk_revisions():
            result.append(
                MigrationStatusEntry(
                    revision=sc.revision,
                    message=sc.doc,
                    down_revision=sc.down_revision,
                )
            )
        return result

    @staticmethod
    def get_env_template() -> str:
        """Return the async env.py template string."""
        return _ENV_TEMPLATE

    @staticmethod
    def _check_production(environment: str, force: bool) -> None:
        if environment == "production" and not force:
            msg = "Refusing to run in production without --force flag"
            raise RuntimeError(msg)

generate(message)

Generate a new migration file from a Jinja template.

Source code in src/arvel/data/migrations.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def generate(self, message: str) -> str:
    """Generate a new migration file from a Jinja template."""
    from datetime import UTC, datetime

    from arvel.cli.templates.engine import render_template

    timestamp = datetime.now(UTC).strftime("%Y_%m_%d_%H%M%S")
    slug = message.lower().replace(" ", "_")
    filename = f"{timestamp}_{slug}.py"

    table_name = self._extract_table_name(message)

    content = render_template(
        "migration.py.j2",
        {"message": message, "table_name": table_name},
    )

    filepath = self._migrations_dir / filename
    filepath.write_text(content)
    return str(filepath)

upgrade(revision='head', *, environment='development', force=False) async

Run pending migrations up to revision.

Source code in src/arvel/data/migrations.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
async def upgrade(
    self,
    revision: str = "head",
    *,
    environment: str = "development",
    force: bool = False,
) -> None:
    """Run pending migrations up to revision."""
    self._check_production(environment, force)
    self._sync_versions()
    inject_revisions(self._migrations_dir)
    cfg = self._build_config()
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, alembic_cmd.upgrade, cfg, revision)

downgrade(steps=1, *, environment='development', force=False) async

Rollback N migration steps. Silently succeeds if there are no migrations to rollback.

Source code in src/arvel/data/migrations.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
async def downgrade(
    self,
    steps: int = 1,
    *,
    environment: str = "development",
    force: bool = False,
) -> None:
    """Rollback N migration steps. Silently succeeds if there are no migrations to rollback."""
    self._check_production(environment, force)
    self._sync_versions()
    inject_revisions(self._migrations_dir)
    cfg = self._build_config()
    target = f"-{steps}"
    try:
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, alembic_cmd.downgrade, cfg, target)
    except CommandError:
        pass

fresh(*, environment='development', force=False) async

Drop all tables then re-run all migrations from scratch.

Unlike downgrade (which replays downgrade scripts), this method uses metadata.drop_all() to unconditionally drop every known table — including the Alembic version table — so the database is truly empty before upgrade runs. This matches Laravel's migrate:fresh semantics.

Source code in src/arvel/data/migrations.py
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
async def fresh(
    self,
    *,
    environment: str = "development",
    force: bool = False,
) -> None:
    """Drop **all** tables then re-run all migrations from scratch.

    Unlike ``downgrade`` (which replays downgrade scripts), this method
    uses ``metadata.drop_all()`` to unconditionally drop every known
    table — including the Alembic version table — so the database is
    truly empty before ``upgrade`` runs.  This matches Laravel's
    ``migrate:fresh`` semantics.
    """
    self._check_production(environment, force)
    self._sync_versions()
    inject_revisions(self._migrations_dir)

    await self._drop_all_tables()

    cfg = self._build_config()
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, alembic_cmd.upgrade, cfg, "head")

status() async

Return a list of migration entries with applied/pending status.

Source code in src/arvel/data/migrations.py
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
async def status(self) -> list[MigrationStatusEntry]:
    """Return a list of migration entries with applied/pending status."""
    self._sync_versions()
    inject_revisions(self._migrations_dir)
    cfg = self._build_config()
    script_dir = ScriptDirectory.from_config(cfg)
    result: list[MigrationStatusEntry] = []
    for sc in script_dir.walk_revisions():
        result.append(
            MigrationStatusEntry(
                revision=sc.revision,
                message=sc.doc,
                down_revision=sc.down_revision,
            )
        )
    return result

get_env_template() staticmethod

Return the async env.py template string.

Source code in src/arvel/data/migrations.py
458
459
460
461
@staticmethod
def get_env_template() -> str:
    """Return the async env.py template string."""
    return _ENV_TEMPLATE

MigrationStatusEntry

Bases: TypedDict

Structured migration status entry.

Source code in src/arvel/data/migrations.py
33
34
35
36
37
38
class MigrationStatusEntry(TypedDict):
    """Structured migration status entry."""

    revision: str
    message: str | None
    down_revision: str | list[str] | tuple[str, ...] | None

ArvelModel

Bases: HasRelationships, DeclarativeBase

Base model class bridging SQLAlchemy and Pydantic.

Subclasses get automatic: - SA table mapping (via DeclarativeBase) - Pydantic schema generation (pydantic_model) - Timestamp fields (created_at, updated_at) when declared - model_dump() / model_validate() compatibility - Declarative relationship helpers via HasRelationships

Set __singular__ on models with irregular plural table names so convention-based FK inference works correctly::

class Person(ArvelModel):
    __tablename__ = "people"
    __singular__ = "person"
Source code in src/arvel/data/model.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
class ArvelModel(HasRelationships, DeclarativeBase):
    """Base model class bridging SQLAlchemy and Pydantic.

    Subclasses get automatic:
    - SA table mapping (via DeclarativeBase)
    - Pydantic schema generation (__pydantic_model__)
    - Timestamp fields (created_at, updated_at) when declared
    - model_dump() / model_validate() compatibility
    - Declarative relationship helpers via HasRelationships

    Set ``__singular__`` on models with irregular plural table names so
    convention-based FK inference works correctly::

        class Person(ArvelModel):
            __tablename__ = "people"
            __singular__ = "person"
    """

    __abstract__ = True
    __pydantic_model__: ClassVar[type[BaseModel]]
    __fillable__: ClassVar[set[str] | None]
    __guarded__: ClassVar[set[str] | None]
    __casts__: ClassVar[dict[str, str | type | Caster]]
    __hidden__: ClassVar[set[str]]
    __visible__: ClassVar[set[str]]
    __appends__: ClassVar[set[str]]
    __scope_registry__: ClassVar[ScopeRegistry]
    __accessor_registry__: ClassVar[AccessorRegistry]
    _resolved_casts: ClassVar[dict[str, Caster]]
    _has_casts: ClassVar[bool]
    _column_names: ClassVar[frozenset[str]]
    _session_resolver: ClassVar[Callable[[], AsyncSession] | None] = None
    _session_factory_resolver: ClassVar[Callable[[], AsyncSession] | None] = None
    _observer_registry_resolver: ClassVar[Callable[[], ObserverRegistry] | None] = None

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(UTC),
        nullable=False,
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(UTC),
        onupdate=lambda: datetime.now(UTC),
        nullable=False,
    )

    def __init_subclass__(cls, **kwargs: Any) -> None:
        if cls.__dict__.get("__abstract__"):
            super().__init_subclass__(**kwargs)
            return

        if "__tablename__" not in cls.__dict__:
            cls.__tablename__ = cls._derive_tablename()

        cls._validate_mass_assignment()
        super().__init_subclass__(**kwargs)
        cls._build_pydantic_model()
        cls.__scope_registry__ = _discover_scopes(cls)
        cls.__accessor_registry__ = _discover_accessors(cls)
        cls._resolve_cast_definitions()
        cls._cache_column_names()
        cls._init_serialization_defaults()

    @classmethod
    def _derive_tablename(cls) -> str:
        """Derive table name from class name like Laravel: User -> users, BlogPost -> blog_posts."""
        from arvel.support.utils import pluralize, to_snake_case

        return pluralize(to_snake_case(cls.__name__))

    @classmethod
    def _resolve_cast_definitions(cls) -> None:
        """Resolve ``__casts__`` specs into ``Caster`` instances."""
        raw: dict[str, str | type | Caster] = getattr(cls, "__casts__", {})
        cls._resolved_casts = {name: resolve_caster(spec) for name, spec in raw.items()}
        cls._has_casts = bool(cls._resolved_casts)

    @classmethod
    def _cache_column_names(cls) -> None:
        """Cache column names for fast __getattribute__/__setattr__ checks."""
        table = getattr(cls, "__table__", None)
        if table is not None:
            cls._column_names = frozenset(col.name for col in table.columns)
        else:
            cls._column_names = frozenset()

    @classmethod
    def _init_serialization_defaults(cls) -> None:
        """Ensure __hidden__, __visible__, __appends__ are set on every subclass."""
        if "__hidden__" not in cls.__dict__:
            cls.__hidden__ = set()
        if "__visible__" not in cls.__dict__:
            cls.__visible__ = set()
        if "__appends__" not in cls.__dict__:
            cls.__appends__ = set()

    @classmethod
    def _validate_mass_assignment(cls) -> None:
        has_fillable = cls.__dict__.get("__fillable__") is not None
        has_guarded = cls.__dict__.get("__guarded__") is not None
        if has_fillable and has_guarded:
            msg = (
                f"{cls.__name__} defines both __fillable__ and __guarded__. "
                f"Use one or the other, not both."
            )
            raise ConfigurationError(msg)

    @classmethod
    def _build_pydantic_model(cls) -> None:
        """Auto-generate a Pydantic BaseModel from SA column definitions.

        All fields default to ``None`` so that partial data is accepted
        (matching Laravel Eloquent's create() semantics).  The database
        enforces NOT NULL constraints at insert time.
        """
        fields: dict[str, Any] = {}
        table = getattr(cls, "__table__", None)
        if table is None:
            return

        for col in table.columns:
            py_type = _python_type_for_column(col)
            fields[col.name] = (py_type | None, None)

        cls.__pydantic_model__ = create_model(
            f"{cls.__name__}Schema",
            **fields,
        )

    @classmethod
    def model_validate(cls, data: dict[str, Any]) -> Self:
        """Validate data through the auto-generated Pydantic schema, then create an instance."""
        validated = cls.__pydantic_model__.model_validate(data)
        validated_dict = validated.model_dump(exclude_unset=True)
        return cls(**validated_dict)

    def __getattribute__(self, name: str) -> Any:
        """Transparent cast on read + accessor resolution.

        For cast columns, the raw SA value is transformed through the
        caster's ``get()`` on every attribute access — no manual
        ``get_cast_value()`` needed.

        Models without ``__casts__`` skip the cast-check branch entirely.
        """
        cls = type(self)

        if cls.__dict__.get("_has_casts"):
            casts: dict[str, Caster] = cls.__dict__.get("_resolved_casts") or {}
            columns: frozenset[str] = cls.__dict__.get("_column_names") or frozenset()
            if name in casts and name in columns:
                raw = super().__getattribute__(name)
                return casts[name].get(raw, name, self)

        try:
            return super().__getattribute__(name)
        except AttributeError:
            pass

        registry: AccessorRegistry | None = cls.__dict__.get("__accessor_registry__")
        if registry is not None:
            acc_fn = registry.get_accessor(name)
            if acc_fn is not None:
                return acc_fn(self)
        raise AttributeError(f"'{cls.__name__}' has no attribute {name!r}")

    def __setattr__(self, name: str, value: Any) -> None:
        """Transparent cast on write + mutator integration.

        For cast columns, the value is transformed through the caster's
        ``set()`` before storage. Mutators run before casts.
        """
        cls = type(self)

        registry: AccessorRegistry | None = cls.__dict__.get("__accessor_registry__")
        if registry is not None:
            mut_fn = registry.get_mutator(name)
            if mut_fn is not None:
                value = mut_fn(self, value)

        if cls.__dict__.get("_has_casts"):
            casts: dict[str, Caster] = cls.__dict__.get("_resolved_casts") or {}
            columns: frozenset[str] = cls.__dict__.get("_column_names") or frozenset()
            if name in casts and name in columns:
                value = casts[name].set(value, name, self)

        super().__setattr__(name, value)

    def get_cast_value(self, attr_name: str) -> Any:
        """Explicitly get a column value with its cast applied.

        With transparent casting, this is equivalent to ``getattr(self, attr_name)``
        for cast columns. Kept for backward compatibility.
        """
        return getattr(self, attr_name)

    # ──── Serialization ────

    def make_hidden(self, *fields: str) -> Self:
        """Hide additional fields on this instance (does not affect the class)."""
        hidden: set[str] = getattr(self, "_instance_hidden", set())
        hidden = hidden | set(fields)
        object.__setattr__(self, "_instance_hidden", hidden)
        return self

    def make_visible(self, *fields: str) -> Self:
        """Un-hide fields on this instance (does not affect the class)."""
        visible: set[str] = getattr(self, "_instance_visible", set())
        visible = visible | set(fields)
        object.__setattr__(self, "_instance_visible", visible)
        return self

    def _effective_hidden(self) -> set[str]:
        """Compute the effective hidden set for this instance."""
        cls = type(self)
        class_hidden: set[str] = getattr(cls, "__hidden__", set())
        inst_hidden: set[str] = getattr(self, "_instance_hidden", set())
        inst_visible: set[str] = getattr(self, "_instance_visible", set())
        return (class_hidden | inst_hidden) - inst_visible

    def _effective_visible(self) -> set[str]:
        """Compute the effective visible set (whitelist mode) for this instance."""
        cls = type(self)
        return set(getattr(cls, "__visible__", set()))

    def model_dump(
        self,
        *,
        include: set[str] | None = None,
        exclude: set[str] | None = None,
        include_relations: bool | list[str] = False,
    ) -> dict[str, Any]:
        """Serialize the model instance to a dict.

        Respects ``__hidden__``, ``__visible__``, ``__appends__``,
        and instance-level ``make_hidden()`` / ``make_visible()`` overrides.

        - ``__visible__`` is a whitelist — if set, ONLY those fields appear
        - ``__hidden__`` is a blacklist — those fields are excluded
        - ``__appends__`` adds accessor-computed values automatically
        - Cast values use their Python-side representations (via __getattribute__)
        """
        result = self._dump_columns(include=include, exclude=exclude)
        self._apply_serialization_control(result)
        self._apply_appended_accessors(result, include=include)
        if include_relations:
            self._dump_relations(result, include_relations)
        return result

    def __repr__(self) -> str:
        cls = type(self)
        table = getattr(cls, "__table__", None)
        if table is None:
            return f"<{cls.__name__}>"
        pk_cols = [col.name for col in table.primary_key.columns]
        pk_parts = [f"{name}={getattr(self, name, '?')!r}" for name in pk_cols]
        return f"<{cls.__name__} {' '.join(pk_parts)}>"

    def __str__(self) -> str:
        return self.model_json()

    def model_json(
        self,
        *,
        include: set[str] | None = None,
        exclude: set[str] | None = None,
        include_relations: bool | list[str] = False,
    ) -> str:
        """Serialize the model instance to a JSON string.

        Convenience wrapper around ``model_dump()`` with JSON encoding
        of non-serializable types (datetime, date, Decimal, etc.).
        """
        data = self.model_dump(
            include=include,
            exclude=exclude,
            include_relations=include_relations,
        )
        return json.dumps(data, default=_json_serial)

    def _dump_columns(
        self,
        *,
        include: set[str] | None = None,
        exclude: set[str] | None = None,
    ) -> dict[str, Any]:
        result: dict[str, Any] = {}
        table = self.__class__.__table__
        for col in table.columns:
            name = col.name
            if include is not None and name not in include:
                continue
            if exclude is not None and name in exclude:
                continue
            result[name] = getattr(self, name, None)
        return result

    def _apply_serialization_control(self, result: dict[str, Any]) -> None:
        """Apply __hidden__ / __visible__ filtering."""
        visible = self._effective_visible()
        if visible:
            for key in list(result.keys()):
                if key not in visible:
                    del result[key]
            return

        hidden = self._effective_hidden()
        if hidden:
            for key in list(result.keys()):
                if key in hidden:
                    del result[key]

    def _apply_appended_accessors(
        self,
        result: dict[str, Any],
        *,
        include: set[str] | None = None,
    ) -> None:
        """Add accessor values from __appends__ and explicitly included names."""
        cls = type(self)
        appends: set[str] = getattr(cls, "__appends__", set())
        registry: AccessorRegistry | None = cls.__dict__.get("__accessor_registry__")
        if registry is None:
            return

        names_to_add = set(appends)
        if include is not None:
            names_to_add |= include & registry.accessor_names()

        for acc_name in names_to_add:
            with contextlib.suppress(AttributeError):
                result[acc_name] = getattr(self, acc_name)

    def _dump_relations(
        self,
        result: dict[str, Any],
        include_relations: bool | list[str],
    ) -> None:
        registry = getattr(self.__class__, "__relationship_registry__", None)
        if registry is None:
            return

        if include_relations is True:
            rel_names = list(registry.all().keys())
        elif isinstance(include_relations, list):
            rel_names = list(include_relations)
        else:
            return

        for rel_name in rel_names:
            value = getattr(self, rel_name, None)
            if value is None:
                result[rel_name] = None
            elif isinstance(value, list):
                result[rel_name] = [
                    item.model_dump() if hasattr(item, "model_dump") else item for item in value
                ]
            elif hasattr(value, "model_dump"):
                result[rel_name] = value.model_dump()
            else:
                result[rel_name] = value

    # ──── Session resolver ────

    @classmethod
    def set_session_resolver(cls, resolver: Callable[[], AsyncSession]) -> None:
        """Set the default session resolver for all models.

        Called by ``DatabaseServiceProvider`` during boot so that
        ``Model.query()`` works without an explicit session.
        """
        cls._session_resolver = resolver

    @classmethod
    def set_session_factory(cls, factory: Callable[[], AsyncSession]) -> None:
        """Set a factory that creates auto-managed sessions for CRUD ops.

        Unlike ``set_session_resolver``, sessions from this factory are
        committed on success and closed on exit by ``_session_scope``.
        """
        cls._session_factory_resolver = factory

    @classmethod
    def clear_session_resolver(cls) -> None:
        """Remove the default session resolver and factory."""
        cls._session_resolver = None
        cls._session_factory_resolver = None

    @classmethod
    def _resolve_session(cls, session: AsyncSession | None = None) -> AsyncSession:
        """Return the given session, or resolve one from the provider."""
        if session is not None:
            return session
        if cls._session_resolver is not None:
            return cls._session_resolver()
        msg = (
            "No session provided and no default session resolver is configured. "
            "Either pass a session explicitly or register "
            "DatabaseServiceProvider in your bootstrap/providers.py."
        )
        raise RuntimeError(msg)

    @classmethod
    @asynccontextmanager
    async def _session_scope(
        cls, session: AsyncSession | None = None
    ) -> AsyncGenerator[AsyncSession]:
        """Yield a session that auto-commits when it was created internally.

        Resolution order:
        1. *session* provided by caller → yield as-is (caller owns lifecycle).
        2. ``_session_factory_resolver`` set → create a fresh session, commit
           on success, rollback + close on failure.  This is the production
           path (``DatabaseServiceProvider`` sets the factory).
        3. ``_session_resolver`` set → yield the resolver's session without
           commit/close (backward compat with test fixtures that manage
           their own transactions).
        """
        if session is not None:
            yield session
            return

        if cls._session_factory_resolver is not None:
            owned = cls._session_factory_resolver()
            try:
                yield owned
                await owned.commit()
            except BaseException:
                await owned.rollback()
                raise
            finally:
                await owned.close()
            return

        yield cls._resolve_session()

    # ──── Observer registry resolver ────

    @classmethod
    def set_observer_registry(cls, resolver: Callable[[], ObserverRegistry]) -> None:
        """Set the default observer registry resolver for all models."""
        cls._observer_registry_resolver = resolver

    @classmethod
    def clear_observer_registry(cls) -> None:
        """Remove the default observer registry resolver."""
        cls._observer_registry_resolver = None

    @classmethod
    def _resolve_observer_registry(cls) -> ObserverRegistry:
        """Return the observer registry, or an empty no-op one."""
        if cls._observer_registry_resolver is not None:
            return cls._observer_registry_resolver()
        from arvel.data.observer import ObserverRegistry as _ObserverRegistry

        return _ObserverRegistry()

    # ──── Query builder entry ────

    @classmethod
    def query(cls, session: AsyncSession | None = None) -> QueryBuilder[Self]:
        """Create a fluent query builder.

        When *session* is omitted, uses the resolver set by
        ``DatabaseServiceProvider``.  Pass an explicit session to
        override (useful in tests or transactions).
        """
        if session is not None:
            return QueryBuilder(cls, session, owns_session=False)
        if cls._session_factory_resolver is not None:
            return QueryBuilder(cls, cls._session_factory_resolver(), owns_session=True)
        return QueryBuilder(cls, cls._resolve_session(), owns_session=False)

    # ──── Helpers ────

    @classmethod
    def _pk_column(cls) -> Any:
        """Return the single PK column for this model."""
        table = cls.__table__
        if not isinstance(table, Table):
            msg = f"{cls.__name__}.__table__ is not a Table"
            raise TypeError(msg)
        pk_cols = list(table.primary_key.columns)
        if len(pk_cols) != 1:
            msg = f"{cls.__name__} must have exactly one PK column"
            raise TypeError(msg)
        return pk_cols[0]

    def _is_new(self) -> bool:
        """Whether this instance has never been persisted (transient)."""
        state = sa_inspect(self, raiseerr=False)
        if state is None:
            return True
        return state.transient or state.pending

    # ──── Static query shortcuts ────

    @classmethod
    def where(
        cls, *criteria: ColumnElement[bool], session: AsyncSession | None = None
    ) -> QueryBuilder[Self]:
        """Shortcut for ``Model.query().where(...)``."""
        return cls.query(session).where(*criteria)

    @classmethod
    async def first(cls, *, session: AsyncSession | None = None) -> Self | None:
        """Return the first record or ``None``."""
        pk = cls._pk_column()
        return await cls.query(session).order_by(pk).first()

    @classmethod
    async def last(cls, *, session: AsyncSession | None = None) -> Self | None:
        """Return the last record (by PK descending) or ``None``."""
        pk = cls._pk_column()
        return await cls.query(session).order_by(pk.desc()).first()

    @classmethod
    async def count(cls, *, session: AsyncSession | None = None) -> int:
        """Return the total record count."""
        return await cls.query(session).count()

    # ──── CRUD: find ────

    @classmethod
    async def find(cls, record_id: int | str, *, session: AsyncSession | None = None) -> Self:
        """Find a record by primary key or raise ``NotFoundError``."""
        pk = cls._pk_column()
        instance = await cls.query(session).where(pk == record_id).order_by(pk).first()
        if instance is None:
            raise NotFoundError(
                f"{cls.__name__} with id={record_id} not found",
                model_name=cls.__name__,
                record_id=record_id,
            )
        return instance

    @classmethod
    async def find_or_none(
        cls, record_id: int | str, *, session: AsyncSession | None = None
    ) -> Self | None:
        """Find a record by primary key, returning ``None`` if not found."""
        pk = cls._pk_column()
        return await cls.query(session).where(pk == record_id).order_by(pk).first()

    @classmethod
    async def find_many(
        cls, record_ids: Sequence[int | str], *, session: AsyncSession | None = None
    ) -> ArvelCollection[Self]:
        """Find multiple records by primary keys.

        Missing IDs are silently skipped — no error is raised.
        """
        if not record_ids:
            from arvel.data.collection import ArvelCollection

            return ArvelCollection()
        pk = cls._pk_column()
        return await cls.query(session).where(pk.in_(record_ids)).all()

    @classmethod
    async def all(cls, *, session: AsyncSession | None = None) -> ArvelCollection[Self]:  # type: ignore[override]
        """Retrieve all records."""
        return await cls.query(session).all()

    # ──── CRUD: create ────

    @classmethod
    async def create(cls, data: dict[str, Any], *, session: AsyncSession | None = None) -> Self:
        """Create a new record with mass-assignment protection and observer dispatch.

        Event order: saving → creating → INSERT → created → saved
        """
        async with cls._session_scope(session) as resolved:
            registry = cls._resolve_observer_registry()
            safe_data = filter_mass_assignable(cls, data)
            instance = cls.model_validate(safe_data)

            if not await registry.dispatch("saving", cls, instance):
                raise CreationAbortedError(
                    f"Save of {cls.__name__} aborted by observer",
                    model_name=cls.__name__,
                )
            if not await registry.dispatch("creating", cls, instance):
                raise CreationAbortedError(
                    f"Creation of {cls.__name__} aborted by observer",
                    model_name=cls.__name__,
                )

            resolved.add(instance)
            await resolved.flush()

            await registry.dispatch("created", cls, instance)
            await registry.dispatch("saved", cls, instance)
            return instance

    # ──── CRUD: update ────

    async def update(self, data: dict[str, Any], *, session: AsyncSession | None = None) -> Self:
        """Update this instance with observer dispatch.

        Event order: saving → updating → UPDATE → updated → saved
        """
        async with self._session_scope(session) as resolved:
            registry = self._resolve_observer_registry()
            model_cls = type(self)

            if not await registry.dispatch("saving", model_cls, self):
                raise UpdateAbortedError(
                    f"Save of {model_cls.__name__} aborted by observer",
                    model_name=model_cls.__name__,
                )
            if not await registry.dispatch("updating", model_cls, self):
                raise UpdateAbortedError(
                    f"Update of {model_cls.__name__} aborted by observer",
                    model_name=model_cls.__name__,
                )

            safe_data = filter_mass_assignable(model_cls, data)
            for key, value in safe_data.items():
                setattr(self, key, value)
            if hasattr(self, "updated_at"):
                self.updated_at = datetime.now(UTC)
            await resolved.flush()

            await registry.dispatch("updated", model_cls, self)
            await registry.dispatch("saved", model_cls, self)
            return self

    # ──── CRUD: save ────

    async def save(self, *, session: AsyncSession | None = None) -> Self:
        """Persist the current state of this instance.

        Detects new vs existing: fires creating/created for inserts,
        updating/updated for updates. Always fires saving/saved.
        """
        async with self._session_scope(session) as resolved:
            registry = self._resolve_observer_registry()
            model_cls = type(self)
            is_new = self._is_new()

            if not await registry.dispatch("saving", model_cls, self):
                raise (
                    CreationAbortedError(
                        f"Save of {model_cls.__name__} aborted by observer",
                        model_name=model_cls.__name__,
                    )
                    if is_new
                    else UpdateAbortedError(
                        f"Save of {model_cls.__name__} aborted by observer",
                        model_name=model_cls.__name__,
                    )
                )

            specific_event = "creating" if is_new else "updating"
            if not await registry.dispatch(specific_event, model_cls, self):
                if is_new:
                    raise CreationAbortedError(
                        f"Creation of {model_cls.__name__} aborted by observer",
                        model_name=model_cls.__name__,
                    )
                raise UpdateAbortedError(
                    f"Update of {model_cls.__name__} aborted by observer",
                    model_name=model_cls.__name__,
                )

            if hasattr(self, "updated_at"):
                self.updated_at = datetime.now(UTC)
            resolved.add(self)
            await resolved.flush()

            post_event = "created" if is_new else "updated"
            await registry.dispatch(post_event, model_cls, self)
            await registry.dispatch("saved", model_cls, self)
            return self

    # ──── CRUD: delete ────

    async def delete(self, *, session: AsyncSession | None = None) -> None:
        """Delete this instance with observer dispatch.

        If the model uses ``SoftDeletes``, sets ``deleted_at`` instead
        of removing the row.
        """
        from arvel.data.soft_deletes import is_soft_deletable

        async with self._session_scope(session) as resolved:
            registry = self._resolve_observer_registry()
            model_cls = type(self)

            if not await registry.dispatch("deleting", model_cls, self):
                raise DeletionAbortedError(
                    f"Deletion of {model_cls.__name__} aborted by observer",
                    model_name=model_cls.__name__,
                )

            if is_soft_deletable(model_cls):
                setattr(self, "deleted_at", datetime.now(UTC))  # noqa: B010
                await resolved.flush()
            else:
                await resolved.delete(self)
                await resolved.flush()

            await registry.dispatch("deleted", model_cls, self)

    # ──── CRUD: refresh ────

    async def refresh(self, *, session: AsyncSession | None = None) -> Self:
        """Reload this instance from the database."""
        async with self._session_scope(session) as resolved:
            await resolved.refresh(self)
            return self

    # ──── CRUD: fill ────

    def fill(self, data: dict[str, Any]) -> Self:
        """Mass-assign attributes without saving.

        Respects ``__fillable__`` / ``__guarded__`` protection.
        """
        safe_data = filter_mass_assignable(type(self), data)
        for key, value in safe_data.items():
            setattr(self, key, value)
        return self

    # ──── Convenience: first_or_create / first_or_new / update_or_create ────

    @classmethod
    async def first_or_create(
        cls,
        search: dict[str, Any],
        values: dict[str, Any] | None = None,
        *,
        session: AsyncSession | None = None,
    ) -> Self:
        """Find a matching record or create one.

        *search* fields are used to find; *values* are merged when creating.
        Observer events fire on creation.
        """
        async with cls._session_scope(session) as resolved_session:
            q = cls.query(resolved_session)
            pk = cls._pk_column()
            for key, value in search.items():
                col = getattr(cls, key, None)
                if col is not None:
                    q = q.where(col == value)
            existing = await q.order_by(pk).first()
            if existing is not None:
                return existing
            merged = {**search, **(values or {})}
            return await cls.create(merged, session=resolved_session)

    @classmethod
    def first_or_new(
        cls,
        search: dict[str, Any],
        values: dict[str, Any] | None = None,
    ) -> Self:
        """Build a new (unsaved) instance from search + values.

        Does NOT persist. Does NOT query the database.
        Use ``first_or_create`` if you need to check the DB first.
        """
        merged = {**search, **(values or {})}
        safe_data = filter_mass_assignable(cls, merged)
        return cls.model_validate(safe_data)

    @classmethod
    async def update_or_create(
        cls,
        search: dict[str, Any],
        values: dict[str, Any] | None = None,
        *,
        session: AsyncSession | None = None,
    ) -> Self:
        """Find a matching record and update it, or create a new one.

        Observer events fire for both update and create paths.
        """
        async with cls._session_scope(session) as resolved_session:
            q = cls.query(resolved_session)
            pk = cls._pk_column()
            for key, value in search.items():
                col = getattr(cls, key, None)
                if col is not None:
                    q = q.where(col == value)
            existing = await q.order_by(pk).first()
            if existing is not None:
                return await existing.update(values or {}, session=resolved_session)
            merged = {**search, **(values or {})}
            return await cls.create(merged, session=resolved_session)

    # ──── Convenience: destroy ────

    @classmethod
    async def destroy(
        cls,
        record_ids: int | str | Sequence[int | str],
        *,
        session: AsyncSession | None = None,
    ) -> int:
        """Delete one or more records by primary key.

        Returns the number of records deleted. Fires observer events
        for each record.
        """
        async with cls._session_scope(session) as resolved_session:
            if isinstance(record_ids, (int, str)):
                record_ids = [record_ids]
            instances = await cls.find_many(record_ids, session=resolved_session)
            for instance in instances:
                await instance.delete(session=resolved_session)
            return len(instances)

model_validate(data) classmethod

Validate data through the auto-generated Pydantic schema, then create an instance.

Source code in src/arvel/data/model.py
250
251
252
253
254
255
@classmethod
def model_validate(cls, data: dict[str, Any]) -> Self:
    """Validate data through the auto-generated Pydantic schema, then create an instance."""
    validated = cls.__pydantic_model__.model_validate(data)
    validated_dict = validated.model_dump(exclude_unset=True)
    return cls(**validated_dict)

__getattribute__(name)

Transparent cast on read + accessor resolution.

For cast columns, the raw SA value is transformed through the caster's get() on every attribute access — no manual get_cast_value() needed.

Models without __casts__ skip the cast-check branch entirely.

Source code in src/arvel/data/model.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def __getattribute__(self, name: str) -> Any:
    """Transparent cast on read + accessor resolution.

    For cast columns, the raw SA value is transformed through the
    caster's ``get()`` on every attribute access — no manual
    ``get_cast_value()`` needed.

    Models without ``__casts__`` skip the cast-check branch entirely.
    """
    cls = type(self)

    if cls.__dict__.get("_has_casts"):
        casts: dict[str, Caster] = cls.__dict__.get("_resolved_casts") or {}
        columns: frozenset[str] = cls.__dict__.get("_column_names") or frozenset()
        if name in casts and name in columns:
            raw = super().__getattribute__(name)
            return casts[name].get(raw, name, self)

    try:
        return super().__getattribute__(name)
    except AttributeError:
        pass

    registry: AccessorRegistry | None = cls.__dict__.get("__accessor_registry__")
    if registry is not None:
        acc_fn = registry.get_accessor(name)
        if acc_fn is not None:
            return acc_fn(self)
    raise AttributeError(f"'{cls.__name__}' has no attribute {name!r}")

__setattr__(name, value)

Transparent cast on write + mutator integration.

For cast columns, the value is transformed through the caster's set() before storage. Mutators run before casts.

Source code in src/arvel/data/model.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def __setattr__(self, name: str, value: Any) -> None:
    """Transparent cast on write + mutator integration.

    For cast columns, the value is transformed through the caster's
    ``set()`` before storage. Mutators run before casts.
    """
    cls = type(self)

    registry: AccessorRegistry | None = cls.__dict__.get("__accessor_registry__")
    if registry is not None:
        mut_fn = registry.get_mutator(name)
        if mut_fn is not None:
            value = mut_fn(self, value)

    if cls.__dict__.get("_has_casts"):
        casts: dict[str, Caster] = cls.__dict__.get("_resolved_casts") or {}
        columns: frozenset[str] = cls.__dict__.get("_column_names") or frozenset()
        if name in casts and name in columns:
            value = casts[name].set(value, name, self)

    super().__setattr__(name, value)

get_cast_value(attr_name)

Explicitly get a column value with its cast applied.

With transparent casting, this is equivalent to getattr(self, attr_name) for cast columns. Kept for backward compatibility.

Source code in src/arvel/data/model.py
309
310
311
312
313
314
315
def get_cast_value(self, attr_name: str) -> Any:
    """Explicitly get a column value with its cast applied.

    With transparent casting, this is equivalent to ``getattr(self, attr_name)``
    for cast columns. Kept for backward compatibility.
    """
    return getattr(self, attr_name)

make_hidden(*fields)

Hide additional fields on this instance (does not affect the class).

Source code in src/arvel/data/model.py
319
320
321
322
323
324
def make_hidden(self, *fields: str) -> Self:
    """Hide additional fields on this instance (does not affect the class)."""
    hidden: set[str] = getattr(self, "_instance_hidden", set())
    hidden = hidden | set(fields)
    object.__setattr__(self, "_instance_hidden", hidden)
    return self

make_visible(*fields)

Un-hide fields on this instance (does not affect the class).

Source code in src/arvel/data/model.py
326
327
328
329
330
331
def make_visible(self, *fields: str) -> Self:
    """Un-hide fields on this instance (does not affect the class)."""
    visible: set[str] = getattr(self, "_instance_visible", set())
    visible = visible | set(fields)
    object.__setattr__(self, "_instance_visible", visible)
    return self

model_dump(*, include=None, exclude=None, include_relations=False)

Serialize the model instance to a dict.

Respects __hidden__, __visible__, __appends__, and instance-level make_hidden() / make_visible() overrides.

  • __visible__ is a whitelist — if set, ONLY those fields appear
  • __hidden__ is a blacklist — those fields are excluded
  • __appends__ adds accessor-computed values automatically
  • Cast values use their Python-side representations (via getattribute)
Source code in src/arvel/data/model.py
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
def model_dump(
    self,
    *,
    include: set[str] | None = None,
    exclude: set[str] | None = None,
    include_relations: bool | list[str] = False,
) -> dict[str, Any]:
    """Serialize the model instance to a dict.

    Respects ``__hidden__``, ``__visible__``, ``__appends__``,
    and instance-level ``make_hidden()`` / ``make_visible()`` overrides.

    - ``__visible__`` is a whitelist — if set, ONLY those fields appear
    - ``__hidden__`` is a blacklist — those fields are excluded
    - ``__appends__`` adds accessor-computed values automatically
    - Cast values use their Python-side representations (via __getattribute__)
    """
    result = self._dump_columns(include=include, exclude=exclude)
    self._apply_serialization_control(result)
    self._apply_appended_accessors(result, include=include)
    if include_relations:
        self._dump_relations(result, include_relations)
    return result

model_json(*, include=None, exclude=None, include_relations=False)

Serialize the model instance to a JSON string.

Convenience wrapper around model_dump() with JSON encoding of non-serializable types (datetime, date, Decimal, etc.).

Source code in src/arvel/data/model.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
def model_json(
    self,
    *,
    include: set[str] | None = None,
    exclude: set[str] | None = None,
    include_relations: bool | list[str] = False,
) -> str:
    """Serialize the model instance to a JSON string.

    Convenience wrapper around ``model_dump()`` with JSON encoding
    of non-serializable types (datetime, date, Decimal, etc.).
    """
    data = self.model_dump(
        include=include,
        exclude=exclude,
        include_relations=include_relations,
    )
    return json.dumps(data, default=_json_serial)

set_session_resolver(resolver) classmethod

Set the default session resolver for all models.

Called by DatabaseServiceProvider during boot so that Model.query() works without an explicit session.

Source code in src/arvel/data/model.py
485
486
487
488
489
490
491
492
@classmethod
def set_session_resolver(cls, resolver: Callable[[], AsyncSession]) -> None:
    """Set the default session resolver for all models.

    Called by ``DatabaseServiceProvider`` during boot so that
    ``Model.query()`` works without an explicit session.
    """
    cls._session_resolver = resolver

set_session_factory(factory) classmethod

Set a factory that creates auto-managed sessions for CRUD ops.

Unlike set_session_resolver, sessions from this factory are committed on success and closed on exit by _session_scope.

Source code in src/arvel/data/model.py
494
495
496
497
498
499
500
501
@classmethod
def set_session_factory(cls, factory: Callable[[], AsyncSession]) -> None:
    """Set a factory that creates auto-managed sessions for CRUD ops.

    Unlike ``set_session_resolver``, sessions from this factory are
    committed on success and closed on exit by ``_session_scope``.
    """
    cls._session_factory_resolver = factory

clear_session_resolver() classmethod

Remove the default session resolver and factory.

Source code in src/arvel/data/model.py
503
504
505
506
507
@classmethod
def clear_session_resolver(cls) -> None:
    """Remove the default session resolver and factory."""
    cls._session_resolver = None
    cls._session_factory_resolver = None

set_observer_registry(resolver) classmethod

Set the default observer registry resolver for all models.

Source code in src/arvel/data/model.py
559
560
561
562
@classmethod
def set_observer_registry(cls, resolver: Callable[[], ObserverRegistry]) -> None:
    """Set the default observer registry resolver for all models."""
    cls._observer_registry_resolver = resolver

clear_observer_registry() classmethod

Remove the default observer registry resolver.

Source code in src/arvel/data/model.py
564
565
566
567
@classmethod
def clear_observer_registry(cls) -> None:
    """Remove the default observer registry resolver."""
    cls._observer_registry_resolver = None

query(session=None) classmethod

Create a fluent query builder.

When session is omitted, uses the resolver set by DatabaseServiceProvider. Pass an explicit session to override (useful in tests or transactions).

Source code in src/arvel/data/model.py
580
581
582
583
584
585
586
587
588
589
590
591
592
@classmethod
def query(cls, session: AsyncSession | None = None) -> QueryBuilder[Self]:
    """Create a fluent query builder.

    When *session* is omitted, uses the resolver set by
    ``DatabaseServiceProvider``.  Pass an explicit session to
    override (useful in tests or transactions).
    """
    if session is not None:
        return QueryBuilder(cls, session, owns_session=False)
    if cls._session_factory_resolver is not None:
        return QueryBuilder(cls, cls._session_factory_resolver(), owns_session=True)
    return QueryBuilder(cls, cls._resolve_session(), owns_session=False)

where(*criteria, session=None) classmethod

Shortcut for Model.query().where(...).

Source code in src/arvel/data/model.py
618
619
620
621
622
623
@classmethod
def where(
    cls, *criteria: ColumnElement[bool], session: AsyncSession | None = None
) -> QueryBuilder[Self]:
    """Shortcut for ``Model.query().where(...)``."""
    return cls.query(session).where(*criteria)

first(*, session=None) async classmethod

Return the first record or None.

Source code in src/arvel/data/model.py
625
626
627
628
629
@classmethod
async def first(cls, *, session: AsyncSession | None = None) -> Self | None:
    """Return the first record or ``None``."""
    pk = cls._pk_column()
    return await cls.query(session).order_by(pk).first()

last(*, session=None) async classmethod

Return the last record (by PK descending) or None.

Source code in src/arvel/data/model.py
631
632
633
634
635
@classmethod
async def last(cls, *, session: AsyncSession | None = None) -> Self | None:
    """Return the last record (by PK descending) or ``None``."""
    pk = cls._pk_column()
    return await cls.query(session).order_by(pk.desc()).first()

count(*, session=None) async classmethod

Return the total record count.

Source code in src/arvel/data/model.py
637
638
639
640
@classmethod
async def count(cls, *, session: AsyncSession | None = None) -> int:
    """Return the total record count."""
    return await cls.query(session).count()

find(record_id, *, session=None) async classmethod

Find a record by primary key or raise NotFoundError.

Source code in src/arvel/data/model.py
644
645
646
647
648
649
650
651
652
653
654
655
@classmethod
async def find(cls, record_id: int | str, *, session: AsyncSession | None = None) -> Self:
    """Find a record by primary key or raise ``NotFoundError``."""
    pk = cls._pk_column()
    instance = await cls.query(session).where(pk == record_id).order_by(pk).first()
    if instance is None:
        raise NotFoundError(
            f"{cls.__name__} with id={record_id} not found",
            model_name=cls.__name__,
            record_id=record_id,
        )
    return instance

find_or_none(record_id, *, session=None) async classmethod

Find a record by primary key, returning None if not found.

Source code in src/arvel/data/model.py
657
658
659
660
661
662
663
@classmethod
async def find_or_none(
    cls, record_id: int | str, *, session: AsyncSession | None = None
) -> Self | None:
    """Find a record by primary key, returning ``None`` if not found."""
    pk = cls._pk_column()
    return await cls.query(session).where(pk == record_id).order_by(pk).first()

find_many(record_ids, *, session=None) async classmethod

Find multiple records by primary keys.

Missing IDs are silently skipped — no error is raised.

Source code in src/arvel/data/model.py
665
666
667
668
669
670
671
672
673
674
675
676
677
678
@classmethod
async def find_many(
    cls, record_ids: Sequence[int | str], *, session: AsyncSession | None = None
) -> ArvelCollection[Self]:
    """Find multiple records by primary keys.

    Missing IDs are silently skipped — no error is raised.
    """
    if not record_ids:
        from arvel.data.collection import ArvelCollection

        return ArvelCollection()
    pk = cls._pk_column()
    return await cls.query(session).where(pk.in_(record_ids)).all()

all(*, session=None) async classmethod

Retrieve all records.

Source code in src/arvel/data/model.py
680
681
682
683
@classmethod
async def all(cls, *, session: AsyncSession | None = None) -> ArvelCollection[Self]:  # type: ignore[override]
    """Retrieve all records."""
    return await cls.query(session).all()

create(data, *, session=None) async classmethod

Create a new record with mass-assignment protection and observer dispatch.

Event order: saving → creating → INSERT → created → saved

Source code in src/arvel/data/model.py
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
@classmethod
async def create(cls, data: dict[str, Any], *, session: AsyncSession | None = None) -> Self:
    """Create a new record with mass-assignment protection and observer dispatch.

    Event order: saving → creating → INSERT → created → saved
    """
    async with cls._session_scope(session) as resolved:
        registry = cls._resolve_observer_registry()
        safe_data = filter_mass_assignable(cls, data)
        instance = cls.model_validate(safe_data)

        if not await registry.dispatch("saving", cls, instance):
            raise CreationAbortedError(
                f"Save of {cls.__name__} aborted by observer",
                model_name=cls.__name__,
            )
        if not await registry.dispatch("creating", cls, instance):
            raise CreationAbortedError(
                f"Creation of {cls.__name__} aborted by observer",
                model_name=cls.__name__,
            )

        resolved.add(instance)
        await resolved.flush()

        await registry.dispatch("created", cls, instance)
        await registry.dispatch("saved", cls, instance)
        return instance

update(data, *, session=None) async

Update this instance with observer dispatch.

Event order: saving → updating → UPDATE → updated → saved

Source code in src/arvel/data/model.py
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
async def update(self, data: dict[str, Any], *, session: AsyncSession | None = None) -> Self:
    """Update this instance with observer dispatch.

    Event order: saving → updating → UPDATE → updated → saved
    """
    async with self._session_scope(session) as resolved:
        registry = self._resolve_observer_registry()
        model_cls = type(self)

        if not await registry.dispatch("saving", model_cls, self):
            raise UpdateAbortedError(
                f"Save of {model_cls.__name__} aborted by observer",
                model_name=model_cls.__name__,
            )
        if not await registry.dispatch("updating", model_cls, self):
            raise UpdateAbortedError(
                f"Update of {model_cls.__name__} aborted by observer",
                model_name=model_cls.__name__,
            )

        safe_data = filter_mass_assignable(model_cls, data)
        for key, value in safe_data.items():
            setattr(self, key, value)
        if hasattr(self, "updated_at"):
            self.updated_at = datetime.now(UTC)
        await resolved.flush()

        await registry.dispatch("updated", model_cls, self)
        await registry.dispatch("saved", model_cls, self)
        return self

save(*, session=None) async

Persist the current state of this instance.

Detects new vs existing: fires creating/created for inserts, updating/updated for updates. Always fires saving/saved.

Source code in src/arvel/data/model.py
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
async def save(self, *, session: AsyncSession | None = None) -> Self:
    """Persist the current state of this instance.

    Detects new vs existing: fires creating/created for inserts,
    updating/updated for updates. Always fires saving/saved.
    """
    async with self._session_scope(session) as resolved:
        registry = self._resolve_observer_registry()
        model_cls = type(self)
        is_new = self._is_new()

        if not await registry.dispatch("saving", model_cls, self):
            raise (
                CreationAbortedError(
                    f"Save of {model_cls.__name__} aborted by observer",
                    model_name=model_cls.__name__,
                )
                if is_new
                else UpdateAbortedError(
                    f"Save of {model_cls.__name__} aborted by observer",
                    model_name=model_cls.__name__,
                )
            )

        specific_event = "creating" if is_new else "updating"
        if not await registry.dispatch(specific_event, model_cls, self):
            if is_new:
                raise CreationAbortedError(
                    f"Creation of {model_cls.__name__} aborted by observer",
                    model_name=model_cls.__name__,
                )
            raise UpdateAbortedError(
                f"Update of {model_cls.__name__} aborted by observer",
                model_name=model_cls.__name__,
            )

        if hasattr(self, "updated_at"):
            self.updated_at = datetime.now(UTC)
        resolved.add(self)
        await resolved.flush()

        post_event = "created" if is_new else "updated"
        await registry.dispatch(post_event, model_cls, self)
        await registry.dispatch("saved", model_cls, self)
        return self

delete(*, session=None) async

Delete this instance with observer dispatch.

If the model uses SoftDeletes, sets deleted_at instead of removing the row.

Source code in src/arvel/data/model.py
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
async def delete(self, *, session: AsyncSession | None = None) -> None:
    """Delete this instance with observer dispatch.

    If the model uses ``SoftDeletes``, sets ``deleted_at`` instead
    of removing the row.
    """
    from arvel.data.soft_deletes import is_soft_deletable

    async with self._session_scope(session) as resolved:
        registry = self._resolve_observer_registry()
        model_cls = type(self)

        if not await registry.dispatch("deleting", model_cls, self):
            raise DeletionAbortedError(
                f"Deletion of {model_cls.__name__} aborted by observer",
                model_name=model_cls.__name__,
            )

        if is_soft_deletable(model_cls):
            setattr(self, "deleted_at", datetime.now(UTC))  # noqa: B010
            await resolved.flush()
        else:
            await resolved.delete(self)
            await resolved.flush()

        await registry.dispatch("deleted", model_cls, self)

refresh(*, session=None) async

Reload this instance from the database.

Source code in src/arvel/data/model.py
828
829
830
831
832
async def refresh(self, *, session: AsyncSession | None = None) -> Self:
    """Reload this instance from the database."""
    async with self._session_scope(session) as resolved:
        await resolved.refresh(self)
        return self

fill(data)

Mass-assign attributes without saving.

Respects __fillable__ / __guarded__ protection.

Source code in src/arvel/data/model.py
836
837
838
839
840
841
842
843
844
def fill(self, data: dict[str, Any]) -> Self:
    """Mass-assign attributes without saving.

    Respects ``__fillable__`` / ``__guarded__`` protection.
    """
    safe_data = filter_mass_assignable(type(self), data)
    for key, value in safe_data.items():
        setattr(self, key, value)
    return self

first_or_create(search, values=None, *, session=None) async classmethod

Find a matching record or create one.

search fields are used to find; values are merged when creating. Observer events fire on creation.

Source code in src/arvel/data/model.py
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
@classmethod
async def first_or_create(
    cls,
    search: dict[str, Any],
    values: dict[str, Any] | None = None,
    *,
    session: AsyncSession | None = None,
) -> Self:
    """Find a matching record or create one.

    *search* fields are used to find; *values* are merged when creating.
    Observer events fire on creation.
    """
    async with cls._session_scope(session) as resolved_session:
        q = cls.query(resolved_session)
        pk = cls._pk_column()
        for key, value in search.items():
            col = getattr(cls, key, None)
            if col is not None:
                q = q.where(col == value)
        existing = await q.order_by(pk).first()
        if existing is not None:
            return existing
        merged = {**search, **(values or {})}
        return await cls.create(merged, session=resolved_session)

first_or_new(search, values=None) classmethod

Build a new (unsaved) instance from search + values.

Does NOT persist. Does NOT query the database. Use first_or_create if you need to check the DB first.

Source code in src/arvel/data/model.py
874
875
876
877
878
879
880
881
882
883
884
885
886
887
@classmethod
def first_or_new(
    cls,
    search: dict[str, Any],
    values: dict[str, Any] | None = None,
) -> Self:
    """Build a new (unsaved) instance from search + values.

    Does NOT persist. Does NOT query the database.
    Use ``first_or_create`` if you need to check the DB first.
    """
    merged = {**search, **(values or {})}
    safe_data = filter_mass_assignable(cls, merged)
    return cls.model_validate(safe_data)

update_or_create(search, values=None, *, session=None) async classmethod

Find a matching record and update it, or create a new one.

Observer events fire for both update and create paths.

Source code in src/arvel/data/model.py
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
@classmethod
async def update_or_create(
    cls,
    search: dict[str, Any],
    values: dict[str, Any] | None = None,
    *,
    session: AsyncSession | None = None,
) -> Self:
    """Find a matching record and update it, or create a new one.

    Observer events fire for both update and create paths.
    """
    async with cls._session_scope(session) as resolved_session:
        q = cls.query(resolved_session)
        pk = cls._pk_column()
        for key, value in search.items():
            col = getattr(cls, key, None)
            if col is not None:
                q = q.where(col == value)
        existing = await q.order_by(pk).first()
        if existing is not None:
            return await existing.update(values or {}, session=resolved_session)
        merged = {**search, **(values or {})}
        return await cls.create(merged, session=resolved_session)

destroy(record_ids, *, session=None) async classmethod

Delete one or more records by primary key.

Returns the number of records deleted. Fires observer events for each record.

Source code in src/arvel/data/model.py
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
@classmethod
async def destroy(
    cls,
    record_ids: int | str | Sequence[int | str],
    *,
    session: AsyncSession | None = None,
) -> int:
    """Delete one or more records by primary key.

    Returns the number of records deleted. Fires observer events
    for each record.
    """
    async with cls._session_scope(session) as resolved_session:
        if isinstance(record_ids, (int, str)):
            record_ids = [record_ids]
        instances = await cls.find_many(record_ids, session=resolved_session)
        for instance in instances:
            await instance.delete(session=resolved_session)
        return len(instances)

ModelObserver

Base class for model lifecycle observers.

Parameterize with the model type for typed hooks::

class UserObserver(ModelObserver[User]):
    async def creating(self, instance: User) -> bool: ...

Using ModelObserver without a type parameter falls back to Any (backward compatible).

Override any hook method. creating, updating, and deleting can return False to abort the operation.

Source code in src/arvel/data/observer.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
class ModelObserver[T: "ArvelModel"]:
    """Base class for model lifecycle observers.

    Parameterize with the model type for typed hooks::

        class UserObserver(ModelObserver[User]):
            async def creating(self, instance: User) -> bool: ...

    Using ``ModelObserver`` without a type parameter falls back to
    ``Any`` (backward compatible).

    Override any hook method. ``creating``, ``updating``, and ``deleting``
    can return ``False`` to abort the operation.
    """

    async def saving(self, instance: T) -> bool:
        return True

    async def saved(self, instance: T) -> None:
        pass

    async def creating(self, instance: T) -> bool:
        return True

    async def created(self, instance: T) -> None:
        pass

    async def updating(self, instance: T) -> bool:
        return True

    async def updated(self, instance: T) -> None:
        pass

    async def deleting(self, instance: T) -> bool:
        return True

    async def deleted(self, instance: T) -> None:
        pass

    async def force_deleting(self, instance: T) -> bool:
        return True

    async def force_deleted(self, instance: T) -> None:
        pass

    async def restoring(self, instance: T) -> bool:
        return True

    async def restored(self, instance: T) -> None:
        pass

ObserverRegistry

Stores and dispatches model lifecycle observers.

Observers can be registered by model class or by model name string (for cross-module decoupling). Multiple observers per model execute in priority order (lower = earlier).

Source code in src/arvel/data/observer.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
class ObserverRegistry:
    """Stores and dispatches model lifecycle observers.

    Observers can be registered by model class or by model name string
    (for cross-module decoupling). Multiple observers per model execute
    in priority order (lower = earlier).
    """

    def __init__(self) -> None:
        self._observers: dict[str, list[tuple[int, ModelObserver[Any]]]] = {}

    def _key(self, model: type[ArvelModel] | str) -> str:
        if isinstance(model, str):
            return model
        return model.__name__

    def register(
        self,
        model: type[ArvelModel] | str,
        observer: ModelObserver[Any],
        *,
        priority: int = 50,
    ) -> None:
        key = self._key(model)
        if key not in self._observers:
            self._observers[key] = []
        self._observers[key].append((priority, observer))
        self._observers[key].sort(key=lambda x: x[0])

    def _get_observers(self, model: type[ArvelModel]) -> list[ModelObserver[Any]]:
        key = model.__name__
        entries = self._observers.get(key, [])
        return [obs for _, obs in entries]

    async def dispatch(
        self,
        event: str,
        model_cls: type[ArvelModel],
        instance: ArvelModel,
    ) -> bool:
        """Dispatch a lifecycle event to all registered observers.

        For pre-events (creating, updating, deleting, etc.): returns
        False if any observer returns False.
        """
        if event not in _VALID_EVENTS:
            msg = f"Invalid observer event: {event!r}"
            raise ValueError(msg)
        observers = self._get_observers(model_cls)
        for obs in observers:
            handler = getattr(obs, event, None)
            if handler is None:
                continue
            result = await handler(instance)
            if event in _PRE_EVENTS and result is False:
                return False
        return True

dispatch(event, model_cls, instance) async

Dispatch a lifecycle event to all registered observers.

For pre-events (creating, updating, deleting, etc.): returns False if any observer returns False.

Source code in src/arvel/data/observer.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
async def dispatch(
    self,
    event: str,
    model_cls: type[ArvelModel],
    instance: ArvelModel,
) -> bool:
    """Dispatch a lifecycle event to all registered observers.

    For pre-events (creating, updating, deleting, etc.): returns
    False if any observer returns False.
    """
    if event not in _VALID_EVENTS:
        msg = f"Invalid observer event: {event!r}"
        raise ValueError(msg)
    observers = self._get_observers(model_cls)
    for obs in observers:
        handler = getattr(obs, event, None)
        if handler is None:
            continue
        result = await handler(instance)
        if event in _PRE_EVENTS and result is False:
            return False
    return True

CursorMeta

Bases: TypedDict

Typed metadata for cursor-based pagination.

Source code in src/arvel/data/pagination.py
36
37
38
39
40
class CursorMeta(TypedDict):
    """Typed metadata for cursor-based pagination."""

    next_cursor: str | None
    has_more: bool

CursorResponse

Bases: TypedDict

Typed response for cursor-based pagination, suitable for API serialization.

Source code in src/arvel/data/pagination.py
43
44
45
46
47
class CursorResponse(TypedDict):
    """Typed response for cursor-based pagination, suitable for API serialization."""

    data: list[Any]
    meta: CursorMeta

CursorResult dataclass

Cursor-based pagination result.

Source code in src/arvel/data/pagination.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
@dataclass(frozen=True)
class CursorResult[T]:
    """Cursor-based pagination result."""

    data: list[T]
    next_cursor: str | None
    has_more: bool

    def to_response(self) -> CursorResponse:
        """Return a typed response dict for API serialization."""
        return CursorResponse(
            data=self.data,
            meta=CursorMeta(
                next_cursor=self.next_cursor,
                has_more=self.has_more,
            ),
        )

to_response()

Return a typed response dict for API serialization.

Source code in src/arvel/data/pagination.py
 95
 96
 97
 98
 99
100
101
102
103
def to_response(self) -> CursorResponse:
    """Return a typed response dict for API serialization."""
    return CursorResponse(
        data=self.data,
        meta=CursorMeta(
            next_cursor=self.next_cursor,
            has_more=self.has_more,
        ),
    )

PaginatedResponse

Bases: TypedDict

Typed response for offset-based pagination, suitable for API serialization.

Source code in src/arvel/data/pagination.py
29
30
31
32
33
class PaginatedResponse(TypedDict):
    """Typed response for offset-based pagination, suitable for API serialization."""

    data: list[Any]
    meta: PaginationMeta

PaginatedResult dataclass

Offset-based pagination result.

Source code in src/arvel/data/pagination.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@dataclass(frozen=True)
class PaginatedResult[T]:
    """Offset-based pagination result."""

    data: list[T]
    total: int
    page: int
    per_page: int

    def __post_init__(self) -> None:
        if self.per_page > MAX_PER_PAGE:
            object.__setattr__(self, "per_page", MAX_PER_PAGE)

    @property
    def last_page(self) -> int:
        if self.total == 0:
            return 0
        return math.ceil(self.total / self.per_page)

    @property
    def has_more(self) -> bool:
        return self.page < self.last_page

    def to_response(self) -> PaginatedResponse:
        """Return a typed response dict for API serialization."""
        return PaginatedResponse(
            data=self.data,
            meta=PaginationMeta(
                total=self.total,
                page=self.page,
                per_page=self.per_page,
                last_page=self.last_page,
                has_more=self.has_more,
            ),
        )

to_response()

Return a typed response dict for API serialization.

Source code in src/arvel/data/pagination.py
73
74
75
76
77
78
79
80
81
82
83
84
def to_response(self) -> PaginatedResponse:
    """Return a typed response dict for API serialization."""
    return PaginatedResponse(
        data=self.data,
        meta=PaginationMeta(
            total=self.total,
            page=self.page,
            per_page=self.per_page,
            last_page=self.last_page,
            has_more=self.has_more,
        ),
    )

PaginationMeta

Bases: TypedDict

Typed metadata for offset-based pagination.

Source code in src/arvel/data/pagination.py
19
20
21
22
23
24
25
26
class PaginationMeta(TypedDict):
    """Typed metadata for offset-based pagination."""

    total: int
    page: int
    per_page: int
    last_page: int
    has_more: bool

DatabaseServiceProvider

Bases: ServiceProvider

Async engine + session factory. Priority 5 (before infra at 10).

Source code in src/arvel/data/provider.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
class DatabaseServiceProvider(ServiceProvider):
    """Async engine + session factory. Priority 5 (before infra at 10)."""

    priority: int = 5

    _engine: AsyncEngine | None
    _session_factory: async_sessionmaker[AsyncSession] | None
    _settings: DatabaseSettings | None
    _own_session_resolver: Any
    _own_observer_resolver: Any

    def __init__(self) -> None:
        super().__init__()
        self._engine = None
        self._session_factory = None
        self._settings = None
        self._own_session_resolver = None
        self._own_observer_resolver = None

    def configure(self, config: AppSettings) -> None:
        with contextlib.suppress(Exception):
            self._settings = get_module_settings(config, DatabaseSettings)

    def _get_settings(self) -> DatabaseSettings:
        if self._settings is not None:
            return self._settings
        return DatabaseSettings()

    def _make_engine(self) -> AsyncEngine:
        if self._engine is not None:
            return self._engine

        import logging

        settings = self._get_settings()
        kwargs: dict[str, Any] = {
            "echo": False,
            "pool_pre_ping": settings.pool_pre_ping,
            "pool_recycle": settings.pool_recycle,
        }
        if settings.driver != "sqlite":
            kwargs["pool_size"] = settings.pool_size
            kwargs["max_overflow"] = settings.pool_max_overflow
            kwargs["pool_timeout"] = settings.pool_timeout

        self._engine = create_async_engine(settings.url, **kwargs)

        if settings.echo:
            logging.getLogger("sqlalchemy.engine.Engine").setLevel(logging.DEBUG)

        _logger.info("database_engine_created", url=settings.url.split("@")[-1])
        return self._engine

    def _make_session_factory(self) -> async_sessionmaker[AsyncSession]:
        if self._session_factory is not None:
            return self._session_factory

        settings = self._get_settings()
        engine = self._make_engine()
        self._session_factory = async_sessionmaker(
            engine,
            expire_on_commit=settings.expire_on_commit,
        )
        return self._session_factory

    def _make_session(self) -> AsyncSession:
        """Fresh unmanaged session for write operations."""
        factory = self._make_session_factory()
        return factory()

    async def register(self, container: ContainerBuilder) -> None:
        container.provide_factory(AsyncEngine, self._make_engine, scope=Scope.APP)
        container.provide_factory(
            async_sessionmaker,
            self._make_session_factory,
            scope=Scope.APP,  # type: ignore[type-abstract]
        )
        container.provide_factory(AsyncSession, self._make_session, scope=Scope.REQUEST)

    async def boot(self, app: Application) -> None:
        from arvel.data.model import ArvelModel
        from arvel.data.observer import ObserverRegistry

        session_resolver = self._make_session
        if ArvelModel._session_resolver is None:
            ArvelModel.set_session_resolver(session_resolver)
            ArvelModel.set_session_factory(session_resolver)
            self._own_session_resolver = session_resolver
        else:
            self._own_session_resolver = None

        if ArvelModel._observer_registry_resolver is None:
            _registry = ObserverRegistry()
            observer_resolver = lambda: _registry  # noqa: E731
            ArvelModel.set_observer_registry(observer_resolver)
            self._own_observer_resolver = observer_resolver
        else:
            self._own_observer_resolver = None

        _logger.info("database_session_and_observer_resolver_set")

    async def shutdown(self, app: Application) -> None:
        from arvel.data.model import ArvelModel

        if (
            self._own_session_resolver is not None
            and ArvelModel._session_resolver is self._own_session_resolver
        ):
            ArvelModel.clear_session_resolver()
        if (
            self._own_observer_resolver is not None
            and ArvelModel._observer_registry_resolver is self._own_observer_resolver
        ):
            ArvelModel.clear_observer_registry()

        if self._engine is not None:
            await self._engine.dispose()
            _logger.info("database_engine_disposed")
            self._engine = None
        self._session_factory = None

QueryBuilder

Fluent query builder producing parameterized SQL.

Usage::

users = await User.query(session).where(User.active == True).order_by(User.name).all()
Source code in src/arvel/data/query.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
class QueryBuilder[T: DeclarativeBase]:
    """Fluent query builder producing parameterized SQL.

    Usage::

        users = await User.query(session).where(User.active == True).order_by(User.name).all()
    """

    DEFAULT_MAX_DEPTH: int = 100

    def __init__(
        self, model_cls: type[T], session: AsyncSession, *, owns_session: bool = False
    ) -> None:
        self._model_cls = model_cls
        self._session = session
        self._owns_session = owns_session
        self._stmt: Select[tuple[T]] = select(model_cls)
        self._count_subqueries: list[tuple[str, Any]] = []
        self._has_order_by = False
        self._excluded_global_scopes: set[str] = set()
        self._global_scopes_applied = False

    async def _release_session(self) -> None:
        """Close the session if this query builder created it."""
        if self._owns_session:
            await self._session.close()

    @property
    def _table(self) -> Table:
        """Narrow __table__ from FromClause to Table for column/name access."""
        table = self._model_cls.__table__
        if not isinstance(table, Table):
            msg = f"{self._model_cls.__name__}.__table__ is not a Table"
            raise TypeError(msg)
        return table

    def where(self, *criteria: ColumnElement[bool]) -> Self:
        self._stmt = self._stmt.where(*criteria)
        return self

    def order_by(self, *columns: _ColumnExpressionOrStrLabelArgument[Any]) -> Self:
        self._stmt = self._stmt.order_by(*columns)
        self._has_order_by = True
        return self

    def limit(self, n: int) -> Self:
        self._stmt = self._stmt.limit(n)
        return self

    def offset(self, n: int) -> Self:
        self._stmt = self._stmt.offset(n)
        return self

    # ------------------------------------------------------------------
    # Scope support
    # ------------------------------------------------------------------

    def __getattr__(self, name: str) -> Any:
        """Resolve local query scopes defined on the model."""
        registry: Any = getattr(self._model_cls, "__scope_registry__", None)
        if registry is not None:
            scope_fn = registry.get_local(name)
            if scope_fn is not None:

                def _call_scope(*args: Any, **kwargs: Any) -> Self:  # type: ignore[type-var]
                    return scope_fn(self, *args, **kwargs)

                return _call_scope
        msg = f"'{type(self).__name__}' has no attribute {name!r}"
        raise AttributeError(msg)

    def without_global_scope(self, scope_name: str) -> Self:
        """Exclude a named global scope from this query."""
        self._excluded_global_scopes.add(scope_name)
        return self

    def without_global_scopes(self) -> Self:
        """Exclude all global scopes from this query."""
        self._exclude_all_global_scopes = True
        return self

    def with_trashed(self) -> Self:
        """Include soft-deleted rows (removes the SoftDeleteScope)."""
        return self.without_global_scope("SoftDeleteScope")

    def only_trashed(self) -> Self:
        """Return only soft-deleted rows."""
        self.without_global_scope("SoftDeleteScope")
        from arvel.data.soft_deletes import is_soft_deletable

        if is_soft_deletable(self._model_cls):
            deleted_at_col = getattr(self._model_cls, "deleted_at")  # noqa: B009  # dynamic: SoftDeletes mixin adds this column
            self._stmt = self._stmt.where(deleted_at_col.isnot(None))
        return self

    def _apply_global_scopes(self) -> None:
        """Apply all non-excluded global scopes to the statement."""
        if self._global_scopes_applied:
            return
        self._global_scopes_applied = True

        registry: Any = getattr(self._model_cls, "__scope_registry__", None)
        if registry is None:
            return

        exclude_all = getattr(self, "_exclude_all_global_scopes", False)
        for gs in registry.get_globals():
            if exclude_all:
                continue
            if gs.name in self._excluded_global_scopes:
                continue
            gs.apply(self)

    def _get_rel_attr(self, relationship_name: str) -> RelationshipProperty[Any]:
        """Resolve a relationship attribute by name, raising on invalid names."""
        rel_attr = getattr(self._model_cls, relationship_name, None)
        if rel_attr is None:
            msg = (
                f"'{self._model_cls.__name__}' has no relationship '{relationship_name}'. "
                f"Declared relationships: {list(self._get_declared_names())}"
            )
            raise ValueError(msg)
        return rel_attr

    def _get_declared_names(self) -> set[str]:
        """Return the set of declared relationship names on the model."""
        registry = getattr(self._model_cls, "__relationship_registry__", None)
        if registry is not None:
            return set(registry.all().keys())
        return set()

    def with_(self, *relationships: str) -> Self:
        """Eager-load relationships by name using selectinload.

        Supports nested dot notation: ``with_("posts", "posts.comments")``
        produces ``selectinload(Model.posts).selectinload(Post.comments)``.

        Raises ValueError if a relationship name does not exist on the model.
        """
        for rel_path in relationships:
            parts = rel_path.split(".")
            loader = self._build_nested_loader(parts)
            self._stmt = self._stmt.options(loader)
        return self

    def _build_nested_loader(self, parts: list[str]) -> Any:
        """Walk a dotted path and chain selectinload() calls.

        Raises ValueError if any segment in the path is not a valid relationship.
        """
        current_cls = self._model_cls
        loader = None

        for part in parts:
            attr = getattr(current_cls, part, None)
            if attr is None:
                msg = (
                    f"'{current_cls.__name__}' has no attribute '{part}' "
                    f"(from path '{'.'.join(parts)}')"
                )
                raise ValueError(msg)

            loader = selectinload(attr) if loader is None else loader.selectinload(attr)

            prop = getattr(attr, "property", None)
            if prop is not None and hasattr(prop, "mapper"):
                current_cls = prop.mapper.class_
            else:
                break

        return loader

    def has(
        self,
        relationship_name: str,
        operator: ComparisonOperator = ">",
        count: int = 0,
    ) -> Self:
        """Filter to models that have related records matching a count condition.

        Raises ValueError if the relationship name doesn't exist.

        Examples::

            User.query(s).has("posts").all()          # users with >=1 post
            User.query(s).has("posts", ">", 5).all()  # users with >5 posts
        """
        rel_attr = self._get_rel_attr(relationship_name)
        subq = self._relationship_count_subquery(rel_attr)
        condition = self._count_condition(subq, operator, count)
        self._stmt = self._stmt.where(condition)
        return self

    def doesnt_have(self, relationship_name: str) -> Self:
        """Filter to models that have zero related records."""
        return self.has(relationship_name, "=", 0)

    def where_has(
        self,
        relationship_name: str,
        callback: Callable[..., ColumnElement[bool]],
    ) -> Self:
        """Filter to models whose related records match extra conditions.

        Raises ValueError if the relationship name doesn't exist.

        The callback receives the related model class and should return
        a SQLAlchemy criterion::

            User.query(s).where_has("posts", lambda Post: Post.is_published == True).all()
        """
        rel_attr = self._get_rel_attr(relationship_name)

        prop = rel_attr.property
        related_cls = prop.mapper.class_
        related_table = related_cls.__table__

        local_pk = self._table.c.id
        fk_col = self._find_fk_column(prop, related_table)
        if fk_col is None:
            msg = (
                f"Cannot resolve FK for relationship '{relationship_name}' "
                f"between '{self._model_cls.__name__}' and '{related_cls.__name__}'"
            )
            raise ValueError(msg)

        extra_condition = callback(related_cls)
        subq = (
            select(func.count())
            .select_from(related_table)
            .where(fk_col == local_pk)
            .where(extra_condition)
            .correlate(self._model_cls)
            .scalar_subquery()
        )
        self._stmt = self._stmt.where(subq > 0)
        return self

    def with_count(self, relationship_name: str) -> Self:
        """Add a ``{relationship}_count`` column to the result.

        The count is computed as a correlated subquery.
        Supports both direct FK relationships and M2M (pivot/secondary) relationships.

        Raises ValueError if the relationship name doesn't exist.
        """
        rel_attr = self._get_rel_attr(relationship_name)
        subq = self._relationship_count_subquery(rel_attr)
        label = f"{relationship_name}_count"
        self._stmt = self._stmt.add_columns(subq.label(label))
        self._count_subqueries.append((label, subq))
        return self

    def _relationship_count_subquery(self, rel_attr: Any) -> Any:
        """Build a correlated COUNT subquery for a relationship property.

        Handles both direct FK and M2M (secondary table) relationships.
        """
        prop = rel_attr.property
        related_cls = prop.mapper.class_
        related_table = related_cls.__table__

        local_pk = self._table.c.id

        if hasattr(prop, "secondary") and prop.secondary is not None:
            return self._m2m_count_subquery(prop.secondary, local_pk)

        fk_col = self._find_fk_column(prop, related_table)
        if fk_col is None:
            return select(func.literal(0)).scalar_subquery()

        return (
            select(func.count())
            .select_from(related_table)
            .where(fk_col == local_pk)
            .correlate(self._model_cls)
            .scalar_subquery()
        )

    def _m2m_count_subquery(self, secondary: Table, local_pk: Column[Any]) -> Any:
        """Build a COUNT subquery for M2M relationships via the pivot table.

        Finds the FK column on the secondary table that references the owner table.
        """
        owner_table_name = self._table.name
        owner_fk_col: Column[Any] | None = None

        for col in secondary.columns:
            for fk in col.foreign_keys:
                if fk.column.table.name == owner_table_name:
                    owner_fk_col = col
                    break
            if owner_fk_col is not None:
                break

        if owner_fk_col is None:
            return select(func.literal(0)).scalar_subquery()

        return (
            select(func.count())
            .select_from(secondary)
            .where(owner_fk_col == local_pk)
            .correlate(self._model_cls)
            .scalar_subquery()
        )

    @staticmethod
    def _find_fk_column(prop: Any, related_table: Any) -> Column[Any] | None:
        """Find the FK column on the related table that points back to the owner."""
        if hasattr(prop, "secondary") and prop.secondary is not None:
            return None

        for pair in prop.local_remote_pairs:
            _local_col, remote_col = pair
            for col in related_table.columns:
                if col.name == remote_col.name:
                    return col
        return None

    @staticmethod
    def _count_condition(
        subq: Any, operator: ComparisonOperator, count: int
    ) -> ColumnElement[bool]:
        ops: dict[str, Callable[[Any, int], ColumnElement[bool]]] = {
            ">": lambda s, c: s > c,
            ">=": lambda s, c: s >= c,
            "<": lambda s, c: s < c,
            "<=": lambda s, c: s <= c,
            "=": lambda s, c: s == c,
            "!=": lambda s, c: s != c,
        }
        op_fn = ops.get(operator)
        if op_fn is None:
            valid = ", ".join(sorted(_VALID_OPERATORS))
            msg = f"Unsupported comparison operator. Valid operators: {valid}"
            raise ValueError(msg)
        return op_fn(subq, count)

    def recursive(
        self,
        anchor: Any,
        step: Callable[..., Any],
        *,
        max_depth: int | None = None,
        cycle_detection: bool = False,
    ) -> RecursiveQueryBuilder[T]:
        """Build a WITH RECURSIVE CTE from anchor and step conditions.

        Returns a ``RecursiveQueryBuilder`` whose ``all()`` / ``first()``
        produce ``TreeNode[T]`` results instead of plain ``T``.

        Args:
            anchor: WHERE clause for the anchor (base case) rows.
            step: Callable receiving the CTE alias and returning the join condition
                  for the recursive term.
            max_depth: Maximum recursion depth (default DEFAULT_MAX_DEPTH).
            cycle_detection: If True, add path-tracking to detect cycles.
        """
        rqb = RecursiveQueryBuilder(self._model_cls, self._session, owns_session=self._owns_session)
        rqb._stmt = self._stmt
        rqb._has_order_by = self._has_order_by
        rqb._excluded_global_scopes = self._excluded_global_scopes
        rqb._global_scopes_applied = self._global_scopes_applied

        if max_depth is None:
            max_depth = self.DEFAULT_MAX_DEPTH

        table = rqb._table
        cols = [c.label(c.name) for c in table.columns]

        anchor_stmt = select(*cols, literal_column("0").label("depth")).where(anchor)
        cte = anchor_stmt.cte(name="tree", recursive=True)

        step_condition = step(cte)
        recursive_cols = [table.c[c.name].label(c.name) for c in table.columns]
        recursive_stmt = (
            select(*recursive_cols, (cte.c.depth + 1).label("depth"))
            .select_from(table.join(cte, step_condition))
            .where(cte.c.depth < max_depth)
        )

        full_cte = cte.union_all(recursive_stmt)
        rqb._stmt = select(full_cte)

        try:
            parent_col, id_col = rqb._find_self_ref_columns()
            rqb._recursive_id_key = id_col.name
            rqb._recursive_parent_key = parent_col.name
        except ValueError:
            pass

        return rqb

    def ancestors(
        self, node_id: int | str, *, max_depth: int | None = None
    ) -> RecursiveQueryBuilder[T]:
        """Return all ancestors of node_id up to the root.

        Auto-detects the parent_id column from self-referencing FK.
        """
        parent_col, id_col = self._find_self_ref_columns()

        rqb = self.recursive(
            anchor=id_col == node_id,
            step=lambda tree: id_col == tree.c[parent_col.name],
            max_depth=max_depth,
        )
        rqb._recursive_id_key = id_col.name
        rqb._recursive_parent_key = parent_col.name
        return rqb._exclude_anchor_row(node_id, id_col)

    def descendants(
        self, node_id: int | str, *, max_depth: int | None = None
    ) -> RecursiveQueryBuilder[T]:
        """Return all descendants of node_id down to the leaves.

        Auto-detects the parent_id column from self-referencing FK.
        """
        parent_col, id_col = self._find_self_ref_columns()

        rqb = self.recursive(
            anchor=id_col == node_id,
            step=lambda tree: parent_col == tree.c[id_col.name],
            max_depth=max_depth,
        )
        rqb._recursive_id_key = id_col.name
        rqb._recursive_parent_key = parent_col.name
        return rqb._exclude_anchor_row(node_id, id_col)

    def _find_self_ref_columns(self) -> tuple[Column[Any], Column[Any]]:
        """Find the parent_id FK column and the id PK column."""
        table = self._table

        pk_col: Column[Any] | None = None
        parent_col: Column[Any] | None = None
        for col in table.columns:
            if col.primary_key:
                pk_col = col
            for fk in col.foreign_keys:
                if fk.column.table is table:
                    parent_col = col
                    break

        if pk_col is None:
            msg = f"No primary key found on {table.name}"
            raise ValueError(msg)
        if parent_col is None:
            msg = f"No self-referencing FK found on {table.name}"
            raise ValueError(msg)
        return parent_col, pk_col

    def build_statement(self) -> Select[tuple[T]]:
        """Return the underlying SA Select for inspection/testing."""
        return self._stmt

    async def all(self) -> ArvelCollection[T]:
        """Execute the query and return all results."""
        try:
            self._apply_global_scopes()
            result = await self._session.execute(self._stmt)
            if self._count_subqueries:
                return ArvelCollection(row[0] for row in result.all())
            return ArvelCollection(result.scalars().all())
        finally:
            await self._release_session()

    async def first(self) -> T | None:
        """Execute the query and return the first result, or ``None``."""
        if not self._has_order_by:
            warnings.warn(
                f"QueryBuilder[{self._model_cls.__name__}].first() called without order_by() "
                f"— results may be non-deterministic",
                stacklevel=2,
            )
        try:
            self._apply_global_scopes()
            result = await self._session.execute(self._stmt)
            if self._count_subqueries:
                row = result.first()
                return row[0] if row is not None else None
            return result.scalars().first()
        finally:
            await self._release_session()

    async def all_with_counts(self) -> list[WithCount[T]]:
        """Execute a ``with_count()`` query and return typed results.

        Each result wraps the model instance with relationship counts::

            results = await User.query(s).with_count("posts").all_with_counts()
            for r in results:
                print(r.instance.name, r.counts["posts"])
        """
        try:
            self._apply_global_scopes()
            result = await self._session.execute(self._stmt)
            return self._build_with_counts(list(result.all()))
        finally:
            await self._release_session()

    async def first_with_count(self) -> WithCount[T] | None:
        """Execute a ``with_count()`` query and return the first typed result."""
        try:
            self._apply_global_scopes()
            result = await self._session.execute(self._stmt)
            row = result.first()
            if row is None:
                return None
            return self._build_with_counts([row])[0]
        finally:
            await self._release_session()

    def _build_with_counts(self, rows: list[Any]) -> list[WithCount[T]]:
        """Wrap composite rows into ``WithCount[T]`` instances."""
        results: list[WithCount[T]] = []
        for row in rows:
            instance = row[0]
            counts: dict[str, int] = {}
            for i, (label, _subq) in enumerate(self._count_subqueries, start=1):
                rel_name = label.removesuffix("_count")
                counts[rel_name] = int(row[i])
            results.append(WithCount(instance=instance, counts=counts))
        return results

    def _get_column_names(self) -> list[str]:
        """Return the column names of the model table (excluding the CTE depth column)."""
        return [col.name for col in self._table.columns]

    def to_sql(self) -> str:
        """Return the compiled SQL string for debugging.

        Disabled in production (``ARVEL_DEBUG`` must be ``true``).
        Raises ``RuntimeError`` if called with debug mode off.
        """
        debug = os.environ.get("ARVEL_DEBUG", "").lower() in ("1", "true", "yes")
        if not debug:
            msg = "to_sql() requires ARVEL_DEBUG=true"
            raise RuntimeError(msg)
        return str(self._stmt.compile(compile_kwargs={"literal_binds": True}))

    def __repr__(self) -> str:
        return f"QueryBuilder({self._model_cls.__name__})"

    async def count(self) -> int:
        try:
            self._apply_global_scopes()
            count_stmt = select(func.count()).select_from(self._stmt.subquery())
            result = await self._session.execute(count_stmt)
            return result.scalar_one()
        finally:
            await self._release_session()

    def _aggregate_subquery(self) -> Any:
        """Build a subquery suitable for aggregate functions.

        Replaces the ORM entity columns with just the table columns so
        SA doesn't generate a cartesian product.
        """
        self._apply_global_scopes()
        return self._stmt.with_only_columns(*self._table.columns).subquery()

    async def max(self, column: _ColumnExpressionOrStrLabelArgument[Any]) -> Any:
        """Return the maximum value for *column*."""
        try:
            sub = self._aggregate_subquery()
            col_name = getattr(column, "key", None) or str(column)
            stmt = select(func.max(sub.c[col_name]))
            result = await self._session.execute(stmt)
            return result.scalar_one_or_none()
        finally:
            await self._release_session()

    async def min(self, column: _ColumnExpressionOrStrLabelArgument[Any]) -> Any:
        """Return the minimum value for *column*."""
        try:
            sub = self._aggregate_subquery()
            col_name = getattr(column, "key", None) or str(column)
            stmt = select(func.min(sub.c[col_name]))
            result = await self._session.execute(stmt)
            return result.scalar_one_or_none()
        finally:
            await self._release_session()

    async def sum(self, column: _ColumnExpressionOrStrLabelArgument[Any]) -> Any:
        """Return the sum for *column*."""
        try:
            sub = self._aggregate_subquery()
            col_name = getattr(column, "key", None) or str(column)
            stmt = select(func.sum(sub.c[col_name]))
            result = await self._session.execute(stmt)
            return result.scalar_one_or_none()
        finally:
            await self._release_session()

    async def avg(self, column: _ColumnExpressionOrStrLabelArgument[Any]) -> Any:
        """Return the average for *column*."""
        try:
            sub = self._aggregate_subquery()
            col_name = getattr(column, "key", None) or str(column)
            stmt = select(func.avg(sub.c[col_name]))
            result = await self._session.execute(stmt)
            return result.scalar_one_or_none()
        finally:
            await self._release_session()

__getattr__(name)

Resolve local query scopes defined on the model.

Source code in src/arvel/data/query.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def __getattr__(self, name: str) -> Any:
    """Resolve local query scopes defined on the model."""
    registry: Any = getattr(self._model_cls, "__scope_registry__", None)
    if registry is not None:
        scope_fn = registry.get_local(name)
        if scope_fn is not None:

            def _call_scope(*args: Any, **kwargs: Any) -> Self:  # type: ignore[type-var]
                return scope_fn(self, *args, **kwargs)

            return _call_scope
    msg = f"'{type(self).__name__}' has no attribute {name!r}"
    raise AttributeError(msg)

without_global_scope(scope_name)

Exclude a named global scope from this query.

Source code in src/arvel/data/query.py
108
109
110
111
def without_global_scope(self, scope_name: str) -> Self:
    """Exclude a named global scope from this query."""
    self._excluded_global_scopes.add(scope_name)
    return self

without_global_scopes()

Exclude all global scopes from this query.

Source code in src/arvel/data/query.py
113
114
115
116
def without_global_scopes(self) -> Self:
    """Exclude all global scopes from this query."""
    self._exclude_all_global_scopes = True
    return self

with_trashed()

Include soft-deleted rows (removes the SoftDeleteScope).

Source code in src/arvel/data/query.py
118
119
120
def with_trashed(self) -> Self:
    """Include soft-deleted rows (removes the SoftDeleteScope)."""
    return self.without_global_scope("SoftDeleteScope")

only_trashed()

Return only soft-deleted rows.

Source code in src/arvel/data/query.py
122
123
124
125
126
127
128
129
130
def only_trashed(self) -> Self:
    """Return only soft-deleted rows."""
    self.without_global_scope("SoftDeleteScope")
    from arvel.data.soft_deletes import is_soft_deletable

    if is_soft_deletable(self._model_cls):
        deleted_at_col = getattr(self._model_cls, "deleted_at")  # noqa: B009  # dynamic: SoftDeletes mixin adds this column
        self._stmt = self._stmt.where(deleted_at_col.isnot(None))
    return self

with_(*relationships)

Eager-load relationships by name using selectinload.

Supports nested dot notation: with_("posts", "posts.comments") produces selectinload(Model.posts).selectinload(Post.comments).

Raises ValueError if a relationship name does not exist on the model.

Source code in src/arvel/data/query.py
168
169
170
171
172
173
174
175
176
177
178
179
180
def with_(self, *relationships: str) -> Self:
    """Eager-load relationships by name using selectinload.

    Supports nested dot notation: ``with_("posts", "posts.comments")``
    produces ``selectinload(Model.posts).selectinload(Post.comments)``.

    Raises ValueError if a relationship name does not exist on the model.
    """
    for rel_path in relationships:
        parts = rel_path.split(".")
        loader = self._build_nested_loader(parts)
        self._stmt = self._stmt.options(loader)
    return self

has(relationship_name, operator='>', count=0)

Filter to models that have related records matching a count condition.

Raises ValueError if the relationship name doesn't exist.

Examples::

User.query(s).has("posts").all()          # users with >=1 post
User.query(s).has("posts", ">", 5).all()  # users with >5 posts
Source code in src/arvel/data/query.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def has(
    self,
    relationship_name: str,
    operator: ComparisonOperator = ">",
    count: int = 0,
) -> Self:
    """Filter to models that have related records matching a count condition.

    Raises ValueError if the relationship name doesn't exist.

    Examples::

        User.query(s).has("posts").all()          # users with >=1 post
        User.query(s).has("posts", ">", 5).all()  # users with >5 posts
    """
    rel_attr = self._get_rel_attr(relationship_name)
    subq = self._relationship_count_subquery(rel_attr)
    condition = self._count_condition(subq, operator, count)
    self._stmt = self._stmt.where(condition)
    return self

doesnt_have(relationship_name)

Filter to models that have zero related records.

Source code in src/arvel/data/query.py
230
231
232
def doesnt_have(self, relationship_name: str) -> Self:
    """Filter to models that have zero related records."""
    return self.has(relationship_name, "=", 0)

where_has(relationship_name, callback)

Filter to models whose related records match extra conditions.

Raises ValueError if the relationship name doesn't exist.

The callback receives the related model class and should return a SQLAlchemy criterion::

User.query(s).where_has("posts", lambda Post: Post.is_published == True).all()
Source code in src/arvel/data/query.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def where_has(
    self,
    relationship_name: str,
    callback: Callable[..., ColumnElement[bool]],
) -> Self:
    """Filter to models whose related records match extra conditions.

    Raises ValueError if the relationship name doesn't exist.

    The callback receives the related model class and should return
    a SQLAlchemy criterion::

        User.query(s).where_has("posts", lambda Post: Post.is_published == True).all()
    """
    rel_attr = self._get_rel_attr(relationship_name)

    prop = rel_attr.property
    related_cls = prop.mapper.class_
    related_table = related_cls.__table__

    local_pk = self._table.c.id
    fk_col = self._find_fk_column(prop, related_table)
    if fk_col is None:
        msg = (
            f"Cannot resolve FK for relationship '{relationship_name}' "
            f"between '{self._model_cls.__name__}' and '{related_cls.__name__}'"
        )
        raise ValueError(msg)

    extra_condition = callback(related_cls)
    subq = (
        select(func.count())
        .select_from(related_table)
        .where(fk_col == local_pk)
        .where(extra_condition)
        .correlate(self._model_cls)
        .scalar_subquery()
    )
    self._stmt = self._stmt.where(subq > 0)
    return self

with_count(relationship_name)

Add a {relationship}_count column to the result.

The count is computed as a correlated subquery. Supports both direct FK relationships and M2M (pivot/secondary) relationships.

Raises ValueError if the relationship name doesn't exist.

Source code in src/arvel/data/query.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
def with_count(self, relationship_name: str) -> Self:
    """Add a ``{relationship}_count`` column to the result.

    The count is computed as a correlated subquery.
    Supports both direct FK relationships and M2M (pivot/secondary) relationships.

    Raises ValueError if the relationship name doesn't exist.
    """
    rel_attr = self._get_rel_attr(relationship_name)
    subq = self._relationship_count_subquery(rel_attr)
    label = f"{relationship_name}_count"
    self._stmt = self._stmt.add_columns(subq.label(label))
    self._count_subqueries.append((label, subq))
    return self

recursive(anchor, step, *, max_depth=None, cycle_detection=False)

Build a WITH RECURSIVE CTE from anchor and step conditions.

Returns a RecursiveQueryBuilder whose all() / first() produce TreeNode[T] results instead of plain T.

Parameters:

Name Type Description Default
anchor Any

WHERE clause for the anchor (base case) rows.

required
step Callable[..., Any]

Callable receiving the CTE alias and returning the join condition for the recursive term.

required
max_depth int | None

Maximum recursion depth (default DEFAULT_MAX_DEPTH).

None
cycle_detection bool

If True, add path-tracking to detect cycles.

False
Source code in src/arvel/data/query.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
def recursive(
    self,
    anchor: Any,
    step: Callable[..., Any],
    *,
    max_depth: int | None = None,
    cycle_detection: bool = False,
) -> RecursiveQueryBuilder[T]:
    """Build a WITH RECURSIVE CTE from anchor and step conditions.

    Returns a ``RecursiveQueryBuilder`` whose ``all()`` / ``first()``
    produce ``TreeNode[T]`` results instead of plain ``T``.

    Args:
        anchor: WHERE clause for the anchor (base case) rows.
        step: Callable receiving the CTE alias and returning the join condition
              for the recursive term.
        max_depth: Maximum recursion depth (default DEFAULT_MAX_DEPTH).
        cycle_detection: If True, add path-tracking to detect cycles.
    """
    rqb = RecursiveQueryBuilder(self._model_cls, self._session, owns_session=self._owns_session)
    rqb._stmt = self._stmt
    rqb._has_order_by = self._has_order_by
    rqb._excluded_global_scopes = self._excluded_global_scopes
    rqb._global_scopes_applied = self._global_scopes_applied

    if max_depth is None:
        max_depth = self.DEFAULT_MAX_DEPTH

    table = rqb._table
    cols = [c.label(c.name) for c in table.columns]

    anchor_stmt = select(*cols, literal_column("0").label("depth")).where(anchor)
    cte = anchor_stmt.cte(name="tree", recursive=True)

    step_condition = step(cte)
    recursive_cols = [table.c[c.name].label(c.name) for c in table.columns]
    recursive_stmt = (
        select(*recursive_cols, (cte.c.depth + 1).label("depth"))
        .select_from(table.join(cte, step_condition))
        .where(cte.c.depth < max_depth)
    )

    full_cte = cte.union_all(recursive_stmt)
    rqb._stmt = select(full_cte)

    try:
        parent_col, id_col = rqb._find_self_ref_columns()
        rqb._recursive_id_key = id_col.name
        rqb._recursive_parent_key = parent_col.name
    except ValueError:
        pass

    return rqb

ancestors(node_id, *, max_depth=None)

Return all ancestors of node_id up to the root.

Auto-detects the parent_id column from self-referencing FK.

Source code in src/arvel/data/query.py
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
def ancestors(
    self, node_id: int | str, *, max_depth: int | None = None
) -> RecursiveQueryBuilder[T]:
    """Return all ancestors of node_id up to the root.

    Auto-detects the parent_id column from self-referencing FK.
    """
    parent_col, id_col = self._find_self_ref_columns()

    rqb = self.recursive(
        anchor=id_col == node_id,
        step=lambda tree: id_col == tree.c[parent_col.name],
        max_depth=max_depth,
    )
    rqb._recursive_id_key = id_col.name
    rqb._recursive_parent_key = parent_col.name
    return rqb._exclude_anchor_row(node_id, id_col)

descendants(node_id, *, max_depth=None)

Return all descendants of node_id down to the leaves.

Auto-detects the parent_id column from self-referencing FK.

Source code in src/arvel/data/query.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
def descendants(
    self, node_id: int | str, *, max_depth: int | None = None
) -> RecursiveQueryBuilder[T]:
    """Return all descendants of node_id down to the leaves.

    Auto-detects the parent_id column from self-referencing FK.
    """
    parent_col, id_col = self._find_self_ref_columns()

    rqb = self.recursive(
        anchor=id_col == node_id,
        step=lambda tree: parent_col == tree.c[id_col.name],
        max_depth=max_depth,
    )
    rqb._recursive_id_key = id_col.name
    rqb._recursive_parent_key = parent_col.name
    return rqb._exclude_anchor_row(node_id, id_col)

build_statement()

Return the underlying SA Select for inspection/testing.

Source code in src/arvel/data/query.py
488
489
490
def build_statement(self) -> Select[tuple[T]]:
    """Return the underlying SA Select for inspection/testing."""
    return self._stmt

all() async

Execute the query and return all results.

Source code in src/arvel/data/query.py
492
493
494
495
496
497
498
499
500
501
async def all(self) -> ArvelCollection[T]:
    """Execute the query and return all results."""
    try:
        self._apply_global_scopes()
        result = await self._session.execute(self._stmt)
        if self._count_subqueries:
            return ArvelCollection(row[0] for row in result.all())
        return ArvelCollection(result.scalars().all())
    finally:
        await self._release_session()

first() async

Execute the query and return the first result, or None.

Source code in src/arvel/data/query.py
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
async def first(self) -> T | None:
    """Execute the query and return the first result, or ``None``."""
    if not self._has_order_by:
        warnings.warn(
            f"QueryBuilder[{self._model_cls.__name__}].first() called without order_by() "
            f"— results may be non-deterministic",
            stacklevel=2,
        )
    try:
        self._apply_global_scopes()
        result = await self._session.execute(self._stmt)
        if self._count_subqueries:
            row = result.first()
            return row[0] if row is not None else None
        return result.scalars().first()
    finally:
        await self._release_session()

all_with_counts() async

Execute a with_count() query and return typed results.

Each result wraps the model instance with relationship counts::

results = await User.query(s).with_count("posts").all_with_counts()
for r in results:
    print(r.instance.name, r.counts["posts"])
Source code in src/arvel/data/query.py
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
async def all_with_counts(self) -> list[WithCount[T]]:
    """Execute a ``with_count()`` query and return typed results.

    Each result wraps the model instance with relationship counts::

        results = await User.query(s).with_count("posts").all_with_counts()
        for r in results:
            print(r.instance.name, r.counts["posts"])
    """
    try:
        self._apply_global_scopes()
        result = await self._session.execute(self._stmt)
        return self._build_with_counts(list(result.all()))
    finally:
        await self._release_session()

first_with_count() async

Execute a with_count() query and return the first typed result.

Source code in src/arvel/data/query.py
537
538
539
540
541
542
543
544
545
546
547
async def first_with_count(self) -> WithCount[T] | None:
    """Execute a ``with_count()`` query and return the first typed result."""
    try:
        self._apply_global_scopes()
        result = await self._session.execute(self._stmt)
        row = result.first()
        if row is None:
            return None
        return self._build_with_counts([row])[0]
    finally:
        await self._release_session()

to_sql()

Return the compiled SQL string for debugging.

Disabled in production (ARVEL_DEBUG must be true). Raises RuntimeError if called with debug mode off.

Source code in src/arvel/data/query.py
565
566
567
568
569
570
571
572
573
574
575
def to_sql(self) -> str:
    """Return the compiled SQL string for debugging.

    Disabled in production (``ARVEL_DEBUG`` must be ``true``).
    Raises ``RuntimeError`` if called with debug mode off.
    """
    debug = os.environ.get("ARVEL_DEBUG", "").lower() in ("1", "true", "yes")
    if not debug:
        msg = "to_sql() requires ARVEL_DEBUG=true"
        raise RuntimeError(msg)
    return str(self._stmt.compile(compile_kwargs={"literal_binds": True}))

max(column) async

Return the maximum value for column.

Source code in src/arvel/data/query.py
598
599
600
601
602
603
604
605
606
607
async def max(self, column: _ColumnExpressionOrStrLabelArgument[Any]) -> Any:
    """Return the maximum value for *column*."""
    try:
        sub = self._aggregate_subquery()
        col_name = getattr(column, "key", None) or str(column)
        stmt = select(func.max(sub.c[col_name]))
        result = await self._session.execute(stmt)
        return result.scalar_one_or_none()
    finally:
        await self._release_session()

min(column) async

Return the minimum value for column.

Source code in src/arvel/data/query.py
609
610
611
612
613
614
615
616
617
618
async def min(self, column: _ColumnExpressionOrStrLabelArgument[Any]) -> Any:
    """Return the minimum value for *column*."""
    try:
        sub = self._aggregate_subquery()
        col_name = getattr(column, "key", None) or str(column)
        stmt = select(func.min(sub.c[col_name]))
        result = await self._session.execute(stmt)
        return result.scalar_one_or_none()
    finally:
        await self._release_session()

sum(column) async

Return the sum for column.

Source code in src/arvel/data/query.py
620
621
622
623
624
625
626
627
628
629
async def sum(self, column: _ColumnExpressionOrStrLabelArgument[Any]) -> Any:
    """Return the sum for *column*."""
    try:
        sub = self._aggregate_subquery()
        col_name = getattr(column, "key", None) or str(column)
        stmt = select(func.sum(sub.c[col_name]))
        result = await self._session.execute(stmt)
        return result.scalar_one_or_none()
    finally:
        await self._release_session()

avg(column) async

Return the average for column.

Source code in src/arvel/data/query.py
631
632
633
634
635
636
637
638
639
640
async def avg(self, column: _ColumnExpressionOrStrLabelArgument[Any]) -> Any:
    """Return the average for *column*."""
    try:
        sub = self._aggregate_subquery()
        col_name = getattr(column, "key", None) or str(column)
        stmt = select(func.avg(sub.c[col_name]))
        result = await self._session.execute(stmt)
        return result.scalar_one_or_none()
    finally:
        await self._release_session()

RecursiveQueryBuilder

Bases: QueryBuilder[T]

Query builder for recursive CTE queries.

Returned by QueryBuilder.recursive(), ancestors(), and descendants(). Terminal methods (all, first) produce TreeNode[T] results; all_as_tree / first_as_tree build a nested hierarchy.

Source code in src/arvel/data/query.py
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
class RecursiveQueryBuilder[T: DeclarativeBase](QueryBuilder[T]):
    """Query builder for recursive CTE queries.

    Returned by ``QueryBuilder.recursive()``, ``ancestors()``, and
    ``descendants()``.  Terminal methods (``all``, ``first``) produce
    ``TreeNode[T]`` results; ``all_as_tree`` / ``first_as_tree`` build
    a nested hierarchy.
    """

    def __init__(
        self, model_cls: type[T], session: AsyncSession, *, owns_session: bool = False
    ) -> None:
        super().__init__(model_cls, session, owns_session=owns_session)
        self._recursive_id_key: str = "id"
        self._recursive_parent_key: str = "parent_id"

    def _exclude_anchor_row(self, node_id: Any, id_col: Any) -> Self:
        """Filter out the anchor row from results."""
        final_froms = self._stmt.get_final_froms()
        cte_alias = final_froms[0] if final_froms else None
        if cte_alias is not None:
            id_col_name = id_col.name if hasattr(id_col, "name") else "id"
            self._stmt = self._stmt.where(cte_alias.c[id_col_name] != node_id)
        return self

    async def all(self) -> ArvelCollection[TreeNode[T]]:  # ty: ignore[invalid-method-override]
        """Execute the recursive CTE and return flat ``TreeNode`` results."""
        try:
            self._apply_global_scopes()
            result = await self._session.execute(self._stmt)
            col_names = self._get_column_names()
            return ArvelCollection(
                TreeNode.from_row(row._tuple(), col_names) for row in result.all()
            )
        finally:
            await self._release_session()

    async def first(self) -> TreeNode[T] | None:  # ty: ignore[invalid-method-override]
        """Execute the recursive CTE and return the first flat ``TreeNode``."""
        try:
            self._apply_global_scopes()
            result = await self._session.execute(self._stmt)
            col_names = self._get_column_names()
            row = result.first()
            if row is None:
                return None
            return TreeNode.from_row(row._tuple(), col_names)
        finally:
            await self._release_session()

    async def all_as_tree(self) -> ArvelCollection[TreeNode[T]]:
        """Execute the recursive CTE and return a nested tree.

        Flat CTE rows are assembled into a hierarchy using the model's
        self-referencing FK.  Returns only root nodes; children are
        accessible via ``node.children``::

            roots = await Category.query(s).descendants(1).all_as_tree()
            for root in roots:
                for child in root.children:
                    print(child.data["name"], child.depth)
        """
        try:
            result = await self._session.execute(self._stmt)
            col_names = self._get_column_names()
            flat = [TreeNode.from_row(row._tuple(), col_names) for row in result.all()]
            roots = TreeNode.build_tree(
                flat,
                id_key=self._recursive_id_key,
                parent_key=self._recursive_parent_key,
            )
            return ArvelCollection(roots)
        finally:
            await self._release_session()

    async def first_as_tree(self) -> TreeNode[T] | None:
        """Execute the recursive CTE and return the first nested root node.

        The full subtree is nested under the returned node.
        """
        roots = await self.all_as_tree()
        return roots[0] if roots else None

    def __repr__(self) -> str:
        return f"RecursiveQueryBuilder({self._model_cls.__name__})"

all() async

Execute the recursive CTE and return flat TreeNode results.

Source code in src/arvel/data/query.py
668
669
670
671
672
673
674
675
676
677
678
async def all(self) -> ArvelCollection[TreeNode[T]]:  # ty: ignore[invalid-method-override]
    """Execute the recursive CTE and return flat ``TreeNode`` results."""
    try:
        self._apply_global_scopes()
        result = await self._session.execute(self._stmt)
        col_names = self._get_column_names()
        return ArvelCollection(
            TreeNode.from_row(row._tuple(), col_names) for row in result.all()
        )
    finally:
        await self._release_session()

first() async

Execute the recursive CTE and return the first flat TreeNode.

Source code in src/arvel/data/query.py
680
681
682
683
684
685
686
687
688
689
690
691
async def first(self) -> TreeNode[T] | None:  # ty: ignore[invalid-method-override]
    """Execute the recursive CTE and return the first flat ``TreeNode``."""
    try:
        self._apply_global_scopes()
        result = await self._session.execute(self._stmt)
        col_names = self._get_column_names()
        row = result.first()
        if row is None:
            return None
        return TreeNode.from_row(row._tuple(), col_names)
    finally:
        await self._release_session()

all_as_tree() async

Execute the recursive CTE and return a nested tree.

Flat CTE rows are assembled into a hierarchy using the model's self-referencing FK. Returns only root nodes; children are accessible via node.children::

roots = await Category.query(s).descendants(1).all_as_tree()
for root in roots:
    for child in root.children:
        print(child.data["name"], child.depth)
Source code in src/arvel/data/query.py
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
async def all_as_tree(self) -> ArvelCollection[TreeNode[T]]:
    """Execute the recursive CTE and return a nested tree.

    Flat CTE rows are assembled into a hierarchy using the model's
    self-referencing FK.  Returns only root nodes; children are
    accessible via ``node.children``::

        roots = await Category.query(s).descendants(1).all_as_tree()
        for root in roots:
            for child in root.children:
                print(child.data["name"], child.depth)
    """
    try:
        result = await self._session.execute(self._stmt)
        col_names = self._get_column_names()
        flat = [TreeNode.from_row(row._tuple(), col_names) for row in result.all()]
        roots = TreeNode.build_tree(
            flat,
            id_key=self._recursive_id_key,
            parent_key=self._recursive_parent_key,
        )
        return ArvelCollection(roots)
    finally:
        await self._release_session()

first_as_tree() async

Execute the recursive CTE and return the first nested root node.

The full subtree is nested under the returned node.

Source code in src/arvel/data/query.py
718
719
720
721
722
723
724
async def first_as_tree(self) -> TreeNode[T] | None:
    """Execute the recursive CTE and return the first nested root node.

    The full subtree is nested under the returned node.
    """
    roots = await self.all_as_tree()
    return roots[0] if roots else None

HasRelationships

Mixin that enables declarative relationship helpers on ArvelModel subclasses.

Replaces RelationshipDescriptor instances in the class namespace with proper SA relationship() objects before DeclarativeBase processes the class.

Models with irregular plural table names should set __singular__ to the singular form (e.g. __singular__ = "person" for __tablename__ = "people").

Source code in src/arvel/data/relationships/mixin.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
class HasRelationships:
    """Mixin that enables declarative relationship helpers on ArvelModel subclasses.

    Replaces RelationshipDescriptor instances in the class namespace with proper
    SA relationship() objects before DeclarativeBase processes the class.

    Models with irregular plural table names should set ``__singular__`` to the
    singular form (e.g. ``__singular__ = "person"`` for ``__tablename__ = "people"``).
    """

    __relationship_registry__: _RelationshipRegistry
    __singular__: str | None

    def __init_subclass__(cls, **kwargs: Any) -> None:
        if cls.__dict__.get("__abstract__"):
            super().__init_subclass__(**kwargs)
            return

        registry = _RelationshipRegistry()
        cls.__relationship_registry__ = registry

        descriptors: dict[str, RelationshipDescriptor] = {}
        for attr_name in list(cls.__dict__):
            attr_value = cls.__dict__[attr_name]
            if isinstance(attr_value, RelationshipDescriptor):
                descriptors[attr_name] = attr_value
                registry.register(attr_name, attr_value)

        for attr_name, desc in descriptors.items():
            rel_prop = _build_relationship(cls, attr_name, desc)
            if rel_prop is not None:
                type.__setattr__(cls, attr_name, rel_prop)

        super().__init_subclass__(**kwargs)

    @classmethod
    def get_relationships(cls) -> dict[str, RelationshipDescriptor]:
        """Return a dict of {name: RelationshipDescriptor} for all declared relationships."""
        reg = getattr(cls, "__relationship_registry__", None)
        if reg is None:
            return {}
        return reg.all()

get_relationships() classmethod

Return a dict of {name: RelationshipDescriptor} for all declared relationships.

Source code in src/arvel/data/relationships/mixin.py
187
188
189
190
191
192
193
@classmethod
def get_relationships(cls) -> dict[str, RelationshipDescriptor]:
    """Return a dict of {name: RelationshipDescriptor} for all declared relationships."""
    reg = getattr(cls, "__relationship_registry__", None)
    if reg is None:
        return {}
    return reg.all()

LazyLoadError

Bases: Exception

Raised when a relationship is accessed without prior eager loading in strict mode.

Source code in src/arvel/data/relationships/mixin.py
30
31
class LazyLoadError(Exception):
    """Raised when a relationship is accessed without prior eager loading in strict mode."""

PivotManager

Manages rows in a many-to-many pivot table for one side of the relationship.

All mutations happen through the session and are flushed immediately so callers can read back consistent state within the same transaction.

Source code in src/arvel/data/relationships/pivot.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
class PivotManager:
    """Manages rows in a many-to-many pivot table for one side of the relationship.

    All mutations happen through the session and are flushed immediately so
    callers can read back consistent state within the same transaction.
    """

    def __init__(
        self,
        *,
        session: AsyncSession,
        pivot_table: Table,
        owner_fk_column: Column[Any],
        related_fk_column: Column[Any],
        owner_id: int | str,
    ) -> None:
        self._session = session
        self._pivot = pivot_table
        self._owner_col = owner_fk_column
        self._related_col = related_fk_column
        self._owner_id = owner_id

    async def attach(self, related_id: int | str, **extra: str | int | float | bool | None) -> None:
        """Insert a pivot row linking owner to related_id.

        Extra kwargs are written to additional pivot columns if they exist.
        """
        values: dict[str, str | int | float | bool | None] = {
            self._owner_col.name: self._owner_id,
            self._related_col.name: related_id,
            **extra,
        }
        stmt = insert(self._pivot).values(values)
        await self._session.execute(stmt)
        await self._session.flush()

    async def detach(self, related_id: int | str) -> None:
        """Remove the pivot row linking owner to related_id."""
        stmt = (
            delete(self._pivot)
            .where(self._owner_col == self._owner_id)
            .where(self._related_col == related_id)
        )
        await self._session.execute(stmt)
        await self._session.flush()

    async def sync(self, related_ids: list[int | str]) -> None:
        """Replace all pivot rows for owner with exactly the given related_ids.

        Operates atomically: removes extras, batch-inserts missing.
        """
        current_stmt = select(self._related_col).where(self._owner_col == self._owner_id)
        result = await self._session.execute(current_stmt)
        current_ids: set[int | str] = {row[0] for row in result.all()}
        desired: set[int | str] = set(related_ids)

        to_remove = current_ids - desired
        to_add = desired - current_ids

        if to_remove:
            stmt = (
                delete(self._pivot)
                .where(self._owner_col == self._owner_id)
                .where(self._related_col.in_(to_remove))
            )
            await self._session.execute(stmt)

        if to_add:
            rows = [
                {self._owner_col.name: self._owner_id, self._related_col.name: rid}
                for rid in to_add
            ]
            await self._session.execute(insert(self._pivot), rows)

        await self._session.flush()

    async def ids(self) -> list[int | str]:
        """Return all related IDs currently linked via the pivot table."""
        stmt = select(self._related_col).where(self._owner_col == self._owner_id)
        result = await self._session.execute(stmt)
        return [row[0] for row in result.all()]

attach(related_id, **extra) async

Insert a pivot row linking owner to related_id.

Extra kwargs are written to additional pivot columns if they exist.

Source code in src/arvel/data/relationships/pivot.py
40
41
42
43
44
45
46
47
48
49
50
51
52
async def attach(self, related_id: int | str, **extra: str | int | float | bool | None) -> None:
    """Insert a pivot row linking owner to related_id.

    Extra kwargs are written to additional pivot columns if they exist.
    """
    values: dict[str, str | int | float | bool | None] = {
        self._owner_col.name: self._owner_id,
        self._related_col.name: related_id,
        **extra,
    }
    stmt = insert(self._pivot).values(values)
    await self._session.execute(stmt)
    await self._session.flush()

detach(related_id) async

Remove the pivot row linking owner to related_id.

Source code in src/arvel/data/relationships/pivot.py
54
55
56
57
58
59
60
61
62
async def detach(self, related_id: int | str) -> None:
    """Remove the pivot row linking owner to related_id."""
    stmt = (
        delete(self._pivot)
        .where(self._owner_col == self._owner_id)
        .where(self._related_col == related_id)
    )
    await self._session.execute(stmt)
    await self._session.flush()

sync(related_ids) async

Replace all pivot rows for owner with exactly the given related_ids.

Operates atomically: removes extras, batch-inserts missing.

Source code in src/arvel/data/relationships/pivot.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
async def sync(self, related_ids: list[int | str]) -> None:
    """Replace all pivot rows for owner with exactly the given related_ids.

    Operates atomically: removes extras, batch-inserts missing.
    """
    current_stmt = select(self._related_col).where(self._owner_col == self._owner_id)
    result = await self._session.execute(current_stmt)
    current_ids: set[int | str] = {row[0] for row in result.all()}
    desired: set[int | str] = set(related_ids)

    to_remove = current_ids - desired
    to_add = desired - current_ids

    if to_remove:
        stmt = (
            delete(self._pivot)
            .where(self._owner_col == self._owner_id)
            .where(self._related_col.in_(to_remove))
        )
        await self._session.execute(stmt)

    if to_add:
        rows = [
            {self._owner_col.name: self._owner_id, self._related_col.name: rid}
            for rid in to_add
        ]
        await self._session.execute(insert(self._pivot), rows)

    await self._session.flush()

ids() async

Return all related IDs currently linked via the pivot table.

Source code in src/arvel/data/relationships/pivot.py
94
95
96
97
98
async def ids(self) -> list[int | str]:
    """Return all related IDs currently linked via the pivot table."""
    stmt = select(self._related_col).where(self._owner_col == self._owner_id)
    result = await self._session.execute(stmt)
    return [row[0] for row in result.all()]

RelationshipDescriptor dataclass

Immutable metadata about a declared relationship.

Stored in the model's relationship_registry for introspection. At runtime, HasRelationships.__init_subclass__ replaces these with SA relationship() objects, so __get__ is never actually called on a configured model. The __get__ exists purely for static type checkers.

Source code in src/arvel/data/relationships/descriptors.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@dataclass(frozen=True)
class RelationshipDescriptor:
    """Immutable metadata about a declared relationship.

    Stored in the model's __relationship_registry__ for introspection.
    At runtime, ``HasRelationships.__init_subclass__`` replaces these with
    SA ``relationship()`` objects, so ``__get__`` is never actually called
    on a configured model.  The ``__get__`` exists purely for static type
    checkers.
    """

    related_model: type | str
    relation_type: RelationType
    foreign_key: str | None = None
    local_key: str | None = None
    back_populates: str | None = None
    pivot_table: str | None = None
    pivot_fields: list[str] = field(default_factory=list)

Repository

Base repository providing typed CRUD operations.

The session is private — custom query methods use self.query() which returns a QueryBuilder[T], not the raw session.

When DatabaseServiceProvider is registered, both session and observer_registry are optional — the repository resolves them from the model's session resolver and creates an empty registry::

repo = UserRepository()  # uses default session
repo = UserRepository(session=my_session)  # explicit override
Source code in src/arvel/data/repository.py
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
class Repository[T: "ArvelModel"]:
    """Base repository providing typed CRUD operations.

    The session is private — custom query methods use ``self.query()``
    which returns a ``QueryBuilder[T]``, not the raw session.

    When ``DatabaseServiceProvider`` is registered, both *session* and
    *observer_registry* are optional — the repository resolves them
    from the model's session resolver and creates an empty registry::

        repo = UserRepository()  # uses default session
        repo = UserRepository(session=my_session)  # explicit override
    """

    def __init__(
        self,
        *,
        session: AsyncSession | None = None,
        observer_registry: ObserverRegistry | None = None,
    ) -> None:
        if session is None:
            from arvel.data.model import ArvelModel

            resolver = ArvelModel._session_resolver
            if resolver is None:
                msg = (
                    "No session provided and no default session resolver is configured. "
                    "Either pass a session explicitly or register "
                    "DatabaseServiceProvider in your bootstrap/providers.py."
                )
                raise RuntimeError(msg)
            session = resolver()

        if observer_registry is None:
            from arvel.data.observer import ObserverRegistry as _ObserverRegistry

            observer_registry = _ObserverRegistry()

        self._session = session
        self._observer_registry = observer_registry
        # Sound cast: _resolve_model_type walks __orig_bases__ to find the concrete T
        self._model_cls: type[T] = cast("type[T]", _resolve_model_type(type(self)))

    @property
    def _pk_column(self) -> Column[Any]:
        """Return the primary key column from the model's table."""
        table = self._model_cls.__table__
        if not isinstance(table, Table):
            msg = f"{self._model_cls.__name__}.__table__ is not a Table"
            raise TypeError(msg)
        pk_cols = list(table.primary_key.columns)
        if len(pk_cols) != 1:
            msg = (
                f"{self._model_cls.__name__} must have exactly one PK column for Repository.find()"
            )
            raise TypeError(msg)
        return pk_cols[0]

    def query(self) -> QueryBuilder[T]:
        return QueryBuilder(self._model_cls, self._session)

    async def find(self, record_id: int | str) -> T:
        instance = (
            await self.query().where(self._pk_column == record_id).order_by(self._pk_column).first()
        )
        if instance is None:
            raise NotFoundError(
                f"{self._model_cls.__name__} with id={record_id} not found",
                model_name=self._model_cls.__name__,
                record_id=record_id,
            )
        return instance

    async def all(self) -> ArvelCollection[T]:
        return await self.query().all()

    async def create(self, data: dict[str, Any]) -> T:
        safe_data = filter_mass_assignable(self._model_cls, data)
        instance = self._model_cls.model_validate(safe_data)

        allowed = await self._observer_registry.dispatch("creating", self._model_cls, instance)
        if not allowed:
            raise CreationAbortedError(
                f"Creation of {self._model_cls.__name__} aborted by observer",
                model_name=self._model_cls.__name__,
            )

        self._session.add(instance)
        await self._session.flush()

        await self._observer_registry.dispatch("created", self._model_cls, instance)
        return instance

    async def update(self, record_id: int | str, data: dict[str, Any]) -> T:
        instance = await self.find(record_id)

        allowed = await self._observer_registry.dispatch("updating", self._model_cls, instance)
        if not allowed:
            raise UpdateAbortedError(
                f"Update of {self._model_cls.__name__} aborted by observer",
                model_name=self._model_cls.__name__,
            )

        safe_data = filter_mass_assignable(self._model_cls, data)
        for key, value in safe_data.items():
            setattr(instance, key, value)

        if hasattr(instance, "updated_at"):
            instance.updated_at = datetime.now(UTC)

        await self._session.flush()

        await self._observer_registry.dispatch("updated", self._model_cls, instance)
        return instance

    async def delete(self, record_id: int | str) -> None:
        from arvel.data.soft_deletes import is_soft_deletable

        instance = await self.find(record_id)

        allowed = await self._observer_registry.dispatch("deleting", self._model_cls, instance)
        if not allowed:
            raise DeletionAbortedError(
                f"Deletion of {self._model_cls.__name__} aborted by observer",
                model_name=self._model_cls.__name__,
            )

        if is_soft_deletable(self._model_cls):
            setattr(instance, "deleted_at", datetime.now(UTC))  # noqa: B010
            await self._session.flush()
        else:
            await self._session.delete(instance)
            await self._session.flush()

        await self._observer_registry.dispatch("deleted", self._model_cls, instance)

    async def restore(self, record_id: int | str) -> T:
        """Restore a soft-deleted record by setting ``deleted_at`` to NULL.

        Raises ``NotFoundError`` if the record doesn't exist.
        Only meaningful for models using the ``SoftDeletes`` mixin.
        """
        from arvel.data.soft_deletes import is_soft_deletable

        if not is_soft_deletable(self._model_cls):
            msg = f"{self._model_cls.__name__} does not support soft deletes"
            raise TypeError(msg)

        stmt = select(self._model_cls).where(self._pk_column == record_id)
        result = await self._session.execute(stmt)
        instance = result.scalars().first()
        if instance is None:
            raise NotFoundError(
                f"{self._model_cls.__name__} with id={record_id} not found",
                model_name=self._model_cls.__name__,
                record_id=record_id,
            )

        allowed = await self._observer_registry.dispatch("restoring", self._model_cls, instance)
        if not allowed:
            msg = f"Restore of {self._model_cls.__name__} aborted by observer"
            raise DeletionAbortedError(msg, model_name=self._model_cls.__name__)

        setattr(instance, "deleted_at", None)  # noqa: B010
        await self._session.flush()

        await self._observer_registry.dispatch("restored", self._model_cls, instance)
        return instance

    async def force_delete(self, record_id: int | str) -> None:
        """Permanently remove a record, bypassing soft deletes.

        Dispatches ``force_deleting``/``force_deleted`` observer events.
        """
        stmt = select(self._model_cls).where(self._pk_column == record_id)
        result = await self._session.execute(stmt)
        instance = result.scalars().first()
        if instance is None:
            raise NotFoundError(
                f"{self._model_cls.__name__} with id={record_id} not found",
                model_name=self._model_cls.__name__,
                record_id=record_id,
            )

        allowed = await self._observer_registry.dispatch(
            "force_deleting", self._model_cls, instance
        )
        if not allowed:
            raise DeletionAbortedError(
                f"Force deletion of {self._model_cls.__name__} aborted by observer",
                model_name=self._model_cls.__name__,
            )

        await self._session.delete(instance)
        await self._session.flush()

        await self._observer_registry.dispatch("force_deleted", self._model_cls, instance)

restore(record_id) async

Restore a soft-deleted record by setting deleted_at to NULL.

Raises NotFoundError if the record doesn't exist. Only meaningful for models using the SoftDeletes mixin.

Source code in src/arvel/data/repository.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
async def restore(self, record_id: int | str) -> T:
    """Restore a soft-deleted record by setting ``deleted_at`` to NULL.

    Raises ``NotFoundError`` if the record doesn't exist.
    Only meaningful for models using the ``SoftDeletes`` mixin.
    """
    from arvel.data.soft_deletes import is_soft_deletable

    if not is_soft_deletable(self._model_cls):
        msg = f"{self._model_cls.__name__} does not support soft deletes"
        raise TypeError(msg)

    stmt = select(self._model_cls).where(self._pk_column == record_id)
    result = await self._session.execute(stmt)
    instance = result.scalars().first()
    if instance is None:
        raise NotFoundError(
            f"{self._model_cls.__name__} with id={record_id} not found",
            model_name=self._model_cls.__name__,
            record_id=record_id,
        )

    allowed = await self._observer_registry.dispatch("restoring", self._model_cls, instance)
    if not allowed:
        msg = f"Restore of {self._model_cls.__name__} aborted by observer"
        raise DeletionAbortedError(msg, model_name=self._model_cls.__name__)

    setattr(instance, "deleted_at", None)  # noqa: B010
    await self._session.flush()

    await self._observer_registry.dispatch("restored", self._model_cls, instance)
    return instance

force_delete(record_id) async

Permanently remove a record, bypassing soft deletes.

Dispatches force_deleting/force_deleted observer events.

Source code in src/arvel/data/repository.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
async def force_delete(self, record_id: int | str) -> None:
    """Permanently remove a record, bypassing soft deletes.

    Dispatches ``force_deleting``/``force_deleted`` observer events.
    """
    stmt = select(self._model_cls).where(self._pk_column == record_id)
    result = await self._session.execute(stmt)
    instance = result.scalars().first()
    if instance is None:
        raise NotFoundError(
            f"{self._model_cls.__name__} with id={record_id} not found",
            model_name=self._model_cls.__name__,
            record_id=record_id,
        )

    allowed = await self._observer_registry.dispatch(
        "force_deleting", self._model_cls, instance
    )
    if not allowed:
        raise DeletionAbortedError(
            f"Force deletion of {self._model_cls.__name__} aborted by observer",
            model_name=self._model_cls.__name__,
        )

    await self._session.delete(instance)
    await self._session.flush()

    await self._observer_registry.dispatch("force_deleted", self._model_cls, instance)

TreeNode

Bases: BaseModel

Nested tree wrapper for recursive CTE query results.

Rows returned by a recursive CTE are flat; build_tree assembles them into a proper nested structure using the parent FK column::

roots = await Category.query(s).descendants(1).all()
for node in roots:
    print(node.model_dump_json(indent=2))
Source code in src/arvel/data/results.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
class TreeNode[T: DeclarativeBase](BaseModel):
    """Nested tree wrapper for recursive CTE query results.

    Rows returned by a recursive CTE are flat; ``build_tree`` assembles
    them into a proper nested structure using the parent FK column::

        roots = await Category.query(s).descendants(1).all()
        for node in roots:
            print(node.model_dump_json(indent=2))
    """

    model_config = {"arbitrary_types_allowed": True}

    data: dict[str, Any]
    depth: int
    children: list[TreeNode[T]] = []

    @classmethod
    def from_row(cls, row: tuple[Any, ...], column_names: list[str]) -> TreeNode[T]:
        """Build a flat ``TreeNode`` from a raw CTE result row.

        The last element is always the ``depth`` column added by the
        recursive builder.
        """
        depth = int(row[-1])
        data = dict(zip(column_names, row[:-1], strict=False))
        return cls(data=data, depth=depth)

    @classmethod
    def build_tree(
        cls,
        flat_nodes: list[TreeNode[T]],
        *,
        id_key: str = "id",
        parent_key: str = "parent_id",
    ) -> list[TreeNode[T]]:
        """Assemble flat nodes into a nested tree.

        Returns the root nodes with ``children`` populated recursively.
        Nodes whose parent is missing from the set are treated as roots.
        """
        by_id: dict[Any, TreeNode[T]] = {}
        for node in flat_nodes:
            node.children = []
            by_id[node.data.get(id_key)] = node

        roots: list[TreeNode[T]] = []
        for node in flat_nodes:
            parent_id = node.data.get(parent_key)
            parent = by_id.get(parent_id)
            if parent is not None and parent is not node:
                parent.children.append(node)
            else:
                roots.append(node)
        return roots

    def model_dump(self, **kwargs: Any) -> dict[str, Any]:
        """Flatten ``data`` into the top level and nest children."""
        result: dict[str, Any] = {**self.data, "depth": self.depth}
        if self.children:
            result["children"] = [child.model_dump(**kwargs) for child in self.children]
        return result

    def __str__(self) -> str:
        return self.model_dump_json(indent=2)

    def __repr__(self) -> str:
        return f"TreeNode(depth={self.depth}, data={self.data!r})"

from_row(row, column_names) classmethod

Build a flat TreeNode from a raw CTE result row.

The last element is always the depth column added by the recursive builder.

Source code in src/arvel/data/results.py
63
64
65
66
67
68
69
70
71
72
@classmethod
def from_row(cls, row: tuple[Any, ...], column_names: list[str]) -> TreeNode[T]:
    """Build a flat ``TreeNode`` from a raw CTE result row.

    The last element is always the ``depth`` column added by the
    recursive builder.
    """
    depth = int(row[-1])
    data = dict(zip(column_names, row[:-1], strict=False))
    return cls(data=data, depth=depth)

build_tree(flat_nodes, *, id_key='id', parent_key='parent_id') classmethod

Assemble flat nodes into a nested tree.

Returns the root nodes with children populated recursively. Nodes whose parent is missing from the set are treated as roots.

Source code in src/arvel/data/results.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
@classmethod
def build_tree(
    cls,
    flat_nodes: list[TreeNode[T]],
    *,
    id_key: str = "id",
    parent_key: str = "parent_id",
) -> list[TreeNode[T]]:
    """Assemble flat nodes into a nested tree.

    Returns the root nodes with ``children`` populated recursively.
    Nodes whose parent is missing from the set are treated as roots.
    """
    by_id: dict[Any, TreeNode[T]] = {}
    for node in flat_nodes:
        node.children = []
        by_id[node.data.get(id_key)] = node

    roots: list[TreeNode[T]] = []
    for node in flat_nodes:
        parent_id = node.data.get(parent_key)
        parent = by_id.get(parent_id)
        if parent is not None and parent is not node:
            parent.children.append(node)
        else:
            roots.append(node)
    return roots

model_dump(**kwargs)

Flatten data into the top level and nest children.

Source code in src/arvel/data/results.py
102
103
104
105
106
107
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
    """Flatten ``data`` into the top level and nest children."""
    result: dict[str, Any] = {**self.data, "depth": self.depth}
    if self.children:
        result["children"] = [child.model_dump(**kwargs) for child in self.children]
    return result

WithCount dataclass

Typed wrapper for query results that include relationship counts.

Access the original model instance via .instance and counts via .counts::

results: list[WithCount[User]] = await User.query(s).with_count("posts").all()
for r in results:
    user = r.instance
    n_posts = r.counts["posts"]
    # or use the convenience accessor:
    n_posts = r.posts_count  # equivalent to r.counts["posts"]
Source code in src/arvel/data/results.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@dataclass
class WithCount[T: DeclarativeBase]:
    """Typed wrapper for query results that include relationship counts.

    Access the original model instance via ``.instance`` and counts
    via ``.counts``::

        results: list[WithCount[User]] = await User.query(s).with_count("posts").all()
        for r in results:
            user = r.instance
            n_posts = r.counts["posts"]
            # or use the convenience accessor:
            n_posts = r.posts_count  # equivalent to r.counts["posts"]
    """

    instance: T
    counts: dict[str, int] = field(default_factory=dict)

    def __getattr__(self, name: str) -> Any:
        if name.endswith("_count"):
            rel_name = name.removesuffix("_count")
            if rel_name in self.counts:
                return self.counts[rel_name]
        raise AttributeError(f"'{type(self).__name__}' has no attribute {name!r}")

GlobalScope

Base class for global scopes applied to every query on a model.

Subclass and implement apply to define the constraint::

class ActiveScope(GlobalScope):
    def apply(self, query):
        return query.where(User.is_active == True)

class User(ArvelModel):
    __global_scopes__ = [ActiveScope()]
Source code in src/arvel/data/scopes.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class GlobalScope:
    """Base class for global scopes applied to every query on a model.

    Subclass and implement ``apply`` to define the constraint::

        class ActiveScope(GlobalScope):
            def apply(self, query):
                return query.where(User.is_active == True)

        class User(ArvelModel):
            __global_scopes__ = [ActiveScope()]
    """

    name: str = ""

    def apply(self, query: QueryBuilder[Any]) -> QueryBuilder[Any]:
        """Apply the scope to the given query builder. Must return the query."""
        raise NotImplementedError

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        if not cls.name:
            cls.name = cls.__name__

apply(query)

Apply the scope to the given query builder. Must return the query.

Source code in src/arvel/data/scopes.py
75
76
77
def apply(self, query: QueryBuilder[Any]) -> QueryBuilder[Any]:
    """Apply the scope to the given query builder. Must return the query."""
    raise NotImplementedError

Seeder

Bases: ABC

Base class for database seeders.

Subclass and implement run() to populate data::

class UserSeeder(Seeder):
    async def run(self, tx: Transaction) -> None:
        await tx.users.create({"name": "Admin", "email": "admin@app.com"})
Source code in src/arvel/data/seeder.py
26
27
28
29
30
31
32
33
34
35
36
37
class Seeder(ABC):
    """Base class for database seeders.

    Subclass and implement ``run()`` to populate data::

        class UserSeeder(Seeder):
            async def run(self, tx: Transaction) -> None:
                await tx.users.create({"name": "Admin", "email": "admin@app.com"})
    """

    @abstractmethod
    async def run(self, tx: Transaction) -> None: ...

SeedRunner

Discovers and executes seeders with production safety.

Source code in src/arvel/data/seeder.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
class SeedRunner:
    """Discovers and executes seeders with production safety."""

    def __init__(self, *, seeders_dir: Path, db_url: str) -> None:
        self._seeders_dir = seeders_dir
        self._db_url = db_url

    async def run(
        self,
        *,
        environment: str = "development",
        force: bool = False,
        seeder_class: str | None = None,
    ) -> None:
        """Run seeders. Refuses in production without force."""
        if environment == "production" and not force:
            msg = "Refusing to seed in production without --force flag"
            raise RuntimeError(msg)

        seeders = discover_seeders(self._seeders_dir)

        if seeder_class:
            seeders = [s for s in seeders if s.__name__ == seeder_class]

        if not seeders:
            return

        from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine

        from arvel.data.observer import ObserverRegistry
        from arvel.data.transaction import Transaction

        engine = create_async_engine(self._db_url, echo=False)
        registry = ObserverRegistry()

        try:
            for seeder_cls in seeders:
                async with engine.connect() as conn:
                    trans = await conn.begin()
                    async with AsyncSession(bind=conn, expire_on_commit=False) as session:
                        tx = Transaction(session=session, observer_registry=registry)
                        seeder = seeder_cls()
                        async with tx:
                            await seeder.run(tx)
                        if trans.is_active:
                            await trans.commit()
        finally:
            await engine.dispose()

run(*, environment='development', force=False, seeder_class=None) async

Run seeders. Refuses in production without force.

Source code in src/arvel/data/seeder.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
async def run(
    self,
    *,
    environment: str = "development",
    force: bool = False,
    seeder_class: str | None = None,
) -> None:
    """Run seeders. Refuses in production without force."""
    if environment == "production" and not force:
        msg = "Refusing to seed in production without --force flag"
        raise RuntimeError(msg)

    seeders = discover_seeders(self._seeders_dir)

    if seeder_class:
        seeders = [s for s in seeders if s.__name__ == seeder_class]

    if not seeders:
        return

    from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine

    from arvel.data.observer import ObserverRegistry
    from arvel.data.transaction import Transaction

    engine = create_async_engine(self._db_url, echo=False)
    registry = ObserverRegistry()

    try:
        for seeder_cls in seeders:
            async with engine.connect() as conn:
                trans = await conn.begin()
                async with AsyncSession(bind=conn, expire_on_commit=False) as session:
                    tx = Transaction(session=session, observer_registry=registry)
                    seeder = seeder_cls()
                    async with tx:
                        await seeder.run(tx)
                    if trans.is_active:
                        await trans.commit()
    finally:
        await engine.dispose()

Blueprint

Table definition builder — collects columns for a single table.

Source code in src/arvel/data/schema.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
class Blueprint:
    """Table definition builder — collects columns for a single table."""

    _KEY_TYPE_MAP: ClassVar[dict[KeyType, type]] = {
        KeyType.INT: Integer,
        KeyType.BIG_INT: BigInteger,
        KeyType.UUID: Uuid,
    }

    def __init__(self) -> None:
        self.columns: list[Column[Any]] = []

    def _add(self, col: Column[Any]) -> ColumnBuilder:
        self.columns.append(col)
        return ColumnBuilder(col)

    def id(self, name: str = "id", key_type: KeyType = KeyType.BIG_INT) -> ColumnBuilder:
        """Add an auto-incrementing or UUID primary key.

        For integer types, uses ``BigInteger`` with a ``Integer`` variant for
        SQLite — SQLite only auto-increments ``INTEGER PRIMARY KEY`` columns.
        """
        if key_type == KeyType.UUID:
            return self._add(Column(name, Uuid, primary_key=True, default=uuid.uuid7))
        sa_type = self._KEY_TYPE_MAP[key_type]
        col_type = sa_type().with_variant(Integer(), "sqlite")
        return self._add(Column(name, col_type, primary_key=True, autoincrement=True))

    def string(self, name: str, length: int = 255) -> ColumnBuilder:
        return self._add(Column(name, String(length), nullable=False))

    def text(self, name: str) -> ColumnBuilder:
        return self._add(Column(name, Text, nullable=False))

    def integer(self, name: str) -> ColumnBuilder:
        return self._add(Column(name, Integer, nullable=False))

    def big_integer(self, name: str) -> ColumnBuilder:
        return self._add(Column(name, BigInteger, nullable=False))

    def float_col(self, name: str) -> ColumnBuilder:
        return self._add(Column(name, Float, nullable=False))

    def decimal(self, name: str, precision: int = 8, scale: int = 2) -> ColumnBuilder:
        return self._add(Column(name, Numeric(precision=precision, scale=scale), nullable=False))

    def boolean(self, name: str) -> ColumnBuilder:
        return self._add(Column(name, Boolean, nullable=False, default=False))

    def uuid(self, name: str) -> ColumnBuilder:
        return self._add(Column(name, Uuid, nullable=False))

    def datetime(self, name: str) -> ColumnBuilder:
        return self._add(Column(name, DateTime(timezone=True), nullable=False))

    def timestamps(self) -> None:
        """Add created_at and updated_at columns."""
        self.columns.append(
            Column(
                "created_at",
                DateTime(timezone=True),
                nullable=False,
                server_default=func.now(),
            )
        )
        self.columns.append(
            Column(
                "updated_at",
                DateTime(timezone=True),
                nullable=False,
                server_default=func.now(),
                onupdate=func.now(),
            )
        )

    def soft_deletes(self) -> None:
        """Add a deleted_at column for soft deletes."""
        self.columns.append(Column("deleted_at", DateTime(timezone=True), nullable=True))

    def foreign_id(self, name: str) -> ColumnBuilder:
        """Add a BigInteger FK column (convention: <model>_id)."""
        return self._add(Column(name, BigInteger, nullable=False, index=True))

id(name='id', key_type=KeyType.BIG_INT)

Add an auto-incrementing or UUID primary key.

For integer types, uses BigInteger with a Integer variant for SQLite — SQLite only auto-increments INTEGER PRIMARY KEY columns.

Source code in src/arvel/data/schema.py
152
153
154
155
156
157
158
159
160
161
162
def id(self, name: str = "id", key_type: KeyType = KeyType.BIG_INT) -> ColumnBuilder:
    """Add an auto-incrementing or UUID primary key.

    For integer types, uses ``BigInteger`` with a ``Integer`` variant for
    SQLite — SQLite only auto-increments ``INTEGER PRIMARY KEY`` columns.
    """
    if key_type == KeyType.UUID:
        return self._add(Column(name, Uuid, primary_key=True, default=uuid.uuid7))
    sa_type = self._KEY_TYPE_MAP[key_type]
    col_type = sa_type().with_variant(Integer(), "sqlite")
    return self._add(Column(name, col_type, primary_key=True, autoincrement=True))

timestamps()

Add created_at and updated_at columns.

Source code in src/arvel/data/schema.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def timestamps(self) -> None:
    """Add created_at and updated_at columns."""
    self.columns.append(
        Column(
            "created_at",
            DateTime(timezone=True),
            nullable=False,
            server_default=func.now(),
        )
    )
    self.columns.append(
        Column(
            "updated_at",
            DateTime(timezone=True),
            nullable=False,
            server_default=func.now(),
            onupdate=func.now(),
        )
    )

soft_deletes()

Add a deleted_at column for soft deletes.

Source code in src/arvel/data/schema.py
211
212
213
def soft_deletes(self) -> None:
    """Add a deleted_at column for soft deletes."""
    self.columns.append(Column("deleted_at", DateTime(timezone=True), nullable=True))

foreign_id(name)

Add a BigInteger FK column (convention: _id).

Source code in src/arvel/data/schema.py
215
216
217
def foreign_id(self, name: str) -> ColumnBuilder:
    """Add a BigInteger FK column (convention: <model>_id)."""
    return self._add(Column(name, BigInteger, nullable=False, index=True))

ColumnBuilder

Fluent builder for column constraints.

Source code in src/arvel/data/schema.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
class ColumnBuilder:
    """Fluent builder for column constraints."""

    def __init__(self, column: Column[Any]) -> None:
        self._column = column

    def nullable(self) -> Self:
        self._column.nullable = True
        return self

    def unique(self) -> Self:
        self._column.unique = True
        return self

    def index(self) -> Self:
        self._column.index = True
        return self

    def default(self, value: object) -> Self:
        # SA Column.default is wider than stubs (scalars, callables, ColumnElement).
        cast("Any", self._column).default = value
        return self

    def server_default(self, value: str) -> Self:
        # SA server_default is wider than stubs (FetchedValue, TextClause, str, ...).
        cast("Any", self._column).server_default = value
        return self

    def _last_foreign_key(self) -> ForeignKey:
        foreign_keys = tuple(self._column.foreign_keys)
        if not foreign_keys:
            msg = "Cannot set on_delete/on_update before defining a foreign key reference"
            raise ValueError(msg)
        return foreign_keys[-1]

    def on_delete(self, action: ForeignKeyAction) -> Self:
        """Set ON DELETE behavior for the last attached foreign key."""
        foreign_key = self._last_foreign_key()
        cast("Any", foreign_key).ondelete = action.value
        return self

    def on_update(self, action: ForeignKeyAction) -> Self:
        """Set ON UPDATE behavior for the last attached foreign key."""
        foreign_key = self._last_foreign_key()
        cast("Any", foreign_key).onupdate = action.value
        return self

    def references(
        self,
        table: str,
        column: str = "id",
        *,
        on_delete: ForeignKeyAction | None = None,
        on_update: ForeignKeyAction | None = None,
    ) -> Self:
        """Attach an explicit foreign-key reference to this column.

        Generates a conventional constraint name ``fk_<col>_<table>`` so
        the FK works in both ``Schema.create()`` and ``Schema.table()``
        (Alembic batch mode requires named constraints).
        """
        target = f"{table}.{column}"
        fk_name = f"fk_{self._column.name}_{table}"
        self._column.append_foreign_key(ForeignKey(target, name=fk_name))
        if on_delete is not None:
            self.on_delete(on_delete)
        if on_update is not None:
            self.on_update(on_update)
        return self

on_delete(action)

Set ON DELETE behavior for the last attached foreign key.

Source code in src/arvel/data/schema.py
100
101
102
103
104
def on_delete(self, action: ForeignKeyAction) -> Self:
    """Set ON DELETE behavior for the last attached foreign key."""
    foreign_key = self._last_foreign_key()
    cast("Any", foreign_key).ondelete = action.value
    return self

on_update(action)

Set ON UPDATE behavior for the last attached foreign key.

Source code in src/arvel/data/schema.py
106
107
108
109
110
def on_update(self, action: ForeignKeyAction) -> Self:
    """Set ON UPDATE behavior for the last attached foreign key."""
    foreign_key = self._last_foreign_key()
    cast("Any", foreign_key).onupdate = action.value
    return self

references(table, column='id', *, on_delete=None, on_update=None)

Attach an explicit foreign-key reference to this column.

Generates a conventional constraint name fk_<col>_<table> so the FK works in both Schema.create() and Schema.table() (Alembic batch mode requires named constraints).

Source code in src/arvel/data/schema.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def references(
    self,
    table: str,
    column: str = "id",
    *,
    on_delete: ForeignKeyAction | None = None,
    on_update: ForeignKeyAction | None = None,
) -> Self:
    """Attach an explicit foreign-key reference to this column.

    Generates a conventional constraint name ``fk_<col>_<table>`` so
    the FK works in both ``Schema.create()`` and ``Schema.table()``
    (Alembic batch mode requires named constraints).
    """
    target = f"{table}.{column}"
    fk_name = f"fk_{self._column.name}_{table}"
    self._column.append_foreign_key(ForeignKey(target, name=fk_name))
    if on_delete is not None:
        self.on_delete(on_delete)
    if on_update is not None:
        self.on_update(on_update)
    return self

ForeignKeyAction

Bases: StrEnum

Allowed ON DELETE / ON UPDATE actions.

Source code in src/arvel/data/schema.py
55
56
57
58
59
60
61
62
class ForeignKeyAction(StrEnum):
    """Allowed ON DELETE / ON UPDATE actions."""

    CASCADE = "CASCADE"
    SET_NULL = "SET NULL"
    RESTRICT = "RESTRICT"
    NO_ACTION = "NO ACTION"
    SET_DEFAULT = "SET DEFAULT"

KeyType

Bases: Enum

Primary key column type.

Source code in src/arvel/data/schema.py
47
48
49
50
51
52
class KeyType(Enum):
    """Primary key column type."""

    INT = "int"
    BIG_INT = "bigint"
    UUID = "uuid"

Schema

Static facade for table DDL operations.

Source code in src/arvel/data/schema.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
class Schema:
    """Static facade for table DDL operations."""

    @staticmethod
    def create(table_name: str, callback: Callable[[Blueprint], None]) -> None:
        """Create a new table from a Blueprint callback."""
        bp = Blueprint()
        callback(bp)
        op.create_table(table_name, *bp.columns)

    @staticmethod
    def table(table_name: str, callback: Callable[[Blueprint], None]) -> None:
        """Alter an existing table via a Blueprint callback.

        Indexes are created outside the batch context to avoid SQLite
        batch-mode issues where the copy-table process tries to recreate
        indexes on columns that may not exist in the target table.
        """
        bp = Blueprint()
        callback(bp)
        deferred_indexes: list[tuple[str, str]] = []
        with op.batch_alter_table(table_name) as batch:
            for col in bp.columns:
                if col.index:
                    col.index = False
                    idx_name = f"ix_{table_name}_{col.name}"
                    deferred_indexes.append((idx_name, col.name))
                batch.add_column(col)
        for idx_name, col_name in deferred_indexes:
            op.create_index(idx_name, table_name, [col_name])

    @staticmethod
    def drop_columns(table_name: str, *columns: str) -> None:
        """Remove columns (with their indexes and FK constraints) from a table.

        For each column this method:

        1. Drops the standalone index ``ix_{table}_{col}`` (created by
           :meth:`table`) **before** entering the batch — SQLite's
           copy-table process would otherwise try to recreate it on
           a column that no longer exists.
        2. Inside the batch, drops the FK constraint ``fk_{col}_*``
           (following the convention set by :meth:`ColumnBuilder.references`)
           and the column itself.
        """
        for col_name in columns:
            with contextlib.suppress(Exception):
                op.drop_index(f"ix_{table_name}_{col_name}", table_name=table_name)

        bind = op.get_context().connection
        if bind is None:
            msg = "drop_columns requires an active migration connection"
            raise RuntimeError(msg)
        insp = sa_inspect(bind)
        fk_names_to_drop: set[str] = set()
        col_set = set(columns)
        for fk in insp.get_foreign_keys(table_name):
            if fk.get("name") and col_set.intersection(fk.get("constrained_columns", ())):
                fk_names_to_drop.add(fk["name"])

        with op.batch_alter_table(table_name) as batch:
            for fk_name in fk_names_to_drop:
                batch.drop_constraint(fk_name, type_="foreignkey")
            for col_name in columns:
                batch.drop_column(col_name)

    @staticmethod
    def drop(table_name: str) -> None:
        """Drop a table."""
        op.drop_table(table_name)

    @staticmethod
    def rename(old_name: str, new_name: str) -> None:
        """Rename a table."""
        op.rename_table(old_name, new_name)

create(table_name, callback) staticmethod

Create a new table from a Blueprint callback.

Source code in src/arvel/data/schema.py
223
224
225
226
227
228
@staticmethod
def create(table_name: str, callback: Callable[[Blueprint], None]) -> None:
    """Create a new table from a Blueprint callback."""
    bp = Blueprint()
    callback(bp)
    op.create_table(table_name, *bp.columns)

table(table_name, callback) staticmethod

Alter an existing table via a Blueprint callback.

Indexes are created outside the batch context to avoid SQLite batch-mode issues where the copy-table process tries to recreate indexes on columns that may not exist in the target table.

Source code in src/arvel/data/schema.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
@staticmethod
def table(table_name: str, callback: Callable[[Blueprint], None]) -> None:
    """Alter an existing table via a Blueprint callback.

    Indexes are created outside the batch context to avoid SQLite
    batch-mode issues where the copy-table process tries to recreate
    indexes on columns that may not exist in the target table.
    """
    bp = Blueprint()
    callback(bp)
    deferred_indexes: list[tuple[str, str]] = []
    with op.batch_alter_table(table_name) as batch:
        for col in bp.columns:
            if col.index:
                col.index = False
                idx_name = f"ix_{table_name}_{col.name}"
                deferred_indexes.append((idx_name, col.name))
            batch.add_column(col)
    for idx_name, col_name in deferred_indexes:
        op.create_index(idx_name, table_name, [col_name])

drop_columns(table_name, *columns) staticmethod

Remove columns (with their indexes and FK constraints) from a table.

For each column this method:

  1. Drops the standalone index ix_{table}_{col} (created by :meth:table) before entering the batch — SQLite's copy-table process would otherwise try to recreate it on a column that no longer exists.
  2. Inside the batch, drops the FK constraint fk_{col}_* (following the convention set by :meth:ColumnBuilder.references) and the column itself.
Source code in src/arvel/data/schema.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
@staticmethod
def drop_columns(table_name: str, *columns: str) -> None:
    """Remove columns (with their indexes and FK constraints) from a table.

    For each column this method:

    1. Drops the standalone index ``ix_{table}_{col}`` (created by
       :meth:`table`) **before** entering the batch — SQLite's
       copy-table process would otherwise try to recreate it on
       a column that no longer exists.
    2. Inside the batch, drops the FK constraint ``fk_{col}_*``
       (following the convention set by :meth:`ColumnBuilder.references`)
       and the column itself.
    """
    for col_name in columns:
        with contextlib.suppress(Exception):
            op.drop_index(f"ix_{table_name}_{col_name}", table_name=table_name)

    bind = op.get_context().connection
    if bind is None:
        msg = "drop_columns requires an active migration connection"
        raise RuntimeError(msg)
    insp = sa_inspect(bind)
    fk_names_to_drop: set[str] = set()
    col_set = set(columns)
    for fk in insp.get_foreign_keys(table_name):
        if fk.get("name") and col_set.intersection(fk.get("constrained_columns", ())):
            fk_names_to_drop.add(fk["name"])

    with op.batch_alter_table(table_name) as batch:
        for fk_name in fk_names_to_drop:
            batch.drop_constraint(fk_name, type_="foreignkey")
        for col_name in columns:
            batch.drop_column(col_name)

drop(table_name) staticmethod

Drop a table.

Source code in src/arvel/data/schema.py
286
287
288
289
@staticmethod
def drop(table_name: str) -> None:
    """Drop a table."""
    op.drop_table(table_name)

rename(old_name, new_name) staticmethod

Rename a table.

Source code in src/arvel/data/schema.py
291
292
293
294
@staticmethod
def rename(old_name: str, new_name: str) -> None:
    """Rename a table."""
    op.rename_table(old_name, new_name)

SoftDeletes

Mixin that adds soft-delete behavior to an ArvelModel subclass.

Declares deleted_at: Mapped[datetime | None] and registers a global scope that filters out soft-deleted rows.

The Repository checks for this mixin at runtime and redirects delete() to set deleted_at instead of issuing a hard DELETE.

Source code in src/arvel/data/soft_deletes.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class SoftDeletes:
    """Mixin that adds soft-delete behavior to an ArvelModel subclass.

    Declares ``deleted_at: Mapped[datetime | None]`` and registers a
    global scope that filters out soft-deleted rows.

    The ``Repository`` checks for this mixin at runtime and redirects
    ``delete()`` to set ``deleted_at`` instead of issuing a hard DELETE.
    """

    deleted_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True),
        default=None,
        nullable=True,
    )

    def __init_subclass__(cls, **kwargs: Any) -> None:
        if not cls.__dict__.get("__abstract__") and hasattr(cls, "__tablename__"):
            setattr(cls, _SOFT_DELETE_ATTR, True)
            _register_soft_delete_scope(cls)
        super().__init_subclass__(**kwargs)

    @property
    def trashed(self) -> bool:
        """Whether this instance is soft-deleted."""
        return self.deleted_at is not None

trashed property

Whether this instance is soft-deleted.

Transaction

Transaction context manager for atomic repository operations.

Preferred pattern — typed @property methods::

class AppTransaction(Transaction):
    @property
    def users(self) -> UserRepository:
        return self._get_repo(UserRepository)

Legacy pattern — annotation-based (returns Any to checkers)::

class AppTransaction(Transaction):
    users: UserRepository

When DatabaseServiceProvider is registered, both session and observer_registry are optional::

async with AppTransaction() as tx:
    user = await tx.users.create(data)
Source code in src/arvel/data/transaction.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
class Transaction:
    """Transaction context manager for atomic repository operations.

    Preferred pattern — typed ``@property`` methods::

        class AppTransaction(Transaction):
            @property
            def users(self) -> UserRepository:
                return self._get_repo(UserRepository)

    Legacy pattern — annotation-based (returns ``Any`` to checkers)::

        class AppTransaction(Transaction):
            users: UserRepository

    When ``DatabaseServiceProvider`` is registered, both *session* and
    *observer_registry* are optional::

        async with AppTransaction() as tx:
            user = await tx.users.create(data)
    """

    def __init__(
        self,
        *,
        session: AsyncSession | None = None,
        observer_registry: ObserverRegistry | None = None,
    ) -> None:
        if session is None:
            from arvel.data.model import ArvelModel

            resolver = ArvelModel._session_resolver
            if resolver is None:
                msg = (
                    "No session provided and no default session resolver is configured. "
                    "Either pass a session explicitly or register "
                    "DatabaseServiceProvider in your bootstrap/providers.py."
                )
                raise RuntimeError(msg)
            session = resolver()

        if observer_registry is None:
            from arvel.data.observer import ObserverRegistry as _ObserverRegistry

            observer_registry = _ObserverRegistry()

        self._session = session
        self._observer_registry = observer_registry
        self._repos: dict[type, Repository[Any]] = {}
        self._repos_by_name: dict[str, Repository[Any]] = {}
        self._nesting_depth = 0

    def _get_repo[R: Repository[Any]](self, repo_cls: type[R]) -> R:
        """Lazily create and cache a repository instance.

        Preserves the concrete repository type through the generic
        ``R`` bound, so ``self._get_repo(UserRepository)`` returns
        ``UserRepository`` to the type checker.
        """
        cached = self._repos.get(repo_cls)
        if cached is not None:
            # Safe: the cache key *is* the class we're casting to — see
            # the assignment three lines below.
            return cast("R", cached)
        repo = repo_cls(session=self._session, observer_registry=self._observer_registry)
        self._repos[repo_cls] = repo
        return repo

    def __getattr__(self, name: str) -> Any:
        if name.startswith("_"):
            raise AttributeError(name)

        if name in self._repos_by_name:
            return self._repos_by_name[name]

        hints = _cached_type_hints(type(self))
        repo_cls = hints.get(name)
        if repo_cls is not None and isinstance(repo_cls, type) and issubclass(repo_cls, Repository):
            repo = self._get_repo(repo_cls)
            self._repos_by_name[name] = repo
            return repo

        raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")

    async def __aenter__(self) -> Self:
        if self._nesting_depth == 0:
            if not self._session.in_transaction():
                await self._session.begin()
        else:
            await self._session.begin_nested()
        self._nesting_depth += 1
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        self._nesting_depth -= 1
        if exc_type is not None:
            await self._session.rollback()
        elif self._nesting_depth == 0:
            await self._session.commit()

    @asynccontextmanager
    async def nested(self) -> AsyncGenerator[Self]:
        """Create a nested savepoint within the current transaction."""
        nested = await self._session.begin_nested()
        self._nesting_depth += 1
        try:
            yield self
        except Exception:
            await nested.rollback()
            self._nesting_depth -= 1
            raise
        else:
            await nested.commit()
            self._nesting_depth -= 1

nested() async

Create a nested savepoint within the current transaction.

Source code in src/arvel/data/transaction.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
@asynccontextmanager
async def nested(self) -> AsyncGenerator[Self]:
    """Create a nested savepoint within the current transaction."""
    nested = await self._session.begin_nested()
    self._nesting_depth += 1
    try:
        yield self
    except Exception:
        await nested.rollback()
        self._nesting_depth -= 1
        raise
    else:
        await nested.commit()
        self._nesting_depth -= 1

accessor(attr_name)

Decorator to register a method as an accessor for attr_name.

Source code in src/arvel/data/accessors.py
36
37
38
39
40
41
42
43
44
45
46
def accessor(attr_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    """Decorator to register a method as an accessor for *attr_name*."""

    def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
        names: list[str] = getattr(fn, _ACCESSOR_ATTR, [])
        if not names:
            object.__setattr__(fn, _ACCESSOR_ATTR, names)
        names.append(attr_name)
        return fn

    return decorator

mutator(attr_name)

Decorator to register a method as a mutator for attr_name.

Source code in src/arvel/data/accessors.py
49
50
51
52
53
54
55
56
57
58
59
def mutator(attr_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    """Decorator to register a method as a mutator for *attr_name*."""

    def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
        names: list[str] = getattr(fn, _MUTATOR_ATTR, [])
        if not names:
            object.__setattr__(fn, _MUTATOR_ATTR, names)
        names.append(attr_name)
        return fn

    return decorator

collect(items=None)

Create an ArvelCollection from any iterable.

::

from arvel.data.collection import collect

numbers = collect([1, 2, 3, 4, 5])
names = collect(user.name for user in users)
Source code in src/arvel/data/collection.py
780
781
782
783
784
785
786
787
788
789
790
791
792
def collect[U](items: Iterable[U] | None = None) -> ArvelCollection[U]:
    """Create an ``ArvelCollection`` from any iterable.

    ::

        from arvel.data.collection import collect

        numbers = collect([1, 2, 3, 4, 5])
        names = collect(user.name for user in users)
    """
    if items is None:
        return ArvelCollection()
    return ArvelCollection(items)

detect_pg_ivm(db_url) async

Check if the pg_ivm extension is available.

Returns False for non-PostgreSQL databases.

Source code in src/arvel/data/materialized_view.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
async def detect_pg_ivm(db_url: str) -> bool:
    """Check if the pg_ivm extension is available.

    Returns False for non-PostgreSQL databases.
    """
    if "postgresql" not in db_url and "asyncpg" not in db_url:
        return False

    from sqlalchemy import text
    from sqlalchemy.ext.asyncio import create_async_engine

    engine = create_async_engine(db_url, echo=False)
    try:
        async with engine.connect() as conn:
            result = await conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_ivm'"))
            row = result.fetchone()
            return row is not None
    except Exception:
        return False
    finally:
        await engine.dispose()

register_framework_migration(filename, content)

Register a migration that ships with the framework.

Called by framework providers (e.g. MediaProvider) at import time. The migration is published into the user's database/migrations/ directory when MigrationRunner.upgrade() or fresh() runs, but only if a file with the same base name doesn't already exist.

Source code in src/arvel/data/migrations.py
46
47
48
49
50
51
52
53
54
def register_framework_migration(filename: str, content: str) -> None:
    """Register a migration that ships with the framework.

    Called by framework providers (e.g. MediaProvider) at import time.
    The migration is published into the user's ``database/migrations/``
    directory when ``MigrationRunner.upgrade()`` or ``fresh()`` runs,
    but only if a file with the same base name doesn't already exist.
    """
    _FRAMEWORK_MIGRATIONS.append((filename, content))

run_alembic_env()

Run Alembic env logic using framework-managed defaults.

Starter/local migration env.py files call this so they don't need to import Alembic or SQLAlchemy directly.

Source code in src/arvel/data/migrations.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def run_alembic_env() -> None:
    """Run Alembic env logic using framework-managed defaults.

    Starter/local migration env.py files call this so they don't need
    to import Alembic or SQLAlchemy directly.
    """
    import asyncio
    from logging.config import fileConfig

    from alembic import context
    from sqlalchemy import create_engine, pool
    from sqlalchemy.ext.asyncio import async_engine_from_config

    config = context.config

    if config.config_file_name is not None:
        fileConfig(config.config_file_name)

    target_metadata = ArvelModel.metadata
    inject_revisions(config.get_main_option("script_location") or ".")

    url = config.get_main_option("sqlalchemy.url") or ""

    def run_migrations_offline() -> None:
        context.configure(
            url=url,
            target_metadata=target_metadata,
            literal_binds=True,
            dialect_opts={"paramstyle": "named"},
        )
        with context.begin_transaction():
            context.run_migrations()

    def do_run_migrations(connection: Connection) -> None:
        context.configure(connection=connection, target_metadata=target_metadata)
        with context.begin_transaction():
            context.run_migrations()

    def run_sync_migrations() -> None:
        if not url:
            msg = "Missing sqlalchemy.url in Alembic config"
            raise RuntimeError(msg)
        connectable = create_engine(url, poolclass=pool.NullPool)
        with connectable.connect() as connection:
            do_run_migrations(connection)
        connectable.dispose()

    async def run_async_migrations() -> None:
        connectable = async_engine_from_config(
            config.get_section(config.config_ini_section, {}),
            prefix="sqlalchemy.",
            poolclass=pool.NullPool,
        )
        async with connectable.connect() as connection:
            await connection.run_sync(do_run_migrations)
        await connectable.dispose()

    if context.is_offline_mode():
        run_migrations_offline()
        return

    if _is_async_url(url):
        asyncio.run(run_async_migrations())
    else:
        run_sync_migrations()

belongs_to(related, *, foreign_key=None, local_key=None, back_populates=None)

belongs_to(
    related: type[T],
    *,
    foreign_key: str | None = ...,
    local_key: str | None = ...,
    back_populates: str | None = ...,
) -> T
belongs_to(
    related: str,
    *,
    foreign_key: str | None = ...,
    local_key: str | None = ...,
    back_populates: str | None = ...,
) -> Any

Declare the inverse of has_one/has_many — the FK lives on this model.

Convention: FK column on this table is {related_tablename}_id.

Source code in src/arvel/data/relationships/descriptors.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def belongs_to(
    related: type | str,
    *,
    foreign_key: str | None = None,
    local_key: str | None = None,
    back_populates: str | None = None,
) -> Any:
    """Declare the inverse of has_one/has_many — the FK lives on *this* model.

    Convention: FK column on this table is ``{related_tablename}_id``.
    """
    return _BelongsToDescriptor(
        related_model=related,
        relation_type=RelationType.BELONGS_TO,
        foreign_key=foreign_key,
        local_key=local_key,
        back_populates=back_populates,
    )

belongs_to_many(related, *, pivot_table=None, foreign_key=None, related_key=None, pivot_fields=None, back_populates=None)

belongs_to_many(
    related: type[T],
    *,
    pivot_table: str | None = ...,
    foreign_key: str | None = ...,
    related_key: str | None = ...,
    pivot_fields: list[str] | None = ...,
    back_populates: str | None = ...,
) -> list[T]
belongs_to_many(
    related: str,
    *,
    pivot_table: str | None = ...,
    foreign_key: str | None = ...,
    related_key: str | None = ...,
    pivot_fields: list[str] | None = ...,
    back_populates: str | None = ...,
) -> Any

Declare a many-to-many relationship through a pivot (association) table.

Conventions: - Pivot table: alphabetical join of both table names (e.g. role_user). - FK columns: {table}_id for each side.

Source code in src/arvel/data/relationships/descriptors.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def belongs_to_many(
    related: type | str,
    *,
    pivot_table: str | None = None,
    foreign_key: str | None = None,
    related_key: str | None = None,
    pivot_fields: list[str] | None = None,
    back_populates: str | None = None,
) -> Any:
    """Declare a many-to-many relationship through a pivot (association) table.

    Conventions:
    - Pivot table: alphabetical join of both table names (e.g. ``role_user``).
    - FK columns: ``{table}_id`` for each side.
    """
    return _HasManyDescriptor(
        related_model=related,
        relation_type=RelationType.BELONGS_TO_MANY,
        foreign_key=foreign_key,
        local_key=related_key,
        back_populates=back_populates,
        pivot_table=pivot_table,
        pivot_fields=pivot_fields or [],
    )

has_many(related, *, foreign_key=None, local_key=None, back_populates=None)

has_many(
    related: type[T],
    *,
    foreign_key: str | None = ...,
    local_key: str | None = ...,
    back_populates: str | None = ...,
) -> list[T]
has_many(
    related: str,
    *,
    foreign_key: str | None = ...,
    local_key: str | None = ...,
    back_populates: str | None = ...,
) -> Any

Declare a one-to-many relationship where the FK lives on the related model.

Convention: FK on the related table is {owner_tablename}_id.

Source code in src/arvel/data/relationships/descriptors.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def has_many(
    related: type | str,
    *,
    foreign_key: str | None = None,
    local_key: str | None = None,
    back_populates: str | None = None,
) -> Any:
    """Declare a one-to-many relationship where the FK lives on the related model.

    Convention: FK on the related table is ``{owner_tablename}_id``.
    """
    return _HasManyDescriptor(
        related_model=related,
        relation_type=RelationType.HAS_MANY,
        foreign_key=foreign_key,
        local_key=local_key,
        back_populates=back_populates,
    )

has_one(related, *, foreign_key=None, local_key=None, back_populates=None)

has_one(
    related: type[T],
    *,
    foreign_key: str | None = ...,
    local_key: str | None = ...,
    back_populates: str | None = ...,
) -> T
has_one(
    related: str,
    *,
    foreign_key: str | None = ...,
    local_key: str | None = ...,
    back_populates: str | None = ...,
) -> Any

Declare a one-to-one relationship where the FK lives on the related model.

Convention: FK on the related table is {owner_tablename}_id.

Source code in src/arvel/data/relationships/descriptors.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def has_one(
    related: type | str,
    *,
    foreign_key: str | None = None,
    local_key: str | None = None,
    back_populates: str | None = None,
) -> Any:
    """Declare a one-to-one relationship where the FK lives on the related model.

    Convention: FK on the related table is ``{owner_tablename}_id``.
    """
    return _HasOneDescriptor(
        related_model=related,
        relation_type=RelationType.HAS_ONE,
        foreign_key=foreign_key,
        local_key=local_key,
        back_populates=back_populates,
    )

load_morph_parent(instance, name, session) async

Eagerly load the polymorphic parent for a morph_to relationship.

Reads {name}_type and {name}_id from the instance, resolves the parent model class via the morph type map, and queries for it.

Source code in src/arvel/data/relationships/morphs.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
async def load_morph_parent(
    instance: ArvelModel,
    name: str,
    session: AsyncSession,
) -> ArvelModel | None:
    """Eagerly load the polymorphic parent for a morph_to relationship.

    Reads ``{name}_type`` and ``{name}_id`` from the instance, resolves
    the parent model class via the morph type map, and queries for it.
    """
    type_col = f"{name}_type"
    id_col = f"{name}_id"

    type_value = getattr(instance, type_col, None)
    id_value = getattr(instance, id_col, None)

    if type_value is None or id_value is None:
        return None

    parent_cls = resolve_morph_type(type_value)
    # AsyncSession.get() keeps the model type and avoids untyped scalar extraction.
    return await session.get(parent_cls, id_value)

morph_many(related, name)

Declare the parent side of a polymorphic one-to-many relationship.

Example::

class Post(ArvelModel):
    comments = morph_many(Comment, "commentable")
Source code in src/arvel/data/relationships/morphs.py
86
87
88
89
90
91
92
93
94
def morph_many(related: type[ArvelModel], name: str) -> MorphDescriptor:
    """Declare the parent side of a polymorphic one-to-many relationship.

    Example::

        class Post(ArvelModel):
            comments = morph_many(Comment, "commentable")
    """
    return MorphDescriptor(morph_name=name, morph_type="morph_many", related_model=related)

morph_to(name)

Declare the child side of a polymorphic relationship.

The model must have {name}_type and {name}_id columns.

Example::

class Comment(ArvelModel):
    commentable_type: Mapped[str] = mapped_column(String(100))
    commentable_id: Mapped[int] = mapped_column()
    commentable = morph_to("commentable")
Source code in src/arvel/data/relationships/morphs.py
71
72
73
74
75
76
77
78
79
80
81
82
83
def morph_to(name: str) -> MorphDescriptor:
    """Declare the child side of a polymorphic relationship.

    The model must have ``{name}_type`` and ``{name}_id`` columns.

    Example::

        class Comment(ArvelModel):
            commentable_type: Mapped[str] = mapped_column(String(100))
            commentable_id: Mapped[int] = mapped_column()
            commentable = morph_to("commentable")
    """
    return MorphDescriptor(morph_name=name, morph_type="morph_to")

morph_to_many(related, name, *, morph_map=None)

Declare a polymorphic many-to-many relationship via a pivot table.

Example::

class Post(ArvelModel):
    tags = morph_to_many(Tag, "taggable")
Source code in src/arvel/data/relationships/morphs.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def morph_to_many(
    related: type[ArvelModel],
    name: str,
    *,
    morph_map: dict[str, type[ArvelModel]] | None = None,
) -> MorphDescriptor:
    """Declare a polymorphic many-to-many relationship via a pivot table.

    Example::

        class Post(ArvelModel):
            tags = morph_to_many(Tag, "taggable")
    """
    return MorphDescriptor(
        morph_name=name,
        morph_type="morph_to_many",
        related_model=related,
        morph_map=morph_map or {},
    )

query_morph_children(parent, related_cls, name, session) async

Query all children of a polymorphic one-to-many relationship.

Filters {name}_type to the parent's morph alias and {name}_id to the parent's PK.

Source code in src/arvel/data/relationships/morphs.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
async def query_morph_children(
    parent: ArvelModel,
    related_cls: type[ArvelModel],
    name: str,
    session: AsyncSession,
) -> list[ArvelModel]:
    """Query all children of a polymorphic one-to-many relationship.

    Filters ``{name}_type`` to the parent's morph alias and
    ``{name}_id`` to the parent's PK.
    """
    from sqlalchemy import select

    parent_cls = type(parent)
    alias = morph_alias(parent_cls)
    pk_value = _get_pk_value(parent)

    type_col = getattr(related_cls, f"{name}_type", None)
    id_col_attr = getattr(related_cls, f"{name}_id", None)

    if type_col is None or id_col_attr is None:
        msg = (
            f"{related_cls.__name__} must have '{name}_type' and '{name}_id' "
            f"columns for morph_many relationship"
        )
        raise ValueError(msg)

    stmt = select(related_cls).where(type_col == alias, id_col_attr == pk_value)
    result = await session.execute(stmt)
    return list(result.scalars().all())

register_morph_type(alias, model_cls)

Register a short alias for a model class in the morph type map.

Source code in src/arvel/data/relationships/morphs.py
27
28
29
def register_morph_type(alias: str, model_cls: type[ArvelModel]) -> None:
    """Register a short alias for a model class in the morph type map."""
    _MORPH_TYPE_MAP[alias] = model_cls

scope(fn)

Mark a static/classmethod as a local query scope.

The decorator sets a marker attribute; __init_subclass__ picks it up and registers the scope name (stripped of the scope_ prefix if present).

Works with both bare functions and @staticmethod/@classmethod wrappers regardless of decorator order.

Source code in src/arvel/data/scopes.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def scope(fn: Any) -> Any:
    """Mark a static/classmethod as a local query scope.

    The decorator sets a marker attribute; ``__init_subclass__`` picks it
    up and registers the scope name (stripped of the ``scope_`` prefix if
    present).

    Works with both bare functions and ``@staticmethod``/``@classmethod``
    wrappers regardless of decorator order.
    """
    if isinstance(fn, staticmethod):
        setattr(fn.__func__, _SCOPE_ATTR, True)
        return fn
    if isinstance(fn, classmethod):
        setattr(fn.__func__, _SCOPE_ATTR, True)
        return fn
    setattr(fn, _SCOPE_ATTR, True)
    return fn

discover_seeders(seeders_dir)

Scan a directory for Seeder subclasses, sorted alphabetically by filename.

The project root (seeders_dir.parent.parent, i.e. two levels up from database/seeders/) is temporarily added to sys.path so that seeder modules can use application-level imports such as from app.models.user import User.

Source code in src/arvel/data/seeder.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def discover_seeders(seeders_dir: Path) -> list[type[Seeder]]:
    """Scan a directory for Seeder subclasses, sorted alphabetically by filename.

    The project root (``seeders_dir.parent.parent``, i.e. two levels up from
    ``database/seeders/``) is temporarily added to ``sys.path`` so that seeder
    modules can use application-level imports such as
    ``from app.models.user import User``.
    """
    if not seeders_dir.is_dir():
        return []

    project_root = str(seeders_dir.parent.parent)
    path_added = project_root not in sys.path
    if path_added:
        sys.path.insert(0, project_root)

    try:
        found: list[type[Seeder]] = []
        for py_file in sorted(seeders_dir.glob("*.py")):
            if py_file.name.startswith("_"):
                continue
            found.extend(_load_seeder_module(py_file))
    finally:
        if path_added:
            with contextlib.suppress(ValueError):
                sys.path.remove(project_root)

    return found

arvel.http

HTTP and Routing layer — route registration, middleware, controller DI, URL generation.

arvel.foundation

Foundation layer — kernel, container, providers, config, pipeline.

PipeSpec = type | Pipe | Callable[..., Any] module-attribute

A pipe specification: a class (resolved via DI), a Pipe callable, or any callable.

Application

Arvel application kernel.

User code should call Application.configure(base_path) which returns an ASGI-compatible object suitable for uvicorn. The async bootstrap runs automatically on the first ASGI event.

Source code in src/arvel/foundation/application.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
class Application:
    """Arvel application kernel.

    User code should call ``Application.configure(base_path)`` which returns an
    ASGI-compatible object suitable for uvicorn.  The async bootstrap runs
    automatically on the first ASGI event.
    """

    _booted: bool
    _testing: bool
    _boot_lock: anyio.Lock
    _shutdown_lock: anyio.Lock
    _shutting_down: bool
    _shutdown_complete: bool

    def __init__(
        self,
        base_path: Path,
        config: AppSettings,
        container: Container,
        providers: list[ServiceProvider],
        fastapi_app: FastAPI,
    ) -> None:
        self.base_path = base_path
        self.config = config
        self.container = container
        self.providers = providers
        self._fastapi_app = fastapi_app
        self._booted = True
        self._testing = False

    # ── Public API ───────────────────────────────────────────

    @classmethod
    def _new_unbooted(cls, base_path: Path, *, testing: bool) -> Application:
        """Create an unbooted instance with all fields initialized."""
        instance = cls.__new__(cls)
        instance.base_path = base_path
        instance._testing = testing
        instance._booted = False
        instance._boot_lock = anyio.Lock()
        instance._shutdown_lock = anyio.Lock()
        instance._shutting_down = False
        instance._shutdown_complete = False
        return instance

    @classmethod
    def configure(
        cls,
        base_path: str | Path = ".",
        *,
        testing: bool = False,
    ) -> Application:
        """Configure an Arvel application (sync, safe for uvicorn factory).

        Returns an ``Application`` that implements the ASGI protocol.  The
        heavy async bootstrap (config loading, provider lifecycle) runs
        automatically on the first ASGI event — not at import time.
        """
        return cls._new_unbooted(Path(base_path).resolve(), testing=testing)

    @classmethod
    async def create(
        cls,
        base_path: str | Path = ".",
        *,
        testing: bool = False,
    ) -> Application:
        """Create and bootstrap an Arvel application eagerly (async).

        Useful for tests or scripts where you need a fully-booted app
        immediately.  For ``arvel serve``, prefer ``configure()``.
        """
        app_instance = cls._new_unbooted(
            Path(base_path).resolve(),  # noqa: ASYNC240
            testing=testing,
        )
        await app_instance._bootstrap(testing=testing)
        return app_instance

    def asgi_app(self) -> FastAPI:
        return self._fastapi_app

    def settings[TModuleSettings: ModuleSettings](
        self,
        settings_type: type[TModuleSettings],
    ) -> TModuleSettings:
        """Return a typed settings slice loaded on this application."""
        if not self._booted:
            raise RuntimeError("Application is not booted yet; settings are unavailable")
        return get_module_settings(self.config, settings_type)

    async def __call__(
        self,
        scope: MutableMapping[str, object],
        receive: ASGIReceive,
        send: ASGISend,
    ) -> None:
        """ASGI interface — lazy-boots on first call, then delegates to FastAPI.

        Exceptions that reach FastAPI's exception handlers are logged once
        there and converted into an HTTP response.  Starlette's
        ``ServerErrorMiddleware`` re-raises after sending that response,
        which causes Uvicorn to log the *same* traceback a second time.
        We absorb the re-raise here so every error produces exactly one
        structured log entry.
        """
        if not self._booted:
            async with self._boot_lock:
                if not self._booted:
                    await self._boot()

        response_started = False
        original_send = send

        async def _track_send(message: MutableMapping[str, object]) -> None:
            nonlocal response_started
            if message.get("type") == "http.response.start":
                response_started = True
            await original_send(message)

        try:
            await self._fastapi_app(scope, receive, _track_send)
        except Exception:
            if response_started:
                # Response already sent — exception was handled by our
                # exception_handler and logged via structlog.  Swallow
                # the re-raise to prevent duplicate tracebacks from
                # Uvicorn's ASGI runner.
                return
            raise

    async def shutdown(self) -> None:
        if self._shutdown_complete:
            return

        # FR-013: Safe to call on unbooted app
        if not self._booted:
            self._shutdown_complete = True
            return

        async with self._shutdown_lock:
            if self._shutdown_complete or self._shutting_down:
                return
            self._shutting_down = True

            try:
                for provider in reversed(self.providers):
                    provider_name = type(provider).__name__
                    logger.debug("provider_shutdown_start", provider=provider_name)
                    try:
                        await provider.shutdown(self)
                    except Exception as exc:
                        # FR-021: Log full error message and traceback
                        import traceback as tb

                        logger.error(
                            "provider_shutdown_failed",
                            provider=provider_name,
                            error=type(exc).__name__,
                            error_message=str(exc),
                            traceback=tb.format_exc(),
                        )
                    else:
                        logger.debug("provider_shutdown_ok", provider=provider_name)
            finally:
                await self.container.close()
                self._shutdown_complete = True
                self._shutting_down = False
                logger.info("application_shutdown_complete")

    # ── Internal ─────────────────────────────────────────────

    async def _bootstrap(self, *, testing: bool = False) -> None:
        """Shared async bootstrap — load config, register providers, build container."""
        t_start = time.monotonic()
        base = self.base_path

        config = await load_config(base, testing=testing)
        t_config = time.monotonic()

        _apply_early_log_level(config)

        if str(base) not in sys.path:
            sys.path.insert(0, str(base))

        builder = ContainerBuilder()
        builder.provide_value(AppSettings, config, scope=Scope.APP)
        for settings_type, settings in config._module_settings.items():
            builder.provide_value(settings_type, settings, scope=Scope.APP)

        provider_classes = self._load_providers(base)
        providers = [pc() for pc in provider_classes]
        providers.sort(key=lambda p: p.priority)

        for provider in providers:
            provider.configure(config)

        for provider in providers:
            provider_name = type(provider).__name__
            logger.debug("provider_register_start", provider=provider_name)
            try:
                await provider.register(builder)
            except Exception as exc:
                logger.error(
                    "provider_register_failed",
                    provider=provider_name,
                    error=type(exc).__name__,
                )
                raise BootError(
                    f"Provider {provider_name} failed during register: {exc}",
                    provider_name=provider_name,
                    cause=exc,
                ) from exc
            logger.debug("provider_register_ok", provider=provider_name)

        container = builder.build()
        t_register = time.monotonic()

        self.config = config
        self.container = container
        self.providers = providers
        self._fastapi_app = self._build_fastapi_app(config)

        await self._boot_providers()
        t_boot = time.monotonic()

        self._booted = True
        logger.info(
            "application_booted",
            app_name=config.app_name,
            app_env=config.app_env,
            debug=config.app_debug,
            providers=len(providers),
            config_ms=round((t_config - t_start) * 1000, 1),
            register_ms=round((t_register - t_config) * 1000, 1),
            boot_ms=round((t_boot - t_register) * 1000, 1),
            total_ms=round((t_boot - t_start) * 1000, 1),
        )

    async def _boot(self) -> None:
        """Run the full async bootstrap (provider lifecycle)."""
        await self._bootstrap(testing=self._testing)

    def _build_fastapi_app(self, config: AppSettings) -> FastAPI:
        @asynccontextmanager
        async def _lifespan(_app: FastAPI):
            yield
            await self.shutdown()

        app = FastAPI(
            title=config.app_name,
            lifespan=_lifespan,
            description=config.app_description,
            version=config.app_version,
            summary=config.app_summary or None,
            terms_of_service=config.app_terms_of_service or None,
            contact=config.app_contact,
            license_info=config.app_license_info,
            openapi_tags=config.app_openapi_tags,
            docs_url=config.app_docs_url,
            redoc_url=config.app_redoc_url,
            openapi_url=config.app_openapi_url,
        )

        security_schemes = config.app_openapi_security_schemes
        global_security = config.app_openapi_global_security
        if security_schemes:
            _install_openapi_security(app, security_schemes, global_security)

        if config.app_exception_handlers:
            from arvel.http.exception_handler import install_exception_handlers

            install_exception_handlers(app, debug=config.app_debug)

        return app

    async def _boot_providers(self) -> None:
        booted: list[ServiceProvider] = []
        for provider in self.providers:
            provider_name = type(provider).__name__
            logger.debug("provider_boot_start", provider=provider_name)
            try:
                await provider.boot(self)
            except Exception as exc:
                logger.error(
                    "provider_boot_failed",
                    provider=provider_name,
                    error=type(exc).__name__,
                    error_message=str(exc),
                )
                # FR-009: Rollback already-booted providers in reverse order
                for booted_provider in reversed(booted):
                    bp_name = type(booted_provider).__name__
                    try:
                        await booted_provider.shutdown(self)
                    except Exception as shutdown_exc:
                        logger.warning(
                            "provider_rollback_shutdown_failed",
                            provider=bp_name,
                            error=str(shutdown_exc),
                        )
                await self.container.close()
                raise BootError(
                    f"Provider {provider_name} failed during boot: {exc}",
                    provider_name=provider_name,
                    cause=exc,
                ) from exc
            booted.append(provider)
            logger.debug("provider_boot_ok", provider=provider_name)

    @staticmethod
    def _load_providers(base_path: Path) -> list[type[ServiceProvider]]:
        """Load service providers from ``bootstrap/providers.py``."""
        providers_file = base_path / "bootstrap" / "providers.py"
        if not providers_file.exists():
            msg = "bootstrap/providers.py not found — every Arvel app must define it"
            raise ProviderNotFoundError(msg, module_path=str(providers_file))

        module_key = f"arvel.bootstrap.providers.{base_path.name}"
        spec = importlib.util.spec_from_file_location(module_key, str(providers_file))
        if spec is None or spec.loader is None:
            msg = f"Cannot load {providers_file}"
            raise ProviderNotFoundError(msg, module_path=str(providers_file))

        module = importlib.util.module_from_spec(spec)
        sys.modules[module_key] = module
        spec.loader.exec_module(module)

        provider_list: list[type[ServiceProvider]] | None = getattr(module, "providers", None)
        if provider_list is None:
            msg = "bootstrap/providers.py must define a 'providers' list"
            raise ProviderNotFoundError(msg, module_path=str(providers_file))

        return list(provider_list)

configure(base_path='.', *, testing=False) classmethod

Configure an Arvel application (sync, safe for uvicorn factory).

Returns an Application that implements the ASGI protocol. The heavy async bootstrap (config loading, provider lifecycle) runs automatically on the first ASGI event — not at import time.

Source code in src/arvel/foundation/application.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@classmethod
def configure(
    cls,
    base_path: str | Path = ".",
    *,
    testing: bool = False,
) -> Application:
    """Configure an Arvel application (sync, safe for uvicorn factory).

    Returns an ``Application`` that implements the ASGI protocol.  The
    heavy async bootstrap (config loading, provider lifecycle) runs
    automatically on the first ASGI event — not at import time.
    """
    return cls._new_unbooted(Path(base_path).resolve(), testing=testing)

create(base_path='.', *, testing=False) async classmethod

Create and bootstrap an Arvel application eagerly (async).

Useful for tests or scripts where you need a fully-booted app immediately. For arvel serve, prefer configure().

Source code in src/arvel/foundation/application.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
@classmethod
async def create(
    cls,
    base_path: str | Path = ".",
    *,
    testing: bool = False,
) -> Application:
    """Create and bootstrap an Arvel application eagerly (async).

    Useful for tests or scripts where you need a fully-booted app
    immediately.  For ``arvel serve``, prefer ``configure()``.
    """
    app_instance = cls._new_unbooted(
        Path(base_path).resolve(),  # noqa: ASYNC240
        testing=testing,
    )
    await app_instance._bootstrap(testing=testing)
    return app_instance

settings(settings_type)

Return a typed settings slice loaded on this application.

Source code in src/arvel/foundation/application.py
201
202
203
204
205
206
207
208
def settings[TModuleSettings: ModuleSettings](
    self,
    settings_type: type[TModuleSettings],
) -> TModuleSettings:
    """Return a typed settings slice loaded on this application."""
    if not self._booted:
        raise RuntimeError("Application is not booted yet; settings are unavailable")
    return get_module_settings(self.config, settings_type)

__call__(scope, receive, send) async

ASGI interface — lazy-boots on first call, then delegates to FastAPI.

Exceptions that reach FastAPI's exception handlers are logged once there and converted into an HTTP response. Starlette's ServerErrorMiddleware re-raises after sending that response, which causes Uvicorn to log the same traceback a second time. We absorb the re-raise here so every error produces exactly one structured log entry.

Source code in src/arvel/foundation/application.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
async def __call__(
    self,
    scope: MutableMapping[str, object],
    receive: ASGIReceive,
    send: ASGISend,
) -> None:
    """ASGI interface — lazy-boots on first call, then delegates to FastAPI.

    Exceptions that reach FastAPI's exception handlers are logged once
    there and converted into an HTTP response.  Starlette's
    ``ServerErrorMiddleware`` re-raises after sending that response,
    which causes Uvicorn to log the *same* traceback a second time.
    We absorb the re-raise here so every error produces exactly one
    structured log entry.
    """
    if not self._booted:
        async with self._boot_lock:
            if not self._booted:
                await self._boot()

    response_started = False
    original_send = send

    async def _track_send(message: MutableMapping[str, object]) -> None:
        nonlocal response_started
        if message.get("type") == "http.response.start":
            response_started = True
        await original_send(message)

    try:
        await self._fastapi_app(scope, receive, _track_send)
    except Exception:
        if response_started:
            # Response already sent — exception was handled by our
            # exception_handler and logged via structlog.  Swallow
            # the re-raise to prevent duplicate tracebacks from
            # Uvicorn's ASGI runner.
            return
        raise

AppSettings

Bases: BaseSettings

Root application settings loaded from APP_* environment variables.

FastAPI metadata fields (description, version, summary, etc.) are forwarded to the FastAPI() constructor at boot time. Set them via environment variables (APP_DESCRIPTION, APP_VERSION, …) or in config/app.py.

Source code in src/arvel/app/config.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class AppSettings(BaseSettings):
    """Root application settings loaded from APP_* environment variables.

    FastAPI metadata fields (description, version, summary, etc.) are forwarded
    to the ``FastAPI()`` constructor at boot time.  Set them via environment
    variables (``APP_DESCRIPTION``, ``APP_VERSION``, …) or in ``config/app.py``.
    """

    app_name: str = "Arvel"
    app_env: str = "development"
    app_debug: bool = False
    app_key: SecretStr = SecretStr("")
    base_path: Path = Path()

    # -- FastAPI metadata (forwarded to the FastAPI constructor) ---------------
    app_description: str = ""
    app_version: str = _default_version()
    app_summary: str = ""
    app_terms_of_service: str = ""
    app_contact: dict[str, str] | None = None
    app_license_info: dict[str, str] | None = None
    app_openapi_tags: list[dict[str, Any]] | None = None

    # -- OpenAPI security schemes (injected into the generated OpenAPI spec) ---
    app_openapi_security_schemes: dict[str, dict[str, Any]] | None = None
    app_openapi_global_security: list[dict[str, list[str]]] | None = None

    # -- Exception handling -----------------------------------------------------
    app_exception_handlers: bool = True

    # -- OpenAPI / docs URLs ---------------------------------------------------
    app_docs_url: str | None = "/docs"
    app_redoc_url: str | None = "/redoc"
    app_openapi_url: str = "/openapi.json"

    model_config = SettingsConfigDict(extra="ignore")

    # Populated per-instance by __init__ and load_config(); not a Pydantic field.
    _module_settings: dict[type[ModuleSettings], ModuleSettings]

    @model_validator(mode="after")
    def _check_app_key_in_production(self) -> Self:
        if self.app_env == "production" and not self.app_key.get_secret_value():
            warnings.warn(
                "APP_KEY is empty in production — encryption and signed "
                "cookies will be insecure. Set APP_KEY to a random 32+ "
                "character string.",
                UserWarning,
                stacklevel=2,
            )
        return self

    def __init__(self, **data: Any) -> None:
        super().__init__(**data)
        object.__setattr__(self, "_module_settings", {})

ModuleSettings

Bases: BaseSettings

Base for module-level config slices.

Subclasses set model_config with their own env_prefix so that DB_HOST maps to DatabaseSettings and CACHE_HOST maps to CacheSettings.

Source code in src/arvel/foundation/config.py
21
22
23
24
25
26
27
28
class ModuleSettings(BaseSettings):
    """Base for module-level config slices.

    Subclasses set model_config with their own env_prefix so that
    DB_HOST maps to DatabaseSettings and CACHE_HOST maps to CacheSettings.
    """

    model_config = {"extra": "ignore"}

Container

Resolved DI container — provides typed dependency resolution.

Source code in src/arvel/foundation/container.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
class Container:
    """Resolved DI container — provides typed dependency resolution."""

    def __init__(
        self,
        bindings: dict[type, _Binding] | ChainMap[type, _Binding],
        scope: Scope,
        parent: Container | None = None,
    ) -> None:
        self._bindings = bindings
        self._scope = scope
        self._parent = parent
        self._instances: dict[
            type, Any
        ] = {}  # type-erased storage; resolve() restores T via type[T] key
        self._closed = False
        self._resolve_locks: dict[type, anyio.Lock] = {}

    def has(self, interface: type) -> bool:
        """Check whether a binding exists without triggering resolution."""
        if interface in self._bindings:
            return True
        if self._parent is not None:
            return self._parent.has(interface)
        return False

    async def resolve[T](self, interface: type[T]) -> T:
        if self._closed:
            raise DependencyError(
                f"Container is closed, cannot resolve {interface.__name__}",
                requested_type=interface,
            )

        if interface in self._instances:
            return self._instances[interface]

        binding = self._bindings.get(interface)
        if binding is None:
            if self._parent is not None:
                return await self._parent.resolve(interface)
            raise DependencyError(
                f"No binding registered for {interface.__name__}",
                requested_type=interface,
            )

        if binding.is_value:
            self._instances[interface] = binding.value
            return cast("T", binding.value)

        if binding.scope == Scope.APP and self._scope != Scope.APP and self._parent:
            return await self._parent.resolve(interface)

        if binding.scope == Scope.SESSION and self._scope == Scope.REQUEST and self._parent:
            return await self._parent.resolve(interface)

        if interface not in self._resolve_locks:
            self._resolve_locks[interface] = anyio.Lock()

        async with self._resolve_locks[interface]:
            if interface in self._instances:
                return self._instances[interface]
            instance = await self._create_instance(binding)
            self._instances[interface] = instance
            return cast("T", instance)

    async def _create_instance(self, binding: _Binding) -> object:
        if binding.factory is not None:
            result = binding.factory()
            if inspect.isawaitable(result):
                return await result
            return result
        if binding.concrete is not None:
            return await self._construct_with_injection(binding.concrete)
        raise DependencyError(
            f"No concrete or factory for {binding.interface.__name__}",
            requested_type=binding.interface,
        )

    async def _construct_with_injection(self, cls: type) -> object:
        """Instantiate *cls* by resolving constructor parameters from the container.

        Falls back to no-arg construction when the class has no typed
        constructor parameters (e.g. default ``object.__init__``).
        """
        hints = self._get_init_hints(cls)
        if hints is None:
            return cls()

        sig = inspect.signature(cls.__init__)
        kwargs: dict[str, Any] = {}
        for name, param in sig.parameters.items():
            if name == "self" or param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
                continue
            await self._resolve_param(cls, name, param, hints, kwargs)
        return cls(**kwargs)

    @staticmethod
    @lru_cache(maxsize=128)
    def _get_init_hints(cls: type) -> MappingProxyType[str, Any] | None:
        """Return type hints for *cls.__init__*, or None if no injection needed.

        Cached per class — constructor signatures don't change at runtime.
        Returns a read-only view to prevent accidental mutation of the cache.
        """
        if cls.__init__ is object.__init__:
            return None
        try:
            hints = get_type_hints(cls.__init__, include_extras=True)
        except Exception:
            return None
        hints.pop("return", None)
        return MappingProxyType(hints) if hints else None

    async def _resolve_param(
        self,
        cls: type,
        name: str,
        param: inspect.Parameter,
        hints: MappingProxyType[str, Any],
        out: dict[str, Any],
    ) -> None:
        hint = hints.get(name)
        if hint is None:
            if param.default is inspect.Parameter.empty:
                raise DependencyError(
                    f"Cannot resolve parameter '{name}' of {cls.__name__}: no type hint",
                    requested_type=cls,
                )
            return

        if get_origin(hint) is Annotated:
            hint = get_args(hint)[0]

        try:
            out[name] = await self.resolve(hint)
        except DependencyError as exc:
            if param.default is inspect.Parameter.empty:
                hint_name = getattr(hint, "__name__", str(hint))
                raise DependencyError(
                    f"Cannot resolve parameter '{name}: {hint_name}' of {cls.__name__}",
                    requested_type=cls,
                ) from exc

    def instance[T](self, interface: type[T], value: T) -> None:
        """Register a pre-built instance at runtime (post-boot).

        Use sparingly — prefer ``ContainerBuilder.provide_factory`` during
        ``register()``.  This exists for services assembled during ``boot()``
        that depend on other resolved services.
        """
        self._instances[interface] = value

    def enter_scope(self, scope: Scope) -> Container:
        """Create a child container with the given scope (O(1) — no dict copy)."""
        child_bindings: ChainMap[type, _Binding] = ChainMap({}, self._bindings)
        return Container(child_bindings, scope=scope, parent=self)

    async def close(self) -> None:
        self._closed = True
        for interface, instance in list(self._instances.items()):
            close_method = getattr(instance, "aclose", None) or getattr(instance, "close", None)
            if close_method is not None and callable(close_method):
                try:
                    result = close_method()
                    if inspect.isawaitable(result):
                        await result
                except Exception as exc:
                    _container_logger.warning(
                        "instance_close_failed",
                        interface=getattr(interface, "__name__", str(interface)),
                        error=str(exc),
                    )
        self._instances.clear()

has(interface)

Check whether a binding exists without triggering resolution.

Source code in src/arvel/foundation/container.py
129
130
131
132
133
134
135
def has(self, interface: type) -> bool:
    """Check whether a binding exists without triggering resolution."""
    if interface in self._bindings:
        return True
    if self._parent is not None:
        return self._parent.has(interface)
    return False

instance(interface, value)

Register a pre-built instance at runtime (post-boot).

Use sparingly — prefer ContainerBuilder.provide_factory during register(). This exists for services assembled during boot() that depend on other resolved services.

Source code in src/arvel/foundation/container.py
254
255
256
257
258
259
260
261
def instance[T](self, interface: type[T], value: T) -> None:
    """Register a pre-built instance at runtime (post-boot).

    Use sparingly — prefer ``ContainerBuilder.provide_factory`` during
    ``register()``.  This exists for services assembled during ``boot()``
    that depend on other resolved services.
    """
    self._instances[interface] = value

enter_scope(scope)

Create a child container with the given scope (O(1) — no dict copy).

Source code in src/arvel/foundation/container.py
263
264
265
266
def enter_scope(self, scope: Scope) -> Container:
    """Create a child container with the given scope (O(1) — no dict copy)."""
    child_bindings: ChainMap[type, _Binding] = ChainMap({}, self._bindings)
    return Container(child_bindings, scope=scope, parent=self)

ContainerBuilder

Collects bindings during the register phase.

Source code in src/arvel/foundation/container.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
class ContainerBuilder:
    """Collects bindings during the register phase."""

    def __init__(self) -> None:
        self._bindings: list[_Binding] = []

    def provide[T](
        self,
        interface: type[T],
        concrete: type[T],
        scope: Scope = Scope.REQUEST,
    ) -> None:
        self._bindings.append(_Binding(interface, concrete=concrete, scope=scope))

    def provide_factory[T](
        self,
        interface: type[T],
        factory: Callable[[], T],
        scope: Scope = Scope.REQUEST,
    ) -> None:
        self._bindings.append(_Binding(interface, factory=factory, scope=scope))

    def provide_value[T](
        self,
        interface: type[T],
        value: T,
        scope: Scope = Scope.APP,
    ) -> None:
        self._bindings.append(_Binding(interface, value=value, scope=scope))

    def build(self) -> Container:
        bindings_map: dict[type, _Binding] = {}
        for b in self._bindings:
            if b.interface in bindings_map:
                warnings.warn(
                    f"Duplicate binding for {b.interface.__name__} — "
                    f"overriding previous registration",
                    stacklevel=2,
                )
            bindings_map[b.interface] = b
        return Container(bindings_map, scope=Scope.APP)

Scope

Bases: Enum

DI lifetime scopes.

APP — singleton for the application lifetime. REQUEST — fresh instance per HTTP request. SESSION — shared within a user session across requests.

Source code in src/arvel/foundation/container.py
28
29
30
31
32
33
34
35
36
37
38
class Scope(Enum):
    """DI lifetime scopes.

    APP — singleton for the application lifetime.
    REQUEST — fresh instance per HTTP request.
    SESSION — shared within a user session across requests.
    """

    APP = "app"
    REQUEST = "request"
    SESSION = "session"

Pipeline

Sends a passable through an ordered list of pipes.

Pipes can be: - Async callables matching the Pipe signature - Sync callables (adapted transparently) - Class references resolved through the DI container

The passable type is erased at the pipeline level (see ADR-001). Individual pipes cast the passable to their expected type at runtime.

Source code in src/arvel/foundation/pipeline.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
class Pipeline:
    """Sends a passable through an ordered list of pipes.

    Pipes can be:
    - Async callables matching the Pipe signature
    - Sync callables (adapted transparently)
    - Class references resolved through the DI container

    The passable type is erased at the pipeline level (see ADR-001).
    Individual pipes cast the passable to their expected type at runtime.
    """

    def __init__(self, container: Container | None = None) -> None:
        self._container = container
        self._passable: Any = None
        self._pipes: list[PipeSpec] = []

    def send(self, passable: Any) -> Self:
        self._passable = passable
        return self

    def through(self, pipes: list[PipeSpec]) -> Self:
        self._pipes = list(pipes)
        return self

    async def then(self, destination: Callable[..., Any]) -> Any:
        pipeline = self._build_pipeline(destination)
        return await pipeline(self._passable)

    async def then_return(self) -> Any:
        async def identity(passable: Any) -> Any:
            return passable

        return await self.then(identity)

    def _build_pipeline(self, destination: Callable[..., Any]) -> Callable[..., Awaitable[Any]]:
        async def final_dest(passable: Any) -> Any:
            if inspect.iscoroutinefunction(destination):
                return await destination(passable)
            return destination(passable)

        pipeline = final_dest

        for pipe in reversed(self._pipes):
            pipeline = self._wrap_pipe(pipe, pipeline)

        return pipeline

    def _wrap_pipe(
        self, pipe: PipeSpec, next_pipeline: Callable[..., Awaitable[Any]]
    ) -> Callable[..., Awaitable[Any]]:
        async def wrapper(passable: Any) -> Any:
            resolved_pipe = await self._resolve_pipe(pipe)

            is_async = inspect.iscoroutinefunction(resolved_pipe)
            if not is_async and callable(resolved_pipe) and not inspect.isfunction(resolved_pipe):
                is_async = inspect.iscoroutinefunction(resolved_pipe.__call__)

            if is_async:
                return await resolved_pipe(passable, next_pipeline)
            return resolved_pipe(passable, next_pipeline)

        return wrapper

    async def _resolve_pipe(self, pipe: PipeSpec) -> Callable[..., Any]:
        if isinstance(pipe, type) and self._container is not None:
            # DI-resolved pipe classes implement __call__; cast is sound
            # because the pipeline contract requires all pipes to be callable.
            return cast("Callable[..., Any]", await self._container.resolve(pipe))
        # Non-type branches of PipeSpec are already Callable
        return cast("Callable[..., Any]", pipe)

ServiceProvider

Base for all module service providers.

Lower priority boots first. Framework: 0-20, user: 50 (default).

Source code in src/arvel/foundation/provider.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class ServiceProvider:
    """Base for all module service providers.

    Lower ``priority`` boots first. Framework: 0-20, user: 50 (default).
    """

    priority: int = 50

    def configure(self, config: AppSettings) -> None:
        """Capture loaded config so factories use .env values, not bare defaults."""

    async def register(self, container: ContainerBuilder) -> None:
        """Declare DI bindings. Don't resolve here — container isn't built yet."""

    async def boot(self, app: Application) -> None:
        """Late-stage wiring: routes, listeners, middleware, resolved deps."""

    async def shutdown(self, app: Application) -> None:
        """Release long-lived resources. Called in reverse provider order."""

configure(config)

Capture loaded config so factories use .env values, not bare defaults.

Source code in src/arvel/foundation/provider.py
21
22
def configure(self, config: AppSettings) -> None:
    """Capture loaded config so factories use .env values, not bare defaults."""

register(container) async

Declare DI bindings. Don't resolve here — container isn't built yet.

Source code in src/arvel/foundation/provider.py
24
25
async def register(self, container: ContainerBuilder) -> None:
    """Declare DI bindings. Don't resolve here — container isn't built yet."""

boot(app) async

Late-stage wiring: routes, listeners, middleware, resolved deps.

Source code in src/arvel/foundation/provider.py
27
28
async def boot(self, app: Application) -> None:
    """Late-stage wiring: routes, listeners, middleware, resolved deps."""

shutdown(app) async

Release long-lived resources. Called in reverse provider order.

Source code in src/arvel/foundation/provider.py
30
31
async def shutdown(self, app: Application) -> None:
    """Release long-lived resources. Called in reverse provider order."""

arvel.auth

Auth module — login, JWT tokens, guards, password reset, OAuth2/OIDC, claims, and audit.

AuthSettings

Bases: ModuleSettings

Auth module settings.

Source code in src/arvel/auth/config.py
32
33
34
35
36
37
38
39
40
41
42
class AuthSettings(ModuleSettings):
    """Auth module settings."""

    model_config = SettingsConfigDict(env_prefix="AUTH_", extra="ignore")
    config_name: ClassVar[str] = "auth"

    default_guard: str = "api"
    default_passwords: str = "users"
    guards: dict[str, dict[str, str]] = Field(default_factory=_default_guards)
    providers: dict[str, dict[str, str]] = Field(default_factory=_default_providers)
    passwords: dict[str, dict[str, int | str]] = Field(default_factory=_default_passwords)

arvel.cache

Cache contract and drivers — swappable caching with TTL support.

arvel.events

Domain event bus — dispatch events, register sync/queued listeners, auto-discovery.

EventDispatcher

In-process event dispatcher with sync and queued listener support.

Source code in src/arvel/events/dispatcher.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class EventDispatcher:
    """In-process event dispatcher with sync and queued listener support."""

    def __init__(self) -> None:
        self._registry: dict[type[Event], list[_Registration]] = {}

    def register(
        self,
        event_type: type[Event],
        listener_class: type[Listener],
        *,
        priority: int = 50,
    ) -> None:
        """Register a listener class for an event type with optional priority."""
        registrations = self._registry.setdefault(event_type, [])
        registrations.append(_Registration(listener_class, priority))
        registrations.sort(key=lambda r: r.priority)

    def listeners_for(self, event_type: type[Event]) -> list[type[Listener]]:
        """Return listener classes registered for an event type, sorted by priority."""
        return [r.listener_class for r in self._registry.get(event_type, [])]

    async def dispatch(self, event: Event) -> None:
        """Dispatch an event to all registered listeners.

        Sync listeners execute inline. Queued listeners are skipped here
        (a real integration would dispatch them via QueueContract).
        """
        registrations = self._registry.get(type(event), [])
        for reg in registrations:
            if getattr(reg.listener_class, "__queued__", False):
                logger.debug(
                    "Skipping queued listener %s (would dispatch to queue)",
                    reg.listener_class.__name__,
                )
                continue
            listener = reg.listener_class()
            await listener.handle(event)

register(event_type, listener_class, *, priority=50)

Register a listener class for an event type with optional priority.

Source code in src/arvel/events/dispatcher.py
30
31
32
33
34
35
36
37
38
39
40
def register(
    self,
    event_type: type[Event],
    listener_class: type[Listener],
    *,
    priority: int = 50,
) -> None:
    """Register a listener class for an event type with optional priority."""
    registrations = self._registry.setdefault(event_type, [])
    registrations.append(_Registration(listener_class, priority))
    registrations.sort(key=lambda r: r.priority)

listeners_for(event_type)

Return listener classes registered for an event type, sorted by priority.

Source code in src/arvel/events/dispatcher.py
42
43
44
def listeners_for(self, event_type: type[Event]) -> list[type[Listener]]:
    """Return listener classes registered for an event type, sorted by priority."""
    return [r.listener_class for r in self._registry.get(event_type, [])]

dispatch(event) async

Dispatch an event to all registered listeners.

Sync listeners execute inline. Queued listeners are skipped here (a real integration would dispatch them via QueueContract).

Source code in src/arvel/events/dispatcher.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
async def dispatch(self, event: Event) -> None:
    """Dispatch an event to all registered listeners.

    Sync listeners execute inline. Queued listeners are skipped here
    (a real integration would dispatch them via QueueContract).
    """
    registrations = self._registry.get(type(event), [])
    for reg in registrations:
        if getattr(reg.listener_class, "__queued__", False):
            logger.debug(
                "Skipping queued listener %s (would dispatch to queue)",
                reg.listener_class.__name__,
            )
            continue
        listener = reg.listener_class()
        await listener.handle(event)

Event

Bases: BaseModel

Base class for domain events.

Events are immutable Pydantic models. Subclass and add fields for your domain-specific payload. Sensitive fields should use exclude=True in their Field definition to prevent queue serialization.

Source code in src/arvel/events/event.py
10
11
12
13
14
15
16
17
18
19
20
class Event(BaseModel):
    """Base class for domain events.

    Events are immutable Pydantic models. Subclass and add fields for
    your domain-specific payload. Sensitive fields should use
    ``exclude=True`` in their Field definition to prevent queue serialization.
    """

    model_config = ConfigDict(frozen=True)

    occurred_at: datetime = Field(default_factory=lambda: datetime.now(UTC))

Listener

Base class for event listeners.

Subclass and implement handle(self, event: YourEvent). The type hint on event determines which event type this listener handles. The dispatcher inspects the subclass's type hint at runtime to route events, so Any on the base is intentional — subclasses narrow it.

Source code in src/arvel/events/listener.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Listener:
    """Base class for event listeners.

    Subclass and implement ``handle(self, event: YourEvent)``. The type
    hint on ``event`` determines which event type this listener handles.
    The dispatcher inspects the subclass's type hint at runtime to route
    events, so ``Any`` on the base is intentional — subclasses narrow it.
    """

    __queued__: ClassVar[bool] = False

    async def handle(self, event: Any) -> None:
        raise NotImplementedError

queued(cls)

Mark a listener for queue dispatch instead of sync execution.

Source code in src/arvel/events/listener.py
23
24
25
26
def queued(cls: type[Listener]) -> type[Listener]:
    """Mark a listener for queue dispatch instead of sync execution."""
    cls.__queued__ = True
    return cls

arvel.queue

Queue system — job dispatch, retries, middleware, chaining, batching.

Batch

Dispatches jobs concurrently and fires a callback when all complete.

Unlike Chain, Batch doesn't stop on failure. It collects results and invokes the callback (if set) after all jobs finish.

Source code in src/arvel/queue/batch.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class Batch:
    """Dispatches jobs concurrently and fires a callback when all complete.

    Unlike Chain, Batch doesn't stop on failure. It collects results
    and invokes the callback (if set) after all jobs finish.
    """

    def __init__(self, jobs: Sequence[Job]) -> None:
        self.jobs = list(jobs)
        self.callback: Job | None = None

    def then(self, callback: Job) -> Batch:
        """Set a job to run after all batch jobs complete."""
        self.callback = callback
        return self

    async def dispatch(self, queue: QueueContract) -> BatchResult:
        """Execute all jobs via the given queue driver and fire callback."""
        result = BatchResult()

        for job in self.jobs:
            try:
                await queue.dispatch(job)
                result.succeeded.append(job)
            except Exception as exc:
                result.failed.append((job, exc))

        if self.callback is not None:
            await queue.dispatch(self.callback)

        return result

then(callback)

Set a job to run after all batch jobs complete.

Source code in src/arvel/queue/batch.py
37
38
39
40
def then(self, callback: Job) -> Batch:
    """Set a job to run after all batch jobs complete."""
    self.callback = callback
    return self

dispatch(queue) async

Execute all jobs via the given queue driver and fire callback.

Source code in src/arvel/queue/batch.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
async def dispatch(self, queue: QueueContract) -> BatchResult:
    """Execute all jobs via the given queue driver and fire callback."""
    result = BatchResult()

    for job in self.jobs:
        try:
            await queue.dispatch(job)
            result.succeeded.append(job)
        except Exception as exc:
            result.failed.append((job, exc))

    if self.callback is not None:
        await queue.dispatch(self.callback)

    return result

Chain

Dispatches jobs in sequence, halting on permanent failure.

Each job runs after the previous one succeeds. If any job fails (after exhausting retries), the chain stops and the error propagates.

Source code in src/arvel/queue/chain.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Chain:
    """Dispatches jobs in sequence, halting on permanent failure.

    Each job runs after the previous one succeeds. If any job fails
    (after exhausting retries), the chain stops and the error propagates.
    """

    def __init__(self, *jobs: Job) -> None:
        self.jobs = list(jobs)

    async def dispatch(self, queue: QueueContract) -> None:
        """Execute all jobs in order via the given queue driver."""
        for job in self.jobs:
            await queue.dispatch(job)

dispatch(queue) async

Execute all jobs in order via the given queue driver.

Source code in src/arvel/queue/chain.py
22
23
24
25
async def dispatch(self, queue: QueueContract) -> None:
    """Execute all jobs in order via the given queue driver."""
    for job in self.jobs:
        await queue.dispatch(job)

QueueSettings

Bases: ModuleSettings

Configuration for the queue subsystem.

Env vars are prefixed with QUEUE_: - QUEUE_DRIVER — which driver to use - QUEUE_DEFAULT — default queue name - QUEUE_REDIS_URL — Redis connection (Taskiq-Redis) - QUEUE_TASKIQ_BROKER — Taskiq broker backend - QUEUE_TASKIQ_URL — Taskiq broker URL (falls back to redis_url for redis)

Source code in src/arvel/queue/config.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class QueueSettings(ModuleSettings):
    """Configuration for the queue subsystem.

    Env vars are prefixed with ``QUEUE_``:
      - ``QUEUE_DRIVER`` — which driver to use
      - ``QUEUE_DEFAULT`` — default queue name
      - ``QUEUE_REDIS_URL`` — Redis connection (Taskiq-Redis)
      - ``QUEUE_TASKIQ_BROKER`` — Taskiq broker backend
      - ``QUEUE_TASKIQ_URL`` — Taskiq broker URL (falls back to redis_url for redis)
    """

    model_config: ClassVar[dict[str, str | bool]] = {
        "env_prefix": "QUEUE_",
    }

    driver: Literal["sync", "null", "taskiq"] = "sync"
    default: str = "default"
    redis_url: str = "redis://localhost:6379"
    taskiq_broker: Literal["redis", "nats", "rabbitmq", "memory"] = "redis"
    taskiq_url: str | None = None

QueueContract

Bases: ABC

Abstract base class for queue drivers.

Implementations: SyncQueue (testing), NullQueue (dry-run), TaskiqQueue (multi-broker via Taskiq).

Source code in src/arvel/queue/contracts.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class QueueContract(ABC):
    """Abstract base class for queue drivers.

    Implementations: SyncQueue (testing), NullQueue (dry-run),
    TaskiqQueue (multi-broker via Taskiq).
    """

    @abstractmethod
    async def dispatch(self, job: Job) -> None:
        """Enqueue a job for immediate processing."""

    @abstractmethod
    async def later(self, delay: timedelta, job: Job) -> None:
        """Enqueue a job for processing after a delay."""

    @abstractmethod
    async def bulk(self, jobs: Sequence[Job]) -> None:
        """Enqueue multiple jobs at once."""

    @abstractmethod
    async def size(self, queue_name: str = "default") -> int:
        """Return the number of pending jobs in the given queue."""

dispatch(job) abstractmethod async

Enqueue a job for immediate processing.

Source code in src/arvel/queue/contracts.py
22
23
24
@abstractmethod
async def dispatch(self, job: Job) -> None:
    """Enqueue a job for immediate processing."""

later(delay, job) abstractmethod async

Enqueue a job for processing after a delay.

Source code in src/arvel/queue/contracts.py
26
27
28
@abstractmethod
async def later(self, delay: timedelta, job: Job) -> None:
    """Enqueue a job for processing after a delay."""

bulk(jobs) abstractmethod async

Enqueue multiple jobs at once.

Source code in src/arvel/queue/contracts.py
30
31
32
@abstractmethod
async def bulk(self, jobs: Sequence[Job]) -> None:
    """Enqueue multiple jobs at once."""

size(queue_name='default') abstractmethod async

Return the number of pending jobs in the given queue.

Source code in src/arvel/queue/contracts.py
34
35
36
@abstractmethod
async def size(self, queue_name: str = "default") -> int:
    """Return the number of pending jobs in the given queue."""

Job

Bases: BaseModel

Base class for background jobs.

Subclass and implement handle(). Configure retries, backoff, timeout, and middleware by overriding the class attributes or the middleware() method.

Backoff accepts three forms: - int: fixed delay in seconds between every retry - list[int]: per-attempt delays (last value repeats for overflow) - "exponential": uses backoff_base ** attempt seconds

Source code in src/arvel/queue/job.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Job(BaseModel):
    """Base class for background jobs.

    Subclass and implement ``handle()``. Configure retries, backoff,
    timeout, and middleware by overriding the class attributes or
    the ``middleware()`` method.

    Backoff accepts three forms:
    - ``int``: fixed delay in seconds between every retry
    - ``list[int]``: per-attempt delays (last value repeats for overflow)
    - ``"exponential"``: uses ``backoff_base ** attempt`` seconds
    """

    model_config = ConfigDict(frozen=False)

    max_retries: int = 3
    backoff: int | list[int] | str = 60
    backoff_base: int = 2
    timeout_seconds: int = 300
    queue_name: str = "default"
    max_exceptions: int | None = None
    retry_until: timedelta | None = None

    unique_for: int | None = None
    unique_id: str | None = None
    unique_until_processing: bool = False

    async def handle(self) -> None:
        """Execute the job's work. Override in subclasses."""
        raise NotImplementedError

    async def on_failure(self, error: Exception) -> None:
        """Called when the job fails permanently (retries exhausted)."""

    def middleware(self) -> list[object]:
        """Return middleware instances to wrap this job's execution."""
        return []

    def get_unique_id(self) -> str:
        """Return the uniqueness key for this job. Override for custom keys."""
        return self.unique_id or f"{type(self).__name__}"

handle() async

Execute the job's work. Override in subclasses.

Source code in src/arvel/queue/job.py
37
38
39
async def handle(self) -> None:
    """Execute the job's work. Override in subclasses."""
    raise NotImplementedError

on_failure(error) async

Called when the job fails permanently (retries exhausted).

Source code in src/arvel/queue/job.py
41
42
async def on_failure(self, error: Exception) -> None:
    """Called when the job fails permanently (retries exhausted)."""

middleware()

Return middleware instances to wrap this job's execution.

Source code in src/arvel/queue/job.py
44
45
46
def middleware(self) -> list[object]:
    """Return middleware instances to wrap this job's execution."""
    return []

get_unique_id()

Return the uniqueness key for this job. Override for custom keys.

Source code in src/arvel/queue/job.py
48
49
50
def get_unique_id(self) -> str:
    """Return the uniqueness key for this job. Override for custom keys."""
    return self.unique_id or f"{type(self).__name__}"

QueueManager

Resolves the configured :class:QueueContract implementation.

Uses QueueSettings.driver to pick from built-in drivers (sync, null, taskiq) or custom-registered ones.

Source code in src/arvel/queue/manager.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class QueueManager:
    """Resolves the configured :class:`QueueContract` implementation.

    Uses ``QueueSettings.driver`` to pick from built-in drivers
    (``sync``, ``null``, ``taskiq``) or custom-registered ones.
    """

    def __init__(self) -> None:
        self._custom_drivers: dict[str, Callable[..., QueueContract]] = {}

    def register_driver(self, name: str, factory: Callable[..., QueueContract]) -> None:
        """Register a custom driver factory by name."""
        self._custom_drivers[name] = factory

    def create_driver(self, settings: QueueSettings | None = None) -> QueueContract:
        """Build and return the queue driver specified by *settings*."""
        if settings is None:
            settings = QueueSettings()

        name = settings.driver

        if name in self._custom_drivers:
            return self._custom_drivers[name]()

        if name in _BUILTIN_DRIVERS:
            return _BUILTIN_DRIVERS[name](settings)

        available = sorted({*_BUILTIN_DRIVERS, *self._custom_drivers})
        raise ConfigurationError(
            f"Unknown queue driver {name!r}. Available: {', '.join(available)}"
        )

register_driver(name, factory)

Register a custom driver factory by name.

Source code in src/arvel/queue/manager.py
39
40
41
def register_driver(self, name: str, factory: Callable[..., QueueContract]) -> None:
    """Register a custom driver factory by name."""
    self._custom_drivers[name] = factory

create_driver(settings=None)

Build and return the queue driver specified by settings.

Source code in src/arvel/queue/manager.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def create_driver(self, settings: QueueSettings | None = None) -> QueueContract:
    """Build and return the queue driver specified by *settings*."""
    if settings is None:
        settings = QueueSettings()

    name = settings.driver

    if name in self._custom_drivers:
        return self._custom_drivers[name]()

    if name in _BUILTIN_DRIVERS:
        return _BUILTIN_DRIVERS[name](settings)

    available = sorted({*_BUILTIN_DRIVERS, *self._custom_drivers})
    raise ConfigurationError(
        f"Unknown queue driver {name!r}. Available: {', '.join(available)}"
    )

JobMiddleware

Base class for job middleware.

Subclass and implement handle(). Call await next_call(job) to continue the chain, or return without calling to skip execution.

Source code in src/arvel/queue/middleware.py
20
21
22
23
24
25
26
27
28
class JobMiddleware:
    """Base class for job middleware.

    Subclass and implement ``handle()``. Call ``await next_call(job)``
    to continue the chain, or return without calling to skip execution.
    """

    async def handle(self, job: Job, next_call: Callable[[Job], Awaitable[None]]) -> None:
        await next_call(job)

RateLimited

Bases: JobMiddleware

Limits job execution to max_attempts within decay_seconds.

Uses a LockContract-based counter. When the limit is exceeded, the job is silently skipped (released back for later processing).

Source code in src/arvel/queue/middleware.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class RateLimited(JobMiddleware):
    """Limits job execution to ``max_attempts`` within ``decay_seconds``.

    Uses a LockContract-based counter. When the limit is exceeded,
    the job is silently skipped (released back for later processing).
    """

    def __init__(
        self,
        *,
        key: str,
        max_attempts: int,
        decay_seconds: int,
        lock: LockContract,
    ) -> None:
        self._key = key
        self._max_attempts = max_attempts
        self._decay_seconds = decay_seconds
        self._lock = lock
        self._attempts: dict[str, list[float]] = {}

    def _rate_key(self) -> str:
        return f"rate-limit:{self._key}"

    async def handle(self, job: Job, next_call: Callable[[Job], Awaitable[None]]) -> None:
        now = time.monotonic()
        rate_key = self._rate_key()

        timestamps = self._attempts.get(rate_key, [])
        cutoff = now - self._decay_seconds
        timestamps = [t for t in timestamps if t > cutoff]

        if len(timestamps) >= self._max_attempts:
            return  # skip — rate limit exceeded

        timestamps.append(now)
        self._attempts[rate_key] = timestamps
        await next_call(job)

WithoutOverlapping

Bases: JobMiddleware

Prevents concurrent execution of jobs with the same key.

Uses a distributed lock. If the lock is already held, the job is silently skipped (released for later processing).

Source code in src/arvel/queue/middleware.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
class WithoutOverlapping(JobMiddleware):
    """Prevents concurrent execution of jobs with the same key.

    Uses a distributed lock. If the lock is already held, the job
    is silently skipped (released for later processing).
    """

    def __init__(
        self,
        *,
        key: str,
        lock: LockContract,
        release_after: int = 300,
    ) -> None:
        self._key = key
        self._lock = lock
        self._release_after = release_after

    def _lock_key(self) -> str:
        return f"without-overlapping:{self._key}"

    async def handle(self, job: Job, next_call: Callable[[Job], Awaitable[None]]) -> None:
        lock_key = self._lock_key()
        acquired = await self._lock.acquire(lock_key, ttl=self._release_after)
        if not acquired:
            return  # skip — another instance is running

        try:
            await next_call(job)
        finally:
            await self._lock.release(lock_key)

UniqueJobGuard

Dispatch-time guard that prevents duplicate job execution.

Parameters:

Name Type Description Default
lock LockContract

A LockContract implementation for distributed uniqueness.

required
Source code in src/arvel/queue/unique_job.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class UniqueJobGuard:
    """Dispatch-time guard that prevents duplicate job execution.

    Args:
        lock: A LockContract implementation for distributed uniqueness.
    """

    def __init__(self, *, lock: LockContract) -> None:
        self._lock = lock

    def _make_key(self, job: Job) -> str:
        """Build a hashed lock key from the job's unique ID."""
        raw_id = job.get_unique_id()
        hashed = hashlib.sha256(raw_id.encode()).hexdigest()
        return f"unique-job:{hashed}"

    async def acquire(self, job: Job) -> bool:
        """Try to acquire the uniqueness lock.

        Returns True if the job should proceed (lock acquired or no
        uniqueness configured). Returns False if the job is a duplicate.
        """
        if not job.unique_for:
            return True

        key = self._make_key(job)
        try:
            acquired = await self._lock.acquire(key, ttl=job.unique_for)
            return acquired
        except Exception:
            logger.warning(
                "Uniqueness lock backend unavailable for %s — allowing dispatch",
                type(job).__name__,
            )
            return True

    async def release_for_processing(self, job: Job) -> None:
        """Release the uniqueness lock when ``unique_until_processing=True``.

        Called at the start of job processing so a new dispatch of the
        same job is allowed while this one runs.
        """
        if not job.unique_for or not job.unique_until_processing:
            return

        key = self._make_key(job)
        try:
            await self._lock.release(key)
        except Exception:
            logger.warning(
                "Failed to release uniqueness lock for %s — continuing",
                type(job).__name__,
            )

acquire(job) async

Try to acquire the uniqueness lock.

Returns True if the job should proceed (lock acquired or no uniqueness configured). Returns False if the job is a duplicate.

Source code in src/arvel/queue/unique_job.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
async def acquire(self, job: Job) -> bool:
    """Try to acquire the uniqueness lock.

    Returns True if the job should proceed (lock acquired or no
    uniqueness configured). Returns False if the job is a duplicate.
    """
    if not job.unique_for:
        return True

    key = self._make_key(job)
    try:
        acquired = await self._lock.acquire(key, ttl=job.unique_for)
        return acquired
    except Exception:
        logger.warning(
            "Uniqueness lock backend unavailable for %s — allowing dispatch",
            type(job).__name__,
        )
        return True

release_for_processing(job) async

Release the uniqueness lock when unique_until_processing=True.

Called at the start of job processing so a new dispatch of the same job is allowed while this one runs.

Source code in src/arvel/queue/unique_job.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
async def release_for_processing(self, job: Job) -> None:
    """Release the uniqueness lock when ``unique_until_processing=True``.

    Called at the start of job processing so a new dispatch of the
    same job is allowed while this one runs.
    """
    if not job.unique_for or not job.unique_until_processing:
        return

    key = self._make_key(job)
    try:
        await self._lock.release(key)
    except Exception:
        logger.warning(
            "Failed to release uniqueness lock for %s — continuing",
            type(job).__name__,
        )

JobRunner

Executes a job with retry logic, timeout, middleware pipeline, and context propagation.

Parameters:

Name Type Description Default
middleware_overrides list[JobMiddleware] | None

If provided, these middleware run instead of job.middleware(). Useful for testing and for drivers that inject middleware externally.

None
Source code in src/arvel/queue/worker.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
class JobRunner:
    """Executes a job with retry logic, timeout, middleware pipeline, and context propagation.

    Args:
        middleware_overrides: If provided, these middleware run instead of ``job.middleware()``.
            Useful for testing and for drivers that inject middleware externally.
    """

    def __init__(
        self,
        middleware_overrides: list[JobMiddleware] | None = None,
    ) -> None:
        self._middleware_overrides = middleware_overrides

    async def execute(
        self,
        job: Job,
        *,
        context: dict[str, Any] | None = None,
    ) -> None:
        """Run *job* through the middleware pipeline, then execute with retry/timeout.

        When *context* is provided, it's hydrated before ``handle()`` and flushed after.
        """
        middleware_list = (
            self._middleware_overrides
            if self._middleware_overrides is not None
            else job.middleware()
        )

        if middleware_list:
            await self._run_with_middleware(job, middleware_list, context)
        else:
            await self._execute_with_retries(job, context)

    async def _run_with_middleware(
        self,
        job: Job,
        middleware_list: list[JobMiddleware] | list[object],
        context: dict[str, Any] | None,
    ) -> None:
        """Build a middleware chain using a simple async pipeline."""
        from arvel.queue.middleware import JobMiddleware as _JobMiddleware

        async def final_handler(j: Job) -> None:
            await self._execute_with_retries(j, context)

        chain: Callable[[Job], Awaitable[None]] = final_handler
        for mw in reversed(middleware_list):
            if not isinstance(mw, _JobMiddleware):
                msg = f"Middleware must be a JobMiddleware instance, got {type(mw).__name__}"
                raise TypeError(msg)
            chain = _make_link(mw, chain)

        await chain(job)

    async def _execute_with_retries(
        self,
        job: Job,
        context: dict[str, Any] | None,
    ) -> None:
        """Core retry loop with timeout and context propagation."""
        delays = self._compute_backoff_delays(job)
        attempt = 0
        exception_count = 0
        start_time = time.monotonic()
        last_error: Exception | None = None
        max_attempts = 1 + job.max_retries

        while attempt < max_attempts:
            if self._should_stop_retrying(job, attempt, exception_count, start_time):
                break

            result = await self._try_once(job, context)
            if result is None:
                return  # success

            last_error = result
            exception_count += 1
            attempt += 1

            await self._backoff_delay(attempt, max_attempts, delays)

        if last_error is not None:
            await job.on_failure(last_error)

        raise JobMaxRetriesError(
            f"{type(job).__name__} failed after {attempt} attempt(s)",
            job_class=type(job).__name__,
            attempts=attempt,
        )

    def _should_stop_retrying(
        self,
        job: Job,
        attempt: int,
        exception_count: int,
        start_time: float,
    ) -> bool:
        """Check deadline and exception-count early-exit conditions."""
        if job.retry_until is not None and attempt > 0:
            elapsed = time.monotonic() - start_time
            if elapsed >= job.retry_until.total_seconds():
                return True
        return job.max_exceptions is not None and exception_count >= job.max_exceptions

    async def _try_once(
        self,
        job: Job,
        context: dict[str, Any] | None,
    ) -> Exception | None:
        """Execute handle() once with context and timeout. Returns None on success."""
        try:
            if context is not None:
                Context.hydrate(context)

            if job.timeout_seconds > 0:
                with anyio.fail_after(job.timeout_seconds):
                    await job.handle()
            else:
                await job.handle()

            return None
        except TimeoutError:
            return JobTimeoutError(
                f"{type(job).__name__} timed out after {job.timeout_seconds}s",
                job_class=type(job).__name__,
                timeout=job.timeout_seconds,
            )
        except Exception as exc:
            return exc
        finally:
            if context is not None:
                Context.flush()

    @staticmethod
    async def _backoff_delay(attempt: int, max_attempts: int, delays: list[int]) -> None:
        if attempt < max_attempts and delays:
            delay_idx = min(attempt - 1, len(delays) - 1)
            delay = delays[delay_idx]
            if delay > 0:
                await anyio.sleep(delay)

    def _compute_backoff_delays(self, job: Job) -> list[int]:
        """Compute the delay sequence for all retry attempts."""
        count = job.max_retries
        if count <= 0:
            return []

        backoff = job.backoff

        if isinstance(backoff, int):
            return [backoff] * count

        if isinstance(backoff, list):
            if not backoff:
                return [0] * count
            result: list[int] = []
            for i in range(count):
                idx = min(i, len(backoff) - 1)
                result.append(backoff[idx])
            return result

        if backoff == "exponential":
            base = job.backoff_base
            return [base ** (i + 1) for i in range(count)]

        return [0] * count

execute(job, *, context=None) async

Run job through the middleware pipeline, then execute with retry/timeout.

When context is provided, it's hydrated before handle() and flushed after.

Source code in src/arvel/queue/worker.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
async def execute(
    self,
    job: Job,
    *,
    context: dict[str, Any] | None = None,
) -> None:
    """Run *job* through the middleware pipeline, then execute with retry/timeout.

    When *context* is provided, it's hydrated before ``handle()`` and flushed after.
    """
    middleware_list = (
        self._middleware_overrides
        if self._middleware_overrides is not None
        else job.middleware()
    )

    if middleware_list:
        await self._run_with_middleware(job, middleware_list, context)
    else:
        await self._execute_with_retries(job, context)

arvel.validation

Arvel validation layer — form requests, rules, and validators.

AuthorizationFailedError

Bases: Exception

Raised when form request authorization fails.

Source code in src/arvel/validation/exceptions.py
56
57
58
59
60
61
class AuthorizationFailedError(Exception):
    """Raised when form request authorization fails."""

    def __init__(self, message: str = "This action is unauthorized.") -> None:
        self.detail = message
        super().__init__(message)

FieldError

Single field validation error.

Source code in src/arvel/validation/exceptions.py
23
24
25
26
27
28
29
30
31
32
33
34
class FieldError:
    """Single field validation error."""

    __slots__ = ("field", "message", "rule")

    def __init__(self, *, field: str, rule: str, message: str) -> None:
        self.field = field
        self.rule = rule
        self.message = message

    def to_dict(self) -> FieldErrorDict:
        return {"field": self.field, "rule": self.rule, "message": self.message}

FieldErrorDict

Bases: TypedDict

Serialized representation of a single field validation error.

Source code in src/arvel/validation/exceptions.py
 8
 9
10
11
12
13
class FieldErrorDict(TypedDict):
    """Serialized representation of a single field validation error."""

    field: str
    rule: str
    message: str

ValidationError

Bases: Exception

Raised when validation fails.

Source code in src/arvel/validation/exceptions.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class ValidationError(Exception):
    """Raised when validation fails."""

    def __init__(
        self,
        errors: list[FieldError],
        message: str = "The given data was invalid.",
    ) -> None:
        self.errors = errors
        self.detail = message
        super().__init__(message)

    def to_dict(self) -> ValidationErrorDict:
        return {
            "message": self.detail,
            "errors": [e.to_dict() for e in self.errors],
        }

ValidationErrorDict

Bases: TypedDict

Serialized representation of a validation error response.

Source code in src/arvel/validation/exceptions.py
16
17
18
19
20
class ValidationErrorDict(TypedDict):
    """Serialized representation of a validation error response."""

    message: str
    errors: list[FieldErrorDict]

FormRequest

Base class for form request objects.

Subclasses override authorize(), rules(), and optionally messages() and after_validation().

Source code in src/arvel/validation/form_request.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class FormRequest:
    """Base class for form request objects.

    Subclasses override `authorize()`, `rules()`, and optionally
    `messages()` and `after_validation()`.
    """

    def authorize(self, request: Request | None) -> bool:
        """Return True if the request is authorized, False otherwise."""
        return True

    def rules(self) -> dict[str, list[Rule | AsyncRule]]:
        """Return validation rules keyed by field name."""
        return {}

    def messages(self) -> dict[str, str]:
        """Return custom error messages keyed by 'field.RuleName'."""
        return {}

    def after_validation(self, data: dict[str, Any]) -> dict[str, Any]:
        """Post-validation hook. Transform or enrich the validated data."""
        return data

    async def validate_request(
        self,
        *,
        request: Request | None,
        data: dict[str, Any],
    ) -> dict[str, Any]:
        """Run authorization, validation, and after-hook in order."""
        if not self.authorize(request):
            raise AuthorizationFailedError()

        field_rules = self.rules()
        custom_messages = self.messages()

        validator = Validator()
        validated = await validator.validate(data, field_rules, messages=custom_messages)

        return self.after_validation(validated)

authorize(request)

Return True if the request is authorized, False otherwise.

Source code in src/arvel/validation/form_request.py
23
24
25
def authorize(self, request: Request | None) -> bool:
    """Return True if the request is authorized, False otherwise."""
    return True

rules()

Return validation rules keyed by field name.

Source code in src/arvel/validation/form_request.py
27
28
29
def rules(self) -> dict[str, list[Rule | AsyncRule]]:
    """Return validation rules keyed by field name."""
    return {}

messages()

Return custom error messages keyed by 'field.RuleName'.

Source code in src/arvel/validation/form_request.py
31
32
33
def messages(self) -> dict[str, str]:
    """Return custom error messages keyed by 'field.RuleName'."""
    return {}

after_validation(data)

Post-validation hook. Transform or enrich the validated data.

Source code in src/arvel/validation/form_request.py
35
36
37
def after_validation(self, data: dict[str, Any]) -> dict[str, Any]:
    """Post-validation hook. Transform or enrich the validated data."""
    return data

validate_request(*, request, data) async

Run authorization, validation, and after-hook in order.

Source code in src/arvel/validation/form_request.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
async def validate_request(
    self,
    *,
    request: Request | None,
    data: dict[str, Any],
) -> dict[str, Any]:
    """Run authorization, validation, and after-hook in order."""
    if not self.authorize(request):
        raise AuthorizationFailedError()

    field_rules = self.rules()
    custom_messages = self.messages()

    validator = Validator()
    validated = await validator.validate(data, field_rules, messages=custom_messages)

    return self.after_validation(validated)

AsyncRule

Bases: Protocol

Async validation rule (for DB queries, external calls).

Source code in src/arvel/validation/rule.py
16
17
18
19
20
21
@runtime_checkable
class AsyncRule(Protocol):
    """Async validation rule (for DB queries, external calls)."""

    async def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool: ...
    def message(self) -> str: ...

Rule

Bases: Protocol

Synchronous validation rule.

Source code in src/arvel/validation/rule.py
 8
 9
10
11
12
13
@runtime_checkable
class Rule(Protocol):
    """Synchronous validation rule."""

    def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool: ...
    def message(self) -> str: ...

ConditionalRule

Base marker for conditional rules.

Conditional rules expose condition_met(data) to signal whether the field's validation should proceed. When the condition is not met, the Validator skips all remaining rules on the field (short-circuit).

Source code in src/arvel/validation/rules/conditional.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ConditionalRule:
    """Base marker for conditional rules.

    Conditional rules expose `condition_met(data)` to signal whether
    the field's validation should proceed. When the condition is not met,
    the Validator skips all remaining rules on the field (short-circuit).
    """

    def condition_met(self, data: dict[str, Any]) -> bool:
        raise NotImplementedError

    def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool:
        raise NotImplementedError

    def message(self) -> str:
        raise NotImplementedError

ProhibitedIf

Bases: ConditionalRule

Field must not be present when another field equals a specific value.

Source code in src/arvel/validation/rules/conditional.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class ProhibitedIf(ConditionalRule):
    """Field must not be present when another field equals a specific value."""

    def __init__(self, other_field: str, other_value: Any) -> None:
        self._other_field = other_field
        self._other_value = other_value

    def condition_met(self, data: dict[str, Any]) -> bool:
        return data.get(self._other_field) == self._other_value

    def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool:
        return value is None or value == ""

    def message(self) -> str:
        return f"This field is prohibited when {self._other_field} is {self._other_value}."

RequiredIf

Bases: ConditionalRule

Field is required when another field equals a specific value.

Source code in src/arvel/validation/rules/conditional.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class RequiredIf(ConditionalRule):
    """Field is required when another field equals a specific value."""

    def __init__(self, other_field: str, other_value: Any) -> None:
        self._other_field = other_field
        self._other_value = other_value

    def condition_met(self, data: dict[str, Any]) -> bool:
        return data.get(self._other_field) == self._other_value

    def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool:
        return value is not None and value != ""

    def message(self) -> str:
        return f"This field is required when {self._other_field} is {self._other_value}."

RequiredUnless

Bases: ConditionalRule

Field is required unless another field equals a specific value.

Source code in src/arvel/validation/rules/conditional.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class RequiredUnless(ConditionalRule):
    """Field is required unless another field equals a specific value."""

    def __init__(self, other_field: str, other_value: Any) -> None:
        self._other_field = other_field
        self._other_value = other_value

    def condition_met(self, data: dict[str, Any]) -> bool:
        return data.get(self._other_field) != self._other_value

    def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool:
        return value is not None and value != ""

    def message(self) -> str:
        return f"This field is required unless {self._other_field} is {self._other_value}."

RequiredWith

Bases: ConditionalRule

Field is required when another field is present.

Source code in src/arvel/validation/rules/conditional.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class RequiredWith(ConditionalRule):
    """Field is required when another field is present."""

    def __init__(self, other_field: str) -> None:
        self._other_field = other_field

    def condition_met(self, data: dict[str, Any]) -> bool:
        other = data.get(self._other_field)
        return other is not None and other != ""

    def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool:
        return value is not None and value != ""

    def message(self) -> str:
        return f"This field is required when {self._other_field} is present."

Exists

Check that a value exists in a database table.

Source code in src/arvel/validation/rules/database.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
class Exists:
    """Check that a value exists in a database table."""

    def __init__(
        self,
        table_name: str,
        column_name: str,
        *,
        session: AsyncSession | None,
    ) -> None:
        self._table_name = table_name
        self._column_name = column_name
        self._session = session

    async def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool:
        if self._session is None:
            msg = "Exists rule requires a database session"
            raise ValueError(msg)

        tbl = table(self._table_name, column(self._column_name))
        stmt = select(tbl.c[self._column_name]).where(tbl.c[self._column_name] == value)

        try:
            result = await self._session.execute(stmt)
        except Exception:
            _log.exception(
                "Database error in Exists rule for %s.%s",
                self._table_name,
                self._column_name,
            )
            return False

        return result.first() is not None

    def message(self) -> str:
        return f"The selected {self._column_name} is invalid."

Unique

Check that a value doesn't already exist in a database table.

Optionally ignores a specific row (for update scenarios).

Source code in src/arvel/validation/rules/database.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class Unique:
    """Check that a value doesn't already exist in a database table.

    Optionally ignores a specific row (for update scenarios).
    """

    def __init__(
        self,
        table_name: str,
        column_name: str,
        *,
        session: AsyncSession | None,
        ignore: int | str | None = None,
        id_column: str = "id",
    ) -> None:
        self._table_name = table_name
        self._column_name = column_name
        self._session = session
        self._ignore = ignore
        self._id_column = id_column

    async def passes(self, attribute: str, value: Any, data: dict[str, Any]) -> bool:
        if self._session is None:
            msg = "Unique rule requires a database session"
            raise ValueError(msg)

        tbl = table(self._table_name, column(self._column_name), column(self._id_column))
        stmt = select(tbl.c[self._column_name]).where(tbl.c[self._column_name] == value)

        if self._ignore is not None:
            stmt = stmt.where(tbl.c[self._id_column] != self._ignore)

        try:
            result = await self._session.execute(stmt)
        except Exception:
            _log.exception(
                "Database error in Unique rule for %s.%s",
                self._table_name,
                self._column_name,
            )
            return False

        return result.first() is None

    def message(self) -> str:
        return f"The {self._column_name} has already been taken."

Validator

Run validation rules against data, collecting all errors.

Source code in src/arvel/validation/validator.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
class Validator:
    """Run validation rules against data, collecting all errors."""

    async def validate(
        self,
        data: dict[str, Any],
        rules: dict[str, list[Rule | AsyncRule]],
        *,
        messages: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        errors: list[FieldError] = []
        custom_messages = messages or {}

        for field, field_rules in rules.items():
            value = data.get(field)
            skip_remaining = False

            for rule in field_rules:
                if skip_remaining:
                    break

                should_skip, field_errors = await self._run_rule(
                    rule, field, value, data, custom_messages
                )
                errors.extend(field_errors)

                if should_skip:
                    skip_remaining = True

        if errors:
            raise ValidationError(errors)

        return data

    async def _run_rule(
        self,
        rule: Rule | AsyncRule,
        field: str,
        value: Any,
        data: dict[str, Any],
        custom_messages: dict[str, str],
    ) -> tuple[bool, list[FieldError]]:
        """Run a single rule and return (should_skip_remaining, errors).

        Conditional rules can signal skip via a `should_skip` attribute.
        """
        from arvel.validation.rules.conditional import ConditionalRule

        if isinstance(rule, ConditionalRule):
            should_apply = rule.condition_met(data)
            if not should_apply:
                return True, []

            if not rule.passes(field, value, data):
                rule_name = type(rule).__name__
                msg = self._resolve_message(field, rule_name, rule.message(), custom_messages)
                return False, [FieldError(field=field, rule=rule_name, message=msg)]
            return False, []

        passes = rule.passes(field, value, data)
        if inspect.isawaitable(passes):
            passes = await passes

        if not passes:
            rule_name = type(rule).__name__
            msg = self._resolve_message(field, rule_name, rule.message(), custom_messages)
            return False, [FieldError(field=field, rule=rule_name, message=msg)]

        return False, []

    @staticmethod
    def _resolve_message(
        field: str,
        rule_name: str,
        default_message: str,
        custom_messages: dict[str, str],
    ) -> str:
        key = f"{field}.{rule_name}"
        return custom_messages.get(key, default_message)

arvel.testing

Testing support — TestClient, DatabaseTestCase, ModelFactory, fakes.

All contract test doubles (fakes) are re-exported here so users can write::

from arvel.testing import CacheFake, MailFake

TestResponse

Wraps httpx.Response with fluent assertion methods.

Every assert_* method returns self so assertions can be chained::

response.assert_status(200).assert_json_path("data.name", "Alice")
Source code in src/arvel/testing/assertions.py
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
class TestResponse:
    """Wraps ``httpx.Response`` with fluent assertion methods.

    Every ``assert_*`` method returns ``self`` so assertions can be chained::

        response.assert_status(200).assert_json_path("data.name", "Alice")
    """

    __test__ = False

    def __init__(self, response: httpx.Response) -> None:
        self._response = response

    def __getattr__(self, name: str) -> Any:
        return getattr(self._response, name)

    def assert_status(self, expected: int) -> Self:
        actual = self._response.status_code
        if actual != expected:
            body = self._response.text[:200]
            raise AssertionError(
                _truncate(f"Expected status {expected}, got {actual}. Body: {body}")
            )
        return self

    def assert_ok(self) -> Self:
        return self.assert_status(200)

    def assert_created(self) -> Self:
        return self.assert_status(201)

    def assert_no_content(self) -> Self:
        return self.assert_status(204)

    def assert_not_found(self) -> Self:
        return self.assert_status(404)

    def assert_unprocessable(self) -> Self:
        return self.assert_status(422)

    def assert_json(self, expected: dict[str, Any]) -> Self:
        actual = self._response.json()
        if actual != expected:
            raise AssertionError(
                _truncate(f"JSON body mismatch.\nExpected: {expected}\nActual: {actual}")
            )
        return self

    def assert_json_path(self, path: str, expected: Any) -> Self:
        data = self._response.json()
        found, actual = _resolve_path(data, path)
        if not found:
            raise AssertionError(_truncate(f"Path '{path}' not found in response JSON"))
        if actual != expected:
            raise AssertionError(
                _truncate(f"Expected '{expected}' at path '{path}', got '{actual}'")
            )
        return self

    def assert_json_structure(self, structure: dict[str, Any]) -> Self:
        data = self._response.json()
        self._check_structure(data, structure, prefix="")
        return self

    def _check_structure(self, data: Any, structure: dict[str, Any], *, prefix: str) -> None:
        if not isinstance(data, dict):
            raise AssertionError(
                _truncate(f"Expected dict at '{prefix}', got {type(data).__name__}")
            )
        for key, value in structure.items():
            full_key = f"{prefix}.{key}" if prefix else key
            if key not in data:
                raise AssertionError(_truncate(f"Missing key '{full_key}' in response JSON"))
            if isinstance(value, dict):
                self._check_structure(data[key], value, prefix=full_key)

    def assert_json_missing(self, key: str) -> Self:
        data = self._response.json()
        if isinstance(data, dict) and key in data:
            raise AssertionError(_truncate(f"Key '{key}' should not be present in response JSON"))
        return self

    def assert_redirect(self, url: str | None = None) -> Self:
        status = self._response.status_code
        if not (300 <= status < 400):
            raise AssertionError(_truncate(f"Expected redirect (3xx), got {status}"))
        if url is not None:
            location = self._response.headers.get("location", "")
            if location != url:
                raise AssertionError(_truncate(f"Expected redirect to '{url}', got '{location}'"))
        return self

    def assert_header(self, name: str, value: str | None = None) -> Self:
        actual = self._response.headers.get(name)
        if actual is None:
            raise AssertionError(_truncate(f"Header '{name}' not found in response"))
        if value is not None and actual != value:
            raise AssertionError(_truncate(f"Header '{name}' expected '{value}', got '{actual}'"))
        return self

    def assert_header_missing(self, name: str) -> Self:
        if name.lower() in (k.lower() for k in self._response.headers):
            raise AssertionError(_truncate(f"Header '{name}' should not be present"))
        return self

    def assert_cookie(self, name: str) -> Self:
        jar = self._response.cookies
        if name not in jar:
            raw_header = self._response.headers.get("set-cookie", "")
            if name not in raw_header:
                raise AssertionError(_truncate(f"Cookie '{name}' not found in response"))
        return self

TestClient

Source code in src/arvel/testing/client.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
class TestClient:
    __test__ = False

    """Test client for Arvel apps.

    Wraps ``httpx.AsyncClient`` with ``ASGITransport`` so requests go through
    the full middleware pipeline. Supports ``acting_as()`` for injecting auth
    context into subsequent requests.

    Usage::

        async with TestClient(app) as client:
            client.acting_as(user_id=42, headers={"Authorization": "Bearer tok"})
            resp = await client.get("/me")
    """

    def __init__(self, app: FastAPI, base_url: str = "http://testserver") -> None:
        self._transport = httpx.ASGITransport(app=app)
        self._base_url = base_url
        self._extra_headers: dict[str, str] = {}
        self._client: httpx.AsyncClient | None = None

    async def __aenter__(self) -> TestClient:
        self._client = httpx.AsyncClient(
            transport=self._transport,
            base_url=self._base_url,
        )
        return self

    async def __aexit__(self, *args: Any) -> None:
        if self._client:
            await self._client.aclose()
            self._client = None

    def acting_as(
        self,
        *,
        user_id: int | str | None = None,
        headers: dict[str, str] | None = None,
    ) -> None:
        """Inject auth headers for subsequent requests.

        Any headers passed here persist for the lifetime of this client session.
        """
        if headers:
            self._extra_headers.update(headers)
        if user_id is not None and "X-User-ID" not in self._extra_headers:
            self._extra_headers["X-User-ID"] = str(user_id)

    def _merge_headers(self, headers: HeaderTypes | None) -> dict[str, str]:
        merged: dict[str, str] = dict(self._extra_headers)
        if headers is not None:
            for key, value in dict(headers).items():
                merged[str(key)] = str(value)
        return merged

    def _ensure_open(self) -> httpx.AsyncClient:
        if self._client is None:
            raise RuntimeError("Use `async with TestClient(app) as client:`")
        return self._client

    async def get(
        self,
        url: str,
        *,
        params: QueryParamTypes | None = None,
        headers: HeaderTypes | None = None,
        cookies: CookieTypes | None = None,
        auth: AuthTypes | None = None,
        follow_redirects: bool = True,
        timeout: TimeoutTypes | None = None,
        extensions: RequestExtensions | None = None,
    ) -> TestResponse:
        kw: dict[str, Any] = {
            "params": params,
            "headers": self._merge_headers(headers),
            "cookies": cookies,
            "follow_redirects": follow_redirects,
            "extensions": extensions,
        }
        if auth is not None:
            kw["auth"] = auth
        if timeout is not None:
            kw["timeout"] = timeout
        return TestResponse(await self._ensure_open().get(url, **kw))

    async def post(
        self,
        url: str,
        *,
        content: RequestContent | None = None,
        data: RequestData | None = None,
        files: RequestFiles | None = None,
        json: Any | None = None,
        params: QueryParamTypes | None = None,
        headers: HeaderTypes | None = None,
        cookies: CookieTypes | None = None,
        auth: AuthTypes | None = None,
        follow_redirects: bool = True,
        timeout: TimeoutTypes | None = None,
        extensions: RequestExtensions | None = None,
    ) -> TestResponse:
        kw: dict[str, Any] = {
            "content": content,
            "data": data,
            "files": files,
            "json": json,
            "params": params,
            "headers": self._merge_headers(headers),
            "cookies": cookies,
            "follow_redirects": follow_redirects,
            "extensions": extensions,
        }
        if auth is not None:
            kw["auth"] = auth
        if timeout is not None:
            kw["timeout"] = timeout
        return TestResponse(await self._ensure_open().post(url, **kw))

    async def put(
        self,
        url: str,
        *,
        content: RequestContent | None = None,
        data: RequestData | None = None,
        files: RequestFiles | None = None,
        json: Any | None = None,
        params: QueryParamTypes | None = None,
        headers: HeaderTypes | None = None,
        cookies: CookieTypes | None = None,
        auth: AuthTypes | None = None,
        follow_redirects: bool = True,
        timeout: TimeoutTypes | None = None,
        extensions: RequestExtensions | None = None,
    ) -> TestResponse:
        kw: dict[str, Any] = {
            "content": content,
            "data": data,
            "files": files,
            "json": json,
            "params": params,
            "headers": self._merge_headers(headers),
            "cookies": cookies,
            "follow_redirects": follow_redirects,
            "extensions": extensions,
        }
        if auth is not None:
            kw["auth"] = auth
        if timeout is not None:
            kw["timeout"] = timeout
        return TestResponse(await self._ensure_open().put(url, **kw))

    async def patch(
        self,
        url: str,
        *,
        content: RequestContent | None = None,
        data: RequestData | None = None,
        files: RequestFiles | None = None,
        json: Any | None = None,
        params: QueryParamTypes | None = None,
        headers: HeaderTypes | None = None,
        cookies: CookieTypes | None = None,
        auth: AuthTypes | None = None,
        follow_redirects: bool = True,
        timeout: TimeoutTypes | None = None,
        extensions: RequestExtensions | None = None,
    ) -> TestResponse:
        kw: dict[str, Any] = {
            "content": content,
            "data": data,
            "files": files,
            "json": json,
            "params": params,
            "headers": self._merge_headers(headers),
            "cookies": cookies,
            "follow_redirects": follow_redirects,
            "extensions": extensions,
        }
        if auth is not None:
            kw["auth"] = auth
        if timeout is not None:
            kw["timeout"] = timeout
        return TestResponse(await self._ensure_open().patch(url, **kw))

    async def delete(
        self,
        url: str,
        *,
        params: QueryParamTypes | None = None,
        headers: HeaderTypes | None = None,
        cookies: CookieTypes | None = None,
        auth: AuthTypes | None = None,
        follow_redirects: bool = True,
        timeout: TimeoutTypes | None = None,
        extensions: RequestExtensions | None = None,
    ) -> TestResponse:
        kw: dict[str, Any] = {
            "params": params,
            "headers": self._merge_headers(headers),
            "cookies": cookies,
            "follow_redirects": follow_redirects,
            "extensions": extensions,
        }
        if auth is not None:
            kw["auth"] = auth
        if timeout is not None:
            kw["timeout"] = timeout
        return TestResponse(await self._ensure_open().delete(url, **kw))

__test__ = False class-attribute instance-attribute

Test client for Arvel apps.

Wraps httpx.AsyncClient with ASGITransport so requests go through the full middleware pipeline. Supports acting_as() for injecting auth context into subsequent requests.

Usage::

async with TestClient(app) as client:
    client.acting_as(user_id=42, headers={"Authorization": "Bearer tok"})
    resp = await client.get("/me")

acting_as(*, user_id=None, headers=None)

Inject auth headers for subsequent requests.

Any headers passed here persist for the lifetime of this client session.

Source code in src/arvel/testing/client.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def acting_as(
    self,
    *,
    user_id: int | str | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Inject auth headers for subsequent requests.

    Any headers passed here persist for the lifetime of this client session.
    """
    if headers:
        self._extra_headers.update(headers)
    if user_id is not None and "X-User-ID" not in self._extra_headers:
        self._extra_headers["X-User-ID"] = str(user_id)

DatabaseTestCase

Test helper providing a database session with rollback isolation.

Wraps an existing AsyncSession (typically from a fixture) and provides convenience methods for seeding data and asserting database state.

Usage::

@pytest.fixture
async def db(db_session):
    return DatabaseTestCase(db_session)

async def test_something(db):
    await db.seed([User(name="Alice")])
    await db.assert_database_has("users", {"name": "Alice"})
Source code in src/arvel/testing/database.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class DatabaseTestCase:
    """Test helper providing a database session with rollback isolation.

    Wraps an existing ``AsyncSession`` (typically from a fixture) and provides
    convenience methods for seeding data and asserting database state.

    Usage::

        @pytest.fixture
        async def db(db_session):
            return DatabaseTestCase(db_session)

        async def test_something(db):
            await db.seed([User(name="Alice")])
            await db.assert_database_has("users", {"name": "Alice"})
    """

    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    @property
    def session(self) -> AsyncSession:
        return self._session

    async def seed(self, models: Sequence[ArvelModel]) -> None:
        """Add multiple model instances to the session and flush."""
        for model in models:
            self._session.add(model)
        await self._session.flush()

    async def refresh(self, instance: ArvelModel) -> None:
        """Refresh a model instance from the database."""
        await self._session.refresh(instance)

    async def assert_database_has(self, table: str, conditions: dict[str, Any]) -> None:
        """Assert at least one row matching *conditions* exists in *table*."""
        where_clauses = " AND ".join(f"{col} = :p{i}" for i, col in enumerate(conditions))
        params = {f"p{i}": v for i, v in enumerate(conditions.values())}
        stmt = text(f"SELECT 1 FROM {table} WHERE {where_clauses} LIMIT 1")  # noqa: S608
        result = await self._session.execute(stmt, params)
        if result.scalar_one_or_none() is None:
            msg = f"Expected row in '{table}' matching {conditions}, but none found"
            raise AssertionError(msg)

    async def assert_database_missing(self, table: str, conditions: dict[str, Any]) -> None:
        """Assert no row matching *conditions* exists in *table*."""
        where_clauses = " AND ".join(f"{col} = :p{i}" for i, col in enumerate(conditions))
        params = {f"p{i}": v for i, v in enumerate(conditions.values())}
        stmt = text(f"SELECT 1 FROM {table} WHERE {where_clauses} LIMIT 1")  # noqa: S608
        result = await self._session.execute(stmt, params)
        if result.scalar_one_or_none() is not None:
            msg = f"Expected no row in '{table}' matching {conditions}, but found one"
            raise AssertionError(msg)

    async def assert_database_count(self, table: str, expected: int) -> None:
        """Assert the total number of rows in *table* equals *expected*."""
        stmt = text(f"SELECT COUNT(*) FROM {table}")  # noqa: S608
        result = await self._session.execute(stmt)
        actual = result.scalar_one()
        if actual != expected:
            msg = f"Expected {expected} rows in '{table}', but found {actual}"
            raise AssertionError(msg)

    def assert_soft_deleted(self, instance: Any) -> None:
        """Assert *instance* has a non-null ``deleted_at`` timestamp."""
        deleted_at = getattr(instance, "deleted_at", None)
        if deleted_at is None:
            type_name = type(instance).__name__
            msg = f"Expected {type_name} to be soft-deleted, but deleted_at is None"
            raise AssertionError(msg)

seed(models) async

Add multiple model instances to the session and flush.

Source code in src/arvel/testing/database.py
41
42
43
44
45
async def seed(self, models: Sequence[ArvelModel]) -> None:
    """Add multiple model instances to the session and flush."""
    for model in models:
        self._session.add(model)
    await self._session.flush()

refresh(instance) async

Refresh a model instance from the database.

Source code in src/arvel/testing/database.py
47
48
49
async def refresh(self, instance: ArvelModel) -> None:
    """Refresh a model instance from the database."""
    await self._session.refresh(instance)

assert_database_has(table, conditions) async

Assert at least one row matching conditions exists in table.

Source code in src/arvel/testing/database.py
51
52
53
54
55
56
57
58
59
async def assert_database_has(self, table: str, conditions: dict[str, Any]) -> None:
    """Assert at least one row matching *conditions* exists in *table*."""
    where_clauses = " AND ".join(f"{col} = :p{i}" for i, col in enumerate(conditions))
    params = {f"p{i}": v for i, v in enumerate(conditions.values())}
    stmt = text(f"SELECT 1 FROM {table} WHERE {where_clauses} LIMIT 1")  # noqa: S608
    result = await self._session.execute(stmt, params)
    if result.scalar_one_or_none() is None:
        msg = f"Expected row in '{table}' matching {conditions}, but none found"
        raise AssertionError(msg)

assert_database_missing(table, conditions) async

Assert no row matching conditions exists in table.

Source code in src/arvel/testing/database.py
61
62
63
64
65
66
67
68
69
async def assert_database_missing(self, table: str, conditions: dict[str, Any]) -> None:
    """Assert no row matching *conditions* exists in *table*."""
    where_clauses = " AND ".join(f"{col} = :p{i}" for i, col in enumerate(conditions))
    params = {f"p{i}": v for i, v in enumerate(conditions.values())}
    stmt = text(f"SELECT 1 FROM {table} WHERE {where_clauses} LIMIT 1")  # noqa: S608
    result = await self._session.execute(stmt, params)
    if result.scalar_one_or_none() is not None:
        msg = f"Expected no row in '{table}' matching {conditions}, but found one"
        raise AssertionError(msg)

assert_database_count(table, expected) async

Assert the total number of rows in table equals expected.

Source code in src/arvel/testing/database.py
71
72
73
74
75
76
77
78
async def assert_database_count(self, table: str, expected: int) -> None:
    """Assert the total number of rows in *table* equals *expected*."""
    stmt = text(f"SELECT COUNT(*) FROM {table}")  # noqa: S608
    result = await self._session.execute(stmt)
    actual = result.scalar_one()
    if actual != expected:
        msg = f"Expected {expected} rows in '{table}', but found {actual}"
        raise AssertionError(msg)

assert_soft_deleted(instance)

Assert instance has a non-null deleted_at timestamp.

Source code in src/arvel/testing/database.py
80
81
82
83
84
85
86
def assert_soft_deleted(self, instance: Any) -> None:
    """Assert *instance* has a non-null ``deleted_at`` timestamp."""
    deleted_at = getattr(instance, "deleted_at", None)
    if deleted_at is None:
        type_name = type(instance).__name__
        msg = f"Expected {type_name} to be soft-deleted, but deleted_at is None"
        raise AssertionError(msg)

FactoryBuilder

Immutable builder returned by ModelFactory.state() for chained creation.

Source code in src/arvel/testing/factory.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class FactoryBuilder[T]:
    """Immutable builder returned by ``ModelFactory.state()`` for chained creation."""

    def __init__(self, factory_cls: type[ModelFactory[T]], state_overrides: dict[str, Any]) -> None:
        self._factory_cls = factory_cls
        self._state_overrides = state_overrides

    def make(self, **overrides: Any) -> T:
        merged = {**self._state_overrides, **overrides}
        return self._factory_cls.make(**merged)

    async def create(self, *, session: AsyncSession, **overrides: Any) -> T:
        merged = {**self._state_overrides, **overrides}
        return await self._factory_cls.create(session=session, **merged)

    async def create_many(self, count: int, *, session: AsyncSession, **overrides: Any) -> list[T]:
        merged = {**self._state_overrides, **overrides}
        return await self._factory_cls.create_many(count, session=session, **merged)

    def make_batch(self, count: int, **overrides: Any) -> list[T]:
        merged = {**self._state_overrides, **overrides}
        return self._factory_cls.make_batch(count, **merged)

ModelFactory

Base factory for generating ArvelModel instances.

make() returns in-memory instances, create() persists to the database, batch() / make_batch() produce multiple.

Sequence-based uniqueness: call _next_seq() inside defaults() to get an auto-incrementing integer unique per factory class.

Source code in src/arvel/testing/factory.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
class ModelFactory[T]:
    """Base factory for generating ArvelModel instances.

    ``make()`` returns in-memory instances, ``create()`` persists to
    the database, ``batch()`` / ``make_batch()`` produce multiple.

    Sequence-based uniqueness: call ``_next_seq()`` inside ``defaults()``
    to get an auto-incrementing integer unique per factory class.
    """

    __model__: ClassVar[type[Any]]
    _counter: ClassVar[int] = 0

    @classmethod
    def _next_seq(cls) -> int:
        """Return an auto-incrementing sequence number unique to this factory."""
        cls._counter += 1
        return cls._counter

    @classmethod
    def _reset_seq(cls) -> None:
        """Reset the sequence counter (useful in test teardown)."""
        cls._counter = 0

    @classmethod
    def defaults(cls) -> dict[str, Any]:
        """Override in subclasses to provide default field values.

        Use ``cls._next_seq()`` for fields that require uniqueness::

            @classmethod
            def defaults(cls) -> dict[str, Any]:
                seq = cls._next_seq()
                return {"name": f"User {seq}", "email": f"user{seq}@test.com"}
        """
        return {}

    @classmethod
    def state(cls, name: str) -> FactoryBuilder[T]:
        """Return a builder pre-loaded with the named state's overrides.

        States are defined as ``state_<name>()`` class methods that return dicts::

            @classmethod
            def state_admin(cls) -> dict[str, Any]:
                return {"role": "admin"}
        """
        method_name = f"state_{name}"
        method = getattr(cls, method_name, None)
        if method is None:
            msg = f"Unknown state '{name}' on {cls.__name__}. Define '{method_name}()'."
            raise ValueError(msg)
        return FactoryBuilder(cls, method())

    @classmethod
    def make(cls, **overrides: Any) -> T:
        """Create an in-memory model instance (not persisted)."""
        attrs = {**cls.defaults(), **overrides}
        return cast("T", cls.__model__(**attrs))

    @classmethod
    async def create(cls, *, session: AsyncSession, **overrides: Any) -> T:
        """Create and persist a model instance to the database."""
        instance = cls.make(**overrides)
        session.add(instance)
        await session.flush()
        await session.refresh(instance)
        return instance

    @classmethod
    def make_batch(cls, count: int, **overrides: Any) -> list[T]:
        """Create multiple in-memory instances."""
        return [cls.make(**overrides) for _ in range(count)]

    @classmethod
    async def batch(cls, count: int, *, session: AsyncSession, **overrides: Any) -> list[T]:
        """Create and persist multiple instances."""
        instances = []
        for _ in range(count):
            inst = await cls.create(session=session, **overrides)
            instances.append(inst)
        return instances

    @classmethod
    async def create_many(cls, count: int, *, session: AsyncSession, **overrides: Any) -> list[T]:
        """Alias for ``batch()`` — creates and persists *count* instances."""
        return await cls.batch(count, session=session, **overrides)

defaults() classmethod

Override in subclasses to provide default field values.

Use cls._next_seq() for fields that require uniqueness::

@classmethod
def defaults(cls) -> dict[str, Any]:
    seq = cls._next_seq()
    return {"name": f"User {seq}", "email": f"user{seq}@test.com"}
Source code in src/arvel/testing/factory.py
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def defaults(cls) -> dict[str, Any]:
    """Override in subclasses to provide default field values.

    Use ``cls._next_seq()`` for fields that require uniqueness::

        @classmethod
        def defaults(cls) -> dict[str, Any]:
            seq = cls._next_seq()
            return {"name": f"User {seq}", "email": f"user{seq}@test.com"}
    """
    return {}

state(name) classmethod

Return a builder pre-loaded with the named state's overrides.

States are defined as state_<name>() class methods that return dicts::

@classmethod
def state_admin(cls) -> dict[str, Any]:
    return {"role": "admin"}
Source code in src/arvel/testing/factory.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
@classmethod
def state(cls, name: str) -> FactoryBuilder[T]:
    """Return a builder pre-loaded with the named state's overrides.

    States are defined as ``state_<name>()`` class methods that return dicts::

        @classmethod
        def state_admin(cls) -> dict[str, Any]:
            return {"role": "admin"}
    """
    method_name = f"state_{name}"
    method = getattr(cls, method_name, None)
    if method is None:
        msg = f"Unknown state '{name}' on {cls.__name__}. Define '{method_name}()'."
        raise ValueError(msg)
    return FactoryBuilder(cls, method())

make(**overrides) classmethod

Create an in-memory model instance (not persisted).

Source code in src/arvel/testing/factory.py
112
113
114
115
116
@classmethod
def make(cls, **overrides: Any) -> T:
    """Create an in-memory model instance (not persisted)."""
    attrs = {**cls.defaults(), **overrides}
    return cast("T", cls.__model__(**attrs))

create(*, session, **overrides) async classmethod

Create and persist a model instance to the database.

Source code in src/arvel/testing/factory.py
118
119
120
121
122
123
124
125
@classmethod
async def create(cls, *, session: AsyncSession, **overrides: Any) -> T:
    """Create and persist a model instance to the database."""
    instance = cls.make(**overrides)
    session.add(instance)
    await session.flush()
    await session.refresh(instance)
    return instance

make_batch(count, **overrides) classmethod

Create multiple in-memory instances.

Source code in src/arvel/testing/factory.py
127
128
129
130
@classmethod
def make_batch(cls, count: int, **overrides: Any) -> list[T]:
    """Create multiple in-memory instances."""
    return [cls.make(**overrides) for _ in range(count)]

batch(count, *, session, **overrides) async classmethod

Create and persist multiple instances.

Source code in src/arvel/testing/factory.py
132
133
134
135
136
137
138
139
@classmethod
async def batch(cls, count: int, *, session: AsyncSession, **overrides: Any) -> list[T]:
    """Create and persist multiple instances."""
    instances = []
    for _ in range(count):
        inst = await cls.create(session=session, **overrides)
        instances.append(inst)
    return instances

create_many(count, *, session, **overrides) async classmethod

Alias for batch() — creates and persists count instances.

Source code in src/arvel/testing/factory.py
141
142
143
144
@classmethod
async def create_many(cls, count: int, *, session: AsyncSession, **overrides: Any) -> list[T]:
    """Alias for ``batch()`` — creates and persists *count* instances."""
    return await cls.batch(count, session=session, **overrides)

BroadcastFake

Bases: BroadcastContract

Captures all broadcasts for test assertions.

Use in tests to replace the real broadcaster and verify that events were broadcast to the correct channels.

Source code in src/arvel/broadcasting/fake.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class BroadcastFake(BroadcastContract):
    """Captures all broadcasts for test assertions.

    Use in tests to replace the real broadcaster and verify that events
    were broadcast to the correct channels.
    """

    def __init__(self) -> None:
        self._broadcasts: list[dict[str, Any]] = []

    @property
    def broadcast_count(self) -> int:
        return len(self._broadcasts)

    async def broadcast(
        self,
        channels: list[Channel],
        event: str,
        data: dict[str, Any],
    ) -> None:
        self._broadcasts.append(
            {
                "channels": [ch.name for ch in channels],
                "event": event,
                "data": data,
            }
        )

    def assert_broadcast(
        self,
        event_name: str,
        *,
        channel: str | None = None,
    ) -> None:
        """Assert that an event with *event_name* was broadcast.

        Optionally filter by *channel* name.
        """
        for entry in self._broadcasts:
            if entry["event"] == event_name and (channel is None or channel in entry["channels"]):
                return
        msg = f"Expected '{event_name}' to be broadcast, but it was never broadcast"
        if channel:
            msg += f" on channel '{channel}'"
        raise AssertionError(msg)

    def assert_broadcast_on(self, channel: str) -> None:
        """Assert that any event was broadcast to *channel*."""
        for entry in self._broadcasts:
            if channel in entry["channels"]:
                return
        msg = f"No broadcast found on channel '{channel}'"
        raise AssertionError(msg)

    def assert_nothing_broadcast(self) -> None:
        """Assert that no events were broadcast."""
        if self._broadcasts:
            events = {e["event"] for e in self._broadcasts}
            msg = f"Expected no broadcasts, but got {len(self._broadcasts)}: {events}"
            raise AssertionError(msg)

    def assert_broadcast_count(self, event_name: str, expected: int) -> None:
        """Assert that *event_name* was broadcast exactly *expected* times."""
        actual = sum(1 for e in self._broadcasts if e["event"] == event_name)
        if actual != expected:
            msg = f"Expected {expected} broadcasts of '{event_name}', but got {actual}"
            raise AssertionError(msg)

assert_broadcast(event_name, *, channel=None)

Assert that an event with event_name was broadcast.

Optionally filter by channel name.

Source code in src/arvel/broadcasting/fake.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def assert_broadcast(
    self,
    event_name: str,
    *,
    channel: str | None = None,
) -> None:
    """Assert that an event with *event_name* was broadcast.

    Optionally filter by *channel* name.
    """
    for entry in self._broadcasts:
        if entry["event"] == event_name and (channel is None or channel in entry["channels"]):
            return
    msg = f"Expected '{event_name}' to be broadcast, but it was never broadcast"
    if channel:
        msg += f" on channel '{channel}'"
    raise AssertionError(msg)

assert_broadcast_on(channel)

Assert that any event was broadcast to channel.

Source code in src/arvel/broadcasting/fake.py
59
60
61
62
63
64
65
def assert_broadcast_on(self, channel: str) -> None:
    """Assert that any event was broadcast to *channel*."""
    for entry in self._broadcasts:
        if channel in entry["channels"]:
            return
    msg = f"No broadcast found on channel '{channel}'"
    raise AssertionError(msg)

assert_nothing_broadcast()

Assert that no events were broadcast.

Source code in src/arvel/broadcasting/fake.py
67
68
69
70
71
72
def assert_nothing_broadcast(self) -> None:
    """Assert that no events were broadcast."""
    if self._broadcasts:
        events = {e["event"] for e in self._broadcasts}
        msg = f"Expected no broadcasts, but got {len(self._broadcasts)}: {events}"
        raise AssertionError(msg)

assert_broadcast_count(event_name, expected)

Assert that event_name was broadcast exactly expected times.

Source code in src/arvel/broadcasting/fake.py
74
75
76
77
78
79
def assert_broadcast_count(self, event_name: str, expected: int) -> None:
    """Assert that *event_name* was broadcast exactly *expected* times."""
    actual = sum(1 for e in self._broadcasts if e["event"] == event_name)
    if actual != expected:
        msg = f"Expected {expected} broadcasts of '{event_name}', but got {actual}"
        raise AssertionError(msg)

CacheFake

Bases: MemoryCache

Extends MemoryCache with assertion helpers for tests.

Source code in src/arvel/cache/fakes.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class CacheFake(MemoryCache):
    """Extends MemoryCache with assertion helpers for tests."""

    def __init__(self) -> None:
        super().__init__()
        self._puts: list[str] = []

    async def put(self, key: str, value: Any, *, ttl: int | None = None) -> None:
        self._puts.append(key)
        await super().put(key, value, ttl=ttl)

    def assert_put(self, key: str) -> None:
        if key not in self._puts:
            msg = f"Expected cache put for '{key}', but it wasn't called"
            raise AssertionError(msg)

    def assert_not_put(self, key: str) -> None:
        if key in self._puts:
            msg = f"Expected '{key}' not to be put in cache, but it was"
            raise AssertionError(msg)

    def assert_nothing_put(self) -> None:
        if self._puts:
            msg = f"Expected no cache puts, but got {len(self._puts)}: {self._puts}"
            raise AssertionError(msg)

EventFake

Captures dispatched events for test assertions.

Use in tests to replace the real EventDispatcher and verify that events were dispatched without executing listeners.

Source code in src/arvel/events/fake.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class EventFake:
    """Captures dispatched events for test assertions.

    Use in tests to replace the real EventDispatcher and verify
    that events were dispatched without executing listeners.
    """

    def __init__(self) -> None:
        self._events: list[Event] = []

    @property
    def dispatched_count(self) -> int:
        return len(self._events)

    async def dispatch(self, event: Event) -> None:
        self._events.append(event)

    def assert_dispatched(
        self,
        event_type: type[Event],
        predicate: Callable[[Event], bool] | None = None,
    ) -> None:
        matches = [e for e in self._events if isinstance(e, event_type)]
        if not matches:
            msg = f"Expected {event_type.__name__} to be dispatched, but it wasn't"
            raise AssertionError(msg)
        if predicate is not None and not any(predicate(m) for m in matches):
            msg = (
                f"Expected {event_type.__name__} matching predicate to be dispatched, "
                f"but none of the {len(matches)} dispatched event(s) matched"
            )
            raise AssertionError(msg)

    def assert_not_dispatched(self, event_type: type[Event]) -> None:
        matches = [e for e in self._events if isinstance(e, event_type)]
        if matches:
            msg = (
                f"Expected {event_type.__name__} not to be dispatched, "
                f"but it was ({len(matches)} time(s))"
            )
            raise AssertionError(msg)

    def assert_dispatched_count(self, event_type: type[Event], expected: int) -> None:
        actual = len([e for e in self._events if isinstance(e, event_type)])
        if actual != expected:
            msg = f"Expected {expected} {event_type.__name__} events, but got {actual}"
            raise AssertionError(msg)

    def assert_nothing_dispatched(self) -> None:
        if self._events:
            types = {type(e).__name__ for e in self._events}
            msg = f"Expected no events dispatched, but got {len(self._events)}: {types}"
            raise AssertionError(msg)

LockFake

Bases: MemoryLock

Extends MemoryLock with assertion helpers for tests.

Source code in src/arvel/lock/fakes.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class LockFake(MemoryLock):
    """Extends MemoryLock with assertion helpers for tests."""

    def __init__(self) -> None:
        super().__init__()
        self._acquired_keys: list[str] = []

    async def acquire(
        self, key: str, ttl: int, *, owner: str | None = None, wait: bool = False
    ) -> bool:
        result = await super().acquire(key, ttl, owner=owner, wait=wait)
        if result:
            self._acquired_keys.append(key)
        return result

    def assert_acquired(self, key: str) -> None:
        if key not in self._acquired_keys:
            msg = f"Expected lock on '{key}' to be acquired, but it wasn't"
            raise AssertionError(msg)

    def assert_nothing_acquired(self) -> None:
        if self._acquired_keys:
            msg = f"Expected no locks acquired, but got: {self._acquired_keys}"
            raise AssertionError(msg)

MailFake

Bases: MailContract

Captures all sent mailables for test assertions.

Source code in src/arvel/mail/fakes.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class MailFake(MailContract):
    """Captures all sent mailables for test assertions."""

    def __init__(self) -> None:
        self._sent: list[Mailable] = []

    @property
    def sent_count(self) -> int:
        return len(self._sent)

    async def send(self, mailable: Mailable) -> None:
        self._sent.append(mailable)

    def assert_sent(self, **kwargs: str) -> None:
        for m in self._sent:
            if all(getattr(m, k, None) == v for k, v in kwargs.items()):
                return
        msg = f"Expected mailable with {kwargs} to be sent, but no match found"
        raise AssertionError(msg)

    def assert_nothing_sent(self) -> None:
        if self._sent:
            msg = f"Expected no emails sent, but got {len(self._sent)}"
            raise AssertionError(msg)

    def assert_sent_to(self, address: str) -> None:
        for m in self._sent:
            if address in m.to:
                return
        msg = f"Expected email sent to '{address}', but no match found"
        raise AssertionError(msg)

    def assert_not_sent(self, **kwargs: str) -> None:
        for m in self._sent:
            if all(getattr(m, k, None) == v for k, v in kwargs.items()):
                msg = f"Expected mailable with {kwargs} not to be sent, but it was"
                raise AssertionError(msg)

    def assert_sent_count(self, expected: int) -> None:
        actual = len(self._sent)
        if actual != expected:
            msg = f"Expected {expected} emails sent, but got {actual}"
            raise AssertionError(msg)

MediaFake

Bases: MediaContract

In-memory media library for tests with validation, events, and assertion helpers.

Source code in src/arvel/media/fakes.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
class MediaFake(MediaContract):
    """In-memory media library for tests with validation, events, and assertion helpers."""

    def __init__(self) -> None:
        self._items: list[Media] = []
        self._collections: dict[tuple[type, str], MediaCollection] = {}
        self._auto_registered: set[type] = set()
        self._next_id = 1
        self._event_listeners: list[Callable[[MediaEvent], None]] = []

    # ── Events ────────────────────────────────────────────────

    def on_event(self, listener: Callable[[MediaEvent], None]) -> None:
        self._event_listeners.append(listener)

    def _dispatch(self, event: MediaEvent) -> None:
        for listener in self._event_listeners:
            listener(event)

    # ── Registration ──────────────────────────────────────────

    def register_collection(self, model_type: type, collection: MediaCollection) -> None:
        self._collections[(model_type, collection.name)] = collection

    def get_registered_collections(self, model_type: type) -> list[MediaCollection]:
        return [col for (mt, _), col in self._collections.items() if mt is model_type]

    # ── Internal helpers ──────────────────────────────────────

    def _get_collection(
        self, model: MediaOwnerOrDict, collection_name: str
    ) -> MediaCollection | None:
        return self._collections.get(
            (type(model) if not isinstance(model, dict) else dict, collection_name)
        )

    def _model_ref(self, model: MediaOwnerOrDict) -> tuple[str, int]:
        if isinstance(model, dict):
            return str(model.get("type", "")), int(model.get("id", 0))  # ty: ignore[no-matching-overload]
        return type(model).__name__, int(getattr(model, "id", 0))

    def _auto_register(self, model: MediaOwnerOrDict) -> None:
        if isinstance(model, dict):
            return
        model_type = type(model)
        if model_type in self._auto_registered:
            return
        self._auto_registered.add(model_type)
        register_fn = getattr(model, "register_media_collections", None)
        if callable(register_fn):
            for col in register_fn():
                self.register_collection(model_type, col)

    def _validate(
        self, model: MediaOwnerOrDict, file: bytes, collection: str, content_type: str | None
    ) -> None:
        col = self._get_collection(model, collection)
        if col is None:
            return
        if col.allowed_mime_types and content_type not in col.allowed_mime_types:
            raise MediaValidationError(collection, f"MIME type '{content_type}' not allowed")
        if col.max_file_size > 0 and len(file) > col.max_file_size:
            raise MediaValidationError(
                collection, f"File size {len(file)} exceeds max {col.max_file_size}"
            )
        if col.accept_file and not col.accept_file(file, collection, content_type):
            raise MediaValidationError(collection, "File rejected by accept_file callback")
        if content_type and is_processable(content_type) and col.max_dimension > 0:
            self._validate_dimensions(file, col.max_dimension, collection)

    def _validate_dimensions(self, file: bytes, max_dim: int, collection: str) -> None:
        try:
            import io

            from PIL import Image

            img = Image.open(io.BytesIO(file))
            w, h = img.size
            if w > max_dim or h > max_dim:
                raise MediaValidationError(
                    collection,
                    f"Image dimensions {w}x{h} exceed max_dimension {max_dim}",
                )
        except MediaValidationError:
            raise
        except Exception:  # noqa: S110 — non-image files silently pass validation
            pass

    def _should_cascade(self, model: MediaOwnerOrDict, collection_name: str) -> bool:
        registered = self._get_collection(model, collection_name)
        if registered is None:
            return True
        return registered.cascade_delete

    async def _enforce_limits(self, model: MediaOwnerOrDict, collection_name: str) -> None:
        model_type, model_id = self._model_ref(model)
        items = [
            m
            for m in self._items
            if m.model_type == model_type
            and m.model_id == model_id
            and m.collection == collection_name
        ]
        items.sort(key=lambda m: m.order_column)
        col = self._get_collection(model, collection_name)
        if col is None:
            return
        limit = 1 if col.single_file else (col.max_items if col.max_items > 0 else 0)
        if limit > 0 and len(items) >= limit:
            for old in items[: len(items) - limit + 1]:
                await self.delete_media(old)

    # ── Core API ──────────────────────────────────────────────

    async def add(
        self,
        model: MediaOwnerOrDict,
        file: bytes,
        filename: str,
        *,
        collection: str = "default",
        content_type: str | None = None,
        process: bool = True,
        custom_properties: dict[str, JsonValue] | None = None,
        name: str = "",
        order: int | None = None,
    ) -> Media:
        self._auto_register(model)
        self._validate(model, file, collection, content_type)
        await self._enforce_limits(model, collection)

        gen_uuid = str(uuid.uuid4())
        gen_name = f"{gen_uuid}-{filename}"
        fake_path = f"fake/{collection}/{gen_name}"

        conversions: dict[str, str] = {}
        if process:
            col = self._get_collection(model, collection)
            if col and col.conversions:
                for conv in col.conversions:
                    if conv.should_apply_to(collection):
                        conversions[conv.name] = f"{fake_path}/conversions/{conv.name}/{gen_name}"

        model_type, model_id = self._model_ref(model)
        media = Media(
            id=self._next_id,
            uuid=gen_uuid,
            model_type=model_type,
            model_id=model_id,
            collection=collection,
            name=name or PurePosixPath(filename).stem,
            filename=gen_name,
            original_filename=filename,
            mime_type=content_type or "application/octet-stream",
            size=len(file),
            disk="fake",
            path=fake_path,
            conversions=conversions,
            custom_properties=custom_properties or {},
            order_column=order if order is not None else self._next_id,
        )
        self._next_id += 1
        self._items.append(media)
        self._dispatch(MediaHasBeenAdded(media=media))
        return media

    # ── Retrieval ─────────────────────────────────────────────

    async def get_media(
        self,
        model: MediaOwnerOrDict,
        collection: str = "default",
        *,
        filters: dict[str, JsonValue] | Callable[[Media], bool] | None = None,
    ) -> list[Media]:
        self._auto_register(model)
        model_type, model_id = self._model_ref(model)
        items = [
            m
            for m in self._items
            if m.model_type == model_type and m.model_id == model_id and m.collection == collection
        ]
        if filters is not None:
            if callable(filters):
                items = [m for m in items if filters(m)]  # ty: ignore[call-top-callable]
            elif isinstance(filters, dict):
                items = [
                    m
                    for m in items
                    if all(m.get_custom_property(k) == v for k, v in filters.items())
                ]
        items.sort(key=lambda m: m.order_column)
        return items

    async def get_all_media(self, model: MediaOwnerOrDict) -> list[Media]:
        self._auto_register(model)
        model_type, model_id = self._model_ref(model)
        items = [m for m in self._items if m.model_type == model_type and m.model_id == model_id]
        items.sort(key=lambda m: m.order_column)
        return items

    async def get_first_media(
        self, model: MediaOwnerOrDict, collection: str = "default"
    ) -> Media | None:
        items = await self.get_media(model, collection)
        return items[0] if items else None

    async def get_last_media(
        self, model: MediaOwnerOrDict, collection: str = "default"
    ) -> Media | None:
        items = await self.get_media(model, collection)
        return items[-1] if items else None

    async def get_first_url(
        self,
        model: MediaOwnerOrDict,
        collection: str = "default",
        conversion: str | None = None,
    ) -> str | None:
        items = await self.get_media(model, collection)
        if not items:
            col = self._get_collection(model, collection)
            if col:
                if conversion and conversion in col.fallback_urls:
                    return col.fallback_urls[conversion]
                if col.fallback_url:
                    return col.fallback_url
            return None
        first = items[0]
        if conversion:
            return first.conversions.get(conversion)
        return f"/fake-storage/{first.path}"

    # ── Deletion ──────────────────────────────────────────────

    async def delete_media(self, media: Media) -> None:
        self._items = [m for m in self._items if m.id != media.id]

    async def delete_all(self, model: MediaOwnerOrDict, collection: str | None = None) -> int:
        self._auto_register(model)
        model_type, model_id = self._model_ref(model)
        targets = [
            m
            for m in self._items
            if m.model_type == model_type
            and m.model_id == model_id
            and (collection is None or m.collection == collection)
        ]

        if collection is None:
            targets = [m for m in targets if self._should_cascade(model, m.collection)]

        removed_ids = {m.id for m in targets}
        before = len(self._items)
        self._items = [m for m in self._items if m.id not in removed_ids]
        count = before - len(self._items)

        if collection is not None:
            self._dispatch(
                CollectionHasBeenCleared(
                    model_type=model_type, model_id=model_id, collection_name=collection
                )
            )
        return count

    async def clear_media_collection_except(
        self, model: MediaOwnerOrDict, collection: str, *, keep: list[int] | None = None
    ) -> int:
        self._auto_register(model)
        keep_ids = set(keep or [])
        model_type, model_id = self._model_ref(model)
        before = len(self._items)
        self._items = [
            m
            for m in self._items
            if not (
                m.model_type == model_type
                and m.model_id == model_id
                and m.collection == collection
                and m.id not in keep_ids
            )
        ]
        return before - len(self._items)

    # ── Ordering ──────────────────────────────────────────────

    async def set_new_order(self, media_ids: list[int], start_order: int = 1) -> None:
        id_to_order = {mid: start_order + i for i, mid in enumerate(media_ids)}
        for m in self._items:
            if m.id in id_to_order:
                m.order_column = id_to_order[m.id]

    # ── Regeneration ──────────────────────────────────────────

    async def regenerate_conversions(
        self, model: MediaOwnerOrDict, collection: str = "default"
    ) -> list[Media]:
        items = await self.get_media(model, collection)
        col = self._get_collection(model, collection)
        if not col or not col.conversions:
            return items
        for media in items:
            for conv in col.conversions:
                if conv.should_apply_to(collection):
                    self._dispatch(ConversionWillStart(media=media, conversion=conv))
                    media.conversions[conv.name] = (
                        f"{media.path}/conversions/{conv.name}/{media.filename}"
                    )
                    self._dispatch(ConversionHasBeenCompleted(media=media, conversion=conv))
        return items

    # ── Test assertions ───────────────────────────────────────

    def assert_added(self, collection: str = "default") -> None:
        matches = [m for m in self._items if m.collection == collection]
        if not matches:
            msg = f"Expected media added to collection '{collection}', but none found"
            raise AssertionError(msg)

    def assert_nothing_added(self) -> None:
        if self._items:
            msg = f"Expected no media added, but got {len(self._items)}"
            raise AssertionError(msg)

    def assert_added_count(self, expected: int) -> None:
        actual = len(self._items)
        if actual != expected:
            msg = f"Expected {expected} media items, but got {actual}"
            raise AssertionError(msg)

NotificationFake

Bases: NotificationContract

Captures all sent notifications for test assertions.

Source code in src/arvel/notifications/fakes.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class NotificationFake(NotificationContract):
    """Captures all sent notifications for test assertions."""

    def __init__(self) -> None:
        self._sent: list[tuple[Any, Notification]] = []

    @property
    def sent_count(self) -> int:
        return len(self._sent)

    async def send(self, notifiable: Any, notification: Notification) -> None:
        self._sent.append((notifiable, notification))

    def assert_sent_to(
        self,
        notifiable: Any,
        notification_type: type[Notification] | None = None,
    ) -> None:
        for n, notif in self._sent:
            type_match = notification_type is None or isinstance(notif, notification_type)
            if n == notifiable and type_match:
                return
        if notification_type is not None:
            msg = f"Expected {notification_type.__name__} sent to '{notifiable}', but none found"
        else:
            msg = f"Expected notification sent to '{notifiable}', but none found"
        raise AssertionError(msg)

    def assert_nothing_sent(self) -> None:
        if self._sent:
            msg = f"Expected no notifications, but got {len(self._sent)}"
            raise AssertionError(msg)

    def assert_sent_type(self, notification_type: type[Notification]) -> None:
        for _, n in self._sent:
            if isinstance(n, notification_type):
                return
        msg = f"Expected {notification_type.__name__} to be sent, but it wasn't"
        raise AssertionError(msg)

QueueFake

Bases: QueueContract

Captures dispatched jobs for test assertions.

Use in tests to replace the real queue driver and verify that jobs were dispatched with expected payloads.

Source code in src/arvel/queue/fake.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class QueueFake(QueueContract):
    """Captures dispatched jobs for test assertions.

    Use in tests to replace the real queue driver and verify
    that jobs were dispatched with expected payloads.
    """

    def __init__(self) -> None:
        self._jobs: list[Job] = []

    @property
    def pushed_count(self) -> int:
        return len(self._jobs)

    async def dispatch(self, job: Job) -> None:
        self._jobs.append(job)

    async def later(self, delay: timedelta, job: Job) -> None:
        self._jobs.append(job)

    async def bulk(self, jobs: Sequence[Job]) -> None:
        self._jobs.extend(jobs)

    async def size(self, queue_name: str = "default") -> int:
        return len([j for j in self._jobs if j.queue_name == queue_name])

    def assert_pushed(self, job_type: type[Job]) -> None:
        matches = [j for j in self._jobs if isinstance(j, job_type)]
        if not matches:
            msg = f"Expected {job_type.__name__} to be pushed, but it wasn't"
            raise AssertionError(msg)

    def assert_pushed_with(self, job_type: type[Job], **kwargs: Any) -> None:
        for job in self._jobs:
            if not isinstance(job, job_type):
                continue
            data = job.model_dump()
            if all(data.get(k) == v for k, v in kwargs.items()):
                return
        msg = f"Expected {job_type.__name__} with {kwargs} to be pushed, but no match found"
        raise AssertionError(msg)

    def assert_pushed_on(self, queue_name: str, job_type: type[Job]) -> None:
        matches = [j for j in self._jobs if isinstance(j, job_type) and j.queue_name == queue_name]
        if not matches:
            msg = f"Expected {job_type.__name__} on queue '{queue_name}', but none found"
            raise AssertionError(msg)

    def assert_pushed_count(self, job_type: type[Job], expected: int) -> None:
        actual = len([j for j in self._jobs if isinstance(j, job_type)])
        if actual != expected:
            msg = f"Expected {expected} {job_type.__name__} jobs, but got {actual}"
            raise AssertionError(msg)

    def assert_nothing_pushed(self) -> None:
        if self._jobs:
            types = {type(j).__name__ for j in self._jobs}
            msg = f"Expected no jobs pushed, but got {len(self._jobs)}: {types}"
            raise AssertionError(msg)

StorageFake

Bases: StorageContract

In-memory storage for tests with assertion helpers.

Source code in src/arvel/storage/fakes.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class StorageFake(StorageContract):
    """In-memory storage for tests with assertion helpers."""

    def __init__(self) -> None:
        self._files: dict[str, bytes] = {}

    async def put(self, path: str, data: bytes, *, content_type: str | None = None) -> None:
        self._files[path] = data

    async def get(self, path: str) -> bytes:
        if path not in self._files:
            raise FileNotFoundError(f"File not found: {path}")
        return self._files[path]

    async def delete(self, path: str) -> bool:
        return self._files.pop(path, None) is not None

    async def exists(self, path: str) -> bool:
        return path in self._files

    async def url(self, path: str) -> str:
        return f"/fake-storage/{path}"

    async def temporary_url(self, path: str, expiration: timedelta) -> str:
        return f"/fake-storage/{path}?expires={int(expiration.total_seconds())}"

    async def size(self, path: str) -> int:
        if path not in self._files:
            raise FileNotFoundError(f"File not found: {path}")
        return len(self._files[path])

    async def list(self, prefix: str = "") -> builtins.list[str]:
        return sorted(k for k in self._files if k.startswith(prefix))

    def assert_stored(self, path: str) -> None:
        if path not in self._files:
            msg = f"Expected file at '{path}' to be stored, but it wasn't"
            raise AssertionError(msg)

    def assert_not_stored(self, path: str) -> None:
        if path in self._files:
            msg = f"Expected file at '{path}' not to be stored, but it was"
            raise AssertionError(msg)

    def assert_nothing_stored(self) -> None:
        if self._files:
            keys = list(self._files.keys())
            msg = f"Expected no files stored, but got {len(self._files)}: {keys}"
            raise AssertionError(msg)