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

55 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-05-22 20:36 +0200

1""" 

2This file contains example Mixins. 

3 

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

5""" 

6 

7import base64 

8import os 

9import typing 

10import warnings 

11from datetime import datetime 

12from typing import Any, Optional 

13 

14from slugify import slugify 

15 

16from .core import ( # noqa F401 - used by example in docstring 

17 QueryBuilder, 

18 T_MetaInstance, 

19 TableMeta, 

20 TypeDAL, 

21 TypedTable, 

22 _TypedTable, 

23) 

24from .fields import DatetimeField, StringField 

25from .types import OpRow, Set 

26 

27 

28class Mixin(_TypedTable): 

29 """ 

30 A mixin should be derived from this class. 

31 

32 The mixin base class itself doesn't do anything, 

33 but using it makes sure the mixin fields are placed AFTER the table's normal fields (instead of before) 

34 

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

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

37 """ 

38 

39 

40class TimestampsMixin(Mixin): 

41 """ 

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

43 """ 

44 

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

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

47 

48 @classmethod 

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

50 """ 

51 Hook called when defining the model to initialize timestamps. 

52 

53 Args: 

54 db (TypeDAL): The database layer. 

55 """ 

56 super().__on_define__(db) 

57 

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

59 """ 

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

61 

62 Args: 

63 _: Set: Unused parameter. 

64 row (OpRow): The row to update. 

65 """ 

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

67 

68 cls._before_update.append(set_updated_at) 

69 

70 

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

72 """ 

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

74 

75 UUID4 uses 16 bytes, but 8 is probably more than enough given you probably don't have THAT much duplicate titles. 

76 Strip away '=' to make it URL-safe 

77 (even though 'urlsafe_b64encode' sounds like it should already be url-safe - it is not) 

78 """ 

79 return base64.urlsafe_b64encode(os.urandom(length)).rstrip(b"=").decode().strip("=") 

80 

81 

82class SlugMixin(Mixin): 

83 """ 

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

85 

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

87 

88 Example: 

89 >>> class MyTable(TypedTable, SlugMixin, slug_field="some_name", slug_suffix_length=8): 

90 >>> some_name: str 

91 >>> ... 

92 """ 

93 

94 # pub: 

95 slug = StringField(unique=True, writable=False) 

96 # priv: 

97 __settings__: typing.TypedDict( # type: ignore 

98 "SlugFieldSettings", 

99 { 

100 "slug_field": str, 

101 "slug_suffix": int, 

102 }, 

103 ) # set via init subclass 

104 

105 def __init_subclass__(cls, slug_field: str = None, slug_suffix_length: int = 0, **kw: Any) -> None: 

106 """ 

107 Bind 'slug field' option to be used later (on_define). 

108 

109 You can control the length of the random suffix with the `slug_suffix_length` option (0 is no suffix). 

110 """ 

111 # unfortunately, PyCharm and mypy do not recognize/autocomplete/typecheck init subclass (keyword) arguments. 

112 if slug_field is None: 

113 raise ValueError( 

114 "SlugMixin requires a valid slug_field setting: " 

115 "e.g. `class MyClass(TypedTable, SlugMixin, slug_field='title'): ...`" 

116 ) 

117 

118 if "slug_suffix" in kw: 

119 warnings.warn( 

120 "The 'slug_suffix' option is deprecated, use 'slug_suffix_length' instead.", 

121 DeprecationWarning, 

122 ) 

123 

124 slug_suffix = slug_suffix_length or kw.get("slug_suffix", 0) 

125 

126 cls.__settings__ = { 

127 "slug_field": slug_field, 

128 "slug_suffix": slug_suffix, 

129 } 

130 

131 @classmethod 

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

133 """ 

134 When db is available, include a before_insert hook to generate and include a slug. 

135 """ 

136 super().__on_define__(db) 

137 

138 # slugs should not be editable (for SEO reasons), so there is only a before insert hook: 

139 def generate_slug_before_insert(row: OpRow) -> None: 

140 settings = cls.__settings__ 

141 

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

143 generated_slug = slugify(text_input) 

144 

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

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

147 

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

149 

150 cls._before_insert.append(generate_slug_before_insert) 

151 

152 @classmethod 

153 def from_slug(cls: typing.Type[T_MetaInstance], slug: str, join: bool = True) -> Optional[T_MetaInstance]: 

154 """ 

155 Find a row by its slug. 

156 """ 

157 builder = cls.where(slug=slug) 

158 if join: 

159 builder = builder.join() 

160 

161 return builder.first() 

162 

163 @classmethod 

164 def from_slug_or_fail(cls: typing.Type[T_MetaInstance], slug: str, join: bool = True) -> T_MetaInstance: 

165 """ 

166 Find a row by its slug, or raise an error if it doesn't exist. 

167 """ 

168 builder = cls.where(slug=slug) 

169 if join: 

170 builder = builder.join() 

171 

172 return builder.first_or_fail()