clear-skies 2.0.5__py3-none-any.whl → 2.0.6__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.

Potentially problematic release.


This version of clear-skies might be problematic. Click here for more details.

Files changed (252) hide show
  1. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/METADATA +1 -1
  2. clear_skies-2.0.6.dist-info/RECORD +251 -0
  3. clearskies/__init__.py +61 -0
  4. clearskies/action.py +7 -0
  5. clearskies/authentication/__init__.py +15 -0
  6. clearskies/authentication/authentication.py +46 -0
  7. clearskies/authentication/authorization.py +16 -0
  8. clearskies/authentication/authorization_pass_through.py +20 -0
  9. clearskies/authentication/jwks.py +163 -0
  10. clearskies/authentication/public.py +5 -0
  11. clearskies/authentication/secret_bearer.py +553 -0
  12. clearskies/autodoc/__init__.py +8 -0
  13. clearskies/autodoc/formats/__init__.py +5 -0
  14. clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
  15. clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
  16. clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
  17. clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
  18. clearskies/autodoc/formats/oai3_json/request.py +68 -0
  19. clearskies/autodoc/formats/oai3_json/response.py +28 -0
  20. clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
  21. clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
  22. clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
  23. clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
  24. clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
  25. clearskies/autodoc/formats/oai3_json/test.json +1985 -0
  26. clearskies/autodoc/py.typed +0 -0
  27. clearskies/autodoc/request/__init__.py +15 -0
  28. clearskies/autodoc/request/header.py +6 -0
  29. clearskies/autodoc/request/json_body.py +6 -0
  30. clearskies/autodoc/request/parameter.py +8 -0
  31. clearskies/autodoc/request/request.py +47 -0
  32. clearskies/autodoc/request/url_parameter.py +6 -0
  33. clearskies/autodoc/request/url_path.py +6 -0
  34. clearskies/autodoc/response/__init__.py +5 -0
  35. clearskies/autodoc/response/response.py +9 -0
  36. clearskies/autodoc/schema/__init__.py +31 -0
  37. clearskies/autodoc/schema/array.py +10 -0
  38. clearskies/autodoc/schema/base64.py +8 -0
  39. clearskies/autodoc/schema/boolean.py +5 -0
  40. clearskies/autodoc/schema/date.py +5 -0
  41. clearskies/autodoc/schema/datetime.py +5 -0
  42. clearskies/autodoc/schema/double.py +5 -0
  43. clearskies/autodoc/schema/enum.py +17 -0
  44. clearskies/autodoc/schema/integer.py +6 -0
  45. clearskies/autodoc/schema/long.py +5 -0
  46. clearskies/autodoc/schema/number.py +6 -0
  47. clearskies/autodoc/schema/object.py +13 -0
  48. clearskies/autodoc/schema/password.py +5 -0
  49. clearskies/autodoc/schema/schema.py +11 -0
  50. clearskies/autodoc/schema/string.py +5 -0
  51. clearskies/backends/__init__.py +65 -0
  52. clearskies/backends/api_backend.py +1178 -0
  53. clearskies/backends/backend.py +136 -0
  54. clearskies/backends/cursor_backend.py +335 -0
  55. clearskies/backends/memory_backend.py +797 -0
  56. clearskies/backends/secrets_backend.py +106 -0
  57. clearskies/column.py +1233 -0
  58. clearskies/columns/__init__.py +71 -0
  59. clearskies/columns/audit.py +206 -0
  60. clearskies/columns/belongs_to_id.py +483 -0
  61. clearskies/columns/belongs_to_model.py +132 -0
  62. clearskies/columns/belongs_to_self.py +105 -0
  63. clearskies/columns/boolean.py +113 -0
  64. clearskies/columns/category_tree.py +275 -0
  65. clearskies/columns/category_tree_ancestors.py +51 -0
  66. clearskies/columns/category_tree_children.py +127 -0
  67. clearskies/columns/category_tree_descendants.py +48 -0
  68. clearskies/columns/created.py +95 -0
  69. clearskies/columns/created_by_authorization_data.py +116 -0
  70. clearskies/columns/created_by_header.py +99 -0
  71. clearskies/columns/created_by_ip.py +92 -0
  72. clearskies/columns/created_by_routing_data.py +97 -0
  73. clearskies/columns/created_by_user_agent.py +92 -0
  74. clearskies/columns/date.py +234 -0
  75. clearskies/columns/datetime.py +282 -0
  76. clearskies/columns/email.py +76 -0
  77. clearskies/columns/float.py +153 -0
  78. clearskies/columns/has_many.py +505 -0
  79. clearskies/columns/has_many_self.py +56 -0
  80. clearskies/columns/has_one.py +14 -0
  81. clearskies/columns/integer.py +160 -0
  82. clearskies/columns/json.py +128 -0
  83. clearskies/columns/many_to_many_ids.py +337 -0
  84. clearskies/columns/many_to_many_ids_with_data.py +274 -0
  85. clearskies/columns/many_to_many_models.py +158 -0
  86. clearskies/columns/many_to_many_pivots.py +134 -0
  87. clearskies/columns/phone.py +159 -0
  88. clearskies/columns/select.py +92 -0
  89. clearskies/columns/string.py +102 -0
  90. clearskies/columns/timestamp.py +164 -0
  91. clearskies/columns/updated.py +110 -0
  92. clearskies/columns/uuid.py +86 -0
  93. clearskies/configs/README.md +105 -0
  94. clearskies/configs/__init__.py +162 -0
  95. clearskies/configs/actions.py +43 -0
  96. clearskies/configs/any.py +13 -0
  97. clearskies/configs/any_dict.py +22 -0
  98. clearskies/configs/any_dict_or_callable.py +23 -0
  99. clearskies/configs/authentication.py +23 -0
  100. clearskies/configs/authorization.py +23 -0
  101. clearskies/configs/boolean.py +16 -0
  102. clearskies/configs/boolean_or_callable.py +18 -0
  103. clearskies/configs/callable_config.py +18 -0
  104. clearskies/configs/columns.py +34 -0
  105. clearskies/configs/conditions.py +30 -0
  106. clearskies/configs/config.py +24 -0
  107. clearskies/configs/datetime.py +18 -0
  108. clearskies/configs/datetime_or_callable.py +19 -0
  109. clearskies/configs/endpoint.py +23 -0
  110. clearskies/configs/endpoint_list.py +29 -0
  111. clearskies/configs/float.py +16 -0
  112. clearskies/configs/float_or_callable.py +18 -0
  113. clearskies/configs/integer.py +16 -0
  114. clearskies/configs/integer_or_callable.py +18 -0
  115. clearskies/configs/joins.py +30 -0
  116. clearskies/configs/list_any_dict.py +30 -0
  117. clearskies/configs/list_any_dict_or_callable.py +31 -0
  118. clearskies/configs/model_class.py +35 -0
  119. clearskies/configs/model_column.py +65 -0
  120. clearskies/configs/model_columns.py +56 -0
  121. clearskies/configs/model_destination_name.py +25 -0
  122. clearskies/configs/model_to_id_column.py +43 -0
  123. clearskies/configs/readable_model_column.py +9 -0
  124. clearskies/configs/readable_model_columns.py +9 -0
  125. clearskies/configs/schema.py +23 -0
  126. clearskies/configs/searchable_model_columns.py +9 -0
  127. clearskies/configs/security_headers.py +39 -0
  128. clearskies/configs/select.py +26 -0
  129. clearskies/configs/select_list.py +47 -0
  130. clearskies/configs/string.py +29 -0
  131. clearskies/configs/string_dict.py +32 -0
  132. clearskies/configs/string_list.py +32 -0
  133. clearskies/configs/string_list_or_callable.py +35 -0
  134. clearskies/configs/string_or_callable.py +18 -0
  135. clearskies/configs/timedelta.py +18 -0
  136. clearskies/configs/timezone.py +18 -0
  137. clearskies/configs/url.py +23 -0
  138. clearskies/configs/validators.py +45 -0
  139. clearskies/configs/writeable_model_column.py +9 -0
  140. clearskies/configs/writeable_model_columns.py +9 -0
  141. clearskies/configurable.py +76 -0
  142. clearskies/contexts/__init__.py +11 -0
  143. clearskies/contexts/cli.py +117 -0
  144. clearskies/contexts/context.py +98 -0
  145. clearskies/contexts/wsgi.py +76 -0
  146. clearskies/contexts/wsgi_ref.py +82 -0
  147. clearskies/decorators.py +33 -0
  148. clearskies/di/__init__.py +14 -0
  149. clearskies/di/additional_config.py +130 -0
  150. clearskies/di/additional_config_auto_import.py +17 -0
  151. clearskies/di/di.py +973 -0
  152. clearskies/di/inject/__init__.py +23 -0
  153. clearskies/di/inject/by_class.py +21 -0
  154. clearskies/di/inject/by_name.py +18 -0
  155. clearskies/di/inject/di.py +13 -0
  156. clearskies/di/inject/environment.py +14 -0
  157. clearskies/di/inject/input_output.py +20 -0
  158. clearskies/di/inject/now.py +13 -0
  159. clearskies/di/inject/requests.py +13 -0
  160. clearskies/di/inject/secrets.py +14 -0
  161. clearskies/di/inject/utcnow.py +13 -0
  162. clearskies/di/inject/uuid.py +15 -0
  163. clearskies/di/injectable.py +29 -0
  164. clearskies/di/injectable_properties.py +131 -0
  165. clearskies/di/test_module/__init__.py +6 -0
  166. clearskies/di/test_module/another_module/__init__.py +2 -0
  167. clearskies/di/test_module/module_class.py +5 -0
  168. clearskies/end.py +183 -0
  169. clearskies/endpoint.py +1314 -0
  170. clearskies/endpoint_group.py +336 -0
  171. clearskies/endpoints/__init__.py +25 -0
  172. clearskies/endpoints/advanced_search.py +526 -0
  173. clearskies/endpoints/callable.py +388 -0
  174. clearskies/endpoints/create.py +205 -0
  175. clearskies/endpoints/delete.py +139 -0
  176. clearskies/endpoints/get.py +271 -0
  177. clearskies/endpoints/health_check.py +183 -0
  178. clearskies/endpoints/list.py +574 -0
  179. clearskies/endpoints/restful_api.py +427 -0
  180. clearskies/endpoints/schema.py +189 -0
  181. clearskies/endpoints/simple_search.py +286 -0
  182. clearskies/endpoints/update.py +193 -0
  183. clearskies/environment.py +104 -0
  184. clearskies/exceptions/__init__.py +19 -0
  185. clearskies/exceptions/authentication.py +2 -0
  186. clearskies/exceptions/authorization.py +2 -0
  187. clearskies/exceptions/client_error.py +2 -0
  188. clearskies/exceptions/input_errors.py +4 -0
  189. clearskies/exceptions/missing_dependency.py +2 -0
  190. clearskies/exceptions/moved_permanently.py +3 -0
  191. clearskies/exceptions/moved_temporarily.py +3 -0
  192. clearskies/exceptions/not_found.py +2 -0
  193. clearskies/functional/__init__.py +7 -0
  194. clearskies/functional/routing.py +92 -0
  195. clearskies/functional/string.py +112 -0
  196. clearskies/functional/validations.py +76 -0
  197. clearskies/input_outputs/__init__.py +13 -0
  198. clearskies/input_outputs/cli.py +171 -0
  199. clearskies/input_outputs/exceptions/__init__.py +2 -0
  200. clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
  201. clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
  202. clearskies/input_outputs/headers.py +45 -0
  203. clearskies/input_outputs/input_output.py +138 -0
  204. clearskies/input_outputs/programmatic.py +69 -0
  205. clearskies/input_outputs/py.typed +0 -0
  206. clearskies/input_outputs/wsgi.py +77 -0
  207. clearskies/model.py +1922 -0
  208. clearskies/py.typed +0 -0
  209. clearskies/query/__init__.py +12 -0
  210. clearskies/query/condition.py +223 -0
  211. clearskies/query/join.py +136 -0
  212. clearskies/query/query.py +196 -0
  213. clearskies/query/sort.py +27 -0
  214. clearskies/schema.py +82 -0
  215. clearskies/secrets/__init__.py +6 -0
  216. clearskies/secrets/additional_configs/__init__.py +32 -0
  217. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
  218. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
  219. clearskies/secrets/akeyless.py +182 -0
  220. clearskies/secrets/exceptions/__init__.py +1 -0
  221. clearskies/secrets/exceptions/not_found.py +2 -0
  222. clearskies/secrets/secrets.py +38 -0
  223. clearskies/security_header.py +15 -0
  224. clearskies/security_headers/__init__.py +11 -0
  225. clearskies/security_headers/cache_control.py +67 -0
  226. clearskies/security_headers/cors.py +50 -0
  227. clearskies/security_headers/csp.py +94 -0
  228. clearskies/security_headers/hsts.py +22 -0
  229. clearskies/security_headers/x_content_type_options.py +0 -0
  230. clearskies/security_headers/x_frame_options.py +0 -0
  231. clearskies/test_base.py +8 -0
  232. clearskies/typing.py +11 -0
  233. clearskies/validator.py +37 -0
  234. clearskies/validators/__init__.py +33 -0
  235. clearskies/validators/after_column.py +62 -0
  236. clearskies/validators/before_column.py +13 -0
  237. clearskies/validators/in_the_future.py +32 -0
  238. clearskies/validators/in_the_future_at_least.py +11 -0
  239. clearskies/validators/in_the_future_at_most.py +10 -0
  240. clearskies/validators/in_the_past.py +32 -0
  241. clearskies/validators/in_the_past_at_least.py +10 -0
  242. clearskies/validators/in_the_past_at_most.py +10 -0
  243. clearskies/validators/maximum_length.py +26 -0
  244. clearskies/validators/maximum_value.py +29 -0
  245. clearskies/validators/minimum_length.py +26 -0
  246. clearskies/validators/minimum_value.py +29 -0
  247. clearskies/validators/required.py +34 -0
  248. clearskies/validators/timedelta.py +59 -0
  249. clearskies/validators/unique.py +30 -0
  250. clear_skies-2.0.5.dist-info/RECORD +0 -4
  251. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/WHEEL +0 -0
  252. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,105 @@
