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

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 

10from datetime import datetime 

11from typing import Any 

12 

13from slugify import slugify 

14 

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 

19 

20 

21class 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 

33class 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 

64def 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 

75class 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)