clear-skies 1.21.3__py3-none-any.whl → 1.22.0__py3-none-any.whl

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

Potentially problematic release.


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

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: clear-skies
3
- Version: 1.21.3
3
+ Version: 1.22.0
4
4
  Summary: A framework for building backends in the cloud
5
5
  Home-page: https://github.com/cmancone/clearskies
6
6
  License: MIT
@@ -45,14 +45,14 @@ clearskies/autodoc/schema/object.py,sha256=GJ5zLw2CzhezQiNuIhVgRyk0esXqfHD5fxZrc
45
45
  clearskies/autodoc/schema/password.py,sha256=Ptj8OeddAL4h69KWqZ6ubZ2awR13xdDIrNe2N0T1jic,196
46
46
  clearskies/autodoc/schema/string.py,sha256=oxZPCxYYhWnNHdbtwD3QuniStbj8XbBBpDTFXgPR1VU,244
47
47
  clearskies/backends/__init__.py,sha256=IqIpTNdA0U14wFNBkotwc9kZr_mMw3ry8LnldOZ0HLs,755
48
- clearskies/backends/api_backend.py,sha256=vcGYub4G3jLLstTDkMMnZkID9JQz-qHmKNapgde4gSc,8875
48
+ clearskies/backends/api_backend.py,sha256=PQyT00pMtZZUQhfWySzVbXZ2GpycO93CRKVOmUFeo10,15073
49
49
  clearskies/backends/backend.py,sha256=fkL-De0MUdzcS2JG_spSUQZIVL9oRFvaL6SP26JPpcI,7399
50
50
  clearskies/backends/cursor_backend.py,sha256=VntlPS6z6bnZOC3XRJ-WFf5gK3pFUhH_qJpnZn8hl9U,11278
51
51
  clearskies/backends/example_backend.py,sha256=jVpv0LZpNUEJGko0XqioLkHmZHbCW6M4YyNvzKlZcDw,1413
52
52
  clearskies/backends/file_backend.py,sha256=tByQdOX1pf6r9-6vRDqOnQ8teRYo0bEWk589qrg598w,1752
53
53
  clearskies/backends/json_backend.py,sha256=uDBqkekQadBm0BMoCVuzSPRB-5SjMTCDSAbuIqqwkF8,180
54
54
  clearskies/backends/memory_backend.py,sha256=6Ts_NtP9S_QisvpNcQKO0CUqhCRAuL3d5LZYPvSgXW4,20837
55
- clearskies/backends/restful_api_advanced_search_backend.py,sha256=uiR4SEKhLNmczYJEAkVMIdPWxQc4YWSp-_WzcSL7DEo,5480
55
+ clearskies/backends/restful_api_advanced_search_backend.py,sha256=kBHO7wO_b24pkDXGWbGe0-5efQkkXjP6NQ3EaHC9V-k,3716
56
56
  clearskies/backends/secrets_backend.py,sha256=4lzrgdL_O_pgCT5HknV2gotFgp9GzjQ5_2n0-4H4kvs,2204
57
57
  clearskies/binding_config.py,sha256=bF8LBNEgJacwKCqToAtDqN9hv5omzU7zt_4qB9KPtE0,457
58
58
  clearskies/column_types/__init__.py,sha256=wofhLfyW00I6tb6o9DMsMx7j9hlbbqefhDzWfw0Row0,4731
@@ -120,7 +120,7 @@ clearskies/di/__init__.py,sha256=T7SgQNny2XAZQPeFkdmp1XxxmEVxtnpcRiGK8YflkwU,304
120
120
  clearskies/di/additional_config.py,sha256=jdoS_HWC0MAabori3WwLRAG1i5YKZmQfQ1o0hCoxsPs,526
121
121
  clearskies/di/additional_config_auto_import.py,sha256=m57IODPbnCAus9iDu3mDp42u4H87oPZvjAlBGoS8uRQ,111
122
122
  clearskies/di/di.py,sha256=g0U0PI73eNp0mkGH3KUN1fmqNic5eEUK-_IB8hQh-Kg,15511
