clear-skies 2.0.27__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 (270) hide show
  1. clear_skies-2.0.27.dist-info/METADATA +78 -0
  2. clear_skies-2.0.27.dist-info/RECORD +270 -0
  3. clear_skies-2.0.27.dist-info/WHEEL +4 -0
  4. clear_skies-2.0.27.dist-info/licenses/LICENSE +7 -0
  5. clearskies/__init__.py +69 -0
  6. clearskies/action.py +7 -0
  7. clearskies/authentication/__init__.py +15 -0
  8. clearskies/authentication/authentication.py +44 -0
  9. clearskies/authentication/authorization.py +23 -0
  10. clearskies/authentication/authorization_pass_through.py +22 -0
  11. clearskies/authentication/jwks.py +165 -0
  12. clearskies/authentication/public.py +5 -0
  13. clearskies/authentication/secret_bearer.py +551 -0
  14. clearskies/autodoc/__init__.py +8 -0
  15. clearskies/autodoc/formats/__init__.py +5 -0
  16. clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
  17. clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
  18. clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
  19. clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
  20. clearskies/autodoc/formats/oai3_json/request.py +68 -0
  21. clearskies/autodoc/formats/oai3_json/response.py +28 -0
  22. clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
  23. clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
  24. clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
  25. clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
  26. clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
  27. clearskies/autodoc/formats/oai3_json/test.json +1985 -0
  28. clearskies/autodoc/py.typed +0 -0
  29. clearskies/autodoc/request/__init__.py +15 -0
  30. clearskies/autodoc/request/header.py +6 -0
  31. clearskies/autodoc/request/json_body.py +6 -0
  32. clearskies/autodoc/request/parameter.py +8 -0
  33. clearskies/autodoc/request/request.py +47 -0
  34. clearskies/autodoc/request/url_parameter.py +6 -0
  35. clearskies/autodoc/request/url_path.py +6 -0
  36. clearskies/autodoc/response/__init__.py +5 -0
  37. clearskies/autodoc/response/response.py +9 -0
  38. clearskies/autodoc/schema/__init__.py +31 -0
  39. clearskies/autodoc/schema/array.py +10 -0
  40. clearskies/autodoc/schema/base64.py +8 -0
  41. clearskies/autodoc/schema/boolean.py +5 -0
  42. clearskies/autodoc/schema/date.py +5 -0
  43. clearskies/autodoc/schema/datetime.py +5 -0
  44. clearskies/autodoc/schema/double.py +5 -0
  45. clearskies/autodoc/schema/enum.py +17 -0
  46. clearskies/autodoc/schema/integer.py +6 -0
  47. clearskies/autodoc/schema/long.py +5 -0
  48. clearskies/autodoc/schema/number.py +6 -0
  49. clearskies/autodoc/schema/object.py +13 -0
  50. clearskies/autodoc/schema/password.py +5 -0
  51. clearskies/autodoc/schema/schema.py +11 -0
  52. clearskies/autodoc/schema/string.py +5 -0
  53. clearskies/backends/__init__.py +67 -0
  54. clearskies/backends/api_backend.py +1194 -0
  55. clearskies/backends/backend.py +137 -0
  56. clearskies/backends/cursor_backend.py +339 -0
  57. clearskies/backends/graphql_backend.py +977 -0
  58. clearskies/backends/memory_backend.py +794 -0
  59. clearskies/backends/secrets_backend.py +100 -0
  60. clearskies/clients/__init__.py +5 -0
  61. clearskies/clients/graphql_client.py +182 -0
  62. clearskies/column.py +1221 -0
  63. clearskies/columns/__init__.py +71 -0
  64. clearskies/columns/audit.py +306 -0
  65. clearskies/columns/belongs_to_id.py +478 -0
  66. clearskies/columns/belongs_to_model.py +145 -0
  67. clearskies/columns/belongs_to_self.py +109 -0
  68. clearskies/columns/boolean.py +110 -0
  69. clearskies/columns/category_tree.py +274 -0
  70. clearskies/columns/category_tree_ancestors.py +51 -0
  71. clearskies/columns/category_tree_children.py +125 -0
  72. clearskies/columns/category_tree_descendants.py +48 -0
  73. clearskies/columns/created.py +92 -0
  74. clearskies/columns/created_by_authorization_data.py +114 -0
  75. clearskies/columns/created_by_header.py +103 -0
  76. clearskies/columns/created_by_ip.py +90 -0
  77. clearskies/columns/created_by_routing_data.py +102 -0
  78. clearskies/columns/created_by_user_agent.py +89 -0
  79. clearskies/columns/date.py +232 -0
  80. clearskies/columns/datetime.py +284 -0
  81. clearskies/columns/email.py +78 -0
  82. clearskies/columns/float.py +149 -0
  83. clearskies/columns/has_many.py +552 -0
  84. clearskies/columns/has_many_self.py +62 -0
  85. clearskies/columns/has_one.py +21 -0
  86. clearskies/columns/integer.py +158 -0
  87. clearskies/columns/json.py +126 -0
  88. clearskies/columns/many_to_many_ids.py +335 -0
  89. clearskies/columns/many_to_many_ids_with_data.py +281 -0
  90. clearskies/columns/many_to_many_models.py +163 -0
  91. clearskies/columns/many_to_many_pivots.py +132 -0
  92. clearskies/columns/phone.py +162 -0
  93. clearskies/columns/select.py +95 -0
  94. clearskies/columns/string.py +102 -0
  95. clearskies/columns/timestamp.py +164 -0
  96. clearskies/columns/updated.py +107 -0
  97. clearskies/columns/uuid.py +83 -0
  98. clearskies/configs/README.md +105 -0
  99. clearskies/configs/__init__.py +170 -0
  100. clearskies/configs/actions.py +43 -0
  101. clearskies/configs/any.py +15 -0
  102. clearskies/configs/any_dict.py +24 -0
  103. clearskies/configs/any_dict_or_callable.py +25 -0
  104. clearskies/configs/authentication.py +23 -0
  105. clearskies/configs/authorization.py +23 -0
  106. clearskies/configs/boolean.py +18 -0
  107. clearskies/configs/boolean_or_callable.py +20 -0
  108. clearskies/configs/callable_config.py +20 -0
  109. clearskies/configs/columns.py +34 -0
  110. clearskies/configs/conditions.py +30 -0
  111. clearskies/configs/config.py +26 -0
  112. clearskies/configs/datetime.py +20 -0
  113. clearskies/configs/datetime_or_callable.py +21 -0
  114. clearskies/configs/email.py +10 -0
  115. clearskies/configs/email_list.py +17 -0
  116. clearskies/configs/email_list_or_callable.py +17 -0
  117. clearskies/configs/email_or_email_list_or_callable.py +59 -0
  118. clearskies/configs/endpoint.py +23 -0
  119. clearskies/configs/endpoint_list.py +29 -0
  120. clearskies/configs/float.py +18 -0
  121. clearskies/configs/float_or_callable.py +20 -0
  122. clearskies/configs/headers.py +28 -0
  123. clearskies/configs/integer.py +18 -0
  124. clearskies/configs/integer_or_callable.py +20 -0
  125. clearskies/configs/joins.py +30 -0
  126. clearskies/configs/list_any_dict.py +32 -0
  127. clearskies/configs/list_any_dict_or_callable.py +33 -0
  128. clearskies/configs/model_class.py +35 -0
  129. clearskies/configs/model_column.py +67 -0
  130. clearskies/configs/model_columns.py +58 -0
  131. clearskies/configs/model_destination_name.py +26 -0
  132. clearskies/configs/model_to_id_column.py +45 -0
  133. clearskies/configs/readable_model_column.py +11 -0
  134. clearskies/configs/readable_model_columns.py +11 -0
  135. clearskies/configs/schema.py +23 -0
  136. clearskies/configs/searchable_model_columns.py +11 -0
  137. clearskies/configs/security_headers.py +39 -0
  138. clearskies/configs/select.py +28 -0
  139. clearskies/configs/select_list.py +49 -0
  140. clearskies/configs/string.py +31 -0
  141. clearskies/configs/string_dict.py +34 -0
  142. clearskies/configs/string_list.py +47 -0
  143. clearskies/configs/string_list_or_callable.py +48 -0
  144. clearskies/configs/string_or_callable.py +18 -0
  145. clearskies/configs/timedelta.py +20 -0
  146. clearskies/configs/timezone.py +20 -0
  147. clearskies/configs/url.py +25 -0
  148. clearskies/configs/validators.py +45 -0
  149. clearskies/configs/writeable_model_column.py +11 -0
  150. clearskies/configs/writeable_model_columns.py +11 -0
  151. clearskies/configurable.py +78 -0
  152. clearskies/contexts/__init__.py +11 -0
  153. clearskies/contexts/cli.py +130 -0
  154. clearskies/contexts/context.py +99 -0
  155. clearskies/contexts/wsgi.py +79 -0
  156. clearskies/contexts/wsgi_ref.py +87 -0
  157. clearskies/cursors/__init__.py +10 -0
  158. clearskies/cursors/cursor.py +161 -0
  159. clearskies/cursors/from_environment/__init__.py +5 -0
  160. clearskies/cursors/from_environment/mysql.py +51 -0
  161. clearskies/cursors/from_environment/postgresql.py +49 -0
  162. clearskies/cursors/from_environment/sqlite.py +35 -0
  163. clearskies/cursors/mysql.py +61 -0
  164. clearskies/cursors/postgresql.py +61 -0
  165. clearskies/cursors/sqlite.py +62 -0
  166. clearskies/decorators.py +33 -0
  167. clearskies/decorators.pyi +10 -0
  168. clearskies/di/__init__.py +15 -0
  169. clearskies/di/additional_config.py +130 -0
  170. clearskies/di/additional_config_auto_import.py +17 -0
  171. clearskies/di/di.py +948 -0
  172. clearskies/di/inject/__init__.py +25 -0
  173. clearskies/di/inject/akeyless_sdk.py +16 -0
  174. clearskies/di/inject/by_class.py +24 -0
  175. clearskies/di/inject/by_name.py +22 -0
  176. clearskies/di/inject/di.py +16 -0
  177. clearskies/di/inject/environment.py +15 -0
  178. clearskies/di/inject/input_output.py +19 -0
  179. clearskies/di/inject/logger.py +16 -0
  180. clearskies/di/inject/now.py +16 -0
  181. clearskies/di/inject/requests.py +16 -0
  182. clearskies/di/inject/secrets.py +15 -0
  183. clearskies/di/inject/utcnow.py +16 -0
  184. clearskies/di/inject/uuid.py +16 -0
  185. clearskies/di/injectable.py +32 -0
  186. clearskies/di/injectable_properties.py +131 -0
  187. clearskies/end.py +219 -0
  188. clearskies/endpoint.py +1303 -0
  189. clearskies/endpoint_group.py +333 -0
  190. clearskies/endpoints/__init__.py +25 -0
  191. clearskies/endpoints/advanced_search.py +519 -0
  192. clearskies/endpoints/callable.py +382 -0
  193. clearskies/endpoints/create.py +201 -0
  194. clearskies/endpoints/delete.py +133 -0
  195. clearskies/endpoints/get.py +267 -0
  196. clearskies/endpoints/health_check.py +181 -0
  197. clearskies/endpoints/list.py +567 -0
  198. clearskies/endpoints/restful_api.py +417 -0
  199. clearskies/endpoints/schema.py +185 -0
  200. clearskies/endpoints/simple_search.py +279 -0
  201. clearskies/endpoints/update.py +188 -0
  202. clearskies/environment.py +106 -0
  203. clearskies/exceptions/__init__.py +19 -0
  204. clearskies/exceptions/authentication.py +2 -0
  205. clearskies/exceptions/authorization.py +2 -0
  206. clearskies/exceptions/client_error.py +2 -0
  207. clearskies/exceptions/input_errors.py +4 -0
  208. clearskies/exceptions/missing_dependency.py +2 -0
  209. clearskies/exceptions/moved_permanently.py +3 -0
  210. clearskies/exceptions/moved_temporarily.py +3 -0
  211. clearskies/exceptions/not_found.py +2 -0
  212. clearskies/functional/__init__.py +7 -0
  213. clearskies/functional/json.py +47 -0
  214. clearskies/functional/routing.py +92 -0
  215. clearskies/functional/string.py +112 -0
  216. clearskies/functional/validations.py +76 -0
  217. clearskies/input_outputs/__init__.py +13 -0
  218. clearskies/input_outputs/cli.py +157 -0
  219. clearskies/input_outputs/exceptions/__init__.py +7 -0
  220. clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
  221. clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
  222. clearskies/input_outputs/headers.py +54 -0
  223. clearskies/input_outputs/input_output.py +116 -0
  224. clearskies/input_outputs/programmatic.py +62 -0
  225. clearskies/input_outputs/py.typed +0 -0
  226. clearskies/input_outputs/wsgi.py +80 -0
  227. clearskies/loggable.py +19 -0
  228. clearskies/model.py +2039 -0
  229. clearskies/py.typed +0 -0
  230. clearskies/query/__init__.py +12 -0
  231. clearskies/query/condition.py +228 -0
  232. clearskies/query/join.py +136 -0
  233. clearskies/query/query.py +195 -0
  234. clearskies/query/sort.py +27 -0
  235. clearskies/schema.py +82 -0
  236. clearskies/secrets/__init__.py +7 -0
  237. clearskies/secrets/additional_configs/__init__.py +32 -0
  238. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
  239. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
  240. clearskies/secrets/akeyless.py +507 -0
  241. clearskies/secrets/exceptions/__init__.py +7 -0
  242. clearskies/secrets/exceptions/not_found_error.py +2 -0
  243. clearskies/secrets/exceptions/permissions_error.py +2 -0
  244. clearskies/secrets/secrets.py +39 -0
  245. clearskies/security_header.py +17 -0
  246. clearskies/security_headers/__init__.py +11 -0
  247. clearskies/security_headers/cache_control.py +68 -0
  248. clearskies/security_headers/cors.py +51 -0
  249. clearskies/security_headers/csp.py +95 -0
  250. clearskies/security_headers/hsts.py +23 -0
  251. clearskies/security_headers/x_content_type_options.py +0 -0
  252. clearskies/security_headers/x_frame_options.py +0 -0
  253. clearskies/typing.py +11 -0
  254. clearskies/validator.py +36 -0
  255. clearskies/validators/__init__.py +33 -0
  256. clearskies/validators/after_column.py +61 -0
  257. clearskies/validators/before_column.py +15 -0
  258. clearskies/validators/in_the_future.py +29 -0
  259. clearskies/validators/in_the_future_at_least.py +13 -0
  260. clearskies/validators/in_the_future_at_most.py +12 -0
  261. clearskies/validators/in_the_past.py +29 -0
  262. clearskies/validators/in_the_past_at_least.py +12 -0
  263. clearskies/validators/in_the_past_at_most.py +12 -0
  264. clearskies/validators/maximum_length.py +25 -0
  265. clearskies/validators/maximum_value.py +28 -0
  266. clearskies/validators/minimum_length.py +25 -0
  267. clearskies/validators/minimum_value.py +28 -0
  268. clearskies/validators/required.py +32 -0
  269. clearskies/validators/timedelta.py +58 -0
  270. clearskies/validators/unique.py +28 -0
