clear-skies 1.19.22__py3-none-any.whl → 2.0.23__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.
Files changed (362) hide show
  1. clear_skies-2.0.23.dist-info/METADATA +76 -0
  2. clear_skies-2.0.23.dist-info/RECORD +265 -0
  3. {clear_skies-1.19.22.dist-info → clear_skies-2.0.23.dist-info}/WHEEL +1 -1
  4. clearskies/__init__.py +37 -21
  5. clearskies/action.py +7 -0
  6. clearskies/authentication/__init__.py +9 -38
  7. clearskies/authentication/authentication.py +44 -0
  8. clearskies/authentication/authorization.py +14 -8
  9. clearskies/authentication/authorization_pass_through.py +22 -0
  10. clearskies/authentication/jwks.py +135 -58
  11. clearskies/authentication/public.py +3 -26
  12. clearskies/authentication/secret_bearer.py +515 -44
  13. clearskies/autodoc/formats/oai3_json/__init__.py +2 -2
  14. clearskies/autodoc/formats/oai3_json/oai3_json.py +11 -9
  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 +10 -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 +16 -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 +56 -17
  41. clearskies/backends/api_backend.py +1128 -166
  42. clearskies/backends/backend.py +54 -85
  43. clearskies/backends/cursor_backend.py +246 -191
  44. clearskies/backends/memory_backend.py +514 -208
  45. clearskies/backends/secrets_backend.py +68 -31
  46. clearskies/column.py +1221 -0
  47. clearskies/columns/__init__.py +71 -0
  48. clearskies/columns/audit.py +306 -0
  49. clearskies/columns/belongs_to_id.py +478 -0
  50. clearskies/columns/belongs_to_model.py +129 -0
  51. clearskies/columns/belongs_to_self.py +109 -0
  52. clearskies/columns/boolean.py +110 -0
  53. clearskies/columns/category_tree.py +273 -0
  54. clearskies/columns/category_tree_ancestors.py +51 -0
  55. clearskies/columns/category_tree_children.py +126 -0
  56. clearskies/columns/category_tree_descendants.py +48 -0
  57. clearskies/columns/created.py +92 -0
  58. clearskies/columns/created_by_authorization_data.py +114 -0
  59. clearskies/columns/created_by_header.py +103 -0
  60. clearskies/columns/created_by_ip.py +90 -0
  61. clearskies/columns/created_by_routing_data.py +102 -0
  62. clearskies/columns/created_by_user_agent.py +89 -0
  63. clearskies/columns/date.py +232 -0
  64. clearskies/columns/datetime.py +284 -0
  65. clearskies/columns/email.py +78 -0
  66. clearskies/columns/float.py +149 -0
  67. clearskies/columns/has_many.py +529 -0
  68. clearskies/columns/has_many_self.py +62 -0
  69. clearskies/columns/has_one.py +21 -0
  70. clearskies/columns/integer.py +158 -0
  71. clearskies/columns/json.py +126 -0
  72. clearskies/columns/many_to_many_ids.py +335 -0
  73. clearskies/columns/many_to_many_ids_with_data.py +274 -0
  74. clearskies/columns/many_to_many_models.py +156 -0
  75. clearskies/columns/many_to_many_pivots.py +132 -0
  76. clearskies/columns/phone.py +162 -0
  77. clearskies/columns/select.py +95 -0
  78. clearskies/columns/string.py +102 -0
  79. clearskies/columns/timestamp.py +164 -0
  80. clearskies/columns/updated.py +107 -0
  81. clearskies/columns/uuid.py +83 -0
  82. clearskies/configs/README.md +105 -0
  83. clearskies/configs/__init__.py +170 -0
  84. clearskies/configs/actions.py +43 -0
  85. clearskies/configs/any.py +15 -0
  86. clearskies/configs/any_dict.py +24 -0
  87. clearskies/configs/any_dict_or_callable.py +25 -0
  88. clearskies/configs/authentication.py +23 -0
  89. clearskies/configs/authorization.py +23 -0
  90. clearskies/configs/boolean.py +18 -0
  91. clearskies/configs/boolean_or_callable.py +20 -0
  92. clearskies/configs/callable_config.py +20 -0
  93. clearskies/configs/columns.py +34 -0
  94. clearskies/configs/conditions.py +30 -0
  95. clearskies/configs/config.py +26 -0
  96. clearskies/configs/datetime.py +20 -0
  97. clearskies/configs/datetime_or_callable.py +21 -0
  98. clearskies/configs/email.py +10 -0
  99. clearskies/configs/email_list.py +17 -0
  100. clearskies/configs/email_list_or_callable.py +17 -0
  101. clearskies/configs/email_or_email_list_or_callable.py +59 -0
  102. clearskies/configs/endpoint.py +23 -0
  103. clearskies/configs/endpoint_list.py +29 -0
  104. clearskies/configs/float.py +18 -0
  105. clearskies/configs/float_or_callable.py +20 -0
  106. clearskies/configs/headers.py +28 -0
  107. clearskies/configs/integer.py +18 -0
  108. clearskies/configs/integer_or_callable.py +20 -0
  109. clearskies/configs/joins.py +30 -0
  110. clearskies/configs/list_any_dict.py +32 -0
  111. clearskies/configs/list_any_dict_or_callable.py +33 -0
  112. clearskies/configs/model_class.py +35 -0
  113. clearskies/configs/model_column.py +67 -0
  114. clearskies/configs/model_columns.py +58 -0
  115. clearskies/configs/model_destination_name.py +26 -0
  116. clearskies/configs/model_to_id_column.py +45 -0
  117. clearskies/configs/readable_model_column.py +11 -0
  118. clearskies/configs/readable_model_columns.py +11 -0
  119. clearskies/configs/schema.py +23 -0
  120. clearskies/configs/searchable_model_columns.py +11 -0
  121. clearskies/configs/security_headers.py +39 -0
  122. clearskies/configs/select.py +28 -0
  123. clearskies/configs/select_list.py +49 -0
  124. clearskies/configs/string.py +31 -0
  125. clearskies/configs/string_dict.py +34 -0
  126. clearskies/configs/string_list.py +47 -0
  127. clearskies/configs/string_list_or_callable.py +48 -0
  128. clearskies/configs/string_or_callable.py +18 -0
  129. clearskies/configs/timedelta.py +20 -0
  130. clearskies/configs/timezone.py +20 -0
  131. clearskies/configs/url.py +25 -0
  132. clearskies/configs/validators.py +45 -0
  133. clearskies/configs/writeable_model_column.py +11 -0
  134. clearskies/configs/writeable_model_columns.py +11 -0
  135. clearskies/configurable.py +78 -0
  136. clearskies/contexts/__init__.py +8 -8
  137. clearskies/contexts/cli.py +129 -43
  138. clearskies/contexts/context.py +93 -56
  139. clearskies/contexts/wsgi.py +79 -33
  140. clearskies/contexts/wsgi_ref.py +87 -0
  141. clearskies/cursors/__init__.py +7 -0
  142. clearskies/cursors/cursor.py +166 -0
  143. clearskies/cursors/from_environment/__init__.py +5 -0
  144. clearskies/cursors/from_environment/mysql.py +51 -0
  145. clearskies/cursors/from_environment/postgresql.py +49 -0
  146. clearskies/cursors/from_environment/sqlite.py +35 -0
  147. clearskies/cursors/mysql.py +61 -0
  148. clearskies/cursors/postgresql.py +61 -0
  149. clearskies/cursors/sqlite.py +62 -0
  150. clearskies/decorators.py +33 -0
  151. clearskies/decorators.pyi +10 -0
  152. clearskies/di/__init__.py +11 -7
  153. clearskies/di/additional_config.py +117 -3
  154. clearskies/di/additional_config_auto_import.py +12 -0
  155. clearskies/di/di.py +717 -126
  156. clearskies/di/inject/__init__.py +23 -0
  157. clearskies/di/inject/akeyless_sdk.py +16 -0
  158. clearskies/di/inject/by_class.py +24 -0
  159. clearskies/di/inject/by_name.py +22 -0
  160. clearskies/di/inject/di.py +16 -0
  161. clearskies/di/inject/environment.py +15 -0
  162. clearskies/di/inject/input_output.py +19 -0
  163. clearskies/di/inject/now.py +16 -0
  164. clearskies/di/inject/requests.py +16 -0
  165. clearskies/di/inject/secrets.py +15 -0
  166. clearskies/di/inject/utcnow.py +16 -0
  167. clearskies/di/inject/uuid.py +16 -0
  168. clearskies/di/injectable.py +32 -0
  169. clearskies/di/injectable_properties.py +131 -0
  170. clearskies/end.py +219 -0
  171. clearskies/endpoint.py +1303 -0
  172. clearskies/endpoint_group.py +333 -0
  173. clearskies/endpoints/__init__.py +25 -0
  174. clearskies/endpoints/advanced_search.py +519 -0
  175. clearskies/endpoints/callable.py +382 -0
  176. clearskies/endpoints/create.py +201 -0
  177. clearskies/endpoints/delete.py +133 -0
  178. clearskies/endpoints/get.py +267 -0
  179. clearskies/endpoints/health_check.py +181 -0
  180. clearskies/endpoints/list.py +567 -0
  181. clearskies/endpoints/restful_api.py +417 -0
  182. clearskies/endpoints/schema.py +185 -0
  183. clearskies/endpoints/simple_search.py +279 -0
  184. clearskies/endpoints/update.py +188 -0
  185. clearskies/environment.py +7 -3
  186. clearskies/exceptions/__init__.py +19 -0
  187. clearskies/{handlers/exceptions/input_error.py → exceptions/input_errors.py} +1 -1
  188. clearskies/exceptions/missing_dependency.py +2 -0
  189. clearskies/exceptions/moved_permanently.py +3 -0
  190. clearskies/exceptions/moved_temporarily.py +3 -0
  191. clearskies/functional/__init__.py +2 -2
  192. clearskies/functional/json.py +47 -0
  193. clearskies/functional/routing.py +92 -0
  194. clearskies/functional/string.py +19 -11
  195. clearskies/functional/validations.py +61 -9
  196. clearskies/input_outputs/__init__.py +9 -7
  197. clearskies/input_outputs/cli.py +135 -152
  198. clearskies/input_outputs/exceptions/__init__.py +6 -1
  199. clearskies/input_outputs/headers.py +54 -0
  200. clearskies/input_outputs/input_output.py +77 -123
  201. clearskies/input_outputs/programmatic.py +62 -0
  202. clearskies/input_outputs/wsgi.py +36 -48
  203. clearskies/model.py +1894 -199
  204. clearskies/query/__init__.py +12 -0
  205. clearskies/query/condition.py +228 -0
  206. clearskies/query/join.py +136 -0
  207. clearskies/query/query.py +193 -0
  208. clearskies/query/sort.py +27 -0
  209. clearskies/schema.py +82 -0
  210. clearskies/secrets/__init__.py +4 -31
  211. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +15 -4
  212. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +11 -5
  213. clearskies/secrets/akeyless.py +421 -155
  214. clearskies/secrets/exceptions/__init__.py +7 -1
  215. clearskies/secrets/exceptions/not_found_error.py +2 -0
  216. clearskies/secrets/exceptions/permissions_error.py +2 -0
  217. clearskies/secrets/secrets.py +12 -11
  218. clearskies/security_header.py +17 -0
  219. clearskies/security_headers/__init__.py +8 -8
  220. clearskies/security_headers/cache_control.py +47 -109
  221. clearskies/security_headers/cors.py +38 -92
  222. clearskies/security_headers/csp.py +76 -150
  223. clearskies/security_headers/hsts.py +14 -15
  224. clearskies/typing.py +11 -0
  225. clearskies/validator.py +36 -0
  226. clearskies/validators/__init__.py +33 -0
  227. clearskies/validators/after_column.py +61 -0
  228. clearskies/validators/before_column.py +15 -0
  229. clearskies/validators/in_the_future.py +29 -0
  230. clearskies/validators/in_the_future_at_least.py +13 -0
  231. clearskies/validators/in_the_future_at_most.py +12 -0
  232. clearskies/validators/in_the_past.py +29 -0
  233. clearskies/validators/in_the_past_at_least.py +12 -0
  234. clearskies/validators/in_the_past_at_most.py +12 -0
  235. clearskies/validators/maximum_length.py +25 -0
  236. clearskies/validators/maximum_value.py +28 -0
  237. clearskies/validators/minimum_length.py +25 -0
  238. clearskies/validators/minimum_value.py +28 -0
  239. clearskies/{input_requirements → validators}/required.py +18 -9
  240. clearskies/validators/timedelta.py +58 -0
  241. clearskies/validators/unique.py +28 -0
  242. clear_skies-1.19.22.dist-info/METADATA +0 -46
  243. clear_skies-1.19.22.dist-info/RECORD +0 -206
  244. clearskies/application.py +0 -29
  245. clearskies/authentication/auth0_jwks.py +0 -118
  246. clearskies/authentication/auth_exception.py +0 -2
  247. clearskies/authentication/jwks_jwcrypto.py +0 -39
  248. clearskies/backends/example_backend.py +0 -43
  249. clearskies/backends/file_backend.py +0 -48
  250. clearskies/backends/json_backend.py +0 -7
  251. clearskies/backends/restful_api_advanced_search_backend.py +0 -138
  252. clearskies/binding_config.py +0 -16
  253. clearskies/column_types/__init__.py +0 -184
  254. clearskies/column_types/audit.py +0 -235
  255. clearskies/column_types/belongs_to.py +0 -250
  256. clearskies/column_types/boolean.py +0 -60
  257. clearskies/column_types/category_tree.py +0 -226
  258. clearskies/column_types/column.py +0 -373
  259. clearskies/column_types/created.py +0 -26
  260. clearskies/column_types/created_by_authorization_data.py +0 -26
  261. clearskies/column_types/created_by_header.py +0 -24
  262. clearskies/column_types/created_by_ip.py +0 -17
  263. clearskies/column_types/created_by_routing_data.py +0 -25
  264. clearskies/column_types/created_by_user_agent.py +0 -17
  265. clearskies/column_types/created_micro.py +0 -26
  266. clearskies/column_types/datetime.py +0 -108
  267. clearskies/column_types/datetime_micro.py +0 -12
  268. clearskies/column_types/email.py +0 -18
  269. clearskies/column_types/float.py +0 -43
  270. clearskies/column_types/has_many.py +0 -139
  271. clearskies/column_types/integer.py +0 -41
  272. clearskies/column_types/json.py +0 -25
  273. clearskies/column_types/many_to_many.py +0 -278
  274. clearskies/column_types/many_to_many_with_data.py +0 -162
  275. clearskies/column_types/select.py +0 -11
  276. clearskies/column_types/string.py +0 -24
  277. clearskies/column_types/updated.py +0 -24
  278. clearskies/column_types/updated_micro.py +0 -24
  279. clearskies/column_types/uuid.py +0 -25
  280. clearskies/columns.py +0 -123
  281. clearskies/condition_parser.py +0 -172
  282. clearskies/contexts/build_context.py +0 -54
  283. clearskies/contexts/convert_to_application.py +0 -190
  284. clearskies/contexts/extract_handler.py +0 -37
  285. clearskies/contexts/test.py +0 -94
  286. clearskies/decorators/__init__.py +0 -39
  287. clearskies/decorators/auth0_jwks.py +0 -22
  288. clearskies/decorators/authorization.py +0 -10
  289. clearskies/decorators/binding_classes.py +0 -9
  290. clearskies/decorators/binding_modules.py +0 -9
  291. clearskies/decorators/bindings.py +0 -9
  292. clearskies/decorators/create.py +0 -10
  293. clearskies/decorators/delete.py +0 -10
  294. clearskies/decorators/docs.py +0 -14
  295. clearskies/decorators/get.py +0 -10
  296. clearskies/decorators/jwks.py +0 -26
  297. clearskies/decorators/merge.py +0 -124
  298. clearskies/decorators/patch.py +0 -10
  299. clearskies/decorators/post.py +0 -10
  300. clearskies/decorators/public.py +0 -11
  301. clearskies/decorators/response_headers.py +0 -10
  302. clearskies/decorators/return_raw_response.py +0 -9
  303. clearskies/decorators/schema.py +0 -10
  304. clearskies/decorators/secret_bearer.py +0 -24
  305. clearskies/decorators/security_headers.py +0 -10
  306. clearskies/di/standard_dependencies.py +0 -140
  307. clearskies/di/test_module/__init__.py +0 -6
  308. clearskies/di/test_module/another_module/__init__.py +0 -2
  309. clearskies/di/test_module/module_class.py +0 -5
  310. clearskies/handlers/__init__.py +0 -41
  311. clearskies/handlers/advanced_search.py +0 -271
  312. clearskies/handlers/base.py +0 -473
  313. clearskies/handlers/callable.py +0 -189
  314. clearskies/handlers/create.py +0 -35
  315. clearskies/handlers/crud_by_method.py +0 -18
  316. clearskies/handlers/database_connector.py +0 -32
  317. clearskies/handlers/delete.py +0 -61
  318. clearskies/handlers/exceptions/__init__.py +0 -5
  319. clearskies/handlers/exceptions/not_found.py +0 -3
  320. clearskies/handlers/get.py +0 -156
  321. clearskies/handlers/health_check.py +0 -59
  322. clearskies/handlers/input_processing.py +0 -79
  323. clearskies/handlers/list.py +0 -530
  324. clearskies/handlers/mygrations.py +0 -82
  325. clearskies/handlers/request_method_routing.py +0 -47
  326. clearskies/handlers/restful_api.py +0 -218
  327. clearskies/handlers/routing.py +0 -62
  328. clearskies/handlers/schema_helper.py +0 -128
  329. clearskies/handlers/simple_routing.py +0 -204
  330. clearskies/handlers/simple_routing_route.py +0 -192
  331. clearskies/handlers/simple_search.py +0 -136
  332. clearskies/handlers/update.py +0 -96
  333. clearskies/handlers/write.py +0 -193
  334. clearskies/input_requirements/__init__.py +0 -68
  335. clearskies/input_requirements/after.py +0 -36
  336. clearskies/input_requirements/before.py +0 -36
  337. clearskies/input_requirements/in_the_future_at_least.py +0 -19
  338. clearskies/input_requirements/in_the_future_at_most.py +0 -19
  339. clearskies/input_requirements/in_the_past_at_least.py +0 -19
  340. clearskies/input_requirements/in_the_past_at_most.py +0 -19
  341. clearskies/input_requirements/maximum_length.py +0 -19
  342. clearskies/input_requirements/minimum_length.py +0 -22
  343. clearskies/input_requirements/requirement.py +0 -25
  344. clearskies/input_requirements/time_delta.py +0 -38
  345. clearskies/input_requirements/unique.py +0 -18
  346. clearskies/mocks/__init__.py +0 -7
  347. clearskies/mocks/input_output.py +0 -124
  348. clearskies/mocks/models.py +0 -142
  349. clearskies/models.py +0 -345
  350. clearskies/security_headers/base.py +0 -12
  351. clearskies/tests/simple_api/models/__init__.py +0 -2
  352. clearskies/tests/simple_api/models/status.py +0 -23
  353. clearskies/tests/simple_api/models/user.py +0 -21
  354. clearskies/tests/simple_api/users_api.py +0 -64
  355. {clear_skies-1.19.22.dist-info → clear_skies-2.0.23.dist-info/licenses}/LICENSE +0 -0
  356. /clearskies/{contexts/bash.py → autodoc/py.typed} +0 -0
  357. /clearskies/{handlers/exceptions → exceptions}/authentication.py +0 -0
  358. /clearskies/{handlers/exceptions → exceptions}/authorization.py +0 -0
  359. /clearskies/{handlers/exceptions → exceptions}/client_error.py +0 -0
  360. /clearskies/{secrets/exceptions → exceptions}/not_found.py +0 -0
  361. /clearskies/{tests/__init__.py → input_outputs/py.typed} +0 -0
  362. /clearskies/{tests/simple_api/__init__.py → py.typed} +0 -0