123
- clearskies/di/standard_dependencies.py,sha256=NW8tNZ2Punwq70XaH_e9IhmQ7ggsFFvum8ymwinoD7U,4522
123
+ clearskies/di/standard_dependencies.py,sha256=Qk1bBowoyptJRMYE6yjQR5Ix-DO7FXxIJk4ZdLj0Y6k,4616
124
124
  clearskies/di/test_module/__init__.py,sha256=7YHQF7JHP0FdI7GdEGANSZ_t1EISQYhUNm1wqOg0NKw,88
125
125
  clearskies/di/test_module/another_module/__init__.py,sha256=8SRmHPDepLKGWTUSc1ucDF6U8mJPsNDsBDmBQCpzPWo,35
126
126
  clearskies/di/test_module/module_class.py,sha256=I_-wnMuHfbsvti-7d2Z4bXnr6deo__uvww9nds9qrlE,46
@@ -182,7 +182,7 @@ clearskies/mocks/__init__.py,sha256=T68OUB9gGCX0WoisGzsY3Bt2cCFX7ILHKPqi6XKTJM0,
182
182
  clearskies/mocks/input_output.py,sha256=2wD5GbUyVSkXcBg1GTZ-Oz9VzcYxNHfTlmZAODW-7CI,3898
183
183
  clearskies/mocks/models.py,sha256=DCzsnMddBvPoBA8JwwbSOhzY7enQWrosgeYD4gx2deI,5124
184
184
  clearskies/model.py,sha256=N5el03awXEfTBfhqjS3Yuc6ozMjATs7cMLs_IoZNuaE,13511
185
- clearskies/models.py,sha256=1H5Vohv1U4avN5_YHqULzc7ynZDRytmZ56x775xWTIo,13038
185
+ clearskies/models.py,sha256=XlNeCu6tGhyAzJ-z9cOX9PXusogljLqgZACtFiL-RnM,13037
186
186
  clearskies/secrets/__init__.py,sha256=ctTmA_etV9G_5U21APWENI1HvThrBS4DidGWRtEDHQs,1053
187
187
  clearskies/secrets/additional_configs/__init__.py,sha256=cFCrbtKF5nuR061S2y1iKZp349x-y8Srdwe3VZbfSFU,1119
188
188
  clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py,sha256=fOt2eOrVtQXhnK05XuSfWw9GoX6klxXLisJcFT0ycAE,2562
@@ -205,7 +205,7 @@ clearskies/tests/simple_api/models/__init__.py,sha256=nUA0W6fgXw_Bxa9CudkaDkC80t
205
205
  clearskies/tests/simple_api/models/status.py,sha256=PEhPbaQh5qdUNHp8O0gz91LOLENAEBtqSaHxUPXchaM,699
206
206
  clearskies/tests/simple_api/models/user.py,sha256=5_P4Tp1tTdX7PkMJ__epPM5MA7JAeVYGas69vcWloLc,819
207
207
  clearskies/tests/simple_api/users_api.py,sha256=KYXCgEofDxHeRdQK67txN5oYUPvxxmB8JTku7L-apk4,2344
208
- clear_skies-1.21.3.dist-info/LICENSE,sha256=3Ehd0g3YOpCj8sqj0Xjq5qbOtjjgk9qzhhD9YjRQgOA,1053
209
- clear_skies-1.21.3.dist-info/METADATA,sha256=tV9vLb2zbcNwdE9QlBah3O39Kec5D4im3eO4dowTkTI,1817
210
- clear_skies-1.21.3.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
211
- clear_skies-1.21.3.dist-info/RECORD,,
208
+ clear_skies-1.22.0.dist-info/LICENSE,sha256=3Ehd0g3YOpCj8sqj0Xjq5qbOtjjgk9qzhhD9YjRQgOA,1053
209
+ clear_skies-1.22.0.dist-info/METADATA,sha256=a0Q4D5KWAlqhWVBabYpHGsj6ZNUClL8Ce1eBaXFE03g,1817
210
+ clear_skies-1.22.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
211
+ clear_skies-1.22.0.dist-info/RECORD,,
@@ -3,6 +3,7 @@ from typing import Any, Callable, Dict, List, Tuple
3
3
  from ..autodoc.schema import Integer as AutoDocInteger
