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.
- flask_modelkit/__init__.py +38 -0
- flask_modelkit/base.py +13 -0
- flask_modelkit/mixins.py +724 -0
- flask_modelkit/py.typed +0 -0
- flask_modelkit-1.0.0.dist-info/METADATA +217 -0
- flask_modelkit-1.0.0.dist-info/RECORD +8 -0
- flask_modelkit-1.0.0.dist-info/WHEEL +4 -0
- flask_modelkit-1.0.0.dist-info/licenses/LICENSE +7 -0
|
@@ -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
|
flask_modelkit/mixins.py
ADDED
|
@@ -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()``."""
|
flask_modelkit/py.typed
ADDED
|
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
|
+
[](https://github.com/AnozieChibuike/FlaskModelKit/actions/workflows/tests.yml)
|
|
33
|
+
[](https://pypi.org/project/flask-modelkit/)
|
|
34
|
+
[](https://anoziechibuike.github.io/FlaskModelKit/)
|
|
35
|
+
[](https://pypi.org/project/flask-modelkit/)
|
|
36
|
+
[](https://pypistats.org/packages/flask-modelkit)
|
|
37
|
+
[](https://pepy.tech/projects/flask-modelkit)
|
|
38
|
+
[](https://github.com/AnozieChibuike/FlaskModelKit/stargazers)
|
|
39
|
+
[](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,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.
|