@@ -0,0 +1,335 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable, Self, overload
4
+
5
+ from clearskies import configs, decorators
6
+ from clearskies.autodoc.schema import Array as AutoDocArray
7
+ from clearskies.autodoc.schema import String as AutoDocString
8
+ from clearskies.column import Column
9
+
10
+ if TYPE_CHECKING:
11
+ from clearskies import Column, Model, typing
12
+
13
+
14
+ class ManyToManyIds(Column):
15
+ """
16
+ A column that represents a many-to-many relationship.
17
+
18
+ This is different from belongs to/has many because with those, every child has only one parent. With a many-to-many
19
+ relationship, both models can have multiple relatives from the other model class. In order to support this, it's necessary
20
+ to have a third model (the pivot model) that records the relationships. In general this table just needs three
21
+ columns: it's own id, and then one column for each other model to store the id of the related records.
22
+ You can specify the names of these columns but it also follows the standard naming convention by default:
23
+ take the class name, convert it to snake case, and append `_id`.
24
+
25
+ Note, there is a variation on this (`ManyToManyIdsWithData`) where additional data is stored in the pivot table
26
+ to record information about the relationship.
27
+
28
+ This column is writeable. You would set it to a list of ids from the related model that denotes which
29
+ records it is related to.
30
+
31
+ The following example shows usage. Normally the many-to-many column exists for both related models, but in this
32
+ specific example it only exists for one of the models. This is done so that the example can fit in a single file
33
+ and therefore be easy to demonstrate. In order to have both models reference eachother, you have to use model
34
+ references to avoid circular imports. There are examples of doing this in the `BelongsTo` column class.
35
+
36
+ ```python
37
+ import clearskies
38
+
39
+
40
+ class ThingyToWidget(clearskies.Model):
41
+ id_column_name = "id"
42
+ backend = clearskies.backends.MemoryBackend()
43
+
44
+ id = clearskies.columns.Uuid()
45
+ # these could also be belongs to relationships, but the pivot model
46
+ # is rarely used directly, so I'm being lazy to avoid having to use
47
+ # model references.
48
+ thingy_id = clearskies.columns.String()
49
+ widget_id = clearskies.columns.String()
50
+
51
+
52
+ class Thingy(clearskies.Model):
53
+ id_column_name = "id"
54
+ backend = clearskies.backends.MemoryBackend()
55
+
56
+ id = clearskies.columns.Uuid()
57
+ name = clearskies.columns.String()
58
+
59
+
60
+ class Widget(clearskies.Model):
61
+ id_column_name = "id"
62
+ backend = clearskies.backends.MemoryBackend()
63
+
64
+ id = clearskies.columns.Uuid()
65
+ name = clearskies.columns.String()
66
+ thingy_ids = clearskies.columns.ManyToManyIds(
67
+ related_model_class=Thingy,
68
+ pivot_model_class=ThingyToWidget,
69
+ )
70
+ thingies = clearskies.columns.ManyToManyModels("thingy_ids")
71
+
72
+
73
+ def my_application(widgets: Widget, thingies: Thingy):
74
+ thing_1 = thingies.create({"name": "Thing 1"})
75
+ thing_2 = thingies.create({"name": "Thing 2"})
76
+ thing_3 = thingies.create({"name": "Thing 3"})
77
+ widget = widgets.create(
78
+ {
79
+ "name": "Widget 1",
80
+ "thingy_ids": [thing_1.id, thing_2.id],
81
+ }
82
+ )
83
+
84
+ # remove an item by saving without it's id in place
85
+ widget.save({"thingy_ids": [thing.id for thing in widget.thingies if thing.id != thing_1.id]})
86
+
87
+ # add an item by saving and adding the new id
88
+ widget.save({"thingy_ids": [*widget.thingy_ids, thing_3.id]})
89
+
90
+ return widget.thingies
91
+
92
+
93
+ cli = clearskies.contexts.Cli(
94
+ clearskies.endpoints.Callable(
95
+ my_application,
96
+ model_class=Thingy,
97
+ return_records=True,
98
+ readable_column_names=["id", "name"],
99
+ ),
100
+ classes=[Widget, Thingy, ThingyToWidget],
101
+ )
102
+
103
+ if __name__ == "__main__":
104
+ cli()
105
+ ```
106
+
107
+ And when executed:
108
+
109
+ ```json
110
+ {
111
+ "status": "success",
112
+ "error": "",
113
+ "data": [
114
+ {"id": "741bc838-c694-4624-9fc2-e9032f6cb962", "name": "Thing 2"},
115
+ {"id": "1808a8ef-e288-44e6-9fed-46e3b0df057f", "name": "Thing 3"},
116
+ ],
117
+ "pagination": {},
118
+ "input_errors": {},
119
+ }
120
+ ```
121
+
122
+ Of course, you can also create or remove individual relationships by using the pivot model directly,
123
+ as shown in these partial code snippets:
124
+
125
+ ```python
126
+ def add_items(thingy_to_widgets):
127
+ thingy_to_widgets.create({
128
+ "thingy_id": "some_id",
129
+ "widget_id": "other_id",
130
+ })
131
+
132
+
133
+ def remove_item(thingy_to_widgets):
134
+ thingy_to_widgets.where("thingy_id=some_id").where("widget_id=other_id").first().delete()
135
+ ```
136
+ """
137
+
138
+ """ The model class for the model that we are related to. """
139
+ related_model_class = configs.ModelClass(required=True)
140
+
141
+ """ The model class for the pivot table - the table used to record connections between ourselves and our related table. """
142
+ pivot_model_class = configs.ModelClass(required=True)
143
+
144
+ """
145
+ The name of the column in the pivot table that contains the id of records from the model with this column.
146
+
147
+ A default name is created by taking the model class name, converting it to snake case, and then appending `_id`.
148
+ If you name your columns according to this standard then you don't have to specify this column name.
149
+ """
150
+ own_column_name_in_pivot = configs.ModelToIdColumn(model_column_config_name="pivot_model_class")
151
+
152
+ """
153
+ The name of the column in the pivot table that contains the id of records from the related table.
154
+
155
+ A default name is created by taking the name of the related model class, converting it to snake case, and then
156
+ appending `_id`. If you name your columns according to this standard then you don't have to specify this column
157
+ name.
158
+ """
159
+ related_column_name_in_pivot = configs.ModelToIdColumn(
160
+ model_column_config_name="pivot_model_class", source_model_class_config_name="related_model_class"
161
+ )
162
+
163
+ """ The name of the pivot table."""
164
+ pivot_table_name = configs.ModelDestinationName("pivot_model_class")
165
+
166
+ """ The list of columns to be loaded from the related models when we are converted to JSON. """
167
+ readable_related_column_names = configs.ReadableModelColumns("related_model_class")
168
+
169
+ default = configs.StringList(default=None) # type: ignore
170
+ setable = configs.StringListOrCallable(default=None) # type: ignore
171
+ is_searchable = configs.Boolean(default=False)
172
+ _descriptor_config_map = None
173
+
174
+ @decorators.parameters_to_properties
175
+ def __init__(
176
+ self,
177
+ related_model_class,
178
+ pivot_model_class,
179
+ own_column_name_in_pivot: str = "",
180
+ related_column_name_in_pivot: str = "",
181
+ readable_related_column_names: list[str] = [],
182
+ default: list[str] = [],
183
+ setable: list[str] | Callable[..., list[str]] = [],
184
+ is_readable: bool = True,
185
+ is_writeable: bool = True,
186
+ is_temporary: bool = False,
187
+ validators: typing.validator | list[typing.validator] = [],
188
+ on_change_pre_save: typing.action | list[typing.action] = [],
189
+ on_change_post_save: typing.action | list[typing.action] = [],
190
+ on_change_save_finished: typing.action | list[typing.action] = [],
191
+ created_by_source_type: str = "",
192
+ created_by_source_key: str = "",
193
+ created_by_source_strict: bool = True,
194
+ ):
195
+ pass
196
+
197
+ def finalize_configuration(self, model_class: type, name: str) -> None:
198
+ """
199
+ Finalize and check the configuration.
200
+
201
+ This is an external trigger called by the model class when the model class is ready.
202
+ The reason it exists here instead of in the constructor is because some columns are tightly
203
+ connected to the model class, and can't validate configuration until they know what the model is.
204
+ Therefore, we need the model involved, and the only way for a property to know what class it is
205
+ in is if the parent class checks in (which is what happens here).
206
+ """
207
+ self.model_class = model_class
208
+ self.name = name
209
+ getattr(self.__class__, "pivot_table_name").finalize_and_validate_configuration(self)
210
+ own_column_name_in_pivot_config = getattr(self.__class__, "own_column_name_in_pivot")
211
+ own_column_name_in_pivot_config.source_model_class = model_class
212
+ own_column_name_in_pivot_config.finalize_and_validate_configuration(self)
213
+ self.finalize_and_validate_configuration()
214
+
215
+ def to_backend(self, data):
216
+ # we can't persist our mapping data to the database directly, so remove anything here
217
+ # and take care of things in post_save
218
+ if self.name in data:
219
+ del data[self.name]
220
+ return data
221
+
222
+ @property
223
+ def pivot_model(self) -> Model:
224
+ return self.di.build(self.pivot_model_class, cache=True)
225
+
226
+ @property
227
+ def related_model(self) -> Model:
228
+ return self.di.build(self.related_model_class, cache=True)
229
+
230
+ @property
231
+ def related_columns(self) -> dict[str, Column]:
232
+ return self.related_model.get_columns()
233
+
234
+ @property
235
+ def pivot_columns(self) -> dict[str, Column]:
236
+ return self.pivot_model.get_columns()
237
+
238
+ @overload
239
+ def __get__(self, instance: None, cls: type[Model]) -> Self:
240
+ pass
241
+
242
+ @overload
243
+ def __get__(self, instance: Model, cls: type[Model]) -> list[str | int]:
244
+ pass
245
+
246
+ def __get__(self, instance, cls):
247
+ if instance is None:
248
+ self.model_class = cls
249
+ return self
250
+
251
+ # this makes sure we're initialized
252
+ if "name" not in self._config: # type: ignore
253
+ instance.get_columns()
254
+
255
+ related_id_column_name = self.related_model_class.id_column_name
256
+ return [getattr(model, related_id_column_name) for model in self.get_related_models(instance)]
257
+
258
+ def __set__(self, instance, value: list[str | int]) -> None:
259
+ # this makes sure we're initialized
260
+ if "name" not in self._config: # type: ignore
261
+ instance.get_columns()
262
+
263
+ instance._next_data[self.name] = value
264
+
265
+ def get_related_models(self, model: Model) -> Model:
266
+ related_column_name_in_pivot = self.related_column_name_in_pivot
267
+ own_column_name_in_pivot = self.own_column_name_in_pivot
268
+ pivot_table_name = self.pivot_table_name
269
+ related_id_column_name = self.related_model_class.id_column_name
270
+ model_id = getattr(model, self.model_class.id_column_name)
271
+ model = self.related_model
272
+ join = f"JOIN {pivot_table_name} ON {pivot_table_name}.{related_column_name_in_pivot}={model.destination_name()}.{related_id_column_name}"
273
+ related_models = model.join(join).where(f"{pivot_table_name}.{own_column_name_in_pivot}={model_id}")
274
+ return related_models
275
+
276
+ def get_pivot_models(self, model: Model) -> Model:
277
+ return self.pivot_model.where(
278
+ f"{self.own_column_name_in_pivot}=" + getattr(model, self.model_class.id_column_name)
279
+ )
280
+
281
+ def post_save(self, data: dict[str, Any], model: Model, id: int | str) -> None:
282
+ # if our incoming data is not in the data array or is None, then nothing has been set and we do not want
283
+ # to make any changes
284
+ if self.name not in data or data[self.name] is None:
285
+ return
286
+
287
+ # figure out what ids need to be created or deleted from the pivot table.
288
+ if not model:
289
+ old_ids = set()
290
+ else:
291
+ old_ids = set(self.__get__(model, model.__class__))
292
+
293
+ new_ids = set(data[self.name])
294
+ to_delete = old_ids - new_ids
295
+ to_create = new_ids - old_ids
296
+ pivot_model = self.pivot_model
297
+ related_column_name_in_pivot = self.related_column_name_in_pivot
298
+ if to_delete:
299
+ for model_to_delete in pivot_model.where(
300
+ f"{related_column_name_in_pivot} IN ({','.join(map(str, to_delete))})"
301
+ ):
302
+ model_to_delete.delete()
303
+ if to_create:
304
+ own_column_name_in_pivot = self.own_column_name_in_pivot
305
+ for id_to_create in to_create:
306
+ pivot_model.create(
307
+ {
308
+ related_column_name_in_pivot: id_to_create,
309
+ own_column_name_in_pivot: id,
310
+ }
311
+ )
312
+
313
+ super().post_save(data, model, id)
314
+
315
+ def add_search(self, model: Model, value: str, operator: str = "", relationship_reference: str = "") -> Model:
316
+ related_column_name_in_pivot = self.related_column_name_in_pivot
317
+ own_column_name_in_pivot = self.own_column_name_in_pivot
318
+ own_id_column_name = self.model_class.id_column_name
319
+ pivot_table_name = self.pivot_table_name
320
+ my_table_name = self.model_class.destination_name()
321
+ related_table_name = self.related_model.destination_name()
322
+ join_pivot = f"JOIN {pivot_table_name} ON {pivot_table_name}.{own_column_name_in_pivot}={my_table_name}.{own_id_column_name}"
323
+ # no reason we can't support searching by both an id or a list of ids
324
+ values = value if type(value) == list else [value]
325
+ search = " IN (" + ", ".join([str(val) for val in value]) + ")"
326
+ return model.join(join_pivot).where(f"{pivot_table_name}.{related_column_name_in_pivot}{search}")
327
+
328
+ def to_json(self, model: Model) -> dict[str, Any]:
329
+ related_id_column_name = self.related_model_class.id_column_name
330
+ records = [getattr(related, related_id_column_name) for related in self.get_related_models(model)]
331
+ return {self.name: records}
332
+
333
+ def documentation(self, name: str | None = None, example: str | None = None, value: str | None = None):
334
+ related_id_column_name = self.related_model_class.id_column_name
335
+ return AutoDocArray(name if name is not None else self.name, AutoDocString(related_id_column_name))
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable, Self, overload
4
+
5
+ from clearskies import configs, decorators
6
+ from clearskies.columns.many_to_many_ids import ManyToManyIds
7
+
8
+ if TYPE_CHECKING:
9
+ from clearskies import Model, typing
10
+
11
+
12
+ class ManyToManyIdsWithData(ManyToManyIds):
13
+ """
14
+ A column to represent a many-to-many relationship with information stored in the relationship itself.
15
+
16
+ This is an extention of the many-to-many column, but with one important addition: data about the
17
+ relationship is stored in the pivot table. This creates some differences, which are best
18
+ explained by example:
19
+
20
+ ```python
21
+ import clearskies
22
+
23
+
24
+ class ThingyWidgets(clearskies.Model):
25
+ id_column_name = "id"
26
+ backend = clearskies.backends.MemoryBackend()
27
+
28
+ id = clearskies.columns.Uuid()
29
+ # these could also be belongs to relationships, but the pivot model
30
+ # is rarely used directly, so I'm being lazy to avoid having to use
31
+ # model references.
32
+ thingy_id = clearskies.columns.String()
33
+ widget_id = clearskies.columns.String()
34
+ name = clearskies.columns.String()
35
+ kind = clearskies.columns.String()
36
+
37
+
38
+ class Thingy(clearskies.Model):
39
+ id_column_name = "id"
40
+ backend = clearskies.backends.MemoryBackend()
41
+
42
+ id = clearskies.columns.Uuid()
43
+ name = clearskies.columns.String()
44
+
45
+
46
+ class Widget(clearskies.Model):
47
+ id_column_name = "id"
48
+ backend = clearskies.backends.MemoryBackend()
49
+
50
+ id = clearskies.columns.Uuid()
51
+ name = clearskies.columns.String()
52
+ thingy_ids = clearskies.columns.ManyToManyIdsWithData(
53
+ related_model_class=Thingy,
54
+ pivot_model_class=ThingyWidgets,
55
+ readable_pivot_column_names=["id", "thingy_id", "widget_id", "name", "kind"],
56
+ )
57
+ thingies = clearskies.columns.ManyToManyModels("thingy_ids")
58
+ thingy_widgets = clearskies.columns.ManyToManyPivots("thingy_ids")
59
+
60
+
61
+ def my_application(widgets: Widget, thingies: Thingy):
62
+ thing_1 = thingies.create({"name": "Thing 1"})
63
+ thing_2 = thingies.create({"name": "Thing 2"})
64
+ thing_3 = thingies.create({"name": "Thing 3"})
65
+ widget = widgets.create(
66
+ {
67
+ "name": "Widget 1",
68
+ "thingy_ids": [
69
+ {"thingy_id": thing_1.id, "name": "Widget Thing 1", "kind": "Special"},
70
+ {"thingy_id": thing_2.id, "name": "Widget Thing 2", "kind": "Also Special"},
71
+ ],
72
+ }
73
+ )
74
+
75
+ return widget
76
+
77
+
78
+ cli = clearskies.contexts.Cli(
79
+ clearskies.endpoints.Callable(
80
+ my_application,
81
+ model_class=Widget,
82
+ return_records=True,
83
+ readable_column_names=["id", "name", "thingy_widgets"],
84
+ ),
85
+ classes=[Widget, Thingy, ThingyWidgets],
86
+ )
87
+
88
+ if __name__ == "__main__":
89
+ cli()
90
+ ```
91
+
92
+ As with setting ids in the ManyToManyIds class, any items left out will result in the relationship
93
+ (including all its related data) being removed. An important difference with the ManyToManyWithData
94
+ column is the way you specify which record is being connected. This is easy for the ManyToManyIds column
95
+ because all you provide is the id from the related model. When working with the ManyToManyWithData
96
+ column, you provide a dictionary for each relationship (so you can provide the data that goes in the
97
+ pivot model). To let it know what record is being connected, you therefore explicitly provide
98
+ the id from the related model in a dictionary key with the name of the related model id column in
99
+ the pivot (e.g. `{"thingy_id": id}` in the first example. However, if there are unique columns in the
100
+ related model, you can provide those instead. If you execute the above example you'll get:
101
+
102
+ ```json
103
+ {
104
+ "status": "success",
105
+ "error": "",
106
+ "data": {
107
+ "id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
108
+ "name": "Widget 1",
109
+ "thingy_widgets": [
110
+ {
111
+ "id": "3a8f6f14-9657-49d8-8844-0db3452525fe",
112
+ "thingy_id": "db292ebc-7b2b-4306-aced-8e6d073ec264",
113
+ "widget_id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
114
+ "name": "Widget Thing 1",
115
+ "kind": "Special",
116
+ },
117
+ {
118
+ "id": "480a0192-70d9-4363-a669-4a59f0b56730",
119
+ "thingy_id": "d469dbe9-556e-46f3-bc48-03f8cb8d8e44",
120
+ "widget_id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
121
+ "name": "Widget Thing 2",
122
+ "kind": "Also Special",
123
+ },
124
+ ],
125
+ },
126
+ "pagination": {},
127
+ "input_errors": {},
128
+ }
129
+ ```
130
+ """
131
+
132
+ """ The list of columns in the pivot model that can be set when saving data from an endpoint. """
133
+ setable_column_names = configs.WriteableModelColumns("pivot_model_class")
134
+
135
+ """ The list of columns in the pivot model that will be included when returning records from an endpoint. """
136
+ readable_pivot_column_names = configs.ReadableModelColumns("pivot_model_class")
137
+
138
+ """
139
+ Complicated, but probably should be false.
140
+
141
+ Sometimes you have to provide data from the related model class in your save data so that
142
+ clearskies can find the right record. Normally, this lookup column is not persisted to the
143
+ pivot table, because it is assumed to only exist in the related table. In some cases though,
144
+ you may want it in both, in which case you can set this to true.
145
+ """
146
+ persist_unique_lookup_column_to_pivot_table = configs.Boolean(default=False)
147
+
148
+ default = configs.ListAnyDict(default=None) # type: ignore
149
+ setable = configs.ListAnyDictOrCallable(default=None) # type: ignore
150
+ _descriptor_config_map = None
151
+
152
+ @decorators.parameters_to_properties
153
+ def __init__(
154
+ self,
155
+ related_model_class,
156
+ pivot_model_class,
157
+ own_column_name_in_pivot: str = "",
158
+ related_column_name_in_pivot: str = "",
159
+ readable_related_columns: list[str] = [],
160
+ readable_pivot_column_names: list[str] = [],
161
+ setable_column_names: list[str] = [],
162
+ persist_unique_lookup_column_to_pivot_table: bool = False,
163
+ default: list[dict[str, Any]] = [],
164
+ setable: list[dict[str, Any]] | Callable[..., list[dict[str, Any]]] = [],
165
+ is_readable: bool = True,
166
+ is_writeable: bool = True,
167
+ is_temporary: bool = False,
168
+ validators: typing.validator | list[typing.validator] = [],
169
+ on_change_pre_save: typing.action | list[typing.action] = [],
170
+ on_change_post_save: typing.action | list[typing.action] = [],
171
+ on_change_save_finished: typing.action | list[typing.action] = [],
172
+ created_by_source_type: str = "",
173
+ created_by_source_key: str = "",
174
+ created_by_source_strict: bool = True,
175
+ ):
176
+ pass
177
+
178
+ @overload
179
+ def __get__(self, instance: None, cls: type[Model]) -> Self:
180
+ pass
181
+
182
+ @overload
183
+ def __get__(self, instance: Model, cls: type[Model]) -> list[Any]:
184
+ pass
185
+
186
+ def __get__(self, instance, cls):
187
+ return super().__get__(instance, cls)
188
+
189
+ def __set__(self, instance, value: list[dict[str, Any]]) -> None: # type: ignore
190
+ # this makes sure we're initialized
191
+ if "name" not in self._config: # type: ignore
192
+ instance.get_columns()
193
+
194
+ instance._next_data[self.name] = value
195
+
196
+ def post_save(self, data, model, id):
197
+ # if our incoming data is not in the data array or is None, then nothing has been set and we do not want
198
+ # to make any changes
199
+ if self.name not in data or data[self.name] is None:
200
+ return data
201
+
202
+ # figure out what ids need to be created or deleted from the pivot table.
203
+ if not model:
204
+ old_ids = set()
205
+ else:
206
+ old_ids = set(self.__get__(model, model.__class__))
207
+
208
+ # this is trickier for many-to-many-with-data compared to many-to-many. We're generally
209
+ # expecting data[self.name] to be a list of dictionaries. For each entry, we need to find
210
+ # the corresponding entry in the pivot table to decide if we need to delete, create, or update.
211
+ # However, since we have a dictionary there are a variety of ways that we can connect to
212
+ # an entry in the related table - either related id or any unique column from the related
213
+ # table. Technically we might also specify a pivot id, but we're generally trying to be
214
+ # transparent to those, so let's ignore that one.
215
+ related_column_name_in_pivot = self.related_column_name_in_pivot
216
+ own_column_name_in_pivot = self.own_column_name_in_pivot
217
+ unique_related_columns = {
218
+ column.name: column.name for column in self.related_columns.values() if column.is_unique
219
+ }
220
+ related_model = self.related_model
221
+ pivot_model = self.pivot_model
222
+ # minor cheating
223
+ if hasattr(pivot_model.backend, "create_table"):
224
+ pivot_model.backend.create_table(pivot_model)
225
+ new_ids = set()
226
+ for pivot_record in data[self.name]:
227
+ # first we need to identify which related column this belongs to.
228
+ related_column_id = None
229
+ # if they provide the related column id in the pivot data then we're good
230
+ if related_column_name_in_pivot in pivot_record:
231
+ related_column_id = pivot_record[related_column_name_in_pivot]
232
+ elif len(unique_related_columns):
233
+ for pivot_column, pivot_value in pivot_record.items():
234
+ if pivot_column not in unique_related_columns:
235
+ continue
236
+ related = related_model.find(f"{pivot_column}={pivot_value}")
237
+ related_column_id = getattr(related, related.id_column_name)
238
+ if related_column_id:
239
+ # remove this column from the data - it was used to lookup the right
240
+ # record, but mostly won't exist in the model, unless we've been instructed
241
+ # to keep it
242
+ if not self._config.get("persist_unique_lookup_column_to_pivot_table"): # type: ignore
243
+ del pivot_record[pivot_column]
244
+ break
245
+ if not related_column_id:
246
+ column_list = "'" + "', '".join(list(unique_related_columns.keys())) + "'"
247
+ raise ValueError(
248
+ f"Missing data for {self.name}: Unable to match related record for a record in the many-to-many relationship: you must provide either '{related_column_name_in_pivot}' with the id column for the related table, or a value from one of the unique columns: {column_list}"
249
+ )
250
+ pivot = (
251
+ pivot_model.where(f"{related_column_name_in_pivot}={related_column_id}")
252
+ .where(f"{own_column_name_in_pivot}={id}")
253
+ .first()
254
+ )
255
+ new_ids.add(related_column_id)
256
+ # this will either update or create accordingly
257
+ pivot.save(
258
+ {
259
+ **pivot_record,
260
+ related_column_name_in_pivot: related_column_id,
261
+ own_column_name_in_pivot: id,
262
+ }
263
+ )
264
+
265
+ # the above took care of isnerting and updating active records. Now we need to delete
266
+ # records that are no longer needed.
267
+ to_delete = old_ids - new_ids
268
+ if to_delete:
269
+ for model_to_delete in pivot_model.where(
270
+ f"{related_column_name_in_pivot} IN (" + ",".join(map(str, to_delete)) + ")"
271
+ ):
272
+ model_to_delete.delete()
273
+
274
+ return data