1
+ from typing import Callable
2
+
3
+ import clearskies.decorators
4
+ import clearskies.typing
5
+ from clearskies.columns.belongs_to_id import BelongsToId
6
+
7
+
8
+ class BelongsToSelf(BelongsToId):
9
+ """
10
+ This is a standard BelongsToId column except it's used in cases where the model relates to itself.
11
+
12
+ This exists because a model can't refer to itself inside it's own class definition. There are
13
+ workarounds, but having this class is usually quicker for the developer.
14
+
15
+ The only difference between this and BelongsToId is that you don't have to provide the parent class.
16
+
17
+ See also HasManySelf
18
+
19
+ ```python
20
+ from typing import Any
21
+
22
+ import clearskies
23
+
24
+
25
+ class Category(clearskies.Model):
26
+ id_column_name = "id"
27
+ backend = clearskies.backends.MemoryBackend()
28
+
29
+ id = clearskies.columns.Uuid()
30
+ name = clearskies.columns.String()
31
+ parent_id = clearskies.columns.BelongsToSelf()
32
+ parent = clearskies.columns.BelongsToModel("parent_id")
33
+ children = clearskies.columns.HasManySelf()
34
+
35
+
36
+ def test_self_relationship(categories: Category) -> dict[str, Any]:
37
+ root = categories.create({"name": "Root"})
38
+ sub = categories.create({"name": "Sub", "parent": root})
39
+ subsub_1 = categories.create({"name": "Sub Sub 1", "parent": sub})
40
+ subsub_2 = categories.create({"name": "Sub Sub 2", "parent_id": sub.id})
41
+
42
+ return {
43
+ "root_from_child": subsub_1.parent.parent.name,
44
+ "subsubs_from_sub": [subsub.name for subsub in sub.children],
45
+ }
46
+
47
+
48
+ cli = clearskies.contexts.Cli(
49
+ clearskies.endpoints.Callable(test_self_relationship),
50
+ classes=[Category],
51
+ )
52
+
53
+ if __name__ == "__main__":
54
+ cli()
55
+ ```
56
+
57
+ Which when invoked returns:
58
+
59
+ ```json
60
+ {
61
+ "status": "success",
62
+ "error": "",
63
+ "data": {"root_from_child": "Root", "subsubs_from_sub": ["Sub Sub 1", "Sub Sub 2"]},
64
+ "pagination": {},
65
+ "input_errors": {},
66
+ }
67
+ ```
68
+ """
69
+
70
+ _descriptor_config_map = None
71
+
72
+ @clearskies.decorators.parameters_to_properties
73
+ def __init__(
74
+ self,
75
+ readable_parent_columns: list[str] = [],
76
+ join_type: str | None = None,
77
+ where: clearskies.typing.condition | list[clearskies.typing.condition] = [],
78
+ default: str | None = None,
79
+ setable: str | Callable | None = None,
80
+ is_readable: bool = True,
81
+ is_writeable: bool = True,
82
+ is_searchable: bool = True,
83
+ is_temporary: bool = False,
84
+ validators: clearskies.typing.validator | list[clearskies.typing.validator] = [],
85
+ on_change_pre_save: clearskies.typing.action | list[clearskies.typing.action] = [],
86
+ on_change_post_save: clearskies.typing.action | list[clearskies.typing.action] = [],
87
+ on_change_save_finished: clearskies.typing.action | list[clearskies.typing.action] = [],
88
+ created_by_source_type: str = "",
89
+ created_by_source_key: str = "",
90
+ created_by_source_strict: bool = True,
91
+ ):
92
+ pass
93
+
94
+ def finalize_configuration(self, model_class, name) -> None:
95
+ """
96
+ Finalize and check the configuration.
97
+
98
+ This is an external trigger called by the model class when the model class is ready.
99
+ The reason it exists here instead of in the constructor is because some columns are tightly
100
+ connected to the model class, and can't validate configuration until they know what the model is.
101
+ Therefore, we need the model involved, and the only way for a property to know what class it is
102
+ in is if the parent class checks in (which is what happens here).
103
+ """
104
+ self.parent_model_class = model_class
105
+ super().finalize_configuration(model_class, name)
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Callable, Self, overload
4
+
5
+ import clearskies.configs.actions
6
+ import clearskies.decorators
7
+ import clearskies.typing
8
+ from clearskies import configs
9
+ from clearskies.autodoc.schema import Boolean as AutoDocBoolean
10
+ from clearskies.autodoc.schema import Schema as AutoDocSchema
11
+ from clearskies.column import Column
12
+ from clearskies.query import Condition
13
+
14
+ if TYPE_CHECKING:
15
+ from clearskies import Model
16
+
17
+
18
+ class Boolean(Column):
19
+ """Represents a column with a true/false type."""
20
+
21
+ """
22
+ Actions to trigger when the column changes to True
23
+ """
24
+ on_true = clearskies.configs.actions.Actions(default=[])
25
+
26
+ """
27
+ Actions to trigger when the column changes to False
28
+ """
29
+ on_false = clearskies.configs.actions.Actions(default=[])
30
+
31
+ """
32
+ The class to use when documenting this column
33
+ """
34
+ auto_doc_class: type[AutoDocSchema] = AutoDocBoolean
35
+
36
+ _allowed_search_operators = ["="]
37
+ default = configs.Boolean() # type: ignore
38
+ setable = configs.BooleanOrCallable() # type: ignore
39
+ _descriptor_config_map = None
40
+
41
+ @clearskies.decorators.parameters_to_properties
42
+ def __init__(
43
+ self,
44
+ default: bool | None = None,
45
+ setable: bool | Callable[..., bool] | None = None,
46
+ is_readable: bool = True,
47
+ is_writeable: bool = True,
48
+ is_searchable: bool = True,
49
+ is_temporary: bool = False,
50
+ validators: clearskies.typing.validator | list[clearskies.typing.validator] = [],
51
+ on_change_pre_save: clearskies.typing.action | list[clearskies.typing.action] = [],
52
+ on_change_post_save: clearskies.typing.action | list[clearskies.typing.action] = [],
53
+ on_change_save_finished: clearskies.typing.action | list[clearskies.typing.action] = [],
54
+ on_true: clearskies.typing.action | list[clearskies.typing.action] = [],
55
+ on_false: clearskies.typing.action | list[clearskies.typing.action] = [],
56
+ created_by_source_type: str = "",
57
+ created_by_source_key: str = "",
58
+ created_by_source_strict: bool = True,
59
+ ):
60
+ pass
61
+
62
+ def from_backend(self, value) -> bool:
63
+ if value == "0":
64
+ return False
65
+ return bool(value)
66
+
67
+ def to_backend(self, data):
68
+ if self.name not in data:
69
+ return data
70
+
71
+ return {**data, self.name: bool(data[self.name])}
72
+
73
+ @overload
74
+ def __get__(self, instance: None, cls: type[Model]) -> Self:
75
+ pass
76
+
77
+ @overload
78
+ def __get__(self, instance: Model, cls: type[Model]) -> bool:
79
+ pass
80
+
81
+ def __get__(self, instance, cls):
82
+ return super().__get__(instance, cls)
83
+
84
+ def __set__(self, instance, value: bool) -> None:
85
+ # this makes sure we're initialized
86
+ if "name" not in self._config: # type: ignore
87
+ instance.get_columns()
88
+
89
+ instance._next_data[self.name] = value
90
+
91
+ def input_error_for_value(self, value: str, operator: str | None = None) -> str:
92
+ return f"{self.name} must be a boolean" if type(value) != bool else ""
93
+
94
+ def build_condition(self, value: str, operator: str | None = None, column_prefix: str = ""):
95
+ condition_value = "1" if value else "0"
96
+ if not operator:
97
+ operator = "="
98
+ return f"{column_prefix}{self.name}{operator}{condition_value}"
99
+
100
+ def save_finished(self, model: Model) -> None:
101
+ """Make any necessary changes needed after a save has completely finished."""
102
+ super().save_finished(model)
103
+
104
+ if (not self.on_true and not self.on_false) or not model.was_changed(self.name):
105
+ return
106
+
107
+ if getattr(model, self.name) and self.on_true:
108
+ self.execute_actions(self.on_true, model)
109
+ if not getattr(model, self.name) and self.on_false:
110
+ self.execute_actions(self.on_false, model)
111
+
112
+ def equals(self, value: bool) -> Condition:
113
+ return super().equals(value)
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable
4
+
5
+ import clearskies.decorators
6
+ import clearskies.typing
7
+ from clearskies import configs
8
+ from clearskies.columns.belongs_to_id import BelongsToId
9
+
10
+ if TYPE_CHECKING:
11
+ from clearskies import Model
12
+
13
+
14
+ class CategoryTree(BelongsToId):
15
+ """
16
+ The category tree helps you do quick lookups on a typical category tree.
17
+
18
+ It's a very niche tool. In general, graph databases solve this problem better, but
19
+ it's not always worth the effort of spinning up a new kind of database.
20
+
21
+ This column needs a special tree table where it will pre-compute and store the
22
+ necessary information to perform quick lookups about relationships in a cateogry
23
+ tree. So, imagine you have a table that represents a standard category heirarchy:
24
+
25
+ ```sql
26
+ CREATE TABLE categories (
27
+ id varchar(255),
28
+ parent_id varchar(255),
29
+ name varchar(255)
30
+ )
31
+
32
+ `parent_id`, in this case, would be a reference to the `categories` table itself -
33
+ hence the heirarchy. This works fine as a starting point but it gets tricky when you want to answer questions like
34
+ "what are all the parent categories of category X?" or "what are all the child categories of category Y?".
35
+ This column class solves that by building a tree table that caches this data as the categories are updated.
36
+ That table should look like this:
37
+
38
+ ```sql
39
+ CREATE TABLE category_tree (
40
+ id varchar(255),
41
+ parent_id varchar(255),
42
+ child_id varchar(255),
43
+ is_parent tinyint(1),
44
+ level tinyint(1),
45
+ )
46
+ ```
47
+
48
+ Then you would attach this column to your category model as a replacement for a typical BelongsToId relationship:
49
+
50
+ ```python
51
+ import clearskies
52
+
53
+ class Tree(clearskies.Model):
54
+ id_column_name = "id"
55
+ backend = clearskies.backends.MemoryBackend(silent_on_missing_tables=True)
56
+
57
+ id = clearskies.columns.Uuid()
58
+ parent_id = clearskies.columns.String()
59
+ child_id = clearskies.columns.String()
60
+ is_parent = clearskies.columns.Boolean()
61
+ level = clearskies.columns.Integer()
62
+
63
+ class Category(clearskies.Model):
64
+ id_column_name = "id"
65
+ backend = clearskies.backends.MemoryBackend(silent_on_missing_tables=True)
66
+
67
+ id = clearskies.columns.Uuid()
68
+ name = clearskies.columns.String()
69
+ parent_id = clearskies.columns.CategoryTree(Tree)
70
+ parent = clearskies.columns.BelongsToModel("parent_id")
71
+ children = clearskies.columns.CategoryTreeChildren("parent_id")
72
+ descendants = clearskies.columns.CategoryTreeDescendants("parent_id")
73
+ ancestors = clearskies.columns.CategoryTreeAncestors("parent_id")
74
+
75
+ def test_category_tree(category: Category):
76
+ root_1 = category.create({"name": "Root 1"})
77
+ root_2 = category.create({"name": "Root 2"})
78
+ sub_1_root_1 = category.create({"name": "Sub 1 of Root 1", "parent_id": root_1.id})
79
+ sub_2_root_1 = category.create({"name": "Sub 2 of Root 1", "parent_id": root_1.id})
80
+ sub_sub = category.create({"name": "Sub Sub", "parent_id": sub_1_root_1.id})
81
+ sub_1_root_2 = category.create({"name": "Sub 1 of Root 2", "parent_id": root_2.id})
82
+
83
+ return {
84
+ "descendants_of_root_1": [descendant.name for descendant in root_1.descendants],
85
+ "children_of_root_1": [child.name for child in root_1.children],
86
+ "descendants_of_root_2": [descendant.name for descendant in root_2.descendants],
87
+ "ancestors_of_sub_sub": [ancestor.name for ancestor in sub_sub.ancestors],
88
+ }
89
+
90
+ cli = clearskies.contexts.Cli(
91
+ clearskies.endpoints.Callable(test_category_tree),
92
+ classes=[Category, Tree],
93
+ )
94
+ cli()
95
+ ```
96
+
97
+ And if you invoke the above you will get:
98
+
99
+ ```json
100
+ {
101
+ "status": "success",
102
+ "error": "",
103
+ "data": {
104
+ "descendants_of_root_1": ["Sub 1 of Root 1", "Sub 2 of Root 1", "Sub Sub"],
105
+ "children_of_root_1": ["Sub 1 of Root 1", "Sub 2 of Root 1"],
106
+ "descendants_of_root_2": ["Sub 1 of Root 2"],
107
+ "ancestors_of_sub_sub": ["Root 1", "Sub 1 of Root 1"],
108
+ },
109
+ "pagination": {},
110
+ "input_errors": {},
111
+ }
112
+ ```
113
+
114
+ In case it's not clear, the definition of these things are:
115
+
116
+ 1. Descendants: All children under a given category (recursively).
117
+ 2. Children: The direct descendants of a given category.
118
+ 3. Ancestors: The parents of a given category, starting from the root category.
119
+ 4. Parent: the immediate parent of the category.
120
+
121
+ """
122
+
123
+ """
124
+ The model class that will persist our tree data
125
+ """
126
+ tree_model_class = configs.ModelClass(required=True)
127
+
128
+ """
129
+ The column in the tree model that references the parent in the relationship
130
+ """
131
+ tree_parent_id_column_name = configs.ModelColumn("tree_model_class", default="parent_id")
132
+
133
+ """
134
+ The column in the tree model that references the child in the relationship
135
+ """
136
+ tree_child_id_column_name = configs.ModelColumn("tree_model_class", default="child_id")
137
+
138
+ """
139
+ The column in the tree model that denotes which node in the relationship represents the tree
140
+ """
141
+ tree_is_parent_column_name = configs.ModelColumn("tree_model_class", default="is_parent")
142
+
143
+ """
144
+ The column in the tree model that references the parent in a relationship
145
+ """
146
+ tree_level_column_name = configs.ModelColumn("tree_model_class", default="level")
147
+
148
+ """
149
+ The maximum expected depth of the tree
150
+ """
151
+ max_iterations = configs.Integer(default=100)
152
+
153
+ """
154
+ The strategy for loading relatives.
155
+
156
+ Choose whatever one actually works for your backend
157
+
158
+ * JOIN: use an actual `JOIN` (e.g. quick and efficient, but mostly only works for SQL backends).
159
+ * WHERE IN: Use a `WHERE IN` condition.
160
+ * INDIVIDUAL: Load each record separately. Works for any backend but is also the slowest.
161
+ """
162
+ load_relatives_strategy = configs.Select(["join", "where_in", "individual"], default="join")
163
+
164
+ _descriptor_config_map = None
165
+
166
+ @clearskies.decorators.parameters_to_properties
167
+ def __init__(
168
+ self,
169
+ tree_model_class,
170
+ tree_parent_id_column_name: str = "parent_id",
171
+ tree_child_id_column_name: str = "child_id",
172
+ tree_is_parent_column_name: str = "is_parent",
173
+ tree_level_column_name: str = "level",
174
+ max_iterations: int = 100,
175
+ load_relatives_strategy: str = "join",
176
+ readable_parent_columns: list[str] = [],
177
+ join_type: str | None = None,
178
+ where: clearskies.typing.condition | list[clearskies.typing.condition] = [],
179
+ default: str | None = None,
180
+ setable: str | Callable | None = None,
181
+ is_readable: bool = True,
182
+ is_writeable: bool = True,
183
+ is_searchable: bool = True,
184
+ is_temporary: bool = False,
185
+ validators: clearskies.typing.validator | list[clearskies.typing.validator] = [],
186
+ on_change_pre_save: clearskies.typing.action | list[clearskies.typing.action] = [],
187
+ on_change_post_save: clearskies.typing.action | list[clearskies.typing.action] = [],
188
+ on_change_save_finished: clearskies.typing.action | list[clearskies.typing.action] = [],
189
+ created_by_source_type: str = "",
190
+ created_by_source_key: str = "",
191
+ created_by_source_strict: bool = True,
192
+ ):
193
+ pass
194
+
195
+ def finalize_configuration(self, model_class, name) -> None:
196
+ """
197
+ Finalize and check the configuration.
198
+
199
+ This is an external trigger called by the model class when the model class is ready.
200
+ The reason it exists here instead of in the constructor is because some columns are tightly
201
+ connected to the model class, and can't validate configuration until they know what the model is.
202
+ Therefore, we need the model involved, and the only way for a property to know what class it is
203
+ in is if the parent class checks in (which is what happens here).
204
+ """
205
+ self.parent_model_class = model_class
206
+ super().finalize_configuration(model_class, name)
207
+
208
+ @property
209
+ def tree_model(self):
210
+ return self.di.build(self.tree_model_class, cache=True)
211
+
212
+ def post_save(self, data: dict[str, Any], model: Model, id: int | str) -> None:
213
+ if not model.is_changing(self.name, data):
214
+ return
215
+
216
+ self.update_tree_table(model, id, model.latest(self.name, data))
217
+ return
218
+
219
+ def force_tree_update(self, model: Model):
220
+ self.update_tree_table(model, getattr(model, model.id_column_name), getattr(model, self.name))
221
+
222
+ def update_tree_table(self, model: Model, child_id: int | str, direct_parent_id: int | str) -> None:
223
+ tree_model = self.tree_model
224
+ parent_model = self.parent_model
225
+ tree_parent_id_column_name = self.tree_parent_id_column_name
226
+ tree_child_id_column_name = self.tree_child_id_column_name
227
+ tree_is_parent_column_name = self.tree_is_parent_column_name
228
+ tree_level_column_name = self.tree_level_column_name
229
+ max_iterations = self.max_iterations
230
+
231
+ # we're going to be lazy and just delete the data for the current record in the tree table,
232
+ # and then re-insert everything (but we can skip this if creating a new record)
233
+ if model:
234
+ for tree in tree_model.where(f"{tree_child_id_column_name}={child_id}"):
235
+ tree.delete()
236
+
237
+ # if we are a root category then we don't have a tree
238
+ if not direct_parent_id:
239
+ return
240
+
241
+ is_root = False
242
+ id_column_name = parent_model.id_column_name
243
+ next_parent = parent_model.find(f"{id_column_name}={direct_parent_id}")
244
+ tree = []
245
+ c = 0
246
+ while not is_root:
247
+ c += 1
248
+ if c > max_iterations:
249
+ self._circular(max_iterations)
250
+
251
+ tree.append(getattr(next_parent, next_parent.id_column_name))
252
+ if not getattr(next_parent, self.name):
253
+ is_root = True
254
+ else:
255
+ next_next_parent_id = getattr(next_parent, self.name)
256
+ next_parent = model.find(f"{id_column_name}={next_next_parent_id}")
257
+
258
+ tree.reverse()
259
+ for index, parent_id in enumerate(tree):
260
+ tree_model.create(
261
+ {
262
+ tree_parent_id_column_name: parent_id,
263
+ tree_child_id_column_name: child_id,
264
+ tree_is_parent_column_name: 1 if parent_id == direct_parent_id else 0,
265
+ tree_level_column_name: index,
266
+ }
267
+ )
268
+
269
+ def _circular(self, max_iterations):
270
+ raise ValueError(
271
+ f"Error for column {self.model_class.__name__}.{self.name}: "
272
+ + f"I've climbed through {max_iterations} parents and haven't found the root yet."
273
+ + "You may have accidentally created a circular cateogry tree. If not, and your category tree "
274
+ + "really _is_ that deep, then adjust the 'max_iterations' configuration for this column accordingly. "
275
+ )
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Self, overload
4
+
5
+ from clearskies.columns.category_tree_children import CategoryTreeChildren
6
+
7
+ if TYPE_CHECKING:
8
+ from clearskies import Model
9
+
10
+
11
+ class CategoryTreeAncestors(CategoryTreeChildren):
12
+ """
13
+ A column to fetch the ancestors from a category tree column.
14
+
15
+ See the CategoryTree column for usage examples.
16
+
17
+ The ancestors are all parents of a given category, starting from the root category and working
18
+ down to the direct parent. So, given the following category tree:
19
+
20
+ ```
21
+ Root/
22
+ ├─ Sub/
23
+ │ ├─ Sub Sub/
24
+ │ │ ├─ Sub Sub Sub/
25
+ ├─ Another Child/
26
+ ```
27
+
28
+ The ancesotrs of `Sub Sub Sub` are `["Root", "Sub", "Sub Sub"]` while the ancestors of `Another Child`
29
+ are `["Root"]`
30
+ """
31
+
32
+ _descriptor_config_map = None
33
+
34
+ @overload
35
+ def __get__(self, instance: None, cls: type) -> Self:
36
+ pass
37
+
38
+ @overload
39
+ def __get__(self, instance: Model, cls: type) -> Model:
40
+ pass
41
+
42
+ def __get__(self, model, cls):
43
+ if model is None:
44
+ self.model_class = cls
45
+ return self # type: ignore
46
+
47
+ # this makes sure we're initialized
48
+ if "name" not in self._config: # type: ignore
49
+ model.get_columns()
50
+
51
+ return self.relatives(model, find_parents=True, include_all=True)
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Self, overload
4
+
5
+ import clearskies.decorators
6
+ from clearskies import configs
7
+ from clearskies.column import Column
8
+ from clearskies.columns import CategoryTree
9
+
10
+ if TYPE_CHECKING:
11
+ from clearskies import Model
12
+
13
+
14
+ class CategoryTreeChildren(Column):
15
+ """
16
+ Return the child categories from a category tree column.
17
+
18
+ See the CategoryTree column for usage examples.
19
+
20
+ The ancestors are all direct descendants of a given category. So, given the following tree:
21
+
22
+ ```
23
+ Root/
24
+ ├─ Sub/
25
+ │ ├─ Sub Sub/
26
+ │ │ ├─ Sub Sub Sub/
27
+ ├─ Another Child/
28
+
29
+ The children of `Root` are `["Sub", "Another Child"]`. The children of `Sub Sub` are `["Sub Sub Sub"]`.
30
+ """
31
+
32
+ """ The name of the category tree column we are connected to. """
33
+ category_tree_column_name = configs.ModelColumn(required=True)
34
+
35
+ is_writeable = configs.Boolean(default=False)
36
+ is_searchable = configs.Boolean(default=False)
37
+ _descriptor_config_map = None
38
+
39
+ @clearskies.decorators.parameters_to_properties
40
+ def __init__(
41
+ self,
42
+ category_tree_column_name: str,
43
+ ):
44
+ pass
45
+
46
+ def finalize_configuration(self, model_class: type, name: str) -> None:
47
+ """Finalize and check the configuration."""
48
+ getattr(self.__class__, "category_tree_column_name").set_model_class(model_class)
49
+ self.model_class = model_class
50
+ self.name = name
51
+ self.finalize_and_validate_configuration()
52
+
53
+ # double check that we are pointed to a category tree column
54
+ category_tree_column = getattr(model_class, self.category_tree_column_name)
55
+ if not isinstance(category_tree_column, CategoryTree):
56
+ raise ValueError(
57
+ f"Error with configuration for {model_class.__name__}.{name}, which is a {self.__class__.__name__}. It needs to point to a category tree column, and it was told to use {model_class.__name__}.{self.category_tree_column_name}, but this is not a CategoryTree column."
58
+ )
59
+
60
+ @overload
61
+ def __get__(self, instance: None, cls: type[Model]) -> Self:
62
+ pass
63
+
64
+ @overload
65
+ def __get__(self, instance: Model, cls: type[Model]) -> Model:
66
+ pass
67
+
68
+ def __get__(self, model, cls):
69
+ if model is None:
70
+ self.model_class = cls
71
+ return self # type: ignore
72
+
73
+ # this makes sure we're initialized
74
+ if "name" not in self._config: # type: ignore
75
+ model.get_columns()
76
+
77
+ return self.relatives(model)
78
+
79
+ def __set__(self, model: Model, value: Model) -> None:
80
+ raise ValueError(
81
+ f"Attempt to set a value to '{model.__class__.__name__}.{self.name}, but this column is not writeable"
82
+ )
83
+
84
+ def relatives(self, model: Model, include_all: bool = False, find_parents: bool = False) -> Model | list[Model]:
85
+ id_column_name = model.id_column_name
86
+ model_id = getattr(model, id_column_name)
87
+ model_table_name = model.destination_name()
88
+ category_tree_column = getattr(self.model_class, self.category_tree_column_name)
89
+ tree_table_name = category_tree_column.tree_model_class.destination_name()
90
+ parent_id_column_name = category_tree_column.tree_parent_id_column_name
91
+ child_id_column_name = category_tree_column.tree_child_id_column_name
92
+ is_parent_column_name = category_tree_column.tree_is_parent_column_name
93
+ level_column_name = category_tree_column.tree_level_column_name
94
+
95
+ if find_parents:
96
+ join_on = parent_id_column_name
97
+ search_on = child_id_column_name
98
+ else:
99
+ join_on = child_id_column_name
100
+ search_on = parent_id_column_name
101
+
102
+ # if we can join then use a join.
103
+ if category_tree_column.load_relatives_strategy:
104
+ relatives = category_tree_column.parent_model.join(
105
+ f"JOIN {tree_table_name} as tree on tree.{join_on}={model_table_name}.{id_column_name}"
106
+ )
107
+ relatives = relatives.where(f"tree.{search_on}={model_id}")
108
+ if not include_all:
109
+ relatives = relatives.where(f"tree.{is_parent_column_name}=1")
110
+ if find_parents:
111
+ relatives = relatives.sort_by(level_column_name, "asc", "tree")
112
+ return relatives
113
+
114
+ # joins only work for SQL-like backends. Otherwise, we have to pull out our list of ids
115
+ branches = category_tree_column.tree_model.where(f"{search_on}={model_id}")
116
+ if not include_all:
117
+ branches = branches.where(f"{is_parent_column_name}=1")
118
+ if find_parents:
119
+ branches = branches.sort_by(level_column_name, "asc")
120
+ ids = [str(branch.get(join_on)) for branch in branches]
121
+
122
+ # Can we search with a WHERE IN() clause? If the backend supports it, it is probably faster
123
+ if category_tree_column.load_relatives_strategy == "where_in":
124
+ return category_tree_column.parent_model.where(f"{id_column_name} IN ('" + "','".join(ids) + "')")
125
+
126
+ # otherwise we have to load each model individually which is SLOW....
127
+ return [category_tree_column.parent_model.find(f"{id_column_name}={id}") for id in ids]