4
4
  from .. import model
5
5
  from ..column_types import JSON, DateTime
6
+ import re
6
7
 
7
8
 
8
9
  class ApiBackend(Backend):
@@ -34,6 +35,36 @@ class ApiBackend(Backend):
34
35
  self.url = url
35
36
  self._auth = auth
36
37
 
38
+ def records_url(self, configuration: Dict[str, Any]) -> str:
39
+ return self.url
40
+
41
+ def count_url(self, configuration: Dict[str, Any]) -> str:
42
+ return self.records_url(configuration)
43
+
44
+ def delete_url(self, id: str, model: model.Model) -> str:
45
+ return self.url
46
+
47
+ def update_url(self, id: str, model: model.Model) -> str:
48
+ return self.url
49
+
50
+ def create_url(self, data: Dict[str, Any], model: model.Model) -> str:
51
+ return self.url
52
+
53
+ def records_method(self, configuration: Dict[str, Any]) -> str:
54
+ return "GET"
55
+
56
+ def count_method(self, configuration: Dict[str, Any]) -> str:
57
+ return "GET"
58
+
59
+ def delete_method(self, id: str, model: model.Model) -> str:
60
+ return "DELETE"
61
+
62
+ def update_method(self, id: str, model: model.Model) -> str:
63
+ return "PATCH"
64
+
65
+ def create_method(self, data: Dict[str, Any], model: model.Model) -> str:
66
+ return "POST"
67
+
37
68
  def update(self, id, data, model):
38
69
  [url, method, json_data, headers] = self._build_update_request(id, data, model)
39
70
  response = self._execute_request(url, method, json=json_data, headers=headers)
@@ -42,7 +73,8 @@ class ApiBackend(Backend):
42
73
  return self._map_update_response(response.json())
43
74
 
44
75
  def _build_update_request(self, id, data, model):
45
- return [self.url, "PATCH", data, {}]
76
+ (url, data) = self._finalize_url_and_data(self.update_url(id, model), data)
77
+ return [url, self.update_method(id, model), data, {}]
46
78
 
47
79
  def _map_update_response(self, json):
48
80
  if not "data" in json:
@@ -55,7 +87,8 @@ class ApiBackend(Backend):
55
87
  return self._map_create_response(response.json())
56
88
 
57
89
  def _build_create_request(self, data, model):
58
- return [self.url, "POST", data, {}]
90
+ (url, data) = self._finalize_url_and_data(self.create_url(data, model), data)
91
+ return [url, self.create_method(data, model), data, {}]
59
92
 
60
93
  def _map_create_response(self, json):
61
94
  if not "data" in json:
@@ -68,7 +101,9 @@ class ApiBackend(Backend):
68
101
  return self._validate_delete_response(response.json())
69
102
 
70
103
  def _build_delete_request(self, id, model):
71
- return [self.url, "DELETE", {model.id_column_name: id}, {}]
104
+ data = model.data
105
+ (url, data) = self._finalize_url_and_data(self.delete_url(id, model), data)
106
+ return [url, self.delete_method(id, model), {model.id_column_name: id}, {}]
72
107
 
73
108
  def _validate_delete_response(self, json):
74
109
  if "status" not in json:
@@ -82,7 +117,13 @@ class ApiBackend(Backend):
82
117
  return self._map_count_response(response.json())
83
118
 
84
119
  def _build_count_request(self, configuration):
85
- return [self.url, "GET", {**{"count_only": True}, **self._as_post_data(configuration)}, {}]
120
+ (url, configuration) = self._finalize_url_and_configuration(self.count_url(configuration), configuration)
121
+ return [
122
+ url,
123
+ self.count_method(configuration),
124
+ {**{"count_only": True}, **self._as_post_data(configuration)},
125
+ {},
126
+ ]
86
127
 
87
128
  def _map_count_response(self, json):
88
129
  if not "total_matches" in json:
@@ -102,7 +143,8 @@ class ApiBackend(Backend):
102
143
  return records
103
144
 
