Coverage for src/typedal/mixins.py: 100%
40 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-04-16 21:32 +0200
« prev ^ index » next coverage.py v7.4.1, created at 2024-04-16 21:32 +0200
1"""
2This file contains example Mixins.
4Mixins can add reusable fields and behavior (optimally both, otherwise it doesn't add much).
5"""
7import base64
8import os
9import typing
10from datetime import datetime
11from typing import Any
13from slugify import slugify
15from .core import TypedTable # noqa F401 - used by example in docstring
16from .core import TypeDAL, _TypedTable
17from .fields import DatetimeField, StringField
18from .types import OpRow, Set
21class Mixin(_TypedTable):
22 """
23 A mixin should be derived from this class.
25 The mixin base class itself doesn't do anything,
26 but using it makes sure the mixin fields are placed AFTER the table's normal fields (instead of before)
28 During runtime, mixin should not have a base class in order to prevent MRO issues
29 ('inconsistent method resolution' or 'metaclass conflicts')
30 """
33class TimestampsMixin(Mixin):
34 """
35 A Mixin class for adding timestamp fields to a model.
36 """
38 created_at = DatetimeField(default=datetime.now, writable=False)
39 updated_at = DatetimeField(default=datetime.now, writable=False)
41 @classmethod
42 def __on_define__(cls, db: TypeDAL) -> None:
43 """
44 Hook called when defining the model to initialize timestamps.
46 Args:
47 db (TypeDAL): The database layer.
48 """
49 super().__on_define__(db)
51 def set_updated_at(_: Set, row: OpRow) -> None:
52 """
53 Callback function to update the 'updated_at' field before saving changes.
55 Args:
56 _: Set: Unused parameter.
57 row (OpRow): The row to update.
58 """
59 row["updated_at"] = datetime.now()
61 cls._before_update.append(set_updated_at)
64def slug_random_suffix(length: int = 8) -> str:
65 """
66 Generate a random suffix to make slugs unique, even when titles are the same.
68 UUID4 uses 16 bytes, but 8 is probably more than enough given you probably don't have THAT much duplicate titles.
69 Strip away '=' to make it URL-safe
70 (even though 'urlsafe_b64encode' sounds like it should already be url-safe - it is not)
71 """
72 return base64.urlsafe_b64encode(os.urandom(length)).rstrip(b"=").decode().strip("=")
75class SlugMixin(Mixin):
76 """
77 (Opinionated) example mixin to add a 'slug' field, which depends on a user-provided other field.
79 Some random bytes are added at the end to prevent duplicates.
81 Example:
82 >>> class MyTable(TypedTable, SlugMixin, slug_field="some_name"):
83 >>> some_name: str
84 >>> ...
85 """
87 # pub:
88 slug = StringField(unique=True)
89 # priv:
90 __settings__: typing.TypedDict( # type: ignore
91 "SlugFieldSettings",
92 {
93 "slug_field": str,
94 "slug_suffix": int,
95 },
96 ) # set via init subclass
98 def __init_subclass__(cls, slug_field: str = None, slug_suffix: int = 8, **kw: Any) -> None:
99 """
100 Bind 'slug field' option to be used later (on_define).
102 You can control the length of the random suffix with the `slug_suffix` option (0 is no suffix).
103 """
104 # unfortunately, PyCharm and mypy do not recognize/autocomplete/typecheck init subclass (keyword) arguments.
105 if slug_field is None:
106 raise ValueError(
107 "SlugMixin requires a valid slug_field setting: "
108 "e.g. `class MyClass(TypedTable, SlugMixin, slug_field='title'): ...`"
109 )
111 cls.__settings__ = {
112 "slug_field": slug_field,
113 "slug_suffix": slug_suffix,
114 }
116 @classmethod
117 def __on_define__(cls, db: TypeDAL) -> None:
118 """
119 When db is available, include a before_insert hook to generate and include a slug.
120 """
121 super().__on_define__(db)
123 # slugs should not be editable (for SEO reasons), so there is only a before insert hook:
124 def generate_slug_before_insert(row: OpRow) -> None:
125 settings = cls.__settings__
127 text_input = row[settings["slug_field"]]
128 generated_slug = slugify(text_input)
130 if suffix_len := settings["slug_suffix"]:
131 generated_slug += f"-{slug_random_suffix(suffix_len)}"
133 row["slug"] = slugify(generated_slug)
135 cls._before_insert.append(generate_slug_before_insert)