flask-modelkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ from .base import BaseModel, db
2
+ from .mixins import (
3
+ BaseModelMixin,
4
+ CRUDMixin,
5
+ DuplicateValueError,
6
+ IntIdMixin,
7
+ InvalidBodyError,
8
+ MissingFieldsError,
9
+ PaginationReturn,
10
+ SerializerMixin,
11
+ SoftDeleteMixin,
12
+ TimestampMixin,
13
+ UUIDMixin,
14
+ hidden_column,
15
+ protected_column,
16
+ secret_column,
17
+ utcnow,
18
+ )
19
+
20
+ __all__ = [
21
+ "BaseModel",
22
+ "BaseModelMixin",
23
+ "CRUDMixin",
24
+ "DuplicateValueError",
25
+ "InvalidBodyError",
26
+ "MissingFieldsError",
27
+ "IntIdMixin",
28
+ "PaginationReturn",
29
+ "SerializerMixin",
30
+ "SoftDeleteMixin",
31
+ "TimestampMixin",
32
+ "UUIDMixin",
33
+ "db",
34
+ "hidden_column",
35
+ "protected_column",
36
+ "secret_column",
37
+ "utcnow",
38
+ ]
flask_modelkit/base.py ADDED
@@ -0,0 +1,13 @@
1
+ """A ready-made ``db`` and abstract ``BaseModel`` for apps that don't have their own."""
2
+
3
+ from flask_sqlalchemy import SQLAlchemy
4
+
5
+ from .mixins import BaseModelMixin
6
+
7
+ db = SQLAlchemy()
8
+
9
+
10
+ class BaseModel(BaseModelMixin, db.Model): # type: ignore[name-defined]
11
+ """Abstract base bound to this package's ``db``."""
12
+
13
+ __abstract__ = True
@@ -0,0 +1,724 @@
1
+ """
2
+ Composable model mixins for Flask-SQLAlchemy.
3
+
4
+ Mixins go *before* ``db.Model`` in the class bases::
5
+
6
+ class Post(UUIDMixin, TimestampMixin, SoftDeleteMixin, CRUDMixin, db.Model):
7
+ ...
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import enum
13
+ import json
14
+ import uuid
15
+ import weakref
16
+ from collections.abc import Mapping
17
+ from datetime import date, datetime, time, timezone
18
+ from decimal import Decimal
19
+ from typing import Any, ClassVar, Iterable, NoReturn, TypedDict, TypeVar
20
+
21
+ import sqlalchemy as sa
22
+ from flask import abort
23
+ from flask_sqlalchemy import SQLAlchemy
24
+ from sqlalchemy import event
25
+ from sqlalchemy.exc import IntegrityError
26
+ from sqlalchemy.orm import (
27
+ Mapped,
28
+ ORMExecuteState,
29
+ Session,
30
+ mapped_column,
31
+ with_loader_criteria,
32
+ )
33
+ from werkzeug.exceptions import BadRequest, Conflict
34
+
35
+ __all__ = [
36
+ "UUIDMixin",
37
+ "IntIdMixin",
38
+ "TimestampMixin",
39
+ "SoftDeleteMixin",
40
+ "CRUDMixin",
41
+ "SerializerMixin",
42
+ "BaseModelMixin",
43
+ "PaginationReturn",
44
+ "InvalidBodyError",
45
+ "MissingFieldsError",
46
+ "DuplicateValueError",
47
+ "hidden_column",
48
+ "protected_column",
49
+ "secret_column",
50
+ "utcnow",
51
+ ]
52
+
53
+ T = TypeVar("T", bound="CRUDMixin")
54
+
55
+
56
+ def utcnow() -> datetime:
57
+ """Current UTC time as a naive ``datetime``.
58
+
59
+ Naive so values round-trip identically on every backend (SQLite drops
60
+ tzinfo). All timestamps written by this package are UTC.
61
+ """
62
+ return datetime.now(timezone.utc).replace(tzinfo=None)
63
+
64
+
65
+ def _new_uuid() -> str:
66
+ return str(uuid.uuid4())
67
+
68
+
69
+ class _SessionMixin:
70
+ """Finds the Flask-SQLAlchemy extension that owns the model."""
71
+
72
+ @classmethod
73
+ def _db(cls) -> SQLAlchemy:
74
+ fsa = getattr(cls, "__fsa__", None)
75
+ if fsa is None:
76
+ raise TypeError(
77
+ f"{cls.__name__} must also inherit from a Flask-SQLAlchemy "
78
+ f"db.Model, e.g. class {cls.__name__}(CRUDMixin, db.Model)"
79
+ )
80
+ return fsa
81
+
82
+ @classmethod
83
+ def _session(cls) -> Session:
84
+ return cls._db().session # type: ignore[return-value]
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Primary keys
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ class UUIDMixin:
93
+ """UUID4 string primary key, assigned when the object is constructed so
94
+ it can be used before the first flush."""
95
+
96
+ id: Mapped[str] = mapped_column(sa.String(126), primary_key=True, default=_new_uuid)
97
+
98
+ def __init__(self, **kwargs: Any) -> None:
99
+ kwargs.setdefault("id", _new_uuid())
100
+ super().__init__(**kwargs)
101
+
102
+
103
+ class IntIdMixin:
104
+ """Auto-incrementing integer primary key, assigned by the database."""
105
+
106
+ id: Mapped[int] = mapped_column(sa.Integer, primary_key=True, autoincrement=True)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Timestamps
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ class TimestampMixin:
115
+ """``created_at`` / ``updated_at`` in UTC.
116
+
117
+ ``updated_at`` is refreshed whenever the row is UPDATEd, whether through
118
+ ``save()``/``update()`` or a plain ``db.session.commit()``.
119
+ """
120
+
121
+ created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, default=utcnow)
122
+ updated_at: Mapped[datetime] = mapped_column(
123
+ sa.DateTime, nullable=False, default=utcnow, onupdate=utcnow
124
+ )
125
+
126
+ def __init__(self, **kwargs: Any) -> None:
127
+ now = utcnow()
128
+ kwargs.setdefault("created_at", now)
129
+ kwargs.setdefault("updated_at", now)
130
+ super().__init__(**kwargs)
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Soft delete
135
+ # ---------------------------------------------------------------------------
136
+
137
+
138
+ class SoftDeleteMixin(_SessionMixin):
139
+ """Mark rows deleted instead of removing them.
140
+
141
+ Soft-deleted rows are hidden from **every** ORM query on the model:
142
+ ``Model.all()``, ``Model.query``, ``db.select(Model)``, ``session.get()``
143
+ and relationship loads. To include them, add
144
+ ``.execution_options(include_deleted=True)`` to a statement or use
145
+ ``Model.select(include_deleted=True)``.
146
+ """
147
+
148
+ deleted_at: Mapped[datetime | None] = mapped_column(
149
+ sa.DateTime, nullable=True, default=None, index=True
150
+ )
151
+
152
+ @property
153
+ def is_deleted(self) -> bool:
154
+ return self.deleted_at is not None
155
+
156
+ def soft_delete(self, commit: bool = True) -> None:
157
+ """Set ``deleted_at`` to now. The row stays in the table."""
158
+ self.deleted_at = utcnow()
159
+ self._persist(commit)
160
+
161
+ def restore(self, commit: bool = True) -> None:
162
+ """Clear ``deleted_at`` so the row is visible again."""
163
+ self.deleted_at = None
164
+ self._persist(commit)
165
+
166
+ def _persist(self, commit: bool) -> None:
167
+ session = self._session()
168
+ session.add(self)
169
+ if commit:
170
+ session.commit()
171
+
172
+
173
+ @event.listens_for(Session, "do_orm_execute")
174
+ def _hide_soft_deleted(state: ORMExecuteState) -> None:
175
+ if (
176
+ state.is_select
177
+ and not state.is_column_load
178
+ and not state.is_relationship_load
179
+ and not state.execution_options.get("include_deleted", False)
180
+ ):
181
+ state.statement = state.statement.options(
182
+ with_loader_criteria(
183
+ SoftDeleteMixin,
184
+ lambda cls: cls.deleted_at.is_(None),
185
+ include_aliases=True,
186
+ )
187
+ )
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # CRUD and queries
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ class _JSONHTTPError:
196
+ """Renders a Werkzeug HTTP error as a JSON body:
197
+ ``{"error": ..., "message": ..., "fields": [...]}``."""
198
+
199
+ error: ClassVar[str] = "error"
200
+ fields: list[str]
201
+ description: str | None
202
+
203
+ def get_body(self, environ: Any = None, scope: Any = None) -> str:
204
+ return json.dumps(
205
+ {"error": self.error, "message": self.description, "fields": self.fields}
206
+ )
207
+
208
+ def get_headers(self, environ: Any = None, scope: Any = None) -> list[tuple[str, str]]:
209
+ return [("Content-Type", "application/json")]
210
+
211
+
212
+ class InvalidBodyError(_JSONHTTPError, BadRequest):
213
+ """400: the input was not a JSON object (dict)."""
214
+
215
+ error = "invalid_body"
216
+
217
+ def __init__(self) -> None:
218
+ self.fields = []
219
+ super().__init__(description="Request body must be a JSON object.")
220
+
221
+
222
+ class MissingFieldsError(_JSONHTTPError, BadRequest):
223
+ """400: required fields were missing or blank. ``.fields`` lists them."""
224
+
225
+ error = "missing_fields"
226
+
227
+ def __init__(self, fields: Iterable[str]) -> None:
228
+ self.fields = list(fields)
229
+ super().__init__(description="Missing required field(s): " + ", ".join(self.fields))
230
+
231
+
232
+ class DuplicateValueError(_JSONHTTPError, Conflict):
233
+ """409: a unique field's value is already taken. ``.fields`` lists them."""
234
+
235
+ error = "duplicate"
236
+
237
+ def __init__(self, model: str, fields: Iterable[str]) -> None:
238
+ self.fields = list(fields)
239
+ super().__init__(
240
+ description=f"A {model} with this {', '.join(self.fields)} already exists."
241
+ )
242
+
243
+
244
+ def _blank(value: Any) -> bool:
245
+ return value is None or (isinstance(value, str) and not value.strip())
246
+
247
+
248
+ _required_cache: weakref.WeakKeyDictionary[type, tuple[str, ...]] = weakref.WeakKeyDictionary()
249
+
250
+
251
+ def _required_columns(cls: type) -> tuple[str, ...]:
252
+ """Attribute keys of NOT NULL columns with no default, in declaration order."""
253
+ keys = _required_cache.get(cls)
254
+ if keys is None:
255
+ found = []
256
+ for attr in sa.inspect(cls).column_attrs:
257
+ col = attr.columns[0]
258
+ if not isinstance(col, sa.Column) or col.nullable:
259
+ continue
260
+ if col.default is not None or col.server_default is not None:
261
+ continue
262
+ if col.table is not None and col is col.table.autoincrement_column:
263
+ continue
264
+ found.append(attr.key)
265
+ keys = _required_cache[cls] = tuple(found)
266
+ return keys
267
+
268
+
269
+ class PaginationReturn(TypedDict):
270
+ items: list[Any]
271
+ total: int
272
+ pages: int
273
+ page: int
274
+ per_page: int
275
+
276
+
277
+ class CRUDMixin(_SessionMixin):
278
+ """``save``/``update``/``delete`` plus class-level query helpers."""
279
+
280
+ #: Attributes ``update()``/``create_or_abort()`` never set from input.
281
+ #: Prefer marking columns with ``protected_column(...)`` or
282
+ #: ``info={"protected": True}``; this set is for fields you don't declare
283
+ #: yourself. Both are combined. Extend it with set union (``|``):
284
+ #: ``__protected_fields__ = CRUDMixin.__protected_fields__ | {"posts"}``
285
+ __protected_fields__: ClassVar[frozenset[str]] = frozenset(
286
+ {"id", "created_at", "updated_at", "deleted_at"}
287
+ )
288
+
289
+ # -- instance methods ---------------------------------------------------
290
+
291
+ def save(self, commit: bool = True) -> None:
292
+ """Add to the session and commit. ``commit=False`` lets you group
293
+ several changes into one transaction and commit yourself."""
294
+ session = self._session()
295
+ session.add(self)
296
+ if commit:
297
+ session.commit()
298
+
299
+ def update(self, updates: dict[str, Any], commit: bool = True) -> None:
300
+ """Set mapped attributes from ``updates`` and save.
301
+
302
+ Unknown keys and ``__protected_fields__`` are ignored, so request
303
+ JSON can be passed straight in.
304
+ """
305
+ allowed = self._writable_fields()
306
+ for key, value in updates.items():
307
+ if key in allowed:
308
+ setattr(self, key, value)
309
+ self.save(commit=commit)
310
+
311
+ def delete(self, commit: bool = True) -> None:
312
+ """Remove the row from the database (a hard delete, even on
313
+ ``SoftDeleteMixin`` models; use ``soft_delete()`` for those)."""
314
+ session = self._session()
315
+ session.delete(self)
316
+ if commit:
317
+ session.commit()
318
+
319
+ def close(self) -> None:
320
+ """Remove the current scoped session."""
321
+ self._session().remove() # type: ignore[attr-defined]
322
+
323
+ # -- class methods ------------------------------------------------------
324
+
325
+ @classmethod
326
+ def select(cls, include_deleted: bool = False) -> sa.Select[Any]:
327
+ """A ``select(Model)`` statement to build custom queries on."""
328
+ stmt = sa.select(cls)
329
+ if include_deleted:
330
+ stmt = stmt.execution_options(include_deleted=True)
331
+ return stmt
332
+
333
+ @classmethod
334
+ def create(cls: type[T], commit: bool = True, **kwargs: Any) -> T:
335
+ """Construct, save and return a new object."""
336
+ obj = cls(**kwargs)
337
+ obj.save(commit=commit)
338
+ return obj
339
+
340
+ @classmethod
341
+ def _writable_fields(cls) -> set[str]:
342
+ """Mapped columns/relationships that untrusted input may set."""
343
+ return set(sa.inspect(cls).attrs.keys()) - cls._protected_keys()
344
+
345
+ @classmethod
346
+ def protected_fields(cls) -> frozenset[str]:
347
+ """Every field input can't set: ``__protected_fields__`` plus columns
348
+ and relationships marked ``info={"protected": True}``."""
349
+ return cls._protected_keys()
350
+
351
+ @classmethod
352
+ def _protected_keys(cls) -> frozenset[str]:
353
+ return cls.__protected_fields__ | _flagged_keys(cls, "protected")
354
+
355
+ @classmethod
356
+ def create_or_abort(
357
+ cls: type[T],
358
+ data: Mapping[str, Any] | None,
359
+ *,
360
+ fields: Iterable[str] | None = None,
361
+ required: Iterable[str] = (),
362
+ commit: bool = True,
363
+ **values: Any,
364
+ ) -> T:
365
+ """Create from untrusted input (e.g. ``request.json``) or raise a
366
+ JSON HTTP error.
367
+
368
+ - Keys that aren't mapped attributes, or are in ``__protected_fields__``,
369
+ are dropped. ``fields`` narrows what's accepted to an allowlist.
370
+ - ``values`` are trusted keyword arguments (e.g. ``author=current_user``,
371
+ a password hash). They override ``data`` and skip protection.
372
+ - NOT NULL columns without a default are required. Passing a
373
+ relationship (``author=...``) satisfies its foreign-key column.
374
+ ``required`` adds names that must be present in ``data`` even if
375
+ they aren't columns (e.g. ``"password"``). ``None`` and blank
376
+ strings count as missing.
377
+
378
+ Raises:
379
+ TypeError: a required column can never be filled because it is
380
+ protected or outside ``fields``, and wasn't passed as a
381
+ keyword argument. A bug in the endpoint; raised on every call.
382
+ InvalidBodyError: 400, ``data`` is not a mapping.
383
+ MissingFieldsError: 400, listing every missing field.
384
+ DuplicateValueError: 409, a unique value is taken. The session is
385
+ rolled back (including other uncommitted changes).
386
+
387
+ Other ``IntegrityError``s are re-raised after rolling back, so
388
+ genuine bugs aren't hidden behind a 409.
389
+ """
390
+ if data is None:
391
+ data = {}
392
+ if not isinstance(data, Mapping):
393
+ raise InvalidBodyError()
394
+
395
+ accepted = cls._writable_fields() # type: ignore[attr-defined]
396
+ if fields is not None:
397
+ accepted &= set(fields)
398
+ accepted -= values.keys()
399
+ clean = {k: v for k, v in data.items() if k in accepted}
400
+ kwargs = {**clean, **values}
401
+
402
+ mapper = sa.inspect(cls)
403
+ cls._check_reachable(mapper, accepted, values.keys(), fields) # type: ignore[attr-defined]
404
+ satisfied = {k for k, v in kwargs.items() if not _blank(v)}
405
+ for rel in mapper.relationships:
406
+ if rel.key in satisfied:
407
+ for col in rel.local_columns:
408
+ satisfied.add(mapper.get_property_by_column(col).key)
409
+
410
+ missing = [k for k in _required_columns(cls) if k in accepted and k not in satisfied]
411
+ missing += [
412
+ k for k in required
413
+ if k not in values and _blank(data.get(k)) and k not in missing
414
+ ]
415
+ if missing:
416
+ raise MissingFieldsError(missing)
417
+
418
+ obj = cls(**kwargs)
419
+ session = cls._session()
420
+ session.add(obj)
421
+ try:
422
+ session.flush()
423
+ if commit:
424
+ session.commit()
425
+ except IntegrityError:
426
+ session.rollback()
427
+ taken = cls._taken_unique_fields(kwargs) # type: ignore[attr-defined]
428
+ if taken:
429
+ raise DuplicateValueError(cls.__name__, taken) from None
430
+ raise
431
+ return obj
432
+
433
+ @classmethod
434
+ def _check_reachable(
435
+ cls,
436
+ mapper: Any,
437
+ accepted: set[str],
438
+ passed: Iterable[str],
439
+ fields: Iterable[str] | None,
440
+ ) -> None:
441
+ """Raise ``TypeError`` if a required column can never be filled.
442
+
443
+ A NOT NULL column without a default that input may not set (it is
444
+ protected, or left out of ``fields=``) and that wasn't passed as a
445
+ keyword argument would always fail at INSERT. That is a bug in the
446
+ endpoint, not the request, so it is reported on every call instead of
447
+ surfacing as a database error only when the rest of the input is
448
+ valid.
449
+ """
450
+ provided = set(passed)
451
+ for rel in mapper.relationships:
452
+ if rel.key in provided:
453
+ provided.update(mapper.get_property_by_column(c).key for c in rel.local_columns)
454
+
455
+ unreachable = [
456
+ k for k in _required_columns(cls) if k not in accepted and k not in provided
457
+ ]
458
+ if not unreachable:
459
+ return
460
+
461
+ protected = cls._protected_keys() # type: ignore[attr-defined]
462
+ reasons = []
463
+ for k in unreachable:
464
+ if k in protected:
465
+ reasons.append(f"'{k}' is protected, so request data can't set it")
466
+ else:
467
+ reasons.append(f"'{k}' is not in fields={sorted(fields or [])!r}")
468
+ example = ", ".join(f"{k}=..." for k in unreachable)
469
+ raise TypeError(
470
+ f"{cls.__name__}.create_or_abort(): required field(s) "
471
+ f"{', '.join(unreachable)} can never be filled: {'; '.join(reasons)}. "
472
+ f"Pass them as keyword arguments, e.g. "
473
+ f"{cls.__name__}.create_or_abort(data, {example}), or, if clients "
474
+ f"should send them, stop protecting them / add them to fields=."
475
+ )
476
+
477
+ @classmethod
478
+ def _taken_unique_fields(cls, values: Mapping[str, Any]) -> list[str]:
479
+ """Which unique fields in ``values`` already exist in the table
480
+ (soft-deleted rows included, since constraints still see them)."""
481
+ mapper = sa.inspect(cls)
482
+ groups: list[list[Any]] = []
483
+ for table in mapper.tables:
484
+ groups.append(list(table.primary_key.columns))
485
+ for cons in table.constraints:
486
+ if isinstance(cons, sa.UniqueConstraint):
487
+ groups.append(list(cons.columns))
488
+ for index in table.indexes:
489
+ if index.unique:
490
+ groups.append(list(index.columns))
491
+
492
+ taken: list[str] = []
493
+ session = cls._session()
494
+ for cols in groups:
495
+ try:
496
+ keys = [mapper.get_property_by_column(c).key for c in cols]
497
+ except Exception:
498
+ continue
499
+ if not keys or any(k not in values or values[k] is None for k in keys):
500
+ continue
501
+ stmt = (
502
+ sa.select(cls)
503
+ .filter_by(**{k: values[k] for k in keys})
504
+ .limit(1)
505
+ .execution_options(include_deleted=True)
506
+ )
507
+ if session.scalars(stmt).first() is not None:
508
+ taken += [k for k in keys if k not in taken]
509
+ return taken
510
+
511
+ @classmethod
512
+ def all(cls: type[T]) -> list[T]:
513
+ return list(cls._session().scalars(sa.select(cls)))
514
+
515
+ @classmethod
516
+ def get(cls: type[T], id: Any, include_deleted: bool = False) -> T | None:
517
+ """Object with primary key ``id``, or None."""
518
+ if id is None or id == "":
519
+ return None
520
+ obj = cls._session().get(
521
+ cls, id, execution_options={"include_deleted": include_deleted}
522
+ )
523
+ # The identity map can return an object soft-deleted in this session.
524
+ if obj is not None and not include_deleted and getattr(obj, "deleted_at", None):
525
+ return None
526
+ return obj
527
+
528
+ @classmethod
529
+ def get_or_404(cls: type[T], id: Any) -> T | NoReturn:
530
+ """Object with primary key ``id``, or abort with HTTP 404."""
531
+ return cls.get(id) or abort(404, description=f"{cls.__name__} not found")
532
+
533
+ @classmethod
534
+ def filter_one(cls: type[T], **filters: Any) -> T | None:
535
+ """First object matching ``column=value`` filters, or None."""
536
+ return cls._session().scalars(sa.select(cls).filter_by(**filters).limit(1)).first()
537
+
538
+ @classmethod
539
+ def filter_all(cls: type[T], **filters: Any) -> list[T]:
540
+ """All objects matching ``column=value`` filters."""
541
+ return list(cls._session().scalars(sa.select(cls).filter_by(**filters)))
542
+
543
+ @classmethod
544
+ def filter_and_count_all(cls, **filters: Any) -> int:
545
+ """Number of rows matching ``column=value`` filters (SQL ``COUNT``)."""
546
+ session = cls._session()
547
+ stmt = sa.select(sa.func.count()).select_from(cls).filter_by(**filters)
548
+ return session.scalar(stmt) or 0
549
+
550
+ @classmethod
551
+ def count_all(cls) -> int:
552
+ """Total number of rows (SQL ``COUNT``; rows are not loaded)."""
553
+ return cls.filter_and_count_all()
554
+
555
+ @classmethod
556
+ def exists(cls, **filters: Any) -> bool:
557
+ """Whether any row matches ``column=value`` filters."""
558
+ session = cls._session()
559
+ return bool(session.scalar(sa.select(sa.select(cls).filter_by(**filters).exists())))
560
+
561
+ @classmethod
562
+ def paginate(cls, page: int = 1, per_page: int = 10) -> PaginationReturn:
563
+ """One page of objects, ordered by ``created_at`` (when present)
564
+ then primary key, so pages are stable."""
565
+ order = [getattr(cls, "created_at")] if hasattr(cls, "created_at") else []
566
+ order += list(sa.inspect(cls).primary_key)
567
+ pagination = cls._db().paginate(
568
+ sa.select(cls).order_by(*order),
569
+ page=page,
570
+ per_page=per_page,
571
+ error_out=False,
572
+ )
573
+ return {
574
+ "items": pagination.items,
575
+ "total": pagination.total or 0,
576
+ "pages": pagination.pages,
577
+ "page": pagination.page,
578
+ "per_page": pagination.per_page,
579
+ }
580
+
581
+
582
+ # ---------------------------------------------------------------------------
583
+ # Serialisation
584
+ # ---------------------------------------------------------------------------
585
+
586
+
587
+ def _json_safe(value: Any) -> Any:
588
+ if isinstance(value, (datetime, date, time)):
589
+ return value.isoformat()
590
+ if isinstance(value, (uuid.UUID, Decimal)):
591
+ return str(value)
592
+ if isinstance(value, enum.Enum):
593
+ return value.value
594
+ if isinstance(value, bytes):
595
+ return value.decode("utf-8", errors="replace")
596
+ return value
597
+
598
+
599
+ def _flagged_column(flags: tuple[str, ...], args: Any, kwargs: dict[str, Any]) -> Any:
600
+ info = dict(kwargs.pop("info", None) or {})
601
+ info.update(dict.fromkeys(flags, True))
602
+ return mapped_column(*args, info=info, **kwargs)
603
+
604
+
605
+ def hidden_column(*args: Any, **kwargs: Any) -> Any:
606
+ """A ``mapped_column`` that ``to_dict()`` never serialises (never *read
607
+ out*). Same arguments as ``db.Column``::
608
+
609
+ internal_notes = hidden_column(db.Text)
610
+
611
+ Shorthand for ``info={"hidden": True}``, which also works on
612
+ ``db.Column``, ``mapped_column`` and ``db.relationship``.
613
+ """
614
+ return _flagged_column(("hidden",), args, kwargs)
615
+
616
+
617
+ def protected_column(*args: Any, **kwargs: Any) -> Any:
618
+ """A ``mapped_column`` that ``update()``/``create_or_abort()`` never set
619
+ from input (never *written in*). Same arguments as ``db.Column``::
620
+
621
+ is_admin = protected_column(db.Boolean, default=False)
622
+
623
+ Shorthand for ``info={"protected": True}``, which also works on
624
+ ``db.Column``, ``mapped_column`` and ``db.relationship``.
625
+ """
626
+ return _flagged_column(("protected",), args, kwargs)
627
+
628
+
629
+ def secret_column(*args: Any, **kwargs: Any) -> Any:
630
+ """Hidden **and** protected: never read out, never written in. Use for
631
+ password hashes, API keys and tokens::
632
+
633
+ password_hash = secret_column(db.String(255), nullable=False)
634
+ """
635
+ return _flagged_column(("hidden", "protected"), args, kwargs)
636
+
637
+
638
+ _flag_cache: weakref.WeakKeyDictionary[type, dict[str, frozenset[str]]] = weakref.WeakKeyDictionary()
639
+
640
+
641
+ def _flagged_keys(cls: type, flag: str) -> frozenset[str]:
642
+ """Attribute names on ``cls`` whose column or relationship has
643
+ ``info={flag: True}``."""
644
+ per_cls = _flag_cache.setdefault(cls, {})
645
+ keys = per_cls.get(flag)
646
+ if keys is None:
647
+ mapper = sa.inspect(cls)
648
+ found = set()
649
+ for attr in mapper.column_attrs:
650
+ if attr.info.get(flag) or any(c.info.get(flag) for c in attr.columns):
651
+ found.add(attr.key)
652
+ for rel in mapper.relationships:
653
+ if rel.info.get(flag):
654
+ found.add(rel.key)
655
+ keys = per_cls[flag] = frozenset(found)
656
+ return keys
657
+
658
+
659
+ class SerializerMixin:
660
+ """``to_dict()`` with hidden fields, depth control and cycle safety."""
661
+
662
+ #: Never serialised, at any depth. Use for secrets such as password hashes.
663
+ #: Columns can instead be marked with ``hidden_column(...)`` or
664
+ #: ``info={"hidden": True}``; both mechanisms are combined.
665
+ __hidden_fields__: ClassVar[frozenset[str]] = frozenset()
666
+
667
+ def to_dict(
668
+ self,
669
+ depth: int | None = 1,
670
+ exclude: Iterable[str] = (),
671
+ json_safe: bool = False,
672
+ _seen: set[int] | None = None,
673
+ ) -> dict[str, Any]:
674
+ """
675
+ Dictionary of column values and related objects.
676
+
677
+ Args:
678
+ depth: How many relationship levels to follow. ``0`` returns
679
+ columns only, ``None`` follows every level. Objects already
680
+ serialised higher in the tree are skipped, so bidirectional
681
+ relationships never recurse forever.
682
+ exclude: Extra field names to leave out of this object.
683
+ json_safe: Convert datetimes/dates to ISO strings, UUIDs and
684
+ Decimals to ``str`` and enums to their value, so the result
685
+ can go straight to ``jsonify``.
686
+ """
687
+ skip = set(exclude) | self.__hidden_fields__ | _flagged_keys(type(self), "hidden")
688
+ seen = set() if _seen is None else _seen
689
+ seen.add(id(self))
690
+ mapper = sa.inspect(type(self))
691
+
692
+ data: dict[str, Any] = {}
693
+ for attr in mapper.column_attrs:
694
+ if attr.key not in skip:
695
+ value = getattr(self, attr.key)
696
+ data[attr.key] = _json_safe(value) if json_safe else value
697
+
698
+ if depth is not None and depth <= 0:
699
+ return data
700
+ child_depth = None if depth is None else depth - 1
701
+
702
+ def dump(obj: Any) -> dict[str, Any]:
703
+ return obj.to_dict(depth=child_depth, json_safe=json_safe, _seen=seen)
704
+
705
+ for rel in mapper.relationships:
706
+ if rel.key in skip:
707
+ continue
708
+ value = getattr(self, rel.key)
709
+ if value is None:
710
+ data[rel.key] = None
711
+ elif rel.uselist:
712
+ data[rel.key] = [dump(o) for o in value if id(o) not in seen]
713
+ elif id(value) not in seen:
714
+ data[rel.key] = dump(value)
715
+ return data
716
+
717
+
718
+ # ---------------------------------------------------------------------------
719
+ # Batteries-included combination
720
+ # ---------------------------------------------------------------------------
721
+
722
+
723
+ class BaseModelMixin(UUIDMixin, TimestampMixin, CRUDMixin, SerializerMixin):
724
+ """UUID key + timestamps + CRUD/query helpers + ``to_dict()``."""
File without changes
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: flask-modelkit
3
+ Version: 1.0.0
4
+ Summary: Composable model mixins for Flask-SQLAlchemy: UUID keys, UTC timestamps, soft delete, safe writes from request data, hidden and protected fields, and typed CRUD helpers.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: flask,sqlalchemy,flask-sqlalchemy,orm,base model,mixin,soft delete,uuid
8
+ Author: AnozieChibuike
9
+ Author-email: chibuikeanozie0@gmail.com
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: Framework :: Flask
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Classifier: Topic :: Database
21
+ Classifier: Typing :: Typed
22
+ Requires-Dist: flask (>=3.0.0,<4.0.0)
23
+ Requires-Dist: flask-sqlalchemy (>=3.1.1,<4.0.0)
24
+ Requires-Dist: sqlalchemy (>=2.0.16,<3.0.0)
25
+ Project-URL: Documentation, https://anoziechibuike.github.io/FlaskModelKit/
26
+ Project-URL: Homepage, https://github.com/AnozieChibuike/FlaskModelKit
27
+ Project-URL: Repository, https://github.com/AnozieChibuike/FlaskModelKit
28
+ Description-Content-Type: text/markdown
29
+
30
+ # flask-modelkit
31
+
32
+ [![Tests](https://github.com/AnozieChibuike/FlaskModelKit/actions/workflows/tests.yml/badge.svg)](https://github.com/AnozieChibuike/FlaskModelKit/actions/workflows/tests.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/flask-modelkit)](https://pypi.org/project/flask-modelkit/)
34
+ [![Docs](https://img.shields.io/badge/docs-flask--modelkit-A84E08)](https://anoziechibuike.github.io/FlaskModelKit/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/flask-modelkit)](https://pypi.org/project/flask-modelkit/)
36
+ [![Downloads/month](https://img.shields.io/pypi/dm/flask-modelkit)](https://pypistats.org/packages/flask-modelkit)
37
+ [![Total downloads](https://img.shields.io/pepy/dt/flask-modelkit)](https://pepy.tech/projects/flask-modelkit)
38
+ [![GitHub stars](https://img.shields.io/github/stars/AnozieChibuike/FlaskModelKit?style=flat&logo=github)](https://github.com/AnozieChibuike/FlaskModelKit/stargazers)
39
+ [![License: MIT](https://img.shields.io/github/license/AnozieChibuike/FlaskModelKit)](https://github.com/AnozieChibuike/FlaskModelKit/blob/master/LICENSE)
40
+
41
+ Composable model mixins for Flask-SQLAlchemy.
42
+
43
+ **Documentation: [anoziechibuike.github.io/FlaskModelKit](https://anoziechibuike.github.io/FlaskModelKit/)**
44
+
45
+ ⭐ If flask-modelkit saves you from writing another base model, [star it on GitHub](https://github.com/AnozieChibuike/FlaskModelKit). It helps other Flask developers find it.
46
+
47
+ > Formerly published as `FlaskSQLAlchemyBaseModel`. See [Migrating from FlaskSQLAlchemyBaseModel](#migrating-from-flasksqlalchemybasemodel).
48
+
49
+ Most Flask projects end up writing the same base model: a UUID primary key, `created_at`/`updated_at`, `save()`, `to_dict()`, maybe soft delete. Each copy has its own subtle bugs. This package gives you those pieces as small mixins that you combine per model. The edge cases are handled and tested.
50
+
51
+ ```python
52
+ class Post(UUIDMixin, TimestampMixin, SoftDeleteMixin, CRUDMixin, SerializerMixin, db.Model):
53
+ title = db.Column(db.String(200), nullable=False)
54
+ ```
55
+
56
+ ## Features
57
+
58
+ - **Pick what you need.** UUID or integer keys, timestamps, soft delete, CRUD helpers and serialisation are separate mixins. `BaseModelMixin` bundles the common set.
59
+ - **Soft delete that actually hides rows.** Deleted rows are filtered out of *every* ORM query: helpers, `Model.query`, `db.select()`, `session.get()` and relationship loads.
60
+ - **Hidden and protected fields.** Mark a column `hidden` (never sent to the client), `protected` (never set from client input) or both, right where you declare it.
61
+ - **Safe updates.** `update(request.json)` only sets mapped columns and never touches `id`, timestamps or any protected field.
62
+ - **Create from request data in one line.** `create_or_abort(request.json)` drops unsafe keys, answers 400 naming every missing field, and 409 naming a duplicate unique field.
63
+ - **Safe serialisation.** `to_dict()` never outputs hidden fields (such as password hashes), limits relationship depth, never loops on `back_populates`, and returns JSON-ready output on request.
64
+ - **Efficient queries.** Counts and existence checks run in SQL. Pagination is stably ordered.
65
+ - **Migration-friendly.** Defaults are generated in Python, so Flask-Migrate/Alembic produces clean migrations with no spurious diffs.
66
+ - **Typed.** Ships `py.typed`, and class methods return your model type.
67
+ - **Works with your own `db`.** No global `SQLAlchemy()` is forced on you.
68
+
69
+ Requires Python 3.10+, Flask 3, Flask-SQLAlchemy 3.1+ and SQLAlchemy 2.0.16+.
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ pip install flask-modelkit
75
+ ```
76
+
77
+ ## Quick start
78
+
79
+ ```python
80
+ from flask import Flask, jsonify, request
81
+ from flask_sqlalchemy import SQLAlchemy
82
+ from flask_modelkit import BaseModelMixin, protected_column, secret_column
83
+
84
+ db = SQLAlchemy()
85
+
86
+
87
+ class User(BaseModelMixin, db.Model): # mixins go before db.Model
88
+ name = db.Column(db.String(50), nullable=False)
89
+ email = db.Column(db.String(100), unique=True, nullable=False)
90
+ is_admin = protected_column(db.Boolean, default=False) # input can't set it
91
+ password_hash = secret_column(db.String(200)) # never read out, never written in
92
+
93
+
94
+ app = Flask(__name__)
95
+ app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.db"
96
+ db.init_app(app)
97
+
98
+ with app.app_context():
99
+ db.create_all() # or Flask-Migrate, see https://anoziechibuike.github.io/FlaskModelKit/#migrations
100
+
101
+
102
+ @app.post("/users")
103
+ def create_user():
104
+ # 400 if name/email are missing, 409 if the email is taken, unknown keys dropped
105
+ user = User.create_or_abort(request.get_json(silent=True))
106
+ return jsonify(user.to_dict(json_safe=True)), 201
107
+
108
+
109
+ @app.patch("/users/<id>")
110
+ def update_user(id):
111
+ user = User.get_or_404(id)
112
+ user.update(request.json) # id, timestamps, is_admin, password_hash can't be set
113
+ return jsonify(user.to_dict(json_safe=True))
114
+
115
+
116
+ @app.get("/users")
117
+ def list_users():
118
+ page = User.paginate(page=request.args.get("page", 1, type=int), per_page=20)
119
+ page["items"] = [u.to_dict(json_safe=True) for u in page["items"]]
120
+ return jsonify(page)
121
+ ```
122
+
123
+ Don't have a `db` yet? Use the bundled one:
124
+
125
+ ```python
126
+ from flask_modelkit import db, BaseModel
127
+
128
+ class User(BaseModel):
129
+ name = db.Column(db.String(50))
130
+ ```
131
+
132
+ ## Documentation
133
+
134
+ The full documentation is at **[anoziechibuike.github.io/FlaskModelKit](https://anoziechibuike.github.io/FlaskModelKit/)**.
135
+
136
+ | Guide | |
137
+ |---|---|
138
+ | [Quick start](https://anoziechibuike.github.io/FlaskModelKit/#quick-start) | A complete app in four steps |
139
+ | [Mixins](https://anoziechibuike.github.io/FlaskModelKit/#mixins) | Every mixin, an interactive builder, and the one ordering rule |
140
+ | [Creating & updating](https://anoziechibuike.github.io/FlaskModelKit/#writing) | `create`, `create_or_abort`, `save`, `update`, transactions |
141
+ | [Reading & filtering](https://anoziechibuike.github.io/FlaskModelKit/#reading) | `get`, filters, counts, pagination, custom queries |
142
+ | [Hidden vs protected](https://anoziechibuike.github.io/FlaskModelKit/#hidden-vs-protected) | Controlling what goes out to and comes in from the client |
143
+ | [Serialisation](https://anoziechibuike.github.io/FlaskModelKit/#serialization) | `to_dict()`, depth, JSON output |
144
+ | [Soft delete](https://anoziechibuike.github.io/FlaskModelKit/#soft-delete) | How rows are hidden, including deleted rows, restoring |
145
+ | [Migrations](https://anoziechibuike.github.io/FlaskModelKit/#migrations) | Flask-Migrate/Alembic, adding mixins to existing tables |
146
+ | [Project examples](https://anoziechibuike.github.io/FlaskModelKit/#example-blog) | A blog API, a shop checkout, lookup tables |
147
+ | [API reference](https://anoziechibuike.github.io/FlaskModelKit/#api-reference) | Every public name |
148
+
149
+ ## Use with AI agents
150
+
151
+ AI coding assistants don't know this package's rules: mixins before `db.Model`, `create_or_abort()` for request data, `secret_column()` for password hashes. [`AGENT.md`](https://anoziechibuike.github.io/FlaskModelKit/AGENT.md) spells them out. Add it to your project once, from the project root:
152
+
153
+ ```bash
154
+ # Claude Code: save it, then import it from CLAUDE.md
155
+ mkdir -p .claude && curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md -o .claude/flask-modelkit.md
156
+ echo "@.claude/flask-modelkit.md" >> CLAUDE.md
157
+
158
+ # Cursor: a project rule attached to Python files
159
+ mkdir -p .cursor/rules
160
+ { printf -- '---\ndescription: How to use flask-modelkit models, fields and endpoints\nglobs: **/*.py\nalwaysApply: false\n---\n\n'
161
+ curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md; } > .cursor/rules/flask-modelkit.mdc
162
+
163
+ # GitHub Copilot: path-specific instructions for Python files
164
+ mkdir -p .github/instructions
165
+ { printf -- '---\napplyTo: "**/*.py"\n---\n\n'
166
+ curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md; } > .github/instructions/flask-modelkit.instructions.md
167
+
168
+ # Codex, Jules, Amp, Zed and other AGENTS.md readers (Gemini CLI: GEMINI.md)
169
+ curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md >> AGENTS.md
170
+ ```
171
+
172
+ In a chat assistant, say: *"Read https://anoziechibuike.github.io/FlaskModelKit/AGENT.md and follow it for all flask-modelkit code."* To pin the rules to your installed version, download from the release tag instead, for example `https://raw.githubusercontent.com/AnozieChibuike/FlaskModelKit/v1.0.0/AGENT.md`. See [Use with AI agents](https://anoziechibuike.github.io/FlaskModelKit/#ai-agents) for details.
173
+
174
+ ## Migrating from FlaskSQLAlchemyBaseModel
175
+
176
+ `flask-modelkit` 1.0 continues `FlaskSQLAlchemyBaseModel` under a new name.
177
+
178
+ ```bash
179
+ pip uninstall FlaskSQLAlchemyBaseModel
180
+ pip install flask-modelkit
181
+ ```
182
+
183
+ Then replace the import:
184
+
185
+ ```python
186
+ # before
187
+ from flasksqlalchemybasemodel import BaseModel, db
188
+ # after
189
+ from flask_modelkit import BaseModel, db
190
+ ```
191
+
192
+ The old `flasksqlalchemybasemodel.src` module is gone; import from `flask_modelkit` directly. The columns the mixins create are unchanged, so no database migration is needed. See [CHANGELOG.md](CHANGELOG.md) for everything else that changed.
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ pip install -e . pytest alembic
198
+ pytest
199
+ ```
200
+
201
+ CI runs the suite on Python 3.10–3.13 and against the minimum supported dependency versions.
202
+
203
+ ## Star history
204
+
205
+ <a href="https://star-history.com/#AnozieChibuike/FlaskModelKit&Date">
206
+ <picture>
207
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=AnozieChibuike/FlaskModelKit&type=Date&theme=dark">
208
+ <img alt="flask-modelkit star history" src="https://api.star-history.com/svg?repos=AnozieChibuike/FlaskModelKit&type=Date" width="600">
209
+ </picture>
210
+ </a>
211
+
212
+ More numbers, including downloads, live on the [Community & stats](https://anoziechibuike.github.io/FlaskModelKit/#community) page.
213
+
214
+ ## License
215
+
216
+ MIT
217
+
@@ -0,0 +1,8 @@
1
+ flask_modelkit/__init__.py,sha256=BXeTmI6WVOIw6KJqq8InJrhpwiIHWZ42A42xzYs7O2Y,714
2
+ flask_modelkit/base.py,sha256=4kW4YQAlpw0n1lNbB4-PzXY5B77WRVV-DYTpPqNit5w,341
3
+ flask_modelkit/mixins.py,sha256=WTZQ166wepoE06S3mKvffY4lqmp1iVCPi7VSQbCv0gw,25957
4
+ flask_modelkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ flask_modelkit-1.0.0.dist-info/METADATA,sha256=EtXp6KqNZ06TfGEcN5rFEy0MJsqymN5t84b46R5XrmQ,11031
6
+ flask_modelkit-1.0.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
7
+ flask_modelkit-1.0.0.dist-info/licenses/LICENSE,sha256=C2Ze_1OvfcFoxprpA4joQ2PX7PfFJQk28lD8MlY5WEk,1062
8
+ flask_modelkit-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ Copyright 2024 Anozie Chibuike
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.