clear-skies 1.22.31__py3-none-any.whl → 2.0.0__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 (344) hide show
  1. {clear_skies-1.22.31.dist-info → clear_skies-2.0.0.dist-info}/METADATA +11 -13
  2. clear_skies-2.0.0.dist-info/RECORD +248 -0
  3. {clear_skies-1.22.31.dist-info → clear_skies-2.0.0.dist-info}/WHEEL +1 -1
  4. clearskies/__init__.py +42 -25
  5. clearskies/action.py +7 -0
  6. clearskies/authentication/__init__.py +8 -41
  7. clearskies/authentication/authentication.py +42 -0
  8. clearskies/authentication/authorization.py +4 -9
  9. clearskies/authentication/authorization_pass_through.py +11 -9
  10. clearskies/authentication/jwks.py +128 -58
  11. clearskies/authentication/public.py +3 -38
  12. clearskies/authentication/secret_bearer.py +516 -54
  13. clearskies/autodoc/formats/oai3_json/__init__.py +1 -1
  14. clearskies/autodoc/formats/oai3_json/oai3_json.py +9 -7
  15. clearskies/autodoc/formats/oai3_json/parameter.py +6 -3
  16. clearskies/autodoc/formats/oai3_json/request.py +7 -5
  17. clearskies/autodoc/formats/oai3_json/response.py +7 -4
  18. clearskies/autodoc/formats/oai3_json/schema/object.py +4 -1
  19. clearskies/autodoc/request/__init__.py +2 -0
  20. clearskies/autodoc/request/header.py +4 -6
  21. clearskies/autodoc/request/json_body.py +4 -6
  22. clearskies/autodoc/request/parameter.py +8 -0
  23. clearskies/autodoc/request/request.py +7 -4
  24. clearskies/autodoc/request/url_parameter.py +4 -6
  25. clearskies/autodoc/request/url_path.py +4 -6
  26. clearskies/autodoc/schema/__init__.py +4 -2
  27. clearskies/autodoc/schema/array.py +5 -6
  28. clearskies/autodoc/schema/boolean.py +4 -10
  29. clearskies/autodoc/schema/date.py +0 -3
  30. clearskies/autodoc/schema/datetime.py +1 -4
  31. clearskies/autodoc/schema/double.py +0 -3
  32. clearskies/autodoc/schema/enum.py +4 -2
  33. clearskies/autodoc/schema/integer.py +4 -9
  34. clearskies/autodoc/schema/long.py +0 -3
  35. clearskies/autodoc/schema/number.py +4 -9
  36. clearskies/autodoc/schema/object.py +5 -7
  37. clearskies/autodoc/schema/password.py +0 -3
  38. clearskies/autodoc/schema/schema.py +11 -0
  39. clearskies/autodoc/schema/string.py +4 -10
  40. clearskies/backends/__init__.py +55 -20
  41. clearskies/backends/api_backend.py +1100 -284
  42. clearskies/backends/backend.py +40 -84
  43. clearskies/backends/cursor_backend.py +236 -186
  44. clearskies/backends/memory_backend.py +519 -226
  45. clearskies/backends/secrets_backend.py +75 -31
  46. clearskies/column.py +1232 -0
  47. clearskies/columns/__init__.py +71 -0
  48. clearskies/columns/audit.py +205 -0
  49. clearskies/columns/belongs_to_id.py +483 -0
  50. clearskies/columns/belongs_to_model.py +128 -0
  51. clearskies/columns/belongs_to_self.py +105 -0
  52. clearskies/columns/boolean.py +109 -0
  53. clearskies/columns/category_tree.py +275 -0
  54. clearskies/columns/category_tree_ancestors.py +51 -0
  55. clearskies/columns/category_tree_children.py +127 -0
  56. clearskies/columns/category_tree_descendants.py +48 -0
  57. clearskies/columns/created.py +94 -0
  58. clearskies/columns/created_by_authorization_data.py +116 -0
  59. clearskies/columns/created_by_header.py +99 -0
  60. clearskies/columns/created_by_ip.py +92 -0
  61. clearskies/columns/created_by_routing_data.py +96 -0
  62. clearskies/columns/created_by_user_agent.py +92 -0
  63. clearskies/columns/date.py +230 -0
  64. clearskies/columns/datetime.py +278 -0
  65. clearskies/columns/email.py +76 -0
  66. clearskies/columns/float.py +149 -0
  67. clearskies/columns/has_many.py +505 -0
  68. clearskies/columns/has_many_self.py +56 -0
  69. clearskies/columns/has_one.py +14 -0
  70. clearskies/columns/integer.py +156 -0
  71. clearskies/columns/json.py +122 -0
  72. clearskies/columns/many_to_many_ids.py +333 -0
  73. clearskies/columns/many_to_many_ids_with_data.py +270 -0
  74. clearskies/columns/many_to_many_models.py +154 -0
  75. clearskies/columns/many_to_many_pivots.py +133 -0
  76. clearskies/columns/phone.py +158 -0
  77. clearskies/columns/select.py +91 -0
  78. clearskies/columns/string.py +98 -0
  79. clearskies/columns/timestamp.py +160 -0
  80. clearskies/columns/updated.py +110 -0
  81. clearskies/columns/uuid.py +86 -0
  82. clearskies/configs/README.md +105 -0
  83. clearskies/configs/__init__.py +159 -0
  84. clearskies/configs/actions.py +43 -0
  85. clearskies/configs/any.py +13 -0
  86. clearskies/configs/any_dict.py +22 -0
  87. clearskies/configs/any_dict_or_callable.py +23 -0
  88. clearskies/configs/authentication.py +23 -0
  89. clearskies/configs/authorization.py +23 -0
  90. clearskies/configs/boolean.py +16 -0
  91. clearskies/configs/boolean_or_callable.py +18 -0
  92. clearskies/configs/callable_config.py +18 -0
  93. clearskies/configs/columns.py +34 -0
  94. clearskies/configs/conditions.py +30 -0
  95. clearskies/configs/config.py +21 -0
  96. clearskies/configs/datetime.py +18 -0
  97. clearskies/configs/datetime_or_callable.py +19 -0
  98. clearskies/configs/endpoint.py +23 -0
  99. clearskies/configs/float.py +16 -0
  100. clearskies/configs/float_or_callable.py +18 -0
  101. clearskies/configs/integer.py +16 -0
  102. clearskies/configs/integer_or_callable.py +18 -0
  103. clearskies/configs/joins.py +30 -0
  104. clearskies/configs/list_any_dict.py +30 -0
  105. clearskies/configs/list_any_dict_or_callable.py +31 -0
  106. clearskies/configs/model_class.py +35 -0
  107. clearskies/configs/model_column.py +65 -0
  108. clearskies/configs/model_columns.py +56 -0
  109. clearskies/configs/model_destination_name.py +25 -0
  110. clearskies/configs/model_to_id_column.py +43 -0
  111. clearskies/configs/readable_model_column.py +9 -0
  112. clearskies/configs/readable_model_columns.py +9 -0
  113. clearskies/configs/schema.py +23 -0
  114. clearskies/configs/searchable_model_columns.py +9 -0
  115. clearskies/configs/security_headers.py +39 -0
  116. clearskies/configs/select.py +26 -0
  117. clearskies/configs/select_list.py +47 -0
  118. clearskies/configs/string.py +29 -0
  119. clearskies/configs/string_dict.py +32 -0
  120. clearskies/configs/string_list.py +32 -0
  121. clearskies/configs/string_list_or_callable.py +35 -0
  122. clearskies/configs/string_or_callable.py +18 -0
  123. clearskies/configs/timedelta.py +18 -0
  124. clearskies/configs/timezone.py +18 -0
  125. clearskies/configs/url.py +23 -0
  126. clearskies/configs/validators.py +45 -0
  127. clearskies/configs/writeable_model_column.py +9 -0
  128. clearskies/configs/writeable_model_columns.py +9 -0
  129. clearskies/configurable.py +76 -0
  130. clearskies/contexts/__init__.py +8 -8
  131. clearskies/contexts/cli.py +5 -42
  132. clearskies/contexts/context.py +78 -56
  133. clearskies/contexts/wsgi.py +13 -30
  134. clearskies/contexts/wsgi_ref.py +49 -0
  135. clearskies/di/__init__.py +10 -7
  136. clearskies/di/additional_config.py +115 -4
  137. clearskies/di/additional_config_auto_import.py +12 -0
  138. clearskies/di/di.py +742 -121
  139. clearskies/di/inject/__init__.py +23 -0
  140. clearskies/di/inject/by_class.py +21 -0
  141. clearskies/di/inject/by_name.py +18 -0
  142. clearskies/di/inject/di.py +13 -0
  143. clearskies/di/inject/environment.py +14 -0
  144. clearskies/di/inject/input_output.py +20 -0
  145. clearskies/di/inject/now.py +13 -0
  146. clearskies/di/inject/requests.py +13 -0
  147. clearskies/di/inject/secrets.py +14 -0
  148. clearskies/di/inject/utcnow.py +13 -0
  149. clearskies/di/inject/uuid.py +15 -0
  150. clearskies/di/injectable.py +29 -0
  151. clearskies/di/injectable_properties.py +131 -0
  152. clearskies/end.py +183 -0
  153. clearskies/endpoint.py +1309 -0
  154. clearskies/endpoint_group.py +297 -0
  155. clearskies/endpoints/__init__.py +23 -0
  156. clearskies/endpoints/advanced_search.py +526 -0
  157. clearskies/endpoints/callable.py +387 -0
  158. clearskies/endpoints/create.py +202 -0
  159. clearskies/endpoints/delete.py +139 -0
  160. clearskies/endpoints/get.py +275 -0
  161. clearskies/endpoints/health_check.py +181 -0
  162. clearskies/endpoints/list.py +573 -0
  163. clearskies/endpoints/restful_api.py +427 -0
  164. clearskies/endpoints/simple_search.py +286 -0
  165. clearskies/endpoints/update.py +190 -0
  166. clearskies/environment.py +5 -3
  167. clearskies/exceptions/__init__.py +17 -0
  168. clearskies/{handlers/exceptions/input_error.py → exceptions/input_errors.py} +1 -1
  169. clearskies/exceptions/moved_permanently.py +3 -0
  170. clearskies/exceptions/moved_temporarily.py +3 -0
  171. clearskies/exceptions/not_found.py +2 -0
  172. clearskies/functional/__init__.py +2 -2
  173. clearskies/functional/routing.py +92 -0
  174. clearskies/functional/string.py +19 -11
  175. clearskies/functional/validations.py +61 -9
  176. clearskies/input_outputs/__init__.py +9 -7
  177. clearskies/input_outputs/cli.py +130 -142
  178. clearskies/input_outputs/exceptions/__init__.py +1 -1
  179. clearskies/input_outputs/headers.py +45 -0
  180. clearskies/input_outputs/input_output.py +91 -122
  181. clearskies/input_outputs/programmatic.py +69 -0
  182. clearskies/input_outputs/wsgi.py +23 -38
  183. clearskies/model.py +489 -184
  184. clearskies/parameters_to_properties.py +31 -0
  185. clearskies/query/__init__.py +12 -0
  186. clearskies/query/condition.py +223 -0
  187. clearskies/query/join.py +136 -0
  188. clearskies/query/query.py +196 -0
  189. clearskies/query/sort.py +27 -0
  190. clearskies/schema.py +82 -0
  191. clearskies/secrets/__init__.py +3 -31
  192. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +15 -4
  193. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +11 -5
  194. clearskies/secrets/akeyless.py +88 -147
  195. clearskies/secrets/secrets.py +8 -8
  196. clearskies/security_header.py +8 -0
  197. clearskies/security_headers/__init__.py +8 -8
  198. clearskies/security_headers/cache_control.py +47 -110
  199. clearskies/security_headers/cors.py +40 -95
  200. clearskies/security_headers/csp.py +76 -151
  201. clearskies/security_headers/hsts.py +14 -16
  202. clearskies/test_base.py +8 -0
  203. clearskies/typing.py +11 -0
  204. clearskies/validator.py +25 -0
  205. clearskies/validators/__init__.py +33 -0
  206. clearskies/validators/after_column.py +62 -0
  207. clearskies/validators/before_column.py +13 -0
  208. clearskies/validators/in_the_future.py +32 -0
  209. clearskies/validators/in_the_future_at_least.py +11 -0
  210. clearskies/validators/in_the_future_at_most.py +10 -0
  211. clearskies/validators/in_the_past.py +32 -0
  212. clearskies/validators/in_the_past_at_least.py +10 -0
  213. clearskies/validators/in_the_past_at_most.py +10 -0
  214. clearskies/validators/maximum_length.py +26 -0
  215. clearskies/validators/maximum_value.py +29 -0
  216. clearskies/validators/minimum_length.py +26 -0
  217. clearskies/validators/minimum_value.py +29 -0
  218. clearskies/validators/required.py +35 -0
  219. clearskies/validators/timedelta.py +59 -0
  220. clearskies/validators/unique.py +31 -0
  221. clear_skies-1.22.31.dist-info/RECORD +0 -214
  222. clearskies/application.py +0 -29
  223. clearskies/authentication/auth0_jwks.py +0 -118
  224. clearskies/authentication/auth_exception.py +0 -2
  225. clearskies/authentication/jwks_jwcrypto.py +0 -51
  226. clearskies/backends/api_get_only_backend.py +0 -48
  227. clearskies/backends/example_backend.py +0 -43
  228. clearskies/backends/file_backend.py +0 -48
  229. clearskies/backends/json_backend.py +0 -7
  230. clearskies/backends/restful_api_advanced_search_backend.py +0 -103
  231. clearskies/binding_config.py +0 -16
  232. clearskies/column_types/__init__.py +0 -203
  233. clearskies/column_types/audit.py +0 -249
  234. clearskies/column_types/belongs_to.py +0 -271
  235. clearskies/column_types/boolean.py +0 -60
  236. clearskies/column_types/category_tree.py +0 -304
  237. clearskies/column_types/column.py +0 -373
  238. clearskies/column_types/created.py +0 -26
  239. clearskies/column_types/created_by_authorization_data.py +0 -26
  240. clearskies/column_types/created_by_header.py +0 -24
  241. clearskies/column_types/created_by_ip.py +0 -17
  242. clearskies/column_types/created_by_routing_data.py +0 -25
  243. clearskies/column_types/created_by_user_agent.py +0 -17
  244. clearskies/column_types/created_micro.py +0 -26
  245. clearskies/column_types/datetime.py +0 -109
  246. clearskies/column_types/datetime_micro.py +0 -12
  247. clearskies/column_types/email.py +0 -18
  248. clearskies/column_types/float.py +0 -43
  249. clearskies/column_types/has_many.py +0 -179
  250. clearskies/column_types/has_one.py +0 -60
  251. clearskies/column_types/integer.py +0 -41
  252. clearskies/column_types/json.py +0 -25
  253. clearskies/column_types/many_to_many.py +0 -278
  254. clearskies/column_types/many_to_many_with_data.py +0 -162
  255. clearskies/column_types/phone.py +0 -48
  256. clearskies/column_types/select.py +0 -11
  257. clearskies/column_types/string.py +0 -24
  258. clearskies/column_types/timestamp.py +0 -73
  259. clearskies/column_types/updated.py +0 -26
  260. clearskies/column_types/updated_micro.py +0 -26
  261. clearskies/column_types/uuid.py +0 -25
  262. clearskies/columns.py +0 -123
  263. clearskies/condition_parser.py +0 -172
  264. clearskies/contexts/build_context.py +0 -54
  265. clearskies/contexts/convert_to_application.py +0 -190
  266. clearskies/contexts/extract_handler.py +0 -37
  267. clearskies/contexts/test.py +0 -94
  268. clearskies/decorators/__init__.py +0 -41
  269. clearskies/decorators/allow_non_json_bodies.py +0 -9
  270. clearskies/decorators/auth0_jwks.py +0 -22
  271. clearskies/decorators/authorization.py +0 -10
  272. clearskies/decorators/binding_classes.py +0 -9
  273. clearskies/decorators/binding_modules.py +0 -9
  274. clearskies/decorators/bindings.py +0 -9
  275. clearskies/decorators/create.py +0 -10
  276. clearskies/decorators/delete.py +0 -10
  277. clearskies/decorators/docs.py +0 -14
  278. clearskies/decorators/get.py +0 -10
  279. clearskies/decorators/jwks.py +0 -26
  280. clearskies/decorators/merge.py +0 -124
  281. clearskies/decorators/patch.py +0 -10
  282. clearskies/decorators/post.py +0 -10
  283. clearskies/decorators/public.py +0 -11
  284. clearskies/decorators/response_headers.py +0 -10
  285. clearskies/decorators/return_raw_response.py +0 -9
  286. clearskies/decorators/schema.py +0 -10
  287. clearskies/decorators/secret_bearer.py +0 -24
  288. clearskies/decorators/security_headers.py +0 -10
  289. clearskies/di/standard_dependencies.py +0 -151
  290. clearskies/handlers/__init__.py +0 -41
  291. clearskies/handlers/advanced_search.py +0 -271
  292. clearskies/handlers/base.py +0 -479
  293. clearskies/handlers/callable.py +0 -192
  294. clearskies/handlers/create.py +0 -35
  295. clearskies/handlers/crud_by_method.py +0 -18
  296. clearskies/handlers/database_connector.py +0 -32
  297. clearskies/handlers/delete.py +0 -61
  298. clearskies/handlers/exceptions/__init__.py +0 -5
  299. clearskies/handlers/exceptions/not_found.py +0 -3
  300. clearskies/handlers/get.py +0 -156
  301. clearskies/handlers/health_check.py +0 -59
  302. clearskies/handlers/input_processing.py +0 -79
  303. clearskies/handlers/list.py +0 -530
  304. clearskies/handlers/mygrations.py +0 -82
  305. clearskies/handlers/request_method_routing.py +0 -47
  306. clearskies/handlers/restful_api.py +0 -218
  307. clearskies/handlers/routing.py +0 -62
  308. clearskies/handlers/schema_helper.py +0 -128
  309. clearskies/handlers/simple_routing.py +0 -206
  310. clearskies/handlers/simple_routing_route.py +0 -197
  311. clearskies/handlers/simple_search.py +0 -136
  312. clearskies/handlers/update.py +0 -102
  313. clearskies/handlers/write.py +0 -193
  314. clearskies/input_requirements/__init__.py +0 -78
  315. clearskies/input_requirements/after.py +0 -36
  316. clearskies/input_requirements/before.py +0 -36
  317. clearskies/input_requirements/in_the_future_at_least.py +0 -19
  318. clearskies/input_requirements/in_the_future_at_most.py +0 -19
  319. clearskies/input_requirements/in_the_past_at_least.py +0 -19
  320. clearskies/input_requirements/in_the_past_at_most.py +0 -19
  321. clearskies/input_requirements/maximum_length.py +0 -19
  322. clearskies/input_requirements/maximum_value.py +0 -19
  323. clearskies/input_requirements/minimum_length.py +0 -22
  324. clearskies/input_requirements/minimum_value.py +0 -19
  325. clearskies/input_requirements/required.py +0 -23
  326. clearskies/input_requirements/requirement.py +0 -25
  327. clearskies/input_requirements/time_delta.py +0 -38
  328. clearskies/input_requirements/unique.py +0 -18
  329. clearskies/mocks/__init__.py +0 -7
  330. clearskies/mocks/input_output.py +0 -124
  331. clearskies/mocks/models.py +0 -142
  332. clearskies/models.py +0 -350
  333. clearskies/security_headers/base.py +0 -12
  334. clearskies/tests/simple_api/models/__init__.py +0 -2
  335. clearskies/tests/simple_api/models/status.py +0 -23
  336. clearskies/tests/simple_api/models/user.py +0 -21
  337. clearskies/tests/simple_api/users_api.py +0 -64
  338. {clear_skies-1.22.31.dist-info → clear_skies-2.0.0.dist-info}/LICENSE +0 -0
  339. /clearskies/{contexts/bash.py → autodoc/py.typed} +0 -0
  340. /clearskies/{handlers/exceptions → exceptions}/authentication.py +0 -0
  341. /clearskies/{handlers/exceptions → exceptions}/authorization.py +0 -0
  342. /clearskies/{handlers/exceptions → exceptions}/client_error.py +0 -0
  343. /clearskies/{tests/__init__.py → input_outputs/py.typed} +0 -0
  344. /clearskies/{tests/simple_api/__init__.py → py.typed} +0 -0