104
145
  def _build_records_request(self, configuration):
105
- return [self.url, "GET", self._as_post_data(configuration), {}]
146
+ (url, configuration) = self._finalize_url_and_configuration(self.records_url(configuration), configuration)
147
+ return [url, self.records_method(configuration), self._as_post_data(configuration), {}]
106
148
 
107
149
  def _map_records_response(self, json):
108
150
  if not "data" in json:
@@ -224,3 +266,85 @@ class ApiBackend(Backend):
224
266
  )
225
267
  return {**backend_data, **{column.name: as_date}}
226
268
  return column.to_backend(backend_data)
269
+
270
+ def _finalize_url_and_data(self, url: str, data: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
271
+ (url, used_columns) = self._finalize_url(url, data, model)
272
+ for used_column in used_columns:
273
+ del data[used_column]
274
+ return (url, data)
275
+
276
+ def _finalize_url_and_configuration(self, url: str, configuration: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
277
+ # we need to convert the wheres in the configuration to a dictionary of key/values, but
278
+ # *only* for cases where we have performed an equals search.
279
+ filters_by_equals = {}
280
+ index_lookup = {}
281
+ for index, where in enumerate(configuration["wheres"]):
282
+ if where["operator"] != "=":
283
+ continue
284
+ filters_by_equals[where["column"]] = where["values"][0]
285
+ index_lookup[where["column"]] = index
286
+
287
+ # always call _finalize_url, even if we don't have any search columns,
288
+ # because if there are placeholders in the URL but we don't have any values,
289
+ # then we need to throw an exception.
290
+ (url, used_columns) = self._finalize_url(url, filters_by_equals, model)
291
+ # we need to remove the used entries from the wheres but in doing so we start at the end
292
+ # of the array so our indexes stay valid.
293
+ to_delete = [index_lookup[used_column] for used_column in used_columns]
294
+ to_delete.sort(reverse=True)
295
+ for index_to_delete in to_delete:
296
+ del configuration["wheres"][index_to_delete]
297
+
298
+ return (url, configuration)
299
+
300
+ def _finalize_url(self, url: str, data: Dict[str, Any], model: model.Models) -> Tuple[str, List[str]]:
301
+ """
302
+ This function is what gives support for placeholders in URLs. We support two formats:
303
+
304
+ 1. /some/path/{some_field}/blah
305
+ 2. /some/path/:some_field/blah
306
+
307
+ The url comes from the `my_url` function, which (by default) is just self.url. You can
308
+ always extend `my_url` to pull the URL from something else (the `model.table_name()` for instance).
309
+
310
+ You would then:
311
+
312
+ ```
313
+ models.where("some_field=some_value")
314
+ ```
315
+
316
+ and when the API backend makes the call it will then build the appropriate URL. Naturally,
317
+ you'll want to add a corresponding column to your model, otherwise the model will complain
318
+ that "some_field is not an allowed column in model class 'BLAH'" (since all search columns
319
+ used in a `where` query go through strict input validation).
320
+ """
321
+ # many Snyk API calls require a resource id in the URL. Let's check if that is the case here,
322
+ # and if so, get it out of the query configuration
323
+ used_columns = []
324
+ resource_references = self._find_resource_references_in_url(url)
325
+ for resource_reference in resource_references:
326
+ resource_name = resource_reference["name"]
327
+ placeholder = resource_reference["placeholder"]
328
+ if not data.get(resource_name):
329
+ raise ValueError(
330
+ f"Error building a request with {self.__class__.__name__}: my url, '{url}', has a URL resource named '{resource_name}' but a request was made without providing a value for this resource. All URL parameters are implicitly required. Also note that only where clauses with an 'equals' operator will be used when providing search terms for the URL. So, make sure you add an appropriate: `models.where('{resource_name}=some_value')` search when using the corresponding models class. Alternatively, if executing a create/delete/update operation, make sure the model and/or save has a value for this column"
331
+ )
332
+
333
+ url = url.replace(placeholder, str(data.get(resource_name)))
334
+ used_columns.append(resource_name)
335
+ return (url, used_columns)
336
+
337
+ def _find_resource_references_in_url(self, url: str) -> list[str]:
338
+ if not url:
339
+ return []
340
+ # To help with the regexp matching, it helps if the URL both starts and ends with a "/".
341
+ # We don't need to modify the URL at all - we just need it for our matching, so it's fine
342
+ # that our changes aren't propogated back to the calling function.
343
+ if url[-1] != "/":
344
+ url += "/"
345
+ if url[0] != "/":
346
+ url = f"/{url}"
347
+ return [
348
+ *[{"name": reference, "placeholder": "{" + reference + "}"} for reference in re.findall(r"{(\w+)}", url)],
349
+ *[{"name": reference, "placeholder": f":{reference}"} for reference in re.findall(r"/:([^/]+)/", url)],
350
+ ]
@@ -1,5 +1,6 @@
1
1
  from .api_backend import ApiBackend
2
2
  from typing import Any, Callable, Dict, List, Tuple
3
+ from .. import model
3
4
  from ..autodoc.schema import Integer as AutoDocInteger
4
5
 
5
6
 
@@ -30,67 +31,39 @@ class RestfulApiAdvancedSearchBackend(ApiBackend):
30
31
  def configure(self, auth=None):
31
32
  self._auth = auth
32
33
 
33
- def update(self, id, data, model):
34
- [url, method, json_data, headers] = self._build_update_request(id, data, model)
35
- response = self._execute_request(url, method, json=json_data, headers=headers)
36
- if not response.content:
37
- return {**model.data, **data}
38
- return self._map_update_response(response.json())
34
+ def records_url(self, configuration: Dict[str, Any]) -> str:
35
+ return configuration["table_name"].rstrip("/") + "/search"
39
36
 
40
- def _build_update_request(self, id, data, model):
41
- url = model.table_name().rstrip("/")
42
- return [f"{url}/{id}", "PATCH", data, {}]
37
+ def delete_url(self, id: str, model: model.Model) -> str:
38
+ table_name = model.table_name().rstrip("/")
39
+ return f"{table_name}/{id}"
43
40
 
44
- def _map_update_response(self, json):
45
- if not "data" in json:
46
- raise ValueError("Unexpected API response to update request")
47
- return json["data"]
41
+ def update_url(self, id: str, model: model.Model) -> str:
42
+ table_name = model.table_name().rstrip("/")
43
+ return f"{table_name}/{id}"
48
44
 
49
- def create(self, data, model):
50
- [url, method, json_data, headers] = self._build_create_request(data, model)
51
- response = self._execute_request(url, method, json=json_data, headers=headers)
52
- return self._map_create_response(response.json())
45
+ def create_url(self, data: Dict[str, Any], model: model.Model) -> str:
46
+ return model.table_name().rstrip("/")
53
47
 
54
- def _build_create_request(self, data, model):
55
- return [model.table_name().rstrip("/"), "POST", data, {}]
48
+ def records_method(self, configuration: Dict[str, Any]) -> str:
49
+ return "POST"
56
50
 
57
- def _map_create_response(self, json):
58
- if not "data" in json:
59
- raise ValueError("Unexpected API response to create request")
60
- return json["data"]
61
-
62
- def delete(self, id, model):
63
- [url, method, json_data, headers] = self._build_delete_request(id, model)
64
- response = self._execute_request(url, method, json=json_data, headers=headers)
65
- return self._validate_delete_response(response.json())
51
+ def count_method(self, configuration: Dict[str, Any]) -> str:
52
+ return "POST"
66
53
 
67
54
  def _build_delete_request(self, id, model):
68
- url = model.table_name().rstrip("/")
69
- return [f"{url}/{id}", "DELETE", {}, {}]
70
-
71
- def _validate_delete_response(self, json):
72
- if "status" not in json:
73
- raise ValueError("Unexpected response to delete API request")
74
- return json["status"] == "success"
75
-
76
- def count(self, configuration, model):
77
- configuration = self._check_query_configuration(configuration)
78
- [url, method, json_data, headers] = self._build_count_request(configuration, model)
79
- response = self._execute_request(url, method, json=json_data, headers=headers)
80
- return self._map_count_response(response.json())
55
+ data = model.data
56
+ (url, data) = self._finalize_url_and_data(self.delete_url(id, model), data)
57
+ return [url, self.delete_method(id, model), {}, {}]
81
58
 
82
- def _build_count_request(self, configuration, model):
83
- url = model.table_name().rstrip("/") + "/search"
84
- return [url, "POST", {**{"count_only": True}, **self._as_post_data(configuration, model)}, {}]
85
-
86
- def _map_count_response(self, json):
87
- if not "total_matches" in json:
88
- raise ValueError("Unexpected API response when executing count request")
89
- return json["total_matches"]
59
+ def _build_count_request(self, configuration):
60
+ [url, method, json_data, headers] = super()._build_count_request(configuration)
61
+ json_data["count_only"] = True
62
+ return [url, method, json_data, headers]
90
63
 
91
64
  def records(self, configuration, model, next_page_data={}):
92
65
  configuration = self._check_query_configuration(configuration)
93
- [url, method, json_data, headers] = self._build_records_request(configuration, model)
66
+ [url, method, json_data, headers] = self._build_records_request(configuration)
94
67
  response = self._execute_request(url, method, json=json_data, headers=headers).json()
95
68
  records = self._map_records_response(response)
96
69
  for next_page_key in ["nextPage", "NextPage", "next_page"]:
@@ -99,37 +72,29 @@ class RestfulApiAdvancedSearchBackend(ApiBackend):
99
72
  next_page_data[key] = value
100
73
  return records
101
74
 
102
- def _build_records_request(self, configuration, model):
103
- url = model.table_name().rstrip("/") + "/search"
104
- return [url, "POST", self._as_post_data(configuration, model), {}]
105
-
106
- def _map_records_response(self, json):
107
- if not "data" in json:
108
- raise ValueError("Unexpected response from records request")
109
- return json["data"]
110
-
111
- def _as_post_data(self, configuration, model):
75
+ def _as_post_data(self, configuration):
112
76
  data = {
113
- "where": list(map(lambda where: self._where_for_post(where, model), configuration["wheres"])),
77
+ "where": list(
78
+ map(lambda where: self._where_for_post(where, configuration["model_columns"]), configuration["wheres"])
79
+ ),
114
80
  "sort": configuration["sorts"],
115
81
  "start": configuration["pagination"].get("start", 0),
116
82
  "limit": configuration["limit"],
117
83
  }
118
84
  return {key: value for (key, value) in data.items() if value}
119
85
 
120
- def _where_for_post(self, where, model):
86
+ def _where_for_post(self, where, columns):
121
87
  prefix = ""
122
88
  if where.get("table"):
123
89
  prefix = where["table"] + "."
124
90
  return {
125
91
  "column": prefix + where["column"],
126
92
  "operator": where["operator"],
127
- "value": self.normalize_outgoing_value(where, model, where["values"][0]),
93
+ "value": self.normalize_outgoing_value(where, columns, where["values"][0]),
128
94
  }
129
95
 
130
- def normalize_outgoing_value(self, where, model, value):
96
+ def normalize_outgoing_value(self, where, columns, value):
131
97
  column_name = where["column"]
132
- columns = model.columns()
133
98
  if where.get("table") or column_name not in columns:
134
99
  return value
135
100
  normalized_data = self.column_to_backend(columns[column_name], {column_name: value})
@@ -143,4 +143,7 @@ class StandardDependencies(DI):
143
143
  """Set the default timezone."""
144
144
  import datetime
145
145
 
146
- return datetime.UTC
146
+ try:
147
+ return datetime.UTC
148
+ except AttributeError as e:
149
+ return datetime.timezone.utc
clearskies/models.py CHANGED
@@ -81,7 +81,7 @@ class Models(ABC, ConditionParser):
81
81
  "selects": self.query_selects,
82
82
  "select_all": self.query_select_all,
83
83
  "table_name": self.get_table_name(),
84
- "model_columns": self._model_columns,
84
+ "model_columns": self.model_columns,
85
85
  }
86
86
 
87
87
  @query_configuration.setter