Coverage for src/typedal/mixins.py: 100%

1"""

2 This file contains example Mixins.

3

4 Mixins can add reusable fields and behavior (optimally both, otherwise it doesn't add much).

5"""

6

7 import base64

8 import os

9 import typing

10 from datetime import datetime

11 from typing import Any

12

13 from slugify import slugify

14

15 from .core import TypedTable # noqa F401 - used by example in docstring

16 from .core import TypeDAL, _TypedTable

17 from .fields import DatetimeField, StringField

18 from .types import OpRow, Set

19

20

21 class Mixin(_TypedTable):

22 """

23 A mixin should be derived from this class.

24

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)

27

28 During runtime, mixin should not have a base class in order to prevent MRO issues

29 ('inconsistent method resolution' or 'metaclass conflicts')

30 """

31

32

33 class TimestampsMixin(Mixin):

34 """

35 A Mixin class for adding timestamp fields to a model.

36 """

37

38 created_at = DatetimeField(default=datetime.now, writable=False)

39 updated_at = DatetimeField(default=datetime.now, writable=False)

40

41 @classmethod

42 def __on_define__(cls, db: TypeDAL) -> None:

43 """

44 Hook called when defining the model to initialize timestamps.

45

46 Args:

47 db (TypeDAL): The database layer.

48 """

49 super().__on_define__(db)

50

51 def set_updated_at(_: Set, row: OpRow) -> None:

52 """

53 Callback function to update the 'updated_at' field before saving changes.

54

55 Args:

56 _: Set: Unused parameter.

57 row (OpRow): The row to update.

58 """

59 row["updated_at"] = datetime.now()

60

61 cls._before_update.append(set_updated_at)

62

63

64 def slug_random_suffix(length: int = 8) -> str:

65 """

66 Generate a random suffix to make slugs unique, even when titles are the same.

67

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("=")

73

74

75 class SlugMixin(Mixin):

76 """

77 (Opinionated) example mixin to add a 'slug' field, which depends on a user-provided other field.

78

79 Some random bytes are added at the end to prevent duplicates.

80

81 Example:

82 >>> class MyTable(TypedTable, SlugMixin, slug_field="some_name"):

83 >>> some_name: str

84 >>> ...

85 """

86

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

97

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).

101

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 )

110

111 cls.__settings__ = {

112 "slug_field": slug_field,

113 "slug_suffix": slug_suffix,

114 }

115

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)

122

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__

126

127 text_input = row[settings["slug_field"]]

128 generated_slug = slugify(text_input)

129

130 if suffix_len := settings["slug_suffix"]:

131 generated_slug += f"-{slug_random_suffix(suffix_len)}"

132

133 row["slug"] = slugify(generated_slug)

134

135 cls._before_insert.append(generate_slug_before_insert)