clear-skies 2.0.5__py3-none-any.whl → 2.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (252) hide show
  1. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/METADATA +1 -1
  2. clear_skies-2.0.6.dist-info/RECORD +251 -0
  3. clearskies/__init__.py +61 -0
  4. clearskies/action.py +7 -0
  5. clearskies/authentication/__init__.py +15 -0
  6. clearskies/authentication/authentication.py +46 -0
  7. clearskies/authentication/authorization.py +16 -0
  8. clearskies/authentication/authorization_pass_through.py +20 -0
  9. clearskies/authentication/jwks.py +163 -0
  10. clearskies/authentication/public.py +5 -0
  11. clearskies/authentication/secret_bearer.py +553 -0
  12. clearskies/autodoc/__init__.py +8 -0
  13. clearskies/autodoc/formats/__init__.py +5 -0
  14. clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
  15. clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
  16. clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
  17. clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
  18. clearskies/autodoc/formats/oai3_json/request.py +68 -0
  19. clearskies/autodoc/formats/oai3_json/response.py +28 -0
  20. clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
  21. clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
  22. clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
  23. clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
  24. clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
  25. clearskies/autodoc/formats/oai3_json/test.json +1985 -0
  26. clearskies/autodoc/py.typed +0 -0
  27. clearskies/autodoc/request/__init__.py +15 -0
  28. clearskies/autodoc/request/header.py +6 -0
  29. clearskies/autodoc/request/json_body.py +6 -0
  30. clearskies/autodoc/request/parameter.py +8 -0
  31. clearskies/autodoc/request/request.py +47 -0
  32. clearskies/autodoc/request/url_parameter.py +6 -0
  33. clearskies/autodoc/request/url_path.py +6 -0
  34. clearskies/autodoc/response/__init__.py +5 -0
  35. clearskies/autodoc/response/response.py +9 -0
  36. clearskies/autodoc/schema/__init__.py +31 -0
  37. clearskies/autodoc/schema/array.py +10 -0
  38. clearskies/autodoc/schema/base64.py +8 -0
  39. clearskies/autodoc/schema/boolean.py +5 -0
  40. clearskies/autodoc/schema/date.py +5 -0
  41. clearskies/autodoc/schema/datetime.py +5 -0
  42. clearskies/autodoc/schema/double.py +5 -0
  43. clearskies/autodoc/schema/enum.py +17 -0
  44. clearskies/autodoc/schema/integer.py +6 -0
  45. clearskies/autodoc/schema/long.py +5 -0
  46. clearskies/autodoc/schema/number.py +6 -0
  47. clearskies/autodoc/schema/object.py +13 -0
  48. clearskies/autodoc/schema/password.py +5 -0
  49. clearskies/autodoc/schema/schema.py +11 -0
  50. clearskies/autodoc/schema/string.py +5 -0
  51. clearskies/backends/__init__.py +65 -0
  52. clearskies/backends/api_backend.py +1178 -0
  53. clearskies/backends/backend.py +136 -0
  54. clearskies/backends/cursor_backend.py +335 -0
  55. clearskies/backends/memory_backend.py +797 -0
  56. clearskies/backends/secrets_backend.py +106 -0
  57. clearskies/column.py +1233 -0
  58. clearskies/columns/__init__.py +71 -0
  59. clearskies/columns/audit.py +206 -0
  60. clearskies/columns/belongs_to_id.py +483 -0
  61. clearskies/columns/belongs_to_model.py +132 -0
  62. clearskies/columns/belongs_to_self.py +105 -0
  63. clearskies/columns/boolean.py +113 -0
  64. clearskies/columns/category_tree.py +275 -0
  65. clearskies/columns/category_tree_ancestors.py +51 -0
  66. clearskies/columns/category_tree_children.py +127 -0
  67. clearskies/columns/category_tree_descendants.py +48 -0
  68. clearskies/columns/created.py +95 -0
  69. clearskies/columns/created_by_authorization_data.py +116 -0
  70. clearskies/columns/created_by_header.py +99 -0
  71. clearskies/columns/created_by_ip.py +92 -0
  72. clearskies/columns/created_by_routing_data.py +97 -0
  73. clearskies/columns/created_by_user_agent.py +92 -0
  74. clearskies/columns/date.py +234 -0
  75. clearskies/columns/datetime.py +282 -0
  76. clearskies/columns/email.py +76 -0
  77. clearskies/columns/float.py +153 -0
  78. clearskies/columns/has_many.py +505 -0
  79. clearskies/columns/has_many_self.py +56 -0
  80. clearskies/columns/has_one.py +14 -0
  81. clearskies/columns/integer.py +160 -0
  82. clearskies/columns/json.py +128 -0
  83. clearskies/columns/many_to_many_ids.py +337 -0
  84. clearskies/columns/many_to_many_ids_with_data.py +274 -0
  85. clearskies/columns/many_to_many_models.py +158 -0
  86. clearskies/columns/many_to_many_pivots.py +134 -0
  87. clearskies/columns/phone.py +159 -0
  88. clearskies/columns/select.py +92 -0
  89. clearskies/columns/string.py +102 -0
  90. clearskies/columns/timestamp.py +164 -0
  91. clearskies/columns/updated.py +110 -0
  92. clearskies/columns/uuid.py +86 -0
  93. clearskies/configs/README.md +105 -0
  94. clearskies/configs/__init__.py +162 -0
  95. clearskies/configs/actions.py +43 -0
  96. clearskies/configs/any.py +13 -0
  97. clearskies/configs/any_dict.py +22 -0
  98. clearskies/configs/any_dict_or_callable.py +23 -0
  99. clearskies/configs/authentication.py +23 -0
  100. clearskies/configs/authorization.py +23 -0
  101. clearskies/configs/boolean.py +16 -0
  102. clearskies/configs/boolean_or_callable.py +18 -0
  103. clearskies/configs/callable_config.py +18 -0
  104. clearskies/configs/columns.py +34 -0
  105. clearskies/configs/conditions.py +30 -0
  106. clearskies/configs/config.py +24 -0
  107. clearskies/configs/datetime.py +18 -0
  108. clearskies/configs/datetime_or_callable.py +19 -0
  109. clearskies/configs/endpoint.py +23 -0
  110. clearskies/configs/endpoint_list.py +29 -0
  111. clearskies/configs/float.py +16 -0
  112. clearskies/configs/float_or_callable.py +18 -0
  113. clearskies/configs/integer.py +16 -0
  114. clearskies/configs/integer_or_callable.py +18 -0
  115. clearskies/configs/joins.py +30 -0
  116. clearskies/configs/list_any_dict.py +30 -0
  117. clearskies/configs/list_any_dict_or_callable.py +31 -0
  118. clearskies/configs/model_class.py +35 -0
  119. clearskies/configs/model_column.py +65 -0
  120. clearskies/configs/model_columns.py +56 -0
  121. clearskies/configs/model_destination_name.py +25 -0
  122. clearskies/configs/model_to_id_column.py +43 -0
  123. clearskies/configs/readable_model_column.py +9 -0
  124. clearskies/configs/readable_model_columns.py +9 -0
  125. clearskies/configs/schema.py +23 -0
  126. clearskies/configs/searchable_model_columns.py +9 -0
  127. clearskies/configs/security_headers.py +39 -0
  128. clearskies/configs/select.py +26 -0
  129. clearskies/configs/select_list.py +47 -0
  130. clearskies/configs/string.py +29 -0
  131. clearskies/configs/string_dict.py +32 -0
  132. clearskies/configs/string_list.py +32 -0
  133. clearskies/configs/string_list_or_callable.py +35 -0
  134. clearskies/configs/string_or_callable.py +18 -0
  135. clearskies/configs/timedelta.py +18 -0
  136. clearskies/configs/timezone.py +18 -0
  137. clearskies/configs/url.py +23 -0
  138. clearskies/configs/validators.py +45 -0
  139. clearskies/configs/writeable_model_column.py +9 -0
  140. clearskies/configs/writeable_model_columns.py +9 -0
  141. clearskies/configurable.py +76 -0
  142. clearskies/contexts/__init__.py +11 -0
  143. clearskies/contexts/cli.py +117 -0
  144. clearskies/contexts/context.py +98 -0
  145. clearskies/contexts/wsgi.py +76 -0
  146. clearskies/contexts/wsgi_ref.py +82 -0
  147. clearskies/decorators.py +33 -0
  148. clearskies/di/__init__.py +14 -0
  149. clearskies/di/additional_config.py +130 -0
  150. clearskies/di/additional_config_auto_import.py +17 -0
  151. clearskies/di/di.py +973 -0
  152. clearskies/di/inject/__init__.py +23 -0
  153. clearskies/di/inject/by_class.py +21 -0
  154. clearskies/di/inject/by_name.py +18 -0
  155. clearskies/di/inject/di.py +13 -0
  156. clearskies/di/inject/environment.py +14 -0
  157. clearskies/di/inject/input_output.py +20 -0
  158. clearskies/di/inject/now.py +13 -0
  159. clearskies/di/inject/requests.py +13 -0
  160. clearskies/di/inject/secrets.py +14 -0
  161. clearskies/di/inject/utcnow.py +13 -0
  162. clearskies/di/inject/uuid.py +15 -0
  163. clearskies/di/injectable.py +29 -0
  164. clearskies/di/injectable_properties.py +131 -0
  165. clearskies/di/test_module/__init__.py +6 -0
  166. clearskies/di/test_module/another_module/__init__.py +2 -0
  167. clearskies/di/test_module/module_class.py +5 -0
  168. clearskies/end.py +183 -0
  169. clearskies/endpoint.py +1314 -0
  170. clearskies/endpoint_group.py +336 -0
  171. clearskies/endpoints/__init__.py +25 -0
  172. clearskies/endpoints/advanced_search.py +526 -0
  173. clearskies/endpoints/callable.py +388 -0
  174. clearskies/endpoints/create.py +205 -0
  175. clearskies/endpoints/delete.py +139 -0
  176. clearskies/endpoints/get.py +271 -0
  177. clearskies/endpoints/health_check.py +183 -0
  178. clearskies/endpoints/list.py +574 -0
  179. clearskies/endpoints/restful_api.py +427 -0
  180. clearskies/endpoints/schema.py +189 -0
  181. clearskies/endpoints/simple_search.py +286 -0
  182. clearskies/endpoints/update.py +193 -0
  183. clearskies/environment.py +104 -0
  184. clearskies/exceptions/__init__.py +19 -0
  185. clearskies/exceptions/authentication.py +2 -0
  186. clearskies/exceptions/authorization.py +2 -0
  187. clearskies/exceptions/client_error.py +2 -0
  188. clearskies/exceptions/input_errors.py +4 -0
  189. clearskies/exceptions/missing_dependency.py +2 -0
  190. clearskies/exceptions/moved_permanently.py +3 -0
  191. clearskies/exceptions/moved_temporarily.py +3 -0
  192. clearskies/exceptions/not_found.py +2 -0
  193. clearskies/functional/__init__.py +7 -0
  194. clearskies/functional/routing.py +92 -0
  195. clearskies/functional/string.py +112 -0
  196. clearskies/functional/validations.py +76 -0
  197. clearskies/input_outputs/__init__.py +13 -0
  198. clearskies/input_outputs/cli.py +171 -0
  199. clearskies/input_outputs/exceptions/__init__.py +2 -0
  200. clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
  201. clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
  202. clearskies/input_outputs/headers.py +45 -0
  203. clearskies/input_outputs/input_output.py +138 -0
  204. clearskies/input_outputs/programmatic.py +69 -0
  205. clearskies/input_outputs/py.typed +0 -0
  206. clearskies/input_outputs/wsgi.py +77 -0
  207. clearskies/model.py +1922 -0
  208. clearskies/py.typed +0 -0
  209. clearskies/query/__init__.py +12 -0
  210. clearskies/query/condition.py +223 -0
  211. clearskies/query/join.py +136 -0
  212. clearskies/query/query.py +196 -0
  213. clearskies/query/sort.py +27 -0
  214. clearskies/schema.py +82 -0
  215. clearskies/secrets/__init__.py +6 -0
  216. clearskies/secrets/additional_configs/__init__.py +32 -0
  217. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
  218. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
  219. clearskies/secrets/akeyless.py +182 -0
  220. clearskies/secrets/exceptions/__init__.py +1 -0
  221. clearskies/secrets/exceptions/not_found.py +2 -0
  222. clearskies/secrets/secrets.py +38 -0
  223. clearskies/security_header.py +15 -0
  224. clearskies/security_headers/__init__.py +11 -0
  225. clearskies/security_headers/cache_control.py +67 -0
  226. clearskies/security_headers/cors.py +50 -0
  227. clearskies/security_headers/csp.py +94 -0
  228. clearskies/security_headers/hsts.py +22 -0
  229. clearskies/security_headers/x_content_type_options.py +0 -0
  230. clearskies/security_headers/x_frame_options.py +0 -0
  231. clearskies/test_base.py +8 -0
  232. clearskies/typing.py +11 -0
  233. clearskies/validator.py +37 -0
  234. clearskies/validators/__init__.py +33 -0
  235. clearskies/validators/after_column.py +62 -0
  236. clearskies/validators/before_column.py +13 -0
  237. clearskies/validators/in_the_future.py +32 -0
  238. clearskies/validators/in_the_future_at_least.py +11 -0
  239. clearskies/validators/in_the_future_at_most.py +10 -0
  240. clearskies/validators/in_the_past.py +32 -0
  241. clearskies/validators/in_the_past_at_least.py +10 -0
  242. clearskies/validators/in_the_past_at_most.py +10 -0
  243. clearskies/validators/maximum_length.py +26 -0
  244. clearskies/validators/maximum_value.py +29 -0
  245. clearskies/validators/minimum_length.py +26 -0
  246. clearskies/validators/minimum_value.py +29 -0
  247. clearskies/validators/required.py +34 -0
  248. clearskies/validators/timedelta.py +59 -0
  249. clearskies/validators/unique.py +30 -0
  250. clear_skies-2.0.5.dist-info/RECORD +0 -4
  251. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/WHEEL +0 -0
  252. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/licenses/LICENSE +0 -0