clearskies/column.py ADDED
@@ -0,0 +1,1232 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable, Self, Type, overload
4
+
5
+ import clearskies.configs.actions
6
+ import clearskies.configs.boolean
7
+ import clearskies.configs.select
8
+ import clearskies.configs.string
9
+ import clearskies.configs.string_or_callable
10
+ import clearskies.configs.validators
11
+ import clearskies.configurable
12
+ import clearskies.di
13
+ import clearskies.model
14
+ import clearskies.parameters_to_properties
15
+ import clearskies.typing
16
+ from clearskies.autodoc.schema import Schema as AutoDocSchema
17
+ from clearskies.autodoc.schema import String as AutoDocString
18
+ from clearskies.query.condition import Condition, ParsedCondition
19
+ from clearskies.validator import Validator
20
+
21
+ if TYPE_CHECKING:
22
+ from clearskies import Model, Schema
23
+
24
+
25
+ class Column(clearskies.configurable.Configurable, clearskies.di.InjectableProperties):
26
+ """
27
+ The base column.
28
+
29
+ This class (well, the children that extend it) are used to define the columns that exist in a given model class -
30
+ they help you build your schema. The column definitions are then used by handlers and other aspects of the
31
+ clearskies framework to automate things like input validation, front-end/backend-transformations, and more.
32
+ Many column classes have their own configuration settings, but there are also some common configuration settings
33
+ defined here.
34
+ """
35
+
36
+ """
37
+ The column class gets the full DI container, because it does a lot of object building itself
38
+ """
39
+ di = clearskies.di.inject.Di()
40
+
41
+ """
42
+ A default value to set for this column.
43
+
44
+ The default is only used when creating a record for the first time, and only if
45
+ a value for this column has not been set.
46
+
47
+ ```python
48
+ import clearskies
49
+
50
+ class Widget(clearskies.Model):
51
+ id_column_name = "id"
52
+ backend = clearskies.backends.MemoryBackend()
53
+
54
+ id = clearskies.columns.Uuid()
55
+ name = clearskies.columns.String(default="Jane Doe")
56
+
57
+ cli = clearskies.contexts.Cli(
58
+ clearskies.endpoints.Callable(
59
+ lambda widgets: widgets.create(no_data=True),
60
+ model_class=Widget,
61
+ readable_column_names=["id", "name"]
62
+ ),
63
+ classes=[Widget],
64
+ )
65
+
66
+ if __name__ == "__main__":
67
+ cli()
68
+ ```
69
+
70
+ Which when invoked returns:
71
+
72
+ ```json
73
+ {
74
+ "status": "success",
75
+ "error": "",
76
+ "data": {
77
+ "id": "03806afa-b189-4729-a43c-9da5aa17bf14",
78
+ "name": "Jane Doe"
79
+ },
80
+ "pagination": {},
81
+ "input_errors": {}
82
+ }
83
+ ```
84
+ """
85
+ default = clearskies.configs.string.String(default=None)
86
+
87
+ """
88
+ A value to set for this column during a save operation.
89
+
90
+ Unlike the default value, a setable value is always set during a save, even on update. It will
91
+ even override other values, so it is intended to be used in cases where the value is always controlled
92
+ programmatically.
93
+
94
+ ```python
95
+ import clearskies
96
+ import datetime
97
+
98
+ class Pet(clearskies.Model):
99
+ id_column_name = "id"
100
+ backend = clearskies.backends.MemoryBackend()
101
+
102
+ id = clearskies.columns.Uuid()
103
+ name = clearskies.columns.String(setable="Spot")
104
+ date_of_birth = clearskies.columns.Date()
105
+ age = clearskies.columns.Integer(
106
+ setable=lambda data, model, now:
107
+ (now-dateparser.parse(model.latest("date_of_birth", data))).total_seconds()/(86400*365),
108
+ )
109
+ created = clearskies.columns.Created()
110
+
111
+ cli = clearskies.contexts.Cli(
112
+ clearskies.endpoints.Callable(
113
+ lambda pets: pets.create({"date_of_birth": "2020-05-03"}),
114
+ model_class=Pet,
115
+ readable_column_names=["id", "name", "date_of_birth", "age"]
116
+ ),
117
+ classes=[Pet],
118
+ )
119
+
120
+ if __name__ == "__main__":
121
+ cli()
122
+ ```
123
+
124
+ Note the use of `model.latest()` above. This function returns either the column information from the data array
125
+ or, if not present, the latest column value from the model itself. This makes it more flexible as it works
126
+ well with both update and create operations.
127
+
128
+ If you then execute this it will return something like:
129
+
130
+ ```json
131
+ {
132
+ "status": "success",
133
+ "error": "",
134
+ "data": {
135
+ "id": "ec4993f4-124a-44a2-8313-816d2ad51aae",
136
+ "name": "Spot",
137
+ "date_of_birth": "2020-05-03",
138
+ "age": 5,
139
+ "created": "2025-05-03T20:23:32+00:00"
140
+ },
141
+ "pagination": {},
142
+ "input_errors": {}
143
+ }
144
+ ```
145
+
146
+ e.g., `date_of_birth` is `age` years behind the current time (as recorded in the `created` timestamp).
147
+
148
+ """
149
+ setable = clearskies.configs.string_or_callable.StringOrCallable(default=None)
150
+
151
+ """
152
+ Whether or not this column can be converted to JSON and included in an API response.
153
+
154
+ If this is set to False for a column and you attempt to set that column as a readable_column in an endpoint,
155
+ clearskies will throw an exception.
156
+ """
157
+ is_readable = clearskies.configs.boolean.Boolean(default=True)
158
+
159
+ """
160
+ Whether or not this column can be set via an API call.
161
+
162
+ If this is set to False for a column and you attempt to set the column as a writeable column in an endpoint,
163
+ clearskies will throw an exception.
164
+ """
165
+ is_writeable = clearskies.configs.boolean.Boolean(default=True)
166
+
167
+ """
168
+ Whether or not it is possible to search by this column
169
+
170
+ If this is set to False for a column and you attempt to set the column as a searchable column in an endpoint,
171
+ clearskies will throw an exception.
172
+ """
173
+ is_searchable = clearskies.configs.boolean.Boolean(default=True)
174
+
175
+ """
176
+ Whether or not this column is temporary. A temporary column is not persisted to the backend.
177
+
178
+ Temporary columns are useful when you want the developer or end user to set a value, but you use that value to
179
+ trigger additional behavior, rather than actually recording it. Temporary columns often team up with actions
180
+ or are used to calculate other values. For instance, in our setable example above, we had both an age and
181
+ a date of birth column, with the date of birth calculated from the age. This obviously results in two columns
182
+ with similar data. One could be marked as temporary and it will be available during the save operation, but
183
+ it will be skipped when saving data to the backend:
184
+
185
+ ```python
186
+ import clearskies
187
+
188
+ class Pet(clearskies.Model):
189
+ id_column_name = "id"
190
+ backend = clearskies.backends.MemoryBackend()
191
+
192
+ id = clearskies.columns.Uuid()
193
+ name = clearskies.columns.String()
194
+ date_of_birth = clearskies.columns.Date(is_temporary=True)
195
+ age = clearskies.columns.Integer(
196
+ setable=lambda data, model, now:
197
+ (now-dateparser.parse(model.latest("date_of_birth", data))).total_seconds()/(86400*365),
198
+ )
199
+ created = clearskies.columns.Created()
200
+
201
+ cli = clearskies.contexts.Cli(
202
+ clearskies.endpoints.Callable(
203
+ lambda pets: pets.create({"name": "Spot", "date_of_birth": "2020-05-03"}),
204
+ model_class=Pet,
205
+ readable_column_names=["id", "age", "date_of_birth"],
206
+ ),
207
+ classes=[Pet],
208
+ )
209
+
210
+ if __name__ == "__main__":
211
+ cli()
212
+ ```
213
+
214
+ Which will return:
215
+
216
+ ```json
217
+ {
218
+ "status": "success",
219
+ "error": "",
220
+ "data": {
221
+ "id": "ee532cfa-91cf-4747-b798-3c6dcd79326e",
222
+ "age": 5,
223
+ "date_of_birth": null
224
+ },
225
+ "pagination": {},
226
+ "input_errors": {}
227
+ }
228
+ ```
229
+
230
+ e.g. the date_of_birth column is empty. To be clear though, it's not just empty - clearskies made no attempt to set it.
231
+ If you were using an SQL database, you would not have to put a `date_of_birth` column in your table.
232
+
233
+ """
234
+ is_temporary = clearskies.configs.boolean.Boolean(default=False)
235
+
236
+ """
237
+ Validators to use when checking the input for this column during write operations from the API.
238
+
239
+ Keep in mind that the validators are only checked when the column is exposed via a supporting endpoint.
240
+ You can still set whatever values you want when saving the model directly, e.g. `model.save(...)`
241
+
242
+ In the below example, we require a name that is at least 5 characters long, and the date of birth must
243
+ be in the past. Note that date of birth is not required, so the end-user can create a record without
244
+ a date of birth. In general, only the `Required` validator will reject a non-existent input.
245
+
246
+ ```python
247
+ import clearskies
248
+
249
+ class Pet(clearskies.Model):
250
+ id_column_name = "id"
251
+ backend = clearskies.backends.MemoryBackend()
252
+
253
+ id = clearskies.columns.Uuid()
254
+ name = clearskies.columns.String(validators=[
255
+ clearskies.validators.Required(),
256
+ clearskies.validators.MinimumLength(5),
257
+ ])
258
+ date_of_birth = clearskies.columns.Date(validators=[
259
+ clearskies.validators.InThePast()
260
+ ])
261
+ created = clearskies.columns.Created()
262
+
263
+ wsgi = clearskies.contexts.WsgiRef(
264
+ clearskies.endpoints.Create(
265
+ model_class=Pet,
266
+ writeable_column_names=["name", "date_of_birth"],
267
+ readable_column_names=["id", "name", "date_of_birth", "created"],
268
+ ),
269
+ )
270
+ wsgi()
271
+ ```
272
+
273
+ You can then see the result of calling the endpoint with various kinds of invalid data:
274
+
275
+ ```bash
276
+ $ curl http://localhost:8080 -d '{"date_of_birth": "asdf"}'
277
+ {
278
+ "status": "input_errors",
279
+ "error": "",
280
+ "data": [],
281
+ "pagination": {},
282
+ "input_errors": {
283
+ "name": "'name' is required.",
284
+ "date_of_birth": "given value did not appear to be a valid date"
285
+ }
286
+ }
287
+
288
+ $ curl http://localhost:8080 -d '{"name":"asdf"}' | jq
289
+ {
290
+ "status": "input_errors",
291
+ "error": "",
292
+ "data": [],
293
+ "pagination": {},
294
+ "input_errors": {
295
+ "name": "'name' must be at least 5 characters long."
296
+ }
297
+ }
298
+
299
+ $ curl http://localhost:8080 -d '{"name":"Longer", "date_of_birth": "2050-01-01"}' | jq
300
+ {
301
+ "status": "input_errors",
302
+ "error": "",
303
+ "data": [],
304
+ "pagination": {},
305
+ "input_errors": {
306
+ "date_of_birth": "'date_of_birth' must be in the past"
307
+ }
308
+ }
309
+
310
+ $ curl http://localhost:8080 -d '{"name":"Long Enough"}' | jq
311
+ {
312
+ "status": "success",
313
+ "error": "",
314
+ "data": {
315
+ "id": "ace16b93-db91-49b3-a8f7-5dc6568d25f6",
316
+ "name": "Long Enough",
317
+ "date_of_birth": null,
318
+ "created": "2025-05-03T19:32:33+00:00"
319
+ },
320
+ "pagination": {},
321
+ "input_errors": {}
322
+ }
323
+ ```
324
+ """
325
+ validators = clearskies.configs.validators.Validators(default=[])
326
+
327
+ """
328
+ Actions to take during the pre-save step of the save process if the column has changed during the active save operation.
329
+
330
+ Pre-save happens before the data is persisted to the backend. Actions/callables in
331
+ this step must return a dictionary. The data in the dictionary will be included in the save operation.
332
+ Since the save hasn't completed, any data in the model itself reflects the model before the save
333
+ operation started. Actions in the pre-save step must **NOT** make any changes directly, but should **ONLY**
334
+ return modified data for the save operation. In addition, they must be idempotent - they should always return
335
+ the same value when called with the same data. This is because clearskies can call them more than once. If
336
+ a pre-save hook changes the save data, then clearskies will call all the pre-save hooks again in case this
337
+ new data needs to trigger further changes. Stateful changes should be reserved for the post_save or save_finished stages.
338
+
339
+ Callables and actions can request any dependencies provided by the DI system. In addition, they can request
340
+ two named parameters:
341
+
342
+ 1. `model` - the model involved in the save operation
343
+ 2. `data` - the new data being saved
344
+
345
+ The key here is that the defined actions will be invoked regardless of how the save happens. Whether the
346
+ model.save() function is called directly or the model is creatd/modified via an endpoint, your business logic
347
+ will always be executed. This makes for easy reusability and consistency throughout your application.
348
+
349
+ Here's an example where we want to record a timestamp anytime an order status becomes a particular value:
350
+
351
+ ```python
352
+ import clearskies
353
+
354
+ class Order(clearskies.Model):
355
+ id_column_name = "id"
356
+ backend = clearskies.backends.MemoryBackend()
357
+
358
+ id = clearskies.columns.Uuid()
359
+ status = clearskies.columns.Select(
360
+ ["Open", "On Hold", "Fulfilled"],
361
+ on_change_pre_save=[
362
+ lambda data, utcnow: {"fulfilled_at": utcnow} if data["status"] == "Fulfilled" else {},
363
+ ],
364
+ )
365
+ fulfilled_at = clearskies.columns.Datetime()
366
+
367
+ wsgi = clearskies.contexts.WsgiRef(
368
+ clearskies.endpoints.Create(
369
+ model_class=Order,
370
+ writeable_column_names=["status"],
371
+ readable_column_names=["id", "status", "fulfilled_at"],
372
+ ),
373
+ )
374
+ wsgi()
375
+ ```
376
+
377
+ You can then see the difference depending on what you set the status to:
378
+
379
+ ```bash
380
+ $ curl http://localhost:8080 -d '{"status":"Open"}' | jq
381
+ {
382
+ "status": "success",
383
+ "error": "",
384
+ "data": {
385
+ "id": "a732545f-51b3-4fd0-a6cf-576cf1b2872f",
386
+ "status": "Open",
387
+ "fulfilled_at": null
388
+ },
389
+ "pagination": {},
390
+ "input_errors": {}
391
+ }
392
+
393
+ $ curl http://localhost:8080 -d '{"status":"Fulfilled"}' | jq
394
+ {
395
+ "status": "success",
396
+ "error": "",
397
+ "data": {
398
+ "id": "c288bf43-2246-48e4-b168-f40cbf5376df",
399
+ "status": "Fulfilled",
400
+ "fulfilled_at": "2025-05-04T02:32:56+00:00"
401
+ },
402
+ "pagination": {},
403
+ "input_errors": {}
404
+ }
405
+
406
+ ```
407
+
408
+ """
409
+ on_change_pre_save = clearskies.configs.actions.Actions(default=[])
410
+
411
+ """
412
+ Actions to take during the post-save step of the process if the column has changed during the active save.
413
+
414
+ Post-save happens after the data is persisted to the backend but before the full save process has finished.
415
+ Since the data has been persisted to the backend, any data returned by the callables/actions will be ignored.
416
+ If you need to make data changes you'll have to execute a separate save operation.
417
+ Since the save hasn't finished, the model is not yet updated with the new data, and
418
+ any data you fetch out of the model will refelect the data in the model before the save started.
419
+
420
+ Callables and actions can request any dependencies provided by the DI system. In addition, they can request
421
+ three named parameters:
422
+
423
+ 1. `model` - the model involved in the save operation
424
+ 2. `data` - the new data being saved
425
+ 3. `id` - the id of the record being saved
426
+
427
+ Here's an example of using a post-save action to record a simple audit trail when the order status changes:
428
+
429
+ ```python
430
+ import clearskies
431
+
432
+ class Order(clearskies.Model):
433
+ id_column_name = "id"
434
+ backend = clearskies.backends.MemoryBackend()
435
+
436
+ id = clearskies.columns.Uuid()
437
+ status = clearskies.columns.Select(
438
+ ["Open", "On Hold", "Fulfilled"],
439
+ on_change_post_save=[
440
+ lambda model, data, order_histories: order_histories.create({
441
+ "order_id": model.latest("id", data),
442
+ "event": "Order status changed to " + data["status"]
443
+ }),
444
+ ],
445
+ )
446
+
447
+ class OrderHistory(clearskies.Model):
448
+ id_column_name = "id"
449
+ backend = clearskies.backends.MemoryBackend()
450
+
451
+ id = clearskies.columns.Uuid()
452
+ event = clearskies.columns.String()
453
+ order_id = clearskies.columns.BelongsToId(Order)
454
+
455
+ # include microseconds in the created_at time so that we can sort our example by created_at
456
+ # and they come out in order (since, for our test program, they will all be created in the same second).
457
+ created_at = clearskies.columns.Created(date_format="%Y-%m-%d %H:%M:%S.%f")
458
+
459
+ def test_post_save(orders: Order, order_histories: OrderHistory):
460
+ my_order = orders.create({"status": "Open"})
461
+ my_order.status = "On Hold"
462
+ my_order.save()
463
+ my_order.save({"status": "Open"})
464
+ my_order.save({"status": "Fulfilled"})
465
+ return order_histories.where(OrderHistory.order_id.equals(my_order.id)).sort_by("created_at", "asc")
466
+
467
+ cli = clearskies.contexts.Cli(
468
+ clearskies.endpoints.Callable(
469
+ test_post_save,
470
+ model_class=OrderHistory,
471
+ return_records=True,
472
+ readable_column_names=["id", "event", "created_at"],
473
+ ),
474
+ classes=[Order, OrderHistory],
475
+ )
476
+ cli()
477
+ ```
478
+
479
+ Note that in our `on_change_post_save` lambda function, we use `model.latest("id", data)`. We can't just use
480
+ `data["id"]` because `data` is a dictionary containing the information present in the save. During the create
481
+ operation `data["id"]` will be populated, but during the subsequent edit operations it won't be - only the status
482
+ column is changing. `model.latest("id", data)` is basically just short hand for: `data.get("id", model.id)`.
483
+ On the other hand, we can just use `data["status"]` because the `on_change` hook is attached to the status field,
484
+ so it will only fire when status is being changed, which means that the `status` key is guaranteed to be in
485
+ the dictionary when the lambda is executed.
486
+
487
+ Finally, the post-save action has a named parameter called `id`, so in this specific case we could use:
488
+
489
+ ```python
490
+ lambda data, id, order_histories: order_histories.create("order_id": id, "event": data["status"])
491
+ ```
492
+
493
+ When we execute the above script it will return something like:
494
+
495
+ ```json
496
+ {
497
+ "status": "success",
498
+ "error": "",
499
+ "data": [
500
+ {
501
+ "id": "c550d714-839b-4f25-a9e1-bd7e977185ff",
502
+ "event": "Order status changed to Open",
503
+ "created_at": "2025-05-04T14:09:42.960119+00:00"
504
+ },
505
+ {
506
+ "id": "f393d7b0-da21-4117-a7a4-0359fab802bb",
507
+ "event": "Order status changed to On Hold",
508
+ "created_at": "2025-05-04T14:09:42.960275+00:00"
509
+ },
510
+ {
511
+ "id": "5b528a10-4a08-47ae-938c-fc7067603f8e",
512
+ "event": "Order status changed to Open",
513
+ "created_at": "2025-05-04T14:09:42.960395+00:00"
514
+ },
515
+ {
516
+ "id": "91f77a88-1c38-49f7-aa1e-7f97bd9f962f",
517
+ "event": "Order status changed to Fulfilled",
518
+ "created_at": "2025-05-04T14:09:42.960514+00:00"
519
+ }
520
+ ],
521
+ "pagination": {},
522
+ "input_errors": {}
523
+ }
524
+ ```
525
+
526
+ """
527
+ on_change_post_save = clearskies.configs.actions.Actions(default=[])
528
+
529
+ """
530
+ Actions to take during the save-finished step of the save process if the column has changed in the save.
531
+
532
+ Save-finished happens after the save process has completely finished and the model is updated with
533
+ the final data. Any data returned by these actions will be ignored, since the save has already finished.
534
+ If you need to make data changes you'll have to execute a separate save operation.
535
+
536
+ Callables and actions can request any dependencies provided by the DI system. In addition, they can request
537
+ the following parameter:
538
+
539
+ 1. `model` - the model involved in the save operation
540
+
541
+ Unlike pre_save and post_save, `data` is not provided because this data has already been merged into the
542
+ model. If you need some context from the completed save operation, use methods like `was_changed` and `previous_value`.
543
+ """
544
+ on_change_save_finished = clearskies.configs.actions.Actions(default=[])
545
+
546
+ """
547
+ Use in conjunction with `created_by_source_type` to have this column automatically populated by data from an HTTP request.
548
+
549
+ So, for instance, setting `created_by_source_type` to `authorization_data` and setting this to `email`
550
+ will result in the email value from the authorization data being persisted into this column when the
551
+ record is saved.
552
+
553
+ NOTE: this is sometimes best set as a column override on an endpoint, rather than directly
554
+ on the model itself. The reason is because the authorization data and header information is typically
555
+ only available during an HTTP request, so if you set this on the model level, you'll get an error
556
+ if you try to make saves to the model in a context where authorization data and/or headers don't exist.
557
+
558
+ See created_by_source_type for usage examples.
559
+ """
560
+ created_by_source_key = clearskies.configs.string.String(default="")
561
+
562
+ """
563
+ Use in conjunction with `created_by_source_key` to have this column automatically populated by data from ann HTTP request.
564
+
565
+ So, for instance, setting this to `authorization_data` and setting `created_by_source_key` to `email`
566
+ will result in the email value from the authorization data being persisted into this column when the
567
+ record is saved.
568
+
569
+ NOTE: this is sometimes best set as a column override on an endpoint, rather than directly
570
+ on the model itself. The reason is because the authorization data and header information is typically
571
+ only available during an HTTP request, so if you set this on the model level, you'll get an error
572
+ if you try to make saves to the model in a context where authorization data and/or headers don't exist.
573
+
574
+ Here's an example:
575
+
576
+ ```python
577
+ class User(clearskies.Model):
578
+ id_column_name = "id"
579
+ backend = clearskies.backends.MemoryBackend()
580
+
581
+ id = clearskies.columns.Uuid()
582
+ name = clearskies.columns.String()
583
+ account_id = clearskies.columns.String(
584
+ created_by_source_type="routing_data",
585
+ created_by_source_key="account_id",
586
+ )
587
+
588
+ wsgi = clearskies.contexts.WsgiRef(
589
+ clearskies.endpoints.Create(
590
+ User,
591
+ readable_column_names=["id", "account_id", "name"],
592
+ writeable_column_names=["name"],
593
+ url="/:account_id",
594
+ ),
595
+ )
596
+ wsgi()
597
+ ```
598
+
599
+ Note that `created_by_source_type` is `routing_data` and `created_by_source_key` is `account_id`.
600
+ This means that the endpoint that creates this record must have a routing parameter named `account_id`.
601
+ Naturally, our endpoint has a url of `/:account_id`, and so the parameter provided by the uesr gets
602
+ reflected into the save.
603
+
604
+ ```bash
605
+ $ curl http://localhost:8080/1-2-3-4 -d '{"name":"Bob"}' | jq
606
+ {
607
+ "status": "success",
608
+ "error": "",
609
+ "data": {
610
+ "id": "250ed725-d940-4823-aa9d-890be800404a",
611
+ "account_id": "1-2-3-4",
612
+ "name": "Bob"
613
+ },
614
+ "pagination": {},
615
+ "input_errors": {}
616
+ }
617
+ ```
618
+
619
+ """
620
+ created_by_source_type = clearskies.configs.select.Select(
621
+ ["authorization_data", "http_header", "routing_data", ""], default=""
622
+ )
623
+
624
+ """
625
+ If True, and the key requested via created_by_source_key doesn't exist in the designated source, an error will be raised.
626
+ """
627
+ created_by_source_strict = clearskies.configs.boolean.Boolean(default=True)
628
+
629
+ """ The model class this column is associated with. """
630
+ model_class = clearskies.configs.Schema()
631
+
632
+ """ The name of this column. """
633
+ name = clearskies.configs.string.String()
634
+
635
+ """
636
+ Simple flag to denote if the column is unique or not.
637
+
638
+ This is an internal cache. Use column.is_unique instead.
639
+ """
640
+ _is_unique = False
641
+
642
+ """
643
+ Specify if this column has additional functionality to solve the n+1 problem.
644
+
645
+ Relationship columns may fetch data from additional tables when outputting results, but by default they
646
+ end up making an additional query for every record (in order to grab related data). This is called the
647
+ n+1 problem - a query may fetch 10 records, and then make 10 individual additional queries to select
648
+ related data for each record (which obviously hampers performance). The solution to this (when using
649
+ sql-like backends) is to add additional joins to the original query so that the data can all be fetched
650
+ at once. Columns that are subject to this issue can set this flag to True and then define the
651
+ `configure_n_plus_one` method to add the necessary joins. This method will be called as needed.
652
+ """
653
+ wants_n_plus_one = False
654
+
655
+ """
656
+ Simple flag to denote if the column is required or not.
657
+
658
+ This is an internal cache. Use column.is_required instead.
659
+ """
660
+ _is_required = False
661
+
662
+ """
663
+ The list of allowed search operators for this column.
664
+
665
+ All the various search methods reference this list. The idea is that a column can just fill out this list
666
+ instead of having to override all the methods.
667
+ """
668
+ _allowed_search_operators = ["<=>", "!=", "<=", ">=", ">", "<", "=", "in", "is not null", "is null", "like"]
669
+
670
+ """
671
+ The class to use when documenting this column
672
+ """
673
+ auto_doc_class: type[AutoDocSchema] = AutoDocString
674
+
675
+ @clearskies.parameters_to_properties.parameters_to_properties
676
+ def __init__(
677
+ self,
678
+ default: str | None = None,
679
+ setable: str | Callable[..., str] | None = None,
680
+ is_readable: bool = True,
681
+ is_writeable: bool = True,
682
+ is_searchable: bool = True,
683
+ is_temporary: bool = False,
684
+ validators: clearskies.typing.validator | list[clearskies.typing.validator] = [],
685
+ on_change_pre_save: clearskies.typing.action | list[clearskies.typing.action] = [],
686
+ on_change_post_save: clearskies.typing.action | list[clearskies.typing.action] = [],
687
+ on_change_save_finished: clearskies.typing.action | list[clearskies.typing.action] = [],
688
+ created_by_source_type: str = "",
689
+ created_by_source_key: str = "",
690
+ created_by_source_strict: bool = True,
691
+ ):
692
+ pass
693
+
694
+ def get_model_columns(self):
695
+ """Return the columns or the model this column is attached to."""
696
+ return self.model_class.get_columns()
697
+
698
+ def finalize_configuration(self, model_class: type[Schema], name: str) -> None:
699
+ """
700
+ Finalize and check the configuration.
701
+
702
+ This is an external trigger called by the model class when the model class is ready.
703
+ The reason it exists here instead of in the constructor is because some columns are tightly
704
+ connected to the model class, and can't validate configuration until they know what the model is.
705
+ Therefore, we need the model involved, and the only way for a property to know what class it is
706
+ in is if the parent class checks in (which is what happens here).
707
+ """
708
+ self.model_class = model_class
709
+ self.name = name
710
+ self.finalize_and_validate_configuration()
711
+
712
+ def from_backend(self, value):
713
+ """
714
+ Take the backend representation and returns a python representation.
715
+
716
+ For instance, for an SQL date field, this will return a Python DateTime object
717
+ """
718
+ return str(value)
719
+
720
+ def to_backend(self, data: dict[str, Any]) -> dict[str, Any]:
721
+ """
722
+ Make any changes needed to save the data to the backend.
723
+
724
+ This typically means formatting changes - converting DateTime objects to database
725
+ date strings, etc...
726
+ """
727
+ if self.name not in data:
728
+ return data
729
+
730
+ return {**data, self.name: str(data[self.name])}
731
+
732
+ @overload
733
+ def __get__(self, instance: None, cls: type) -> Self:
734
+ pass
735
+
736
+ @overload
737
+ def __get__(self, instance: Model, cls: type):
738
+ pass
739
+
740
+ def __get__(self, instance, cls):
741
+ if instance is None:
742
+ # Normally this gets filled in when the model is initialized. However, the condition builders (self.equals, etc...)
743
+ # can be called from the class directly, before the model is initialized and everything is populated. This
744
+ # can cause trouble, but by filling in the model class we can give enough information for them to get the
745
+ # job done. They have a special flow for this, we just have to provide the model class (and the __get__
746
+ # function is always called, so this fixes it).
747
+ self.model_class = cls
748
+ return self
749
+
750
+ # this makes sure we're initialized
751
+ if "name" not in self._config: # type: ignore
752
+ instance.get_columns()
753
+
754
+ if self.name not in instance._data:
755
+ return None # type: ignore
756
+
757
+ if self.name not in instance._transformed_data:
758
+ instance._transformed_data[self.name] = self.from_backend(instance._data[self.name])
759
+
760
+ return instance._transformed_data[self.name]
761
+
762
+ def __set__(self, instance: Model, value) -> None:
763
+ instance._next_data[self.name] = value
764
+
765
+ def finalize_and_validate_configuration(self):
766
+ super().finalize_and_validate_configuration()
767
+
768
+ if self.setable is not None and self.created_by_source_type:
769
+ raise ValueError(
770
+ "You attempted to set both 'setable' and 'created_by_source_type', but these configurations are mutually exclusive. You can only set one for a given column"
771
+ )
772
+
773
+ if (self.created_by_source_type and not self.created_by_source_key) or (
774
+ not self.created_by_source_type and self.created_by_source_key
775
+ ):
776
+ raise ValueError(
777
+ "You only set one of 'created_by_source_type' and 'created_by_source_key'. You have to either set both of them (which enables the 'created_by' feature of the column) or you must set neither of them."
778
+ )
779
+
780
+ @property
781
+ def is_unique(self) -> bool:
782
+ """Return True/False to denote if this column should always have unique values."""
783
+ if self._is_unique is None:
784
+ self._is_unique = any([validator.is_unique for validator in self.validators])
785
+ return self._is_unique
786
+
787
+ @property
788
+ def is_required(self):
789
+ """Return True/False to denote if this column should is required."""
790
+ if self._is_required is None:
791
+ self._is_required = any([validator.is_required for validator in self.validators])
792
+ return self._is_required
793
+
794
+ def additional_write_columns(self, is_create=False) -> dict[str, Self]:
795
+ """
796
+ Return any additional columns that should be included in write operations.
797
+
798
+ Some column types, and some validation requirements, necessitate the presence of additional
799
+ columns in the save operation. This function adds those in so they can be included in the
800
+ API call.
801
+ """
802
+ additional_write_columns: dict[str, Self] = {}
803
+ for validator in self.validators:
804
+ if not isinstance(validator, Validator):
805
+ continue
806
+ additional_write_columns = {
807
+ **additional_write_columns,
808
+ **validator.additional_write_columns(is_create=is_create), # type: ignore
809
+ }
810
+ return additional_write_columns
811
+
812
+ def to_json(self, model: clearskies.model.Model) -> dict[str, Any]:
813
+ """Grabs the column out of the model and converts it into a representation that can be turned into JSON."""
814
+ return {self.name: self.__get__(model, model.__class__)}
815
+
816
+ def input_errors(self, model: clearskies.model.Model, data: dict[str, Any]) -> dict[str, Any]:
817
+ """
818
+ Check the given dictionary of data for any possible input errors.
819
+
820
+ This accepts all the data being saved, and not just the value for this column. The reason is because
821
+ some input valdiation flows require more than one piece of data. For instance, a user may be asked
822
+ to type a specific piece of input more than once to minimize the chance of typos, or a user may
823
+ have to provide their password when changing security-related columns.
824
+
825
+ This also returns a dictionary, rather than an error message, so that a column can also return an error
826
+ message for more than one column at a time if needed.
827
+
828
+ If there are no input errors then this should simply return an empty dictionary.
829
+
830
+ This method calls `self.input_error_for_value` and then also calls all the validators attached to the
831
+ column so, if you're building your own column and have some specific input validation you need to do,
832
+ you probably want to extend `input_error_for_value` as that is the one intended checks for a column type.
833
+
834
+ Note: this is not called when you directly invoke the `save`/`create` method of a model. This is only
835
+ used by handlers when processing user input (e.g. API calls).
836
+ """
837
+ if self.name in data and data[self.name]:
838
+ error = self.input_error_for_value(data[self.name])
839
+ if error:
840
+ return {self.name: error}
841
+
842
+ for validator in self.validators:
843
+ if hasattr(validator, "injectable_properties"):
844
+ validator.injectable_properties(self.di)
845
+
846
+ error = validator(model, self.name, data)
847
+ if error:
848
+ return {self.name: error}
849
+
850
+ return {}
851
+
852
+ def check_search_value(
853
+ self, value: str, operator: str | None = None, relationship_reference: str | None = None
854
+ ) -> str:
855
+ """
856
+ Check if the given value is an allowed value.
857
+
858
+ This is called by the search operation in the various API-related handlers to validate a search value.
859
+
860
+ Generally, this just defers to self.input_error_for_value, but it is a separate method in case you
861
+ need to change your input validation logic specifically when checking a search value.
862
+ """
863
+ return self.input_error_for_value(value, operator=operator)
864
+
865
+ def input_error_for_value(self, value: str, operator: str | None = None) -> str:
866
+ """
867
+ Check if the given value is an allowed value.
868
+
869
+ This method is intended for checks that are specific to the column type (e.g. this is where an
870
+ email column checks that the value is an actual email, or a datetime column checks for a valid
871
+ datetime). The `input_errors` method does a bit more, so in general it's easier to extend this one.
872
+
873
+ This method is passed in the value to check. It should return a string. If the data is valid,
874
+ then return an empty string. Otherwise return a human-readable error message.
875
+
876
+ At times an operator will be passed in. This is used when the user is searching instead of saving.
877
+ In this case, the check can vary depending on the operator. For instance, if it's a wildcard search
878
+ then an email field only has to verify the type is a string (since the user may have only entered
879
+ the beginning of an email address), but if it's an exact search then you would expect the value to be
880
+ an actual email.
881
+
882
+ Note: this is not called when you directly invoke the `save`/`create` method of a model. This is only
883
+ used by handlers when processing user input (e.g. API calls).
884
+ """
885
+ return ""
886
+
887
+ def pre_save(self, data: dict[str, Any], model: clearskies.model.Model) -> dict[str, Any]:
888
+ """
889
+ Make any necessary changes to the data before starting the save process.
890
+
891
+ The difference between this and to_backend is that to_backend only affects
892
+ the data as it is going into the database, while this affects the data that will get persisted
893
+ in the object as well. So for instance, for a "created" field, pre_save may fill in the current
894
+ date with a Python DateTime object when the record is being saved, and then to_backend may
895
+ turn that into an SQL-compatible date string.
896
+
897
+ Note: this is called during the `pre_save` step in the lifecycle of the save process. See the
898
+ model class for more details.
899
+ """
900
+ if not model and self.created_by_source_type:
901
+ data[self.name] = self._extract_value_from_source_type()
902
+ if self.setable:
903
+ if callable(self.setable):
904
+ input_output = self.di.build("input_output", cache=True)
905
+ data[self.name] = self.di.call_function(
906
+ self.setable, data=data, model=model, **input_output.get_context_for_callables()
907
+ )
908
+ else:
909
+ data[self.name] = self.setable
910
+ if not model and self.default and self.name not in data:
911
+ data[self.name] = self.default
912
+ if self.on_change_pre_save and model.is_changing(self.name, data):
913
+ data = self.execute_actions_with_data(self.on_change_pre_save, model, data)
914
+ return data
915
+
916
+ def post_save(self, data: dict[str, Any], model: clearskies.model.Model, id: int | str) -> None:
917
+ """
918
+ Make any changes needed after persisting data to the backend.
919
+
920
+ This lives in the `post_save` hook of the save lifecycle. See the model class for more details.
921
+ `data` is the data dictionary being saved. `model` is obviously the model object that initiated the
922
+ save.
923
+
924
+ This happens after the backend is updated but before the model is updated. Therefore, You can tell the
925
+ difference between a create operation and an update operation by checking if the model exists: `if model`.
926
+ For a create operation, the model will be empty (it evaluates to False). The opposite is true for an update
927
+ operation.
928
+
929
+ Any return value will be ignored. If you need to make additional changes in the backend, you
930
+ have to execute a new save operation.
931
+ """
932
+ if self.on_change_post_save and model.is_changing(self.name, data):
933
+ self.execute_actions_with_data(
934
+ self.on_change_post_save,
935
+ model,
936
+ data,
937
+ id=id,
938
+ context="on_change_post_save",
939
+ require_dict_return_value=False,
940
+ )
941
+
942
+ def save_finished(self, model: clearskies.model.Model) -> None:
943
+ """
944
+ Make any necessary changes needed after a save has completely finished.
945
+
946
+ This is typically used for actions set by the developer. Column-specific behavior usually lives in
947
+ `pre_save` or `post_save`. See the model class for more details about the various lifecycle hooks during
948
+ a save.
949
+ """
950
+ if self.on_change_save_finished and model.was_changed(self.name):
951
+ self.execute_actions(self.on_change_save_finished, model)
952
+
953
+ def pre_delete(self, model):
954
+ """Make any changes needed to the data before starting the delete process."""
955
+ pass
956
+
957
+ def post_delete(self, model):
958
+ """Make any changes needed to the data before finishing the delete process."""
959
+ pass
960
+
961
+ def _extract_value_from_source_type(self) -> Any:
962
+ """For columns with `created_by_source_type` set, this fetches the appropriate value from the request."""
963
+ input_output = self.di.build("input_output", cache=True)
964
+ source_type = self.created_by_source_type
965
+ if source_type == "authorization_data":
966
+ data = input_output.authorization_data
967
+ elif source_type == "http_header":
968
+ data = input_output.request_headers
969
+ elif source_type == "routing_data":
970
+ data = input_output.routing_data
971
+
972
+ if self.created_by_source_key not in data and self.created_by_source_strict:
973
+ raise ValueError(
974
+ f"Column '{self.name}' is configured to load the key named '{self.created_by_source_key}' from "
975
+ + f"the {self.created_by_source_type}', but this key was not present in the request."
976
+ )
977
+
978
+ return data.get(self.created_by_source_key, "N/A")
979
+
980
+ def execute_actions_with_data(
981
+ self,
982
+ actions: list[clearskies.typing.action],
983
+ model: clearskies.model.Model,
984
+ data: dict[str, Any],
985
+ id: int | str | None = None,
986
+ context: str = "on_change_pre_save",
987
+ require_dict_return_value: bool = True,
988
+ ) -> dict[str, Any]:
989
+ """Execute a given set of actions and expects data to be both provided and returned."""
990
+ input_output = self.di.build("input_output", cache=True)
991
+ for index, action in enumerate(actions):
992
+ new_data = self.di.call_function(
993
+ action,
994
+ **{
995
+ **input_output.get_context_for_callables(),
996
+ **{
997
+ "model": model,
998
+ "data": data,
999
+ "id": id,
1000
+ },
1001
+ },
1002
+ )
1003
+ if not isinstance(new_data, dict):
1004
+ if require_dict_return_value:
1005
+ raise ValueError(
1006
+ f"Return error for action #{index + 1} in 'on_change_pre_save' for column '{self.name}' in model '{self.model_class.__name__}': this action must return a dictionary but returned an object of type '{new_data.__class__.__name__}' instead"
1007
+ )
1008
+ else:
1009
+ return new_data
1010
+ data = {
1011
+ **data,
1012
+ **new_data,
1013
+ }
1014
+ return data
1015
+
1016
+ def execute_actions(
1017
+ self,
1018
+ actions: list[clearskies.typing.action],
1019
+ model: clearskies.model.Model,
1020
+ ) -> None:
1021
+ """Execute a given set of actions."""
1022
+ input_output = self.di.build("input_output", cache=True)
1023
+ for action in actions:
1024
+ self.di.call_function(action, model=model, **input_output.get_context_for_callables())
1025
+
1026
+ def values_match(self, value_1, value_2):
1027
+ """
1028
+ Compare two values to see if they are the same.
1029
+
1030
+ This is mainly used to compare incoming data with old data to determine if a column has changed.
1031
+
1032
+ Note that these checks shouldn't make any assumptions about whether or not data has gone through the
1033
+ to_backend/from_backend functions. For instance, a datetime field may find one value has a date
1034
+ that is formatted as a string, and the other as a DateTime object. Plan appropriately.
1035
+ """
1036
+ return value_1 == value_2
1037
+
1038
+ def add_search(
1039
+ self, model: clearskies.model.Model, value: str, operator: str = "", relationship_reference: str = ""
1040
+ ) -> clearskies.model.Model:
1041
+ return model.where(self.condition(operator, value))
1042
+
1043
+ def build_condition(self, value: str, operator: str = "", column_prefix: str = ""):
1044
+ """
1045
+ Build condition for the read (and related) handlers to turn user input into a condition.
1046
+
1047
+ Note that this may look like it is vulnerable to SQLi, but it isn't. These conditions aren't passed directly
1048
+ into a query. Rather, they are parsed by the condition parser before being sent into the backend.
1049
+ The condition parser can safely reconstruct the original pieces, and the backend can then use the data
1050
+ safely (and remember, the backend may not be an SQL anyway)
1051
+
1052
+ As a result, this is perfectly safe for any user input, assuming normal system flow.
1053
+
1054
+ That being said, this should probably be replaced by self.condition()...
1055
+ """
1056
+ if not operator:
1057
+ operator = "="
1058
+ if operator.lower() == "like":
1059
+ return f"{column_prefix}{self.name} LIKE '%{value}%'"
1060
+ return f"{column_prefix}{self.name}{operator}{value}"
1061
+
1062
+ def is_allowed_operator(
1063
+ self,
1064
+ operator: str,
1065
+ relationship_reference: str = "",
1066
+ ):
1067
+ """Process user data to decide if the end-user is specifying an allowed operator."""
1068
+ return operator.lower() in self._allowed_search_operators
1069
+
1070
+ def n_plus_one_add_joins(
1071
+ self, model: clearskies.model.Model, column_names: list[str] = []
1072
+ ) -> clearskies.model.Model:
1073
+ """Add any additional joins to solve the N+1 problem."""
1074
+ return model
1075
+
1076
+ def n_plus_one_join_table_alias_prefix(self):
1077
+ """
1078
+ Create a table alias to use with joins for n+1 solutions.
1079
+
1080
+ When joining tables in for n+1 solutions, you can't just do a SELECT * on the new table, because that
1081
+ often results in duplicate column names. A solution that generally works across the board is to select
1082
+ specific columns from the joined table and alias them, adding a common prefix. Then, the data from the
1083
+ joined table can be reconstructed automatically by finding all columns with that prefix (and then removing
1084
+ the prefix). This function returns that prefix for that alias.
1085
+
1086
+ Now, technically this isn't function isn't used at all by the base class, so this definition is fairly
1087
+ pointless. It isn't marked as an abstract method because most model columns don't need it either.
1088
+ Rather, this function is here mostly for documentation so it's easier to understand how to implement
1089
+ support for n+1 solutions when needed. See the belongs_to column for a full implementation reference.
1090
+ """
1091
+ return "join_table_" + self.name
1092
+
1093
+ def add_join(self, model: Model) -> Model:
1094
+ return model
1095
+
1096
+ def where_for_request(
1097
+ self,
1098
+ model: clearskies.model.Model,
1099
+ routing_data: dict[str, str],
1100
+ authorization_data: dict[str, Any],
1101
+ input_output,
1102
+ ) -> clearskies.model.Model:
1103
+ """
1104
+ Create a hook to automatically apply filtering whenever the column makes an appearance in a get/update/list/search handler.
1105
+
1106
+ This hook is called by all the handlers that execute queries, so if your column needs to automatically
1107
+ do some filtering whenever the model shows up in an API endpoint, this is the place for it.
1108
+ """
1109
+ return model
1110
+
1111
+ def name_for_building_condition(self) -> str:
1112
+ if self._config and "name" in self._config:
1113
+ return self.name
1114
+
1115
+ if not self._config or not self._config.get("model_class"):
1116
+ raise ValueError(
1117
+ f"A condition builder was called but the model class isn't set. This means that the __get__ method for column class {self.__class__.__name__} forgot to set `self.model_class = cls`"
1118
+ )
1119
+
1120
+ for attribute_name in dir(self.model_class):
1121
+ if id(getattr(self.model_class, attribute_name)) != id(self):
1122
+ continue
1123
+ self.name = attribute_name
1124
+ break
1125
+
1126
+ return self.name
1127
+
1128
+ def equals(self, value) -> Condition:
1129
+ name = self.name_for_building_condition()
1130
+ if "=" not in self._allowed_search_operators:
1131
+ raise ValueError(f"An 'equals search' is not allowed for '{self.model_class.__name__}.{name}'.")
1132
+ value = self.to_backend({name: value}).get(name)
1133
+ return ParsedCondition(name, "=", [value])
1134
+
1135
+ def spaceship(self, value) -> Condition:
1136
+ name = self.name_for_building_condition()
1137
+ if "<=>" not in self._allowed_search_operators:
1138
+ raise ValueError(f"A 'spaceship' search is not allowed for '{self.model_class.__name__}.{name}'.")
1139
+ value = self.to_backend({name: value}).get(name)
1140
+ return ParsedCondition(name, "<=>", [value])
1141
+
1142
+ def not_equals(self, value) -> Condition:
1143
+ name = self.name_for_building_condition()
1144
+ if "!=" not in self._allowed_search_operators:
1145
+ raise ValueError(f"A 'not equals' search is not allowed for '{self.model_class.__name__}.{name}'.")
1146
+ value = self.to_backend({name: value}).get(name)
1147
+ return ParsedCondition(name, "!=", [value])
1148
+
1149
+ def less_than_equals(self, value) -> Condition:
1150
+ name = self.name_for_building_condition()
1151
+ if "<=" not in self._allowed_search_operators:
1152
+ raise ValueError(f"A 'less than or equals' search is not allowed for '{self.model_class.__name__}.{name}'.")
1153
+ value = self.to_backend({name: value}).get(name)
1154
+ return ParsedCondition(name, "<=", [value])
1155
+
1156
+ def greater_than_equals(self, value) -> Condition:
1157
+ name = self.name_for_building_condition()
1158
+ if ">=" not in self._allowed_search_operators:
1159
+ raise ValueError(
1160
+ f"A 'greater than' or equals search is not allowed for '{self.model_class.__name__}.{name}'."
1161
+ )
1162
+ value = self.to_backend({name: value}).get(name)
1163
+ return ParsedCondition(name, ">=", [value])
1164
+
1165
+ def less_than(self, value) -> Condition:
1166
+ name = self.name_for_building_condition()
1167
+ if "<" not in self._allowed_search_operators:
1168
+ raise ValueError(f"A 'less than' search is not allowed for '{self.model_class.__name__}.{name}'.")
1169
+ value = self.to_backend({name: value}).get(name)
1170
+ return ParsedCondition(name, "<", [value])
1171
+
1172
+ def greater_than(self, value) -> Condition:
1173
+ name = self.name_for_building_condition()
1174
+ if ">" not in self._allowed_search_operators:
1175
+ raise ValueError(f"A 'greater than' search is not allowed for '{self.model_class.__name__}.{name}'.")
1176
+ value = self.to_backend({name: value}).get(name)
1177
+ return ParsedCondition(name, ">", [value])
1178
+
1179
+ def is_in(self, values) -> Condition:
1180
+ name = self.name_for_building_condition()
1181
+ if "in" not in self._allowed_search_operators:
1182
+ raise ValueError(f"An 'in' search is not allowed for '{self.model_class.__name__}.{name}'.")
1183
+ if not isinstance(values, list):
1184
+ raise TypeError("You must pass a list in to column.is_in")
1185
+ final_values = []
1186
+ for value in values:
1187
+ final_values.append(self.to_backend({name: value}).get(name))
1188
+ return ParsedCondition(name, "in", final_values) # type: ignore
1189
+
1190
+ def is_not_null(self) -> Condition:
1191
+ name = self.name_for_building_condition()
1192
+ if "is not null" not in self._allowed_search_operators:
1193
+ raise ValueError(f"An 'is not null' search is not allowed for '{self.model_class.__name__}.{name}'.")
1194
+ return ParsedCondition(name, "is not null", [])
1195
+
1196
+ def is_null(self) -> Condition:
1197
+ name = self.name_for_building_condition()
1198
+ if "is null" not in self._allowed_search_operators:
1199
+ raise ValueError(f"An 'is null' search is not allowed for '{self.model_class.__name__}.{name}'.")
1200
+ return ParsedCondition(name, "is null", [])
1201
+
1202
+ def like(self, value) -> Condition:
1203
+ name = self.name_for_building_condition()
1204
+ if "like" not in self._allowed_search_operators:
1205
+ raise ValueError(f"A 'like' search is not allowed for '{self.model_class.__name__}.{name}'.")
1206
+ value = self.to_backend({name: value}).get(name)
1207
+ return ParsedCondition(name, "like", [value])
1208
+
1209
+ def condition(self, operator: str, value) -> Condition:
1210
+ name = self.name_for_building_condition()
1211
+ operator = operator.lower()
1212
+ if operator not in self._allowed_search_operators:
1213
+ raise ValueError(f"The operator '{operator}' is not allowed for '{self.model_class.__name__}.{name}'.")
1214
+
1215
+ # the search methods are more or less identical except:
1216
+ if operator == "in":
1217
+ return self.is_in(value)
1218
+
1219
+ value = self.to_backend({name: value}).get(name)
1220
+ return ParsedCondition(name, operator, [value])
1221
+
1222
+ def is_allowed_search_operator(self, operator: str, relationship_reference: str = "") -> bool:
1223
+ return operator in self._allowed_search_operators
1224
+
1225
+ def allowed_search_operators(self, relationship_reference: str = ""):
1226
+ return self._allowed_search_operators
1227
+
1228
+ def join_table_alias(self) -> str:
1229
+ raise NotImplementedError("Ooops, I don't support joins")
1230
+
1231
+ def documentation(self, name=None, example=None, value=None) -> list[AutoDocSchema]:
1232
+ return [self.auto_doc_class(name if name is not None else self.name, example=example, value=value)]