@@ -0,0 +1,794 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cmp_to_key
4
+ from typing import TYPE_CHECKING, Any, Callable
5
+
6
+ from clearskies import functional
7
+ from clearskies.autodoc.schema import Integer as AutoDocInteger
8
+ from clearskies.autodoc.schema import Schema as AutoDocSchema
9
+ from clearskies.backends.backend import Backend
10
+ from clearskies.di import InjectableProperties, inject
11
+
12
+ if TYPE_CHECKING:
13
+ from clearskies import Model
14
+ from clearskies.query import Condition, Join, Query, Sort
15
+
16
+
17
+ class Null:
18
+ def __lt__(self, other):
19
+ return True
20
+
21
+ def __gt__(self, other):
22
+ return False
23
+
24
+ def __eq__(self, other):
25
+ return isinstance(other, Null) or other is None
26
+
27
+
28
+ # for some comparisons we prefer comparing floats, but we need to be able to
29
+ # fall back on string comparison
30
+ def gentle_float_conversion(value):
31
+ try:
32
+ return float(value)
33
+ except:
34
+ return value
35
+
36
+
37
+ def _sort(row_a: Any, row_b: Any, sorts: list[Sort], default_table_name: str) -> int:
38
+ for sort in sorts:
39
+ # so, if we've done a join then the rows will have data from all joined tables via a dict of dicts.
40
+ # if there wasn't a join then we'll just have the data
41
+ if sort.table_name in row_a and isinstance(row_a[sort.table_name], dict):
42
+ sort_data_a = row_a[sort.table_name]
43
+ elif not sort.table_name and default_table_name in row_a and isinstance(row_a[default_table_name], dict):
44
+ sort_data_a = row_a[default_table_name]
45
+ else:
46
+ sort_data_a = row_a
47
+
48
+ if sort.table_name in row_b and isinstance(row_b[sort.table_name], dict):
49
+ sort_data_b = row_b[sort.table_name]
50
+ elif not sort.table_name and default_table_name in row_b and isinstance(row_b[default_table_name], dict):
51
+ sort_data_b = row_b[default_table_name]
52
+ else:
53
+ sort_data_b = row_b
54
+
55
+ reverse = 1 if sort.direction.lower() == "asc" else -1
56
+ value_a = sort_data_a[sort.column_name] if sort.column_name in sort_data_a else None
57
+ value_b = sort_data_b[sort.column_name] if sort.column_name in sort_data_b else None
58
+ if value_a == value_b:
59
+ continue
60
+ if value_a is None:
61
+ return -1 * reverse
62
+ if value_b is None:
63
+ return 1 * reverse
64
+ return reverse * (1 if value_a > value_b else -1)
65
+ return 0
66
+
67
+
68
+ def cheating_equals(column, values, null):
69
+ """
70
+ Cheating because this solves a very specific problem that likely is a generic issue.
71
+
72
+ The memory backend has some matching failures because boolean columns stay boolean in the
73
+ memory store, but the incoming search values are not converted to boolean and tend to be
74
+ str(1) or str(0). The issue is that save data goes through the `to_backend` flow, but search
75
+ data doesn't. This doesn't matter most of the time because, in practice, the backend itself
76
+ often does its own type conversion, but it causes problems for the memory backend. I can't
77
+ decide if fixing this will cause more problems than it solves, so for now I'm just cheating
78
+ and putting in a hack for this specific use case :shame:.
79
+ """
80
+
81
+ def inner(row):
82
+ backend_value = row[column] if column in row else null
83
+ if isinstance(backend_value, bool):
84
+ return backend_value == bool(values[0])
85
+ return str(backend_value) == str(values[0])
86
+
87
+ return inner
88
+
89
+
90
+ class MemoryTable:
91
+ _table_name: str = ""
92
+ _column_names: list[str] = []
93
+ _rows: list[dict[str, Any] | None] = []
94
+ null: Null = Null()
95
+ _id_index: dict[int | str, int] = {}
96
+ id_column_name: str = ""
97
+ _next_id: int = 1
98
+ _model_class: type[Model] = None # type: ignore
99
+
100
+ # here be dragons. This is not a 100% drop-in replacement for the equivalent SQL operators
101
+ # https://codereview.stackexchange.com/questions/259198/in-memory-table-filtering-in-python
102
+ _operator_lambda_builders = {
103
+ "<=>": lambda column, values, null: lambda row: row.get(column, null) == values[0],
104
+ "!=": lambda column, values, null: lambda row: row.get(column, null) != values[0],
105
+ "<=": (
106
+ lambda column, values, null: lambda row: gentle_float_conversion(row.get(column, null))
107
+ <= gentle_float_conversion(values[0])
108
+ ),
109
+ ">=": (
110
+ lambda column, values, null: lambda row: gentle_float_conversion(row.get(column, null))
111
+ >= gentle_float_conversion(values[0])
112
+ ),
113
+ ">": (
114
+ lambda column, values, null: lambda row: gentle_float_conversion(row.get(column, null))
115
+ > gentle_float_conversion(values[0])
116
+ ),
117
+ "<": (
118
+ lambda column, values, null: lambda row: gentle_float_conversion(row.get(column, null))
119
+ < gentle_float_conversion(values[0])
120
+ ),
121
+ "=": cheating_equals,
122
+ "is not null": lambda column, values, null: lambda row: (column in row and row[column] is not None),
123
+ "is null": lambda column, values, null: lambda row: (column not in row or row[column] is None),
124
+ "is not": lambda column, values, null: lambda row: row.get(column, null) != values[0],
125
+ "is": lambda column, values, null: lambda row: row.get(column, null) == str(values[0]),
126
+ "like": lambda column, values, null: lambda row: row.get(column, null) == str(values[0]),
127
+ "in": lambda column, values, null: lambda row: row.get(column, null) in values,
128
+ }
129
+
130
+ def __init__(self, model_class: type[Model]) -> None:
131
+ self._rows = []
132
+ self._id_index = {}
133
+ self.id_column_name = model_class.id_column_name
134
+ self._next_id = 1
135
+ self._model_class = model_class
136
+
137
+ self._table_name = model_class.destination_name()
138
+ self._column_names = list(model_class.get_columns().keys())
139
+ if self.id_column_name not in self._column_names:
140
+ self._column_names.append(self.id_column_name)
141
+
142
+ def update(self, id: int | str, data: dict[str, Any]) -> dict[str, Any]:
143
+ if id not in self._id_index:
144
+ raise ValueError(f"Attempt to update non-existent record with '{self.id_column_name}' of '{id}'")
145
+ index = self._id_index[id]
146
+ row = self._rows[index]
147
+ if row is None:
148
+ raise ValueError(
149
+ f"Cannot update record with '{self.id_column_name}' of '{id}' because it was already deleted"
150
+ )
151
+ for column_name in data.keys():
152
+ if column_name not in self._column_names:
153
+ raise ValueError(
154
+ f"Cannot update record: column '{column_name}' does not exist in table '{self._table_name}'"
155
+ )
156
+ self._rows[index] = {
157
+ **self._rows[index], # type: ignore
158
+ **data,
159
+ }
160
+ return self._rows[index] # type: ignore
161
+
162
+ def create(self, data: dict[str, Any]) -> dict[str, Any]:
163
+ for column_name in data.keys():
164
+ if column_name not in self._column_names:
165
+ raise ValueError(
166
+ f"Cannot create record: column '{column_name}' does not exist for model '{self._model_class.__name__}'"
167
+ )
168
+ incoming_id = data.get(self.id_column_name)
169
+ if not incoming_id:
170
+ incoming_id = self._next_id
171
+ data[self.id_column_name] = incoming_id
172
+ self._next_id += 1
173
+ try:
174
+ incoming_as_int = int(incoming_id)
175
+ if incoming_as_int >= self._next_id:
176
+ self._next_id = incoming_as_int + 1
177
+ except:
178
+ pass
179
+ if incoming_id in self._id_index and self._rows[self._id_index[data[self.id_column_name]]] is not None:
180
+ return self.update(data[self.id_column_name], data)
181
+ for column_name in self._column_names:
182
+ if column_name not in data:
183
+ data[column_name] = None
184
+ self._rows.append({**data})
185
+ self._id_index[data[self.id_column_name]] = len(self._rows) - 1
186
+ return data
187
+
188
+ def delete(self, id):
189
+ if id not in self._id_index:
190
+ return True
191
+ index = self._id_index[id]
192
+ if self._rows[index] is None:
193
+ return True
194
+ # we set the row to None because if we remove it we'll change the indexes of the rest
195
+ # of the rows, and I like being able to calculate the index from the id
196
+ self._rows[index] = None
197
+ return True
198
+
199
+ def count(self, query: Query):
200
+ return len(self.rows(query, query.conditions, filter_only=True))
201
+
202
+ def rows(
203
+ self,
204
+ query: Query,
205
+ conditions: list[Condition],
206
+ filter_only: bool = False,
207
+ next_page_data: dict[str, Any] | None = None,
208
+ ):
209
+ rows = [row for row in self._rows if row is not None]
210
+ for condition in conditions:
211
+ rows = list(filter(self._condition_as_filter(condition), rows))
212
+ rows = [*rows]
213
+ if filter_only:
214
+ return rows
215
+ if query.sorts:
216
+ rows = sorted(
217
+ rows,
218
+ key=cmp_to_key(
219
+ lambda row_a, row_b: _sort(row_a, row_b, query.sorts, query.model_class.destination_name())
220
+ ),
221
+ )
222
+ if query.limit or query.pagination.get("start"):
223
+ number_rows = len(rows)
224
+ start = int(query.pagination.get("start", 0))
225
+ if not start:
226
+ start = 0
227
+ if int(start) >= number_rows:
228
+ start = number_rows - 1
229
+ end = len(rows)
230
+ if query.limit and start + int(query.limit) <= number_rows:
231
+ end = start + int(query.limit)
232
+ if end < number_rows and type(next_page_data) == dict:
233
+ next_page_data["start"] = start + int(query.limit)
234
+ rows = rows[start:end]
235
+ return rows
236
+
237
+ @classmethod
238
+ def _condition_as_filter(cls, condition: Condition) -> Callable:
239
+ column = condition.column_name
240
+ values = condition.values
241
+ return cls._operator_lambda_builders[condition.operator.lower()](column, values, cls.null)
242
+
243
+
244
+ class MemoryBackend(Backend, InjectableProperties):
245
+ """
246
+ Store data in an in-memory store built in to clearskies.
247
+
248
+ ## Usage
249
+
250
+ Since the memory backend is built into clearskies, there's no configuration necessary to make it work:
251
+ simply attach it to any of your models and it will manage data for you. If you want though, you can declare
252
+ a binding named `memory_backend_default_data` which you fill with records for your models to pre-populate
253
+ the memory backend. This can be helpful for tests as well as tables with fixed values.
254
+
255
+ ### Testing
256
+
257
+ A primary use case of the memory backend is for building unit tests of your code. You can use the dependency
258
+ injection system to override other backends with the memory backend. You can still operate with model classes
259
+ in the exact same way, so this can be an easy way to mock out databases/api endpoints/etc... Of course,
260
+ there can be behavioral differences between the memory backend and other backends, so this isn't always perfect.
261
+ Hence why this works well for unit tests, but can't replace all testing, especially integration tests or
262
+ end-to-end tests. Here's an example of that. Note that the UserPreference model uses the cursor backend,
263
+ but when you run this code it will actually end up with the memory backend, so the code will run even without
264
+ attempting to connect to a database.
265
+
266
+ ```python
267
+ import clearskies
268
+
269
+
270
+ class UserPreference(clearskies.Model):
271
+ id_column_name = "id"
272
+ backend = clearskies.backends.CursorBackend()
273
+ id = clearskies.columns.Uuid()
274
+
275
+
276
+ cli = clearskies.contexts.Cli(
277
+ clearskies.endpoints.Callable(
278
+ lambda user_preferences: user_preferences.create(no_data=True).id,
279
+ ),
280
+ classes=[UserPreference],
281
+ class_overrides={
282
+ clearskies.backends.CursorBackend: clearskies.backends.MemoryBackend(),
283
+ },
284
+ )
285
+
286
+ if __name__ == "__main__":
287
+ cli()
288
+ ```
289
+
290
+ Note that the model requests the CursorBackend, but then we switch that for the memory backend via the
291
+ `class_overrides` kwarg to `clearskies.contexts.Cli`. Therefore, the above code works regardless of
292
+ whether or not a database is running. Since the models are used to interact with the backend
293
+ (rather than using the cursor directly), the above code works the same despite the change in backend.
294
+
295
+ Again, this is helpful as a quick way to manage tests - swap out a database backend (or any other backend)
296
+ for the memory backend, and then pre-populate records to test your application logic. Obviously, this
297
+ won't cach backend-specific issues (e.g. forgetting to add a column to your database, mismatches between
298
+ column schema and backend schema, missing indexes, etc...), which is why this helps for unit tests
299
+ but not for integration tests.
300
+
301
+ ### Production Usage
302
+
303
+ You can use the memory backend in production if you want, although there are some important caveats to keep
304
+ in mind:
305
+
306
+ 1. There is limited attempts at performance optimization, so you should test it before putting it under
307
+ substantial loads.
308
+ 2. There's no concept of replication. If you have multiple workers, then write operations won't be
309
+ persisted between them.
310
+
311
+ So, while there may be some cases where this is useful in production, it's by no means a replacement for
312
+ more typical in-memory data stores.
313
+
314
+ ### Predefined Records
315
+
316
+ You can declare a binding named `memory_backend_default_data` to seed the memory backend with records. This
317
+ can be helpful in testing to setup your tests, and is occassionally helpful for keeping track of data in
318
+ fixed, read-only tables. Here's an example:
319
+
320
+ ```python
321
+ import clearskies
322
+
323
+
324
+ class Owner(clearskies.Model):
325
+ id_column_name = "id"
326
+ backend = clearskies.backends.MemoryBackend()
327
+
328
+ id = clearskies.columns.Uuid()
329
+ name = clearskies.columns.String()
330
+ phone = clearskies.columns.Phone()
331
+
332
+
333
+ class Pet(clearskies.Model):
334
+ id_column_name = "id"
335
+ backend = clearskies.backends.MemoryBackend()
336
+
337
+ id = clearskies.columns.Uuid()
338
+ name = clearskies.columns.String()
339
+ species = clearskies.columns.String()
340
+ owner_id = clearskies.columns.BelongsToId(Owner, readable_parent_columns=["id", "name"])
341
+ owner = clearskies.columns.BelongsToModel("owner_id")
342
+
343
+
344
+ wsgi = clearskies.contexts.WsgiRef(
345
+ clearskies.endpoints.List(
346
+ model_class=Pet,
347
+ readable_column_names=["id", "name", "species", "owner"],
348
+ sortable_column_names=["id", "name", "species"],
349
+ default_sort_column_name="name",
350
+ ),
351
+ classes=[Owner, Pet],
352
+ bindings={
353
+ "memory_backend_default_data": [
354
+ {
355
+ "model_class": Owner,
356
+ "records": [
357
+ {"id": "1-2-3-4", "name": "John Doe", "phone": "555-123-4567"},
358
+ {"id": "5-6-7-8", "name": "Jane Doe", "phone": "555-321-0987"},
359
+ ],
360
+ },
361
+ {
362
+ "model_class": Pet,
363
+ "records": [
364
+ {"id": "a-b-c-d", "name": "Fido", "species": "Dog", "owner_id": "1-2-3-4"},
365
+ {"id": "e-f-g-h", "name": "Spot", "species": "Dog", "owner_id": "1-2-3-4"},
366
+ {
367
+ "id": "i-j-k-l",
368
+ "name": "Puss in Boots",
369
+ "species": "Cat",
370
+ "owner_id": "5-6-7-8",
371
+ },
372
+ ],
373
+ },
374
+ ],
375
+ },
376
+ )
377
+
378
+ if __name__ == "__main__":
379
+ wsgi()
380
+ ```
381
+
382
+ And if you invoke it:
383
+
384
+ ```bash
385
+ $ curl 'http://localhost:8080' | jq
386
+ {
387
+ "status": "success",
388
+ "error": "",
389
+ "data": [
390
+ {
391
+ "id": "a-b-c-d",
392
+ "name": "Fido",
393
+ "species": "Dog",
394
+ "owner": {
395
+ "id": "1-2-3-4",
396
+ "name": "John Doe"
397
+ }
398
+ },
399
+ {
400
+ "id": "i-j-k-l",
401
+ "name": "Puss in Boots",
402
+ "species": "Cat",
403
+ "owner": {
404
+ "id": "5-6-7-8",
405
+ "name": "Jane Doe"
406
+ }
407
+ },
408
+ {
409
+ "id": "e-f-g-h",
410
+ "name": "Spot",
411
+ "species": "Dog",
412
+ "owner": {
413
+ "id": "1-2-3-4",
414
+ "name": "John Doe"
415
+ }
416
+ }
417
+ ],
418
+ "pagination": {
419
+ "number_results": 3,
420
+ "limit": 50,
421
+ "next_page": {}
422
+ },
423
+ "input_errors": {}
424
+ }
425
+ ```
426
+ """
427
+
428
+ default_data = inject.ByName("memory_backend_default_data")
429
+ default_data_loaded = False
430
+ _tables: dict[str, MemoryTable] = {}
431
+ _silent_on_missing_tables: bool = True
432
+
433
+ def __init__(self, silent_on_missing_tables=True):
434
+ self.__class__._tables = {}
435
+ self._silent_on_missing_tables = silent_on_missing_tables
436
+
437
+ @classmethod
438
+ def clear_table_cache(cls):
439
+ cls._tables = {}
440
+
441
+ def load_default_data(self):
442
+ if self.default_data_loaded:
443
+ return
444
+ self.default_data_loaded = True
445
+ if not isinstance(self.default_data, list):
446
+ raise TypeError(
447
+ f"'memory_backend_default_data' should be populated with a list, but I received a value of type '{self.default_data.__class__.__name__}'"
448
+ )
449
+ for index, table_data in enumerate(self.default_data):
450
+ if "model_class" not in table_data:
451
+ raise TypeError(
452
+ f"Each entry in the 'memory_backend_default_data' list should have a key named 'model_class', but entry #{index + 1} is missing this key."
453
+ )
454
+ model_class = table_data["model_class"]
455
+ if not functional.validations.is_model_class(table_data["model_class"]):
456
+ raise TypeError(
457
+ f"The 'model_class' key in 'memory_backend_default_data' for entry #{index + 1} is not a model class."
458
+ )
459
+ if "records" not in table_data:
460
+ raise TypeError(
461
+ f"Each entry in the 'memory_backend_default_data' list should have a key named 'records', but entry #{index + 1} is missing this key."
462
+ )
463
+ records = table_data["records"]
464
+ if not isinstance(records, list):
465
+ raise TypeError(
466
+ f"The 'records' key in 'memory_backend_default_data' for entry #{index + 1} was not a list."
467
+ )
468
+ self.create_table(model_class)
469
+ for record in records:
470
+ self.create_with_model_class(record, model_class)
471
+
472
+ def silent_on_missing_tables(self, silent=True):
473
+ self._silent_on_missing_tables = silent
474
+
475
+ def create_table(self, model_class: type[Model]):
476
+ self.load_default_data()
477
+ table_name = model_class.destination_name()
478
+ if table_name in self.__class__._tables:
479
+ return
480
+ self.__class__._tables[table_name] = MemoryTable(model_class)
481
+
482
+ def has_table(self, model_class: type[Model]) -> bool:
483
+ self.load_default_data()
484
+ table_name = model_class.destination_name()
485
+ return table_name in self.__class__._tables
486
+
487
+ def get_table(self, model_class: type[Model], create_if_missing=False) -> MemoryTable:
488
+ table_name = model_class.destination_name()
489
+ if table_name not in self.__class__._tables:
490
+ if create_if_missing:
491
+ self.create_table(model_class)
492
+ else:
493
+ raise ValueError(
494
+ f"The memory backend was asked to work with the model '{model_class.__name__}' but this model hasn't been explicitly added to the memory backend. This typically means that you are querying for records in a model but haven't created any yet."
495
+ )
496
+ return self.__class__._tables[table_name]
497
+
498
+ def create_with_model_class(self, data: dict[str, Any], model_class: type[Model]) -> dict[str, Any]:
499
+ self.create_table(model_class)
500
+ return self.get_table(model_class).create(data)
501
+
502
+ def update(self, id: int | str, data: dict[str, Any], model: Model) -> dict[str, Any]:
503
+ self.create_table(model.__class__)
504
+ return self.get_table(model.__class__).update(id, data)
505
+
506
+ def create(self, data: dict[str, Any], model: Model) -> dict[str, Any]:
507
+ self.create_table(model.__class__)
508
+ return self.get_table(model.__class__).create(data)
509
+
510
+ def delete(self, id: int | str, model: Model) -> bool:
511
+ self.create_table(model.__class__)
512
+ return self.get_table(model.__class__).delete(id)
513
+
514
+ def count(self, query: Query) -> int:
515
+ self.check_query(query)
516
+ if not self.has_table(query.model_class):
517
+ if self._silent_on_missing_tables:
518
+ return 0
519
+
520
+ raise ValueError(
521
+ f"Attempt to count records for model '{query.model_class.__name__}' that hasn't yet been loaded into the MemoryBackend"
522
+ )
523
+
524
+ # this is easy if we have no joins, so just return early so I don't have to think about it
525
+ if not query.joins:
526
+ return self.get_table(query.model_class).count(query)
527
+
528
+ # we can ignore left joins when counting
529
+ query.joins = [join for join in query.joins if join.join_type != "LEFT"]
530
+ return len(self.rows_with_joins(query))
531
+
532
+ def records(self, query: Query, next_page_data: dict[str, str | int] | None = None) -> list[dict[str, Any]]:
533
+ self.check_query(query)
534
+ if not self.has_table(query.model_class):
535
+ if self._silent_on_missing_tables:
536
+ return []
537
+
538
+ raise ValueError(
539
+ f"Attempt to fetch records for model '{query.model_class.__name__} that hasn't yet been loaded into the MemoryBackend"
540
+ )
541
+
542
+ # this is easy if we have no joins, so just return early so I don't have to think about it
543
+ if not query.joins:
544
+ return self.get_table(query.model_class).rows(query, query.conditions, next_page_data=next_page_data)
545
+ rows = self.rows_with_joins(query)
546
+
547
+ if query.sorts:
548
+ default_table_name = query.model_class.destination_name()
549
+ rows = sorted(
550
+ rows, key=cmp_to_key(lambda row_a, row_b: _sort(row_a, row_b, query.sorts, default_table_name))
551
+ )
552
+
553
+ # currently we don't do much with selects, so just limit results down to the data from the original
554
+ # table.
555
+ rows = [row[query.model_class.destination_name()] for row in rows]
556
+
557
+ if "start" in query.pagination or query.limit:
558
+ number_rows = len(rows)
559
+ start = query.pagination.get("start", 0)
560
+ if start >= number_rows:
561
+ start = number_rows - 1
562
+ end = len(rows)
563
+ if query.limit and start + query.limit <= number_rows:
564
+ end = start + query.limit
565
+ rows = rows[start:end]
566
+ if end < number_rows and type(next_page_data) == dict:
567
+ next_page_data["start"] = start + query.limit
568
+ return rows
569
+
570
+ def rows_with_joins(self, query: Query) -> list[dict[str, Any]]:
571
+ joins = [*query.joins]
572
+ conditions = [*query.conditions]
573
+ # quick sanity check
574
+ for join in query.joins:
575
+ if join.unaliased_table_name not in self.__class__._tables:
576
+ if not self._silent_on_missing_tables:
577
+ raise ValueError(
578
+ f"Join '{join._raw_join}' refrences table '{join.unaliased_table_name}' which does not exist in MemoryBackend"
579
+ )
580
+ return []
581
+
582
+ # start with the matches in the main table
583
+ left_table_name = query.model_class.destination_name()
584
+ left_conditions = self.conditions_for_table(left_table_name, conditions, is_left=True)
585
+ main_rows = self.get_table(query.model_class).rows(query, left_conditions, filter_only=True)
586
+ # and now adjust the way data is stored in our rows list to support the joining process.
587
+ # we're going to go from something like: `[{row_1}, {row_2}]` to something like:
588
+ # [{table_1: table_1_row_1, table_2: table_2_row_1}, {table_1: table_1_row_2, table_2: table_2_row_2}]
589
+ # etc...
590
+ rows = [{left_table_name: row} for row in main_rows]
591
+ joined_tables = [left_table_name]
592
+
593
+ # and now work through our joins. The tricky part is order - we need to manage the joins in the
594
+ # proper order, but they may not be in the correcet order in our join list. I still don't feel like building
595
+ # a full graph, so cheat and be dumb: loop through them all and join in the ones we can, skipping the ones
596
+ # we can't. If we get to the end and there are still joins left in the queue, then repeat, and eventually
597
+ # complain (since the joins may not be a valid object graph)
598
+ for i in range(10):
599
+ for index, join in enumerate(joins):
600
+ left_table_name = join.left_table_name
601
+ alias = join.alias
602
+ right_table_name = join.right_table_name
603
+ table_name_for_join = alias if alias else right_table_name
604
+ if left_table_name not in joined_tables:
605
+ continue
606
+
607
+ join_rows = self.__class__._tables[join.unaliased_table_name].rows(query, [], filter_only=True)
608
+
609
+ rows = self.join_rows(rows, join_rows, join, joined_tables)
610
+
611
+ # done with this one!
612
+ del joins[index]
613
+ joined_tables.append(table_name_for_join)
614
+
615
+ # are we done yet?
616
+ if not joins:
617
+ break
618
+
619
+ if joins:
620
+ raise ValueError(
621
+ "Unable to fulfill joins for query - perhaps a necessary join is missing? "
622
+ + "One way to get this error is if you tried to join on another table which hasn't been "
623
+ + "joined itself. e.g.: SELECT * FROM users JOIN type ON type.id=categories.type_id"
624
+ )
625
+
626
+ # now apply any remaining conditions.
627
+ left_condition_ids = [id(condition) for condition in left_conditions]
628
+ for condition in [condition for condition in conditions if id(condition) not in left_condition_ids]:
629
+ condition_filter = MemoryTable._condition_as_filter(condition)
630
+ rows = list(
631
+ filter(lambda row: condition.table_name in row and condition_filter(row[condition.table_name]), rows)
632
+ )
633
+
634
+ return rows
635
+
636
+ def all_rows(self, table_name: str) -> list[dict[str, Any]]:
637
+ if table_name not in self.__class__._tables:
638
+ if self._silent_on_missing_tables:
639
+ return []
640
+
641
+ raise ValueError(f"Cannot return rows for unknown table '{table_name}'")
642
+ return list(filter(None, self.__class__._tables[table_name]._rows))
643
+
644
+ def check_query(self, query: Query) -> None:
645
+ if query.group_by:
646
+ raise KeyError(
647
+ f"MemoryBackend does not support group_by clauses in queries. You may be using the wrong backend."
648
+ )
649
+
650
+ def conditions_for_table(self, table_name: str, conditions: list[Condition], is_left=False) -> list[Condition]:
651
+ """
652
+ Return only the conditions for the given table.
653
+
654
+ If you set is_left=True then it assumes this is the "default" table and so will also return conditions
655
+ without a table name.
656
+ """
657
+ return [
658
+ condition
659
+ for condition in conditions
660
+ if condition.table_name == table_name or (is_left and not condition.table_name)
661
+ ]
662
+
663
+ def join_rows(
664
+ self,
665
+ rows: list[dict[str, Any]],
666
+ join_rows: list[dict[str, Any]],
667
+ join: Join,
668
+ joined_tables: list[str],
669
+ ) -> list[dict[str, Any]]:
670
+ """
671
+ Add the rows in `join_rows` in to the `rows` holder.
672
+
673
+ `rows` should be something like:
674
+
675
+ ```python
676
+ [
677
+ {
678
+ "table_1": {"table_1_row_1"},
679
+ "table_2": {"table_2_row_1"},
680
+ },
681
+ {
682
+ "table_1": {"table_1_row_2"},
683
+ "table_2": {"table_2_row_2"},
684
+ },
685
+ ]
686
+ ```
687
+
688
+ and join_rows should be the rows for the new table, something like:
689
+
690
+ `[{table_3_row_1}, {table_3_row_2}]`
691
+
692
+ which will then get merged into the rows variable properly (which it will return as a new list)
693
+ """
694
+ join_table_name = join.alias if join.alias else join.right_table_name
695
+ join_type = join.join_type
696
+
697
+ #######
698
+ ########
699
+ ## our problem is here. When we join rows we can end up with multiple copies of the records from the left table because
700
+ # there can be more than one matching record in the right table. This isn't happening, and so we're not getting the
701
+ # proper results because the one record that is chosen to match with the left table doesn't meet the where condition
702
+ # that is applied at the very end. If we have multiple records that match, they all need to get retunred in the
703
+ # final list of rows here, so we can properly search everything.
704
+
705
+ # loop through each entry in rows, find a matching table in join_rows, and take action depending on join type
706
+ rows = [*rows]
707
+ matched_right_row_indexes = set()
708
+ left_table_name = join.left_table_name
709
+ left_column_name = join.left_column_name
710
+ # we're
711
+ for row_index in range(len(rows)):
712
+ row = rows[row_index]
713
+ matching_row = None
714
+ if left_table_name not in row:
715
+ raise ValueError("Attempted to check join data from unjoined table, which should not happen...")
716
+ left_value = (
717
+ row[left_table_name][left_column_name]
718
+ if (row[left_table_name] is not None and left_column_name in row[left_table_name])
719
+ else None
720
+ )
721
+ matching_rows = []
722
+ for join_index, join_row in enumerate(join_rows):
723
+ right_value = join_row[join.right_column_name] if join.right_column_name in join_row else None
724
+ # for now we are assuming the operator for the matching is `=`. This is mainly because
725
+ # our join parsing doesn't bother checking for the matching operator, because it is `=` in
726
+ # 99% of cases. We can always adjust down the line.
727
+ if (right_value is None and left_value is None) or (right_value == left_value):
728
+ matching_rows.append(join_row)
729
+ matched_right_row_indexes.add(right_value)
730
+
731
+ # if we have matching rows then join them in.
732
+ for index, matching_row in enumerate(matching_rows):
733
+ if not index:
734
+ rows[row_index][join_table_name] = matching_row
735
+ else:
736
+ rows.append({**row, **{join_table_name: matching_row}})
737
+
738
+ # if we don't have matching rows then remove them for an inner or right join
739
+ if not matching_rows:
740
+ if join_type == "LEFT" or join_type == "OUTER":
741
+ rows[row_index][join_table_name] = matching_row = None
742
+ else:
743
+ # we can't immediately delete the row because we're looping over the array it is in,
744
+ # so just mark it as None and remove it later
745
+ rows[row_index] = None # type: ignore
746
+
747
+ rows = [row for row in rows if row is not None]
748
+
749
+ # now for outer/right rows we add on any unmatched rows
750
+ if (join_type == "OUTER" or join_type == "RIGHT") and len(matched_right_row_indexes) < len(join_rows):
751
+ for join_index in set(range(len(join_rows))) - matched_right_row_indexes:
752
+ rows.append(
753
+ {
754
+ join_table_name: join_rows[join_index],
755
+ **{table_name: None for table_name in joined_tables},
756
+ }
757
+ )
758
+
759
+ return rows
760
+
761
+ def validate_pagination_data(self, data: dict[str, Any], case_mapping: Callable) -> str:
762
+ extra_keys = set(data.keys()) - set(self.allowed_pagination_keys())
763
+ if len(extra_keys):
764
+ key_name = case_mapping("start")
765
+ return "Invalid pagination key(s): '" + "','".join(extra_keys) + f"'. Only '{key_name}' is allowed"
766
+ if "start" not in data:
767
+ key_name = case_mapping("start")
768
+ return f"You must specify '{key_name}' when setting pagination"
769
+ start = data["start"]
770
+ try:
771
+ start = int(start)
772
+ except:
773
+ key_name = case_mapping("start")
774
+ return f"Invalid pagination data: '{key_name}' must be a number"
775
+ return ""
776
+
777
+ def allowed_pagination_keys(self) -> list[str]:
778
+ return ["start"]
779
+
780
+ def documentation_pagination_next_page_response(self, case_mapping: Callable[[str], str]) -> list[Any]:
781
+ return [AutoDocInteger(case_mapping("start"), example=0)]
782
+
783
+ def documentation_pagination_next_page_example(self, case_mapping: Callable[[str], str]) -> dict[str, Any]:
784
+ return {case_mapping("start"): 0}
785
+
786
+ def documentation_pagination_parameters(
787
+ self, case_mapping: Callable[[str], str]
788
+ ) -> list[tuple[AutoDocSchema, str]]:
789
+ return [
790
+ (
791
+ AutoDocInteger(case_mapping("start"), example=0),
792
+ "The zero-indexed record number to start listing results from",
793
+ )
794
+ ]