clearskies/py.typed ADDED
File without changes
@@ -0,0 +1,12 @@
1
+ from clearskies.query.condition import Condition, ParsedCondition
2
+ from clearskies.query.join import Join
3
+ from clearskies.query.query import Query
4
+ from clearskies.query.sort import Sort
5
+
6
+ __all__ = [
7
+ "Condition",
8
+ "Join",
9
+ "ParsedCondition",
10
+ "Sort",
11
+ "Query",
12
+ ]
@@ -0,0 +1,223 @@
1
+ class Condition:
2
+ """
3
+ Parses a condition string, e.g. "column=value" or "table.column<=other_value".
4
+
5
+ Allowed operators: ["<=>", "!=", "<=", ">=", ">", "<", "=", "in", "is not null", "is null", "is not", "is", "like"]
6
+
7
+ NOTE: Not all backends support all operators, so make sure the condition you are building works for your backend
8
+
9
+ This is safe to use with untrusted input because it expects a stringent and easy-to-verify format. The incoming
10
+ string must be one of these patterns:
11
+
12
+ 1. [column_name][operator][value]
13
+ 2. [table_name].[column_name][operator][value]
14
+
15
+ SQL-like syntax is allowed, so:
16
+
17
+ 1. Spaces are optionally allowed around the operator.
18
+ 2. Backticks are optionally allowd around the table/column name.
19
+ 3. Single quotes are optionally allowed around the values.
20
+ 4. operators are case-insensitive.
21
+
22
+ In the case of an IN operator, the parser expects a series of comma separated values enclosed in parenthesis,
23
+ with each value optionally enclosed in single quotes. This parsing is very simple and there is not currently a way
24
+ to escape commas or single quotes.
25
+
26
+ NOTE: operators (when they are english words, of course) are always output in all upper-case.
27
+
28
+ Some examples:
29
+
30
+ ```python
31
+ condition = Condition("id=asdf-qwerty") # note: same results for: Condition("id = asdf-qwerty")
32
+ print(condition.table_name) # prints ''
33
+ print(condition.column_name) # prints 'id'
34
+ print(condition.operator) # prints '='
35
+ print(condition.values) # prints ['asdf-qwerty']
36
+ print(condition.parsed) # prints 'id=%s'
37
+
38
+ condition = Condition("orders.status_id in ('ACTIVE', 'PENDING')")
39
+ print(condition.table_name) # prints 'orders'
40
+ print(condition.column_name) # prints 'status_id'
41
+ print(condition.operator) # prints 'IN'
42
+ print(condition.values) # prints ['ACTIVE', 'PENDING']
43
+ print(condition.parsed) # prints 'status_id IN (%s, %s)'
44
+ ```
45
+ """
46
+
47
+ """
48
+ The name of the table this condition is searching on (if there is one).
49
+ """
50
+ table_name: str = ""
51
+
52
+ """
53
+ The name of the column the condition is searching.
54
+ """
55
+ column_name: str = ""
56
+
57
+ """
58
+ The operator we are searching with (e.g. '=', '<=', etc...)
59
+ """
60
+ operator: str = ""
61
+
62
+ """
63
+ The values the condition is searching for.
64
+
65
+ Note this is always a list, although most of the time there is only one value in the list. Multiple values
66
+ are only present when searching with the IN operator.
67
+ """
68
+ values: list[str] = []
69
+
70
+ """
71
+ An SQL-ready string
72
+ """
73
+ parsed: str = ""
74
+
75
+ """
76
+ The original condition string
77
+ """
78
+ _raw_condition: str = ""
79
+
80
+ """
81
+ The list of operators we can match
82
+
83
+ Note: the order is very important because this list is used to find the operator in the condition string.
84
+ As a result, the order of the operators in this list is important. The condition matching algorithm used
85
+ below will select whichever operator matches earlier in the string, but there are some operators that
86
+ start with the same characters: '<=>' and '<=', as well as 'is', 'is null', 'is not', etc... This leaves
87
+ room for ambiguity since all of these operators will match at the same location. In the event of a "tie" the
88
+ algorithm gives preference to the first matching operator. Therefore, for ambiguous operators, we put the
89
+ longer one first, which means it matches first, and so a condition with a '<=>' operator won't accidentally
90
+ match to the '<=' operator.
91
+ """
92
+ operators: list[str] = [
93
+ "<=>",
94
+ "!=",
95
+ "<=",
96
+ ">=",
97
+ ">",
98
+ "<",
99
+ "=",
100
+ "in",
101
+ "is not null",
102
+ "is null",
103
+ "is not",
104
+ "is",
105
+ "like",
106
+ ]
107
+
108
+ operator_lengths: dict[str, int] = {
109
+ "<=>": 3,
110
+ "<=": 2,
111
+ ">=": 2,
112
+ "!=": 2,
113
+ ">": 1,
114
+ "<": 1,
115
+ "=": 1,
116
+ "in": 4,
117
+ "is not null": 12,
118
+ "is null": 8,
119
+ "is not": 8,
120
+ "is": 4,
121
+ "like": 6,
122
+ }
123
+
124
+ # some operators require spaces around them
125
+ operators_for_matching: dict[str, str] = {
126
+ "like": " like ",
127
+ "in": " in ",
128
+ "is not null": " is not null",
129
+ "is null": " is null",
130
+ "is": " is ",
131
+ "is not": " is not ",
132
+ }
133
+
134
+ operators_with_simple_placeholders: dict[str, bool] = {
135
+ "<=>": True,
136
+ "<=": True,
137
+ ">=": True,
138
+ "!=": True,
139
+ "=": True,
140
+ "<": True,
141
+ ">": True,
142
+ }
143
+
144
+ operators_without_placeholders: dict[str, bool] = {
145
+ "is not null": True,
146
+ "is null": True,
147
+ }
148
+
149
+ def __init__(self, condition: str):
150
+ self._raw_condition = condition
151
+ lowercase_condition = condition.lower()
152
+ self.operator = ""
153
+ matching_index = len(condition)
154
+ # figure out which operator comes earliest in the string: make sure and check all so we match the
155
+ # earliest operator so we don't get unpredictable results for things like 'age=name<=5'. We want
156
+ # our operator to **ALWAYS** match whatever comes first in the condition string.
157
+ for operator in self.operators:
158
+ try:
159
+ operator_for_match = self.operators_for_matching.get(operator, operator)
160
+ index = lowercase_condition.index(operator_for_match)
161
+ except ValueError:
162
+ continue
163
+ if index < matching_index:
164
+ matching_index = index
165
+ self.operator = operator
166
+
167
+ if not self.operator:
168
+ raise ValueError(f"No supported operators found in condition {condition}")
169
+
170
+ self.column_name = condition[:matching_index].strip().replace("`", "")
171
+ value = condition[matching_index + self.operator_lengths[self.operator] :].strip()
172
+ if value and (value[0] == "'" and value[-1] == "'"):
173
+ value = value.strip("'")
174
+ self.values = self._parse_condition_list(value) if self.operator == "in" else [value]
175
+ self.table_name = ""
176
+ if "." in self.column_name:
177
+ [self.table_name, self.column_name] = self.column_name.split(".")
178
+ column_for_parsed = f"{self.table_name}.{self.column_name}" if self.table_name else self.column_name
179
+
180
+ if self.operator in self.operators_without_placeholders:
181
+ self.values = []
182
+
183
+ self.operator = self.operator.upper()
184
+ self.parsed = self._with_placeholders(
185
+ column_for_parsed, self.operator, self.values, escape=False if self.table_name else True
186
+ )
187
+
188
+ def _parse_condition_list(self, value):
189
+ if value[0] != "(" and value[-1] != ")":
190
+ raise ValueError(f"Invalid search value {value} for condition. For IN operator use `IN (value1,value2)`")
191
+
192
+ # note: this is not very smart and will mess things up if there are single quotes/commas in the data
193
+ return list(map(lambda value: value.strip().strip("'"), value[1:-1].split(",")))
194
+
195
+ def _with_placeholders(self, column, operator, values, escape=True, escape_character="`"):
196
+ quote = escape_character if escape else ""
197
+ column = column.replace("`", "")
198
+ upper_case_operator = operator.upper()
199
+ lower_case_operator = operator.lower()
200
+ if lower_case_operator in self.operators_with_simple_placeholders:
201
+ return f"{quote}{column}{quote}{upper_case_operator}%s"
202
+ if lower_case_operator in self.operators_without_placeholders:
203
+ return f"{quote}{column}{quote} {upper_case_operator}"
204
+ if lower_case_operator == "is" or lower_case_operator == "is not" or lower_case_operator == "like":
205
+ return f"{quote}{column}{quote} {upper_case_operator} %s"
206
+
207
+ # the only thing left is "in" which has a variable number of placeholders
208
+ return f"{quote}{column}{quote} IN (" + ", ".join(["%s" for i in range(len(values))]) + ")"
209
+
210
+
211
+ class ParsedCondition(Condition):
212
+ def __init__(self, column_name: str, operator: str, values: list[str], table_name: str = ""):
213
+ self.column_name = column_name
214
+ if operator not in self.operators:
215
+ raise ValueError(f"Unknown operator '{operator}'")
216
+ self.operator = operator
217
+ self.values = values
218
+ self.table_name = table_name
219
+ column_for_parsed = f"{self.table_name}.{self.column_name}" if self.table_name else self.column_name
220
+ self.parsed = self._with_placeholders(
221
+ column_for_parsed, self.operator, self.values, escape=False if self.table_name else True
222
+ )
223
+ self._raw_condition = self.parsed
@@ -0,0 +1,136 @@
1
+ import re
2
+
3
+
4
+ class Join:
5
+ """
6
+ Parses a join clause.
7
+
8
+ Note that this expects a few very specific pattern:
9
+
10
+ 1. [TYPE] JOIN [right_table_name] ON [left_table_name].[left_column_name]=[right_table_name].[right_column_name]
11
+ 2. [TYPE] JOIN [right_table_name] AS [alias] ON [alias].[left_column_name]=[right_table_name].[right_column_name]
12
+ 3. [TYPE] JOIN [right_table_name] [alias] ON [alias].[left_column_name]=[right_table_name].[right_column_name]
13
+
14
+ NOTE: The allowed join types are ["INNER", "OUTER", "LEFT", "RIGHT"]
15
+
16
+ NOTE: backticks are optionally allowed around column and table names.
17
+
18
+ Examples:
19
+ ```python
20
+ join = Join("INNER JOIN orders ON users.id=orders.user_id")
21
+ print(f"{join.left_table_name}.{join.left_column_name}") # prints 'users.id'
22
+ print(f"{join.right_table_name}.{join.right_column_name}") # prints 'orders.user_id'
23
+ print(join.type) # prints 'INNER'
24
+ print(join.alias) # prints ''
25
+ print(join.unaliased_table_name) # prints 'orders'
26
+
27
+ join = Join("JOIN some_long_table_name AS new_table ON old_table.id=new_table.old_id")
28
+ print(f"{join.left_table_name}.{join.left_column_name}") # prints 'old_table.id'
29
+ print(f"{join.right_table_name}.{join.right_column_name}") # prints 'new_table.old_id'
30
+ print(join.type) # prints 'LEFT'
31
+ print(join.alias) # prints 'new_table'
32
+ print(join.unaliased_table_name) # prints 'some_long_table_name'
33
+ ```
34
+ """
35
+
36
+ """
37
+ The name of the table on the left side of the join
38
+ """
39
+ left_table_name: str = ""
40
+
41
+ """
42
+ The name of the column on the left side of the join
43
+ """
44
+ left_column_name: str = ""
45
+
46
+ """
47
+ The name of the table on the right side of the join
48
+ """
49
+ right_table_name: str = ""
50
+
51
+ """
52
+ The name of the column on the right side of the join
53
+ """
54
+ right_column_name: str = ""
55
+
56
+ """
57
+ The type of join (LEFT, RIGHT, INNER, OUTER)
58
+ """
59
+ join_type: str = ""
60
+
61
+ """
62
+ An alias for the joined table, if needed
63
+ """
64
+ alias: str = ""
65
+
66
+ """
67
+ The actual name of the right table, regardless of alias
68
+ """
69
+ unaliased_table_name: str = ""
70
+
71
+ """
72
+ The original join string
73
+ """
74
+ _raw_join: str = ""
75
+
76
+ """
77
+ The allowed join types
78
+ """
79
+ _allowed_types = ["INNER", "OUTER", "LEFT", "RIGHT"]
80
+
81
+ def __init__(self, join: str):
82
+ self._raw_join = join
83
+ # doing this the simple and stupid way, until that doesn't work. Yes, it is ugly.
84
+ # Splitting this into two regexps for simplicity: this one does not check for an alias
85
+ matches = re.match(
86
+ "(\\w+\\s+)?join\\s+`?([^\\s`]+)`?\\s+on\\s+`?([^`]+)`?\\.`?([^`]+)`?\\s*=\\s*`?([^`]+)`?\\.`?([^`]+)`?",
87
+ join,
88
+ re.IGNORECASE,
89
+ )
90
+ if matches:
91
+ groups = matches.groups()
92
+ alias = ""
93
+ join_type = groups[0]
94
+ right_table = groups[1]
95
+ first_table = groups[2]
96
+ first_column = groups[3]
97
+ second_table = groups[4]
98
+ second_column = groups[5]
99
+ else:
100
+ matches = re.match(
101
+ "(\\w+\\s+)?join\\s+`?([^\\s`]+)`?\\s+(as\\s+)?(\\S+)\\s+on\\s+`?([^`]+)`?\\.`?([^`]+)`?\\s*=\\s*`?([^`]+)`?\\.`?([^`]+)`?",
102
+ join,
103
+ re.IGNORECASE,
104
+ )
105
+ if not matches:
106
+ raise ValueError(f"Specified join condition, '{join}' does not appear to be a valid join statement")
107
+ groups = matches.groups()
108
+ join_type = groups[0]
109
+ right_table = groups[1]
110
+ alias = groups[3]
111
+ first_table = groups[4]
112
+ first_column = groups[5]
113
+ second_table = groups[6]
114
+ second_column = groups[7]
115
+
116
+ # which is the left table and which is the right table?
117
+ match_by = alias if alias else right_table
118
+ if first_table == match_by:
119
+ self.left_table_name = second_table
120
+ self.left_column_name = second_column
121
+ self.right_table_name = first_table
122
+ self.right_column_name = first_column
123
+ elif second_table == match_by:
124
+ self.left_table_name = first_table
125
+ self.left_column_name = first_column
126
+ self.right_table_name = second_table
127
+ self.right_column_name = second_column
128
+ else:
129
+ raise ValueError(
130
+ f"Specified join condition, '{join}' was not understandable because the joined table "
131
+ + "is not referenced in the 'on' clause"
132
+ )
133
+
134
+ self.join_type = groups[0].strip().upper() if groups[0] else "INNER"
135
+ self.alias = alias
136
+ self.unaliased_table_name = right_table
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Self
4
+
5
+ from .condition import Condition
6
+ from .join import Join
7
+ from .sort import Sort
8
+
9
+ if TYPE_CHECKING:
10
+ from clearskies import Model
11
+
12
+
13
+ class Query:
14
+ """
15
+ Track the various aspects of a query.
16
+
17
+ This is mostly just used by the Model class to keep track of a list request.
18
+ """
19
+
20
+ """
21
+ The model class
22
+ """
23
+ model_class: type[Model] = None # type: ignore
24
+
25
+ """
26
+ The list of where conditions for the query.
27
+ """
28
+ conditions: list[Condition] = []
29
+
30
+ """
31
+ The conditions, but organized by column.
32
+ """
33
+ conditions_by_column: dict[str, list[Condition]] = {}
34
+
35
+ """
36
+ Joins for the query.
37
+ """
38
+ joins: list[Join] = []
39
+
40
+ """
41
+ The sort directives for the query
42
+ """
43
+ sorts: list[Sort] = []
44
+
45
+ """
46
+ The maximum number of records to return.
47
+ """
48
+ limit: int = 0
49
+
50
+ """
51
+ Pagination information (e.g. start/next_token/etc... the details depend on the backend.
52
+ """
53
+ pagination: dict[str, Any] = {}
54
+
55
+ """
56
+ A list of select statements.
57
+ """
58
+ selects: list[str] = []
59
+
60
+ """
61
+ Whether or not to just select all columns.
62
+ """
63
+ select_all: bool = True
64
+
65
+ """
66
+ The name of the column to group by.
67
+ """
68
+ group_by = ""
69
+
70
+ def __init__(
71
+ self,
72
+ model_class: type[Model],
73
+ conditions: list[Condition] = [],
74
+ joins: list[Join] = [],
75
+ sorts: list[Sort] = [],
76
+ limit: int = 0,
77
+ group_by: str = "",
78
+ pagination: dict[str, Any] = {},
79
+ selects: list[str] = [],
80
+ select_all: bool = True,
81
+ ):
82
+ self.model_class = model_class
83
+ self.conditions = [*conditions]
84
+ self.joins = [*joins]
85
+ self.sorts = [*sorts]
86
+ self.limit = limit
87
+ self.group_by = group_by
88
+ self.pagination = {**pagination}
89
+ self.selects = [*selects]
90
+ self.select_all = select_all
91
+ self.conditions_by_column = {}
92
+ if conditions:
93
+ for condition in conditions:
94
+ if condition.column_name not in self.conditions_by_column:
95
+ self.conditions_by_column[condition.column_name] = []
96
+ self.conditions_by_column[condition.column_name].append(condition)
97
+
98
+ def as_kwargs(self):
99
+ """Return the properties of this query as a dictionary so it can be used as kwargs when creating another one."""
100
+ return {
101
+ "model_class": self.model_class,
102
+ "conditions": [*self.conditions],
103
+ "joins": [*self.joins],
104
+ "sorts": [*self.sorts],
105
+ "limit": self.limit,
106
+ "group_by": self.group_by,
107
+ "pagination": self.pagination,
108
+ "selects": [*self.selects],
109
+ "select_all": self.select_all,
110
+ }
111
+
112
+ def add_where(self, condition: Condition) -> Self:
113
+ self.validate_column(condition.column_name, "filter", table=condition.table_name)
114
+ new_kwargs = self.as_kwargs()
115
+ new_kwargs["conditions"].append(condition)
116
+ return self.__class__(**new_kwargs)
117
+
118
+ def add_join(self, join: Join) -> Self:
119
+ new_kwargs = self.as_kwargs()
120
+ new_kwargs["joins"].append(join)
121
+ return self.__class__(**new_kwargs)
122
+
123
+ def set_sort(self, sort: Sort, secondary_sort: Sort | None = None) -> Self:
124
+ self.validate_column(sort.column_name, "sort", table=sort.table_name)
125
+ new_kwargs = self.as_kwargs()
126
+ new_kwargs["sorts"] = [sort]
127
+ if secondary_sort:
128
+ new_kwargs["sorts"].append(secondary_sort)
129
+
130
+ return self.__class__(**new_kwargs)
131
+
132
+ def set_limit(self, limit: int) -> Self:
133
+ if not isinstance(limit, int):
134
+ raise TypeError(
135
+ f"The limit in a query must be of type int but I received a value of type '{limit.__class__.__name__}'"
136
+ )
137
+ return self.__class__(
138
+ **{
139
+ **self.as_kwargs(),
140
+ "limit": limit,
141
+ }
142
+ )
143
+
144
+ def set_group_by(self, column_name) -> Self:
145
+ self.validate_column(column_name, "group")
146
+ return self.__class__(
147
+ **{
148
+ **self.as_kwargs(),
149
+ "group_by": column_name,
150
+ }
151
+ )
152
+
153
+ def set_pagination(self, pagination: dict[str, Any]) -> Self:
154
+ return self.__class__(
155
+ **{
156
+ **self.as_kwargs(),
157
+ "pagination": pagination,
158
+ }
159
+ )
160
+
161
+ def add_select(self, select: str) -> Self:
162
+ new_kwargs = self.as_kwargs()
163
+ new_kwargs["selects"].append(select)
164
+ return self.__class__(**new_kwargs)
165
+
166
+ def set_select_all(self, select_all: bool) -> Self:
167
+ return self.__class__(
168
+ **{
169
+ **self.as_kwargs(),
170
+ "select_all": select_all,
171
+ }
172
+ )
173
+
174
+ def validate_column(self: Self, column_name: str, action: str, table: str | None = None) -> None:
175
+ # for now, only validate columns that belong to *our* table.
176
+ # in some cases we are explicitly told the column name
177
+ if table is not None:
178
+ # note that table may be '', in which case it is implicitly "our" table
179
+ if table != "" and table != self.model_class.destination_name():
180
+ return
181
+
182
+ # but in some cases we should check and see if it is included with the column name
183
+ column_name = column_name.replace("`", "")
184
+ if "." in column_name:
185
+ parts = column_name.split(".")
186
+ if parts[0] != self.model_class.destination_name():
187
+ return
188
+ column_name = column_name.split(".")[1]
189
+
190
+ model_columns = self.model_class.get_columns()
191
+ if column_name not in model_columns:
192
+ raise KeyError(
193
+ f"Cannot {action} by column '{column_name}' for model class {self.model_class.__name__} because this "
194
+ + "column does not exist for the model. You can suppress this error by adding a matching column "
195
+ + "to your model definition"
196
+ )
@@ -0,0 +1,27 @@
1
+ class Sort:
2
+ """Stores a sort directive."""
3
+
4
+ """
5
+ The name of the table to sort on.
6
+ """
7
+ table_name: str = ""
8
+
9
+ """
10
+ The name of the column to sort on.
11
+ """
12
+ column_name: str = ""
13
+
14
+ """
15
+ The direction to sort.
16
+ """
17
+ direction: str = ""
18
+
19
+ def __init__(self, table_name: str, column_name: str, direction: str):
20
+ if not column_name:
21
+ raise ValueError("Missing 'column_name' for sort")
22
+ direction = direction.upper().strip()
23
+ if direction != "ASC" and direction != "DESC":
24
+ raise ValueError(f"Invalid sort direction: should be ASC or DESC, not '{direction}'")
25
+ self.table_name = table_name
26
+ self.column_name = column_name
27
+ self.direction = direction
clearskies/schema.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+ from typing import TYPE_CHECKING, Self
5
+
6
+ if TYPE_CHECKING:
7
+ from clearskies import Column
8
+
9
+
10
+ class Schema:
11
+ """
12
+ Define a schema by extending and declaring columns.
13
+
14
+ ```python
15
+ from clearskies.schema import Schema
16
+ from clearskies.validators import Required, Unique
17
+
18
+ import clearskies.columns
19
+
20
+
21
+ class Person(Schema):
22
+ id = clearskies.columns.Uuid()
23
+ name = clearskies.columns.String(validators=[Required()])
24
+ date_of_birth = clearskies.columns.Datetime(validators=[Required(), InThePast()])
25
+ email = clearskies.columns.Email()
26
+ ```
27
+ """
28
+
29
+ id_column_name: str = ""
30
+ _columns: dict[str, Column] = {}
31
+
32
+ @classmethod
33
+ def destination_name(cls: type[Self]) -> str:
34
+ raise NotImplementedError()
35
+
36
+ def __init__(self):
37
+ self._data = {}
38
+
39
+ @classmethod
40
+ def get_columns(cls: type[Self], overrides={}) -> dict[str, Column]:
41
+ """
42
+ Return an ordered dictionary with the configuration for the columns.
43
+
44
+ Generally, this method is meant for internal use. It just pulls the column configuration
45
+ information out of class attributes. It doesn't return the fully prepared columns,
46
+ so you probably can't use the return value of this function. For that, see
47
+ `model.columns()`.
48
+ """
49
+ # no caching if we have overrides
50
+ if cls._columns and not overrides:
51
+ return cls._columns
52
+
53
+ overrides = {**overrides}
54
+ columns: dict[str, Column] = OrderedDict()
55
+ for attribute_name in dir(cls):
56
+ attribute = getattr(cls, attribute_name)
57
+ # use duck typing instead of isinstance to decide which attribute is a column.
58
+ # We have to do this to avoid circular imports.
59
+ if not hasattr(attribute, "from_backend") and not hasattr(attribute, "to_backend"):
60
+ continue
61
+
62
+ if attribute_name in overrides:
63
+ columns[attribute_name] = overrides[attribute_name]
64
+ del overrides[attribute_name]
65
+ columns[attribute_name] = attribute
66
+
67
+ for attribute_name, column in overrides.items():
68
+ columns[attribute_name] = column # type: ignore
69
+
70
+ if not overrides:
71
+ cls._columns = columns
72
+
73
+ # now go through and finalize everything. We have to do this after setting cls._columns, because finalization
74
+ # sometimes depends on fetching the list of columns, so if we do it before caching the answer, we may end up
75
+ # creating circular loops. I don't *think* this will cause painful side-effects, but we'll find out!
76
+ for column_name, column in cls._columns.items():
77
+ column.finalize_configuration(cls, column_name)
78
+
79
+ return columns
80
+
81
+ def __bool__(self):
82
+ return False
@@ -0,0 +1,6 @@
1
+ # from .akeyless import AKeyless, AKeylessAdditionalConfig
2
+ from clearskies.secrets.secrets import Secrets
3
+
4
+ __all__ = [
5
+ "Secrets",
6
+ ]