clear-skies 0.0.3a0__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.
- clear_skies-0.0.3a0.dist-info/LICENSE +7 -0
- clear_skies-0.0.3a0.dist-info/METADATA +46 -0
- clear_skies-0.0.3a0.dist-info/RECORD +248 -0
- clear_skies-0.0.3a0.dist-info/WHEEL +4 -0
- clearskies/__init__.py +62 -0
- clearskies/action.py +7 -0
- clearskies/authentication/__init__.py +15 -0
- clearskies/authentication/authentication.py +42 -0
- clearskies/authentication/authorization.py +12 -0
- clearskies/authentication/authorization_pass_through.py +20 -0
- clearskies/authentication/jwks.py +158 -0
- clearskies/authentication/public.py +5 -0
- clearskies/authentication/secret_bearer.py +553 -0
- clearskies/autodoc/__init__.py +8 -0
- clearskies/autodoc/formats/__init__.py +5 -0
- clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
- clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
- clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
- clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
- clearskies/autodoc/formats/oai3_json/request.py +68 -0
- clearskies/autodoc/formats/oai3_json/response.py +28 -0
- clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
- clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
- clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
- clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
- clearskies/autodoc/formats/oai3_json/schema/object.py +29 -0
- clearskies/autodoc/formats/oai3_json/test.json +1985 -0
- clearskies/autodoc/py.typed +0 -0
- clearskies/autodoc/request/__init__.py +15 -0
- clearskies/autodoc/request/header.py +6 -0
- clearskies/autodoc/request/json_body.py +6 -0
- clearskies/autodoc/request/parameter.py +8 -0
- clearskies/autodoc/request/request.py +38 -0
- clearskies/autodoc/request/url_parameter.py +6 -0
- clearskies/autodoc/request/url_path.py +6 -0
- clearskies/autodoc/response/__init__.py +5 -0
- clearskies/autodoc/response/response.py +9 -0
- clearskies/autodoc/schema/__init__.py +31 -0
- clearskies/autodoc/schema/array.py +10 -0
- clearskies/autodoc/schema/base64.py +8 -0
- clearskies/autodoc/schema/boolean.py +5 -0
- clearskies/autodoc/schema/date.py +5 -0
- clearskies/autodoc/schema/datetime.py +5 -0
- clearskies/autodoc/schema/double.py +5 -0
- clearskies/autodoc/schema/enum.py +17 -0
- clearskies/autodoc/schema/integer.py +6 -0
- clearskies/autodoc/schema/long.py +5 -0
- clearskies/autodoc/schema/number.py +6 -0
- clearskies/autodoc/schema/object.py +13 -0
- clearskies/autodoc/schema/password.py +5 -0
- clearskies/autodoc/schema/schema.py +11 -0
- clearskies/autodoc/schema/string.py +5 -0
- clearskies/backends/__init__.py +65 -0
- clearskies/backends/api_backend.py +1178 -0
- clearskies/backends/backend.py +123 -0
- clearskies/backends/cursor_backend.py +335 -0
- clearskies/backends/memory_backend.py +797 -0
- clearskies/backends/secrets_backend.py +107 -0
- clearskies/column.py +1232 -0
- clearskies/columns/__init__.py +71 -0
- clearskies/columns/audit.py +205 -0
- clearskies/columns/belongs_to_id.py +483 -0
- clearskies/columns/belongs_to_model.py +128 -0
- clearskies/columns/belongs_to_self.py +105 -0
- clearskies/columns/boolean.py +109 -0
- clearskies/columns/category_tree.py +275 -0
- clearskies/columns/category_tree_ancestors.py +51 -0
- clearskies/columns/category_tree_children.py +127 -0
- clearskies/columns/category_tree_descendants.py +48 -0
- clearskies/columns/created.py +94 -0
- clearskies/columns/created_by_authorization_data.py +116 -0
- clearskies/columns/created_by_header.py +99 -0
- clearskies/columns/created_by_ip.py +92 -0
- clearskies/columns/created_by_routing_data.py +96 -0
- clearskies/columns/created_by_user_agent.py +92 -0
- clearskies/columns/date.py +230 -0
- clearskies/columns/datetime.py +278 -0
- clearskies/columns/email.py +76 -0
- clearskies/columns/float.py +149 -0
- clearskies/columns/has_many.py +505 -0
- clearskies/columns/has_many_self.py +56 -0
- clearskies/columns/has_one.py +14 -0
- clearskies/columns/integer.py +156 -0
- clearskies/columns/json.py +122 -0
- clearskies/columns/many_to_many_ids.py +333 -0
- clearskies/columns/many_to_many_ids_with_data.py +270 -0
- clearskies/columns/many_to_many_models.py +154 -0
- clearskies/columns/many_to_many_pivots.py +133 -0
- clearskies/columns/phone.py +158 -0
- clearskies/columns/select.py +91 -0
- clearskies/columns/string.py +98 -0
- clearskies/columns/timestamp.py +160 -0
- clearskies/columns/updated.py +110 -0
- clearskies/columns/uuid.py +86 -0
- clearskies/configs/README.md +105 -0
- clearskies/configs/__init__.py +159 -0
- clearskies/configs/actions.py +43 -0
- clearskies/configs/any.py +13 -0
- clearskies/configs/any_dict.py +22 -0
- clearskies/configs/any_dict_or_callable.py +23 -0
- clearskies/configs/authentication.py +23 -0
- clearskies/configs/authorization.py +23 -0
- clearskies/configs/boolean.py +16 -0
- clearskies/configs/boolean_or_callable.py +18 -0
- clearskies/configs/callable_config.py +18 -0
- clearskies/configs/columns.py +34 -0
- clearskies/configs/conditions.py +30 -0
- clearskies/configs/config.py +21 -0
- clearskies/configs/datetime.py +18 -0
- clearskies/configs/datetime_or_callable.py +19 -0
- clearskies/configs/endpoint.py +23 -0
- clearskies/configs/float.py +16 -0
- clearskies/configs/float_or_callable.py +18 -0
- clearskies/configs/integer.py +16 -0
- clearskies/configs/integer_or_callable.py +18 -0
- clearskies/configs/joins.py +30 -0
- clearskies/configs/list_any_dict.py +30 -0
- clearskies/configs/list_any_dict_or_callable.py +31 -0
- clearskies/configs/model_class.py +35 -0
- clearskies/configs/model_column.py +65 -0
- clearskies/configs/model_columns.py +56 -0
- clearskies/configs/model_destination_name.py +25 -0
- clearskies/configs/model_to_id_column.py +43 -0
- clearskies/configs/readable_model_column.py +9 -0
- clearskies/configs/readable_model_columns.py +9 -0
- clearskies/configs/schema.py +23 -0
- clearskies/configs/searchable_model_columns.py +9 -0
- clearskies/configs/security_headers.py +39 -0
- clearskies/configs/select.py +26 -0
- clearskies/configs/select_list.py +47 -0
- clearskies/configs/string.py +29 -0
- clearskies/configs/string_dict.py +32 -0
- clearskies/configs/string_list.py +32 -0
- clearskies/configs/string_list_or_callable.py +35 -0
- clearskies/configs/string_or_callable.py +18 -0
- clearskies/configs/timedelta.py +18 -0
- clearskies/configs/timezone.py +18 -0
- clearskies/configs/url.py +23 -0
- clearskies/configs/validators.py +45 -0
- clearskies/configs/writeable_model_column.py +9 -0
- clearskies/configs/writeable_model_columns.py +9 -0
- clearskies/configurable.py +76 -0
- clearskies/contexts/__init__.py +11 -0
- clearskies/contexts/cli.py +7 -0
- clearskies/contexts/context.py +84 -0
- clearskies/contexts/wsgi.py +16 -0
- clearskies/contexts/wsgi_ref.py +49 -0
- clearskies/di/__init__.py +14 -0
- clearskies/di/additional_config.py +130 -0
- clearskies/di/additional_config_auto_import.py +17 -0
- clearskies/di/di.py +968 -0
- clearskies/di/inject/__init__.py +23 -0
- clearskies/di/inject/by_class.py +21 -0
- clearskies/di/inject/by_name.py +18 -0
- clearskies/di/inject/di.py +13 -0
- clearskies/di/inject/environment.py +14 -0
- clearskies/di/inject/input_output.py +20 -0
- clearskies/di/inject/now.py +13 -0
- clearskies/di/inject/requests.py +13 -0
- clearskies/di/inject/secrets.py +14 -0
- clearskies/di/inject/utcnow.py +13 -0
- clearskies/di/inject/uuid.py +15 -0
- clearskies/di/injectable.py +29 -0
- clearskies/di/injectable_properties.py +131 -0
- clearskies/di/test_module/__init__.py +6 -0
- clearskies/di/test_module/another_module/__init__.py +2 -0
- clearskies/di/test_module/module_class.py +5 -0
- clearskies/end.py +183 -0
- clearskies/endpoint.py +1309 -0
- clearskies/endpoint_group.py +297 -0
- clearskies/endpoints/__init__.py +23 -0
- clearskies/endpoints/advanced_search.py +526 -0
- clearskies/endpoints/callable.py +387 -0
- clearskies/endpoints/create.py +202 -0
- clearskies/endpoints/delete.py +139 -0
- clearskies/endpoints/get.py +275 -0
- clearskies/endpoints/health_check.py +181 -0
- clearskies/endpoints/list.py +573 -0
- clearskies/endpoints/restful_api.py +427 -0
- clearskies/endpoints/simple_search.py +286 -0
- clearskies/endpoints/update.py +190 -0
- clearskies/environment.py +104 -0
- clearskies/exceptions/__init__.py +17 -0
- clearskies/exceptions/authentication.py +2 -0
- clearskies/exceptions/authorization.py +2 -0
- clearskies/exceptions/client_error.py +2 -0
- clearskies/exceptions/input_errors.py +4 -0
- clearskies/exceptions/moved_permanently.py +3 -0
- clearskies/exceptions/moved_temporarily.py +3 -0
- clearskies/exceptions/not_found.py +2 -0
- clearskies/functional/__init__.py +7 -0
- clearskies/functional/routing.py +92 -0
- clearskies/functional/string.py +112 -0
- clearskies/functional/validations.py +76 -0
- clearskies/input_outputs/__init__.py +13 -0
- clearskies/input_outputs/cli.py +170 -0
- clearskies/input_outputs/exceptions/__init__.py +2 -0
- clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
- clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
- clearskies/input_outputs/headers.py +45 -0
- clearskies/input_outputs/input_output.py +138 -0
- clearskies/input_outputs/programmatic.py +69 -0
- clearskies/input_outputs/py.typed +0 -0
- clearskies/input_outputs/wsgi.py +77 -0
- clearskies/model.py +662 -0
- clearskies/parameters_to_properties.py +31 -0
- clearskies/py.typed +0 -0
- clearskies/query/__init__.py +12 -0
- clearskies/query/condition.py +223 -0
- clearskies/query/join.py +136 -0
- clearskies/query/query.py +196 -0
- clearskies/query/sort.py +27 -0
- clearskies/schema.py +82 -0
- clearskies/secrets/__init__.py +6 -0
- clearskies/secrets/additional_configs/__init__.py +32 -0
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
- clearskies/secrets/akeyless.py +182 -0
- clearskies/secrets/exceptions/__init__.py +1 -0
- clearskies/secrets/exceptions/not_found.py +2 -0
- clearskies/secrets/secrets.py +38 -0
- clearskies/security_header.py +8 -0
- clearskies/security_headers/__init__.py +11 -0
- clearskies/security_headers/cache_control.py +67 -0
- clearskies/security_headers/cors.py +50 -0
- clearskies/security_headers/csp.py +94 -0
- clearskies/security_headers/hsts.py +22 -0
- clearskies/security_headers/x_content_type_options.py +0 -0
- clearskies/security_headers/x_frame_options.py +0 -0
- clearskies/test_base.py +8 -0
- clearskies/typing.py +11 -0
- clearskies/validator.py +25 -0
- clearskies/validators/__init__.py +33 -0
- clearskies/validators/after_column.py +62 -0
- clearskies/validators/before_column.py +13 -0
- clearskies/validators/in_the_future.py +32 -0
- clearskies/validators/in_the_future_at_least.py +11 -0
- clearskies/validators/in_the_future_at_most.py +10 -0
- clearskies/validators/in_the_past.py +32 -0
- clearskies/validators/in_the_past_at_least.py +10 -0
- clearskies/validators/in_the_past_at_most.py +10 -0
- clearskies/validators/maximum_length.py +26 -0
- clearskies/validators/maximum_value.py +29 -0
- clearskies/validators/minimum_length.py +26 -0
- clearskies/validators/minimum_value.py +29 -0
- clearskies/validators/required.py +35 -0
- clearskies/validators/timedelta.py +59 -0
- clearskies/validators/unique.py +31 -0
clearskies/model.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from abc import abstractmethod
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator, Self
|
|
6
|
+
|
|
7
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
8
|
+
from clearskies.di import InjectableProperties, inject
|
|
9
|
+
from clearskies.functional import string
|
|
10
|
+
from clearskies.query import Condition, Join, Query, Sort
|
|
11
|
+
from clearskies.schema import Schema
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from clearskies import Column
|
|
15
|
+
from clearskies.backends import Backend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Model(Schema, InjectableProperties):
|
|
19
|
+
"""
|
|
20
|
+
A clearskies model.
|
|
21
|
+
|
|
22
|
+
To be useable, a model class needs three things:
|
|
23
|
+
|
|
24
|
+
1. Column definitions
|
|
25
|
+
2. The name of the id column
|
|
26
|
+
3. A backend
|
|
27
|
+
4. A destination name (equivalent to a table name for SQL backends)
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_previous_data: dict[str, Any] = {}
|
|
32
|
+
_data: dict[str, Any] = {}
|
|
33
|
+
_next_data: dict[str, Any] = {}
|
|
34
|
+
_transformed_data: dict[str, Any] = {}
|
|
35
|
+
_touched_columns: dict[str, bool] = {}
|
|
36
|
+
_query: Query | None = None
|
|
37
|
+
_query_executed: bool = False
|
|
38
|
+
_count: int | None = None
|
|
39
|
+
_next_page_data: dict[str, Any] | None = None
|
|
40
|
+
|
|
41
|
+
id_column_name: str = ""
|
|
42
|
+
backend: Backend = None # type: ignore
|
|
43
|
+
|
|
44
|
+
_di = inject.Di()
|
|
45
|
+
|
|
46
|
+
def __init__(self):
|
|
47
|
+
if not self.id_column_name:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"You must define the 'id_column_name' property for every model class, but this is missing for model '{self.__class__.__name__}'"
|
|
50
|
+
)
|
|
51
|
+
if not isinstance(self.id_column_name, str):
|
|
52
|
+
raise TypeError(
|
|
53
|
+
f"The 'id_column_name' property of a model must be a string that specifies the name of the id column, but that is not the case for model '{self.__class__.__name__}'."
|
|
54
|
+
)
|
|
55
|
+
if not self.backend:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"You must define the 'backend' property for every model class, but this is missing for model '{self.__class__.__name__}'"
|
|
58
|
+
)
|
|
59
|
+
if not hasattr(self.backend, "documentation_pagination_parameters"):
|
|
60
|
+
raise TypeError(
|
|
61
|
+
f"The 'backend' property of a model must be an object that extends the clearskies.Backend class, but that is not the case for model '{self.__class__.__name__}'."
|
|
62
|
+
)
|
|
63
|
+
self._previous_data = {}
|
|
64
|
+
self._data = {}
|
|
65
|
+
self._next_data = {}
|
|
66
|
+
self._transformed_data = {}
|
|
67
|
+
self._touched_columns = {}
|
|
68
|
+
self._query = None
|
|
69
|
+
self._query_executed = False
|
|
70
|
+
self._count = None
|
|
71
|
+
self._next_page_data = None
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def destination_name(cls: type[Self]) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Return the name of the destination that the model uses for data storage.
|
|
77
|
+
|
|
78
|
+
For SQL backends, this would return the table name. Other backends will use this
|
|
79
|
+
same function but interpret it in whatever way it makes sense. For instance, an
|
|
80
|
+
API backend may treat it as a URL (or URL path), an SQS backend may expect a queue
|
|
81
|
+
URL, etc...
|
|
82
|
+
|
|
83
|
+
By default this takes the class name, converts from title case to snake case, and then
|
|
84
|
+
makes it plural.
|
|
85
|
+
"""
|
|
86
|
+
singular = string.camel_case_to_snake_case(cls.__name__)
|
|
87
|
+
if singular[-1] == "y":
|
|
88
|
+
return singular[:-1] + "ies"
|
|
89
|
+
if singular[-1] == "s":
|
|
90
|
+
return singular + "es"
|
|
91
|
+
return f"{singular}s"
|
|
92
|
+
|
|
93
|
+
def supports_n_plus_one(self: Self):
|
|
94
|
+
return self.backend.supports_n_plus_one # type: ignore
|
|
95
|
+
|
|
96
|
+
def __bool__(self: Self) -> bool: # noqa: D105
|
|
97
|
+
if self._query:
|
|
98
|
+
return bool(self.__len__())
|
|
99
|
+
|
|
100
|
+
return True if self._data else False
|
|
101
|
+
|
|
102
|
+
def get_raw_data(self: Self) -> dict[str, Any]:
|
|
103
|
+
self.no_queries()
|
|
104
|
+
return self._data
|
|
105
|
+
|
|
106
|
+
def set_raw_data(self: Self, data: dict[str, Any]) -> None:
|
|
107
|
+
self.no_queries()
|
|
108
|
+
self._data = {} if data is None else data
|
|
109
|
+
self._transformed_data = {}
|
|
110
|
+
|
|
111
|
+
def save(self: Self, data: dict[str, Any] | None = None, columns: dict[str, Column] = {}, no_data=False) -> bool:
|
|
112
|
+
"""
|
|
113
|
+
Save data to the database and update the model.
|
|
114
|
+
|
|
115
|
+
Executes an update if the model corresponds to a record already, or an insert if not.
|
|
116
|
+
|
|
117
|
+
There are two supported flows. One is to pass in a dictionary of data to save:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
model.save({
|
|
121
|
+
"some_column": "New Value",
|
|
122
|
+
"another_column": 5,
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
And the other is to set new values on the columns attributes and then call save without data:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
model.some_column = "New Value"
|
|
130
|
+
model.another_column = 5
|
|
131
|
+
model.save()
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
You cannot combine these methods. If you set a value on a column attribute and also pass
|
|
135
|
+
in a dictionary of data to the save, then an exception will be raised.
|
|
136
|
+
"""
|
|
137
|
+
self.no_queries()
|
|
138
|
+
if not data and not self._next_data and not no_data:
|
|
139
|
+
raise ValueError("You have to pass in something to save, or set no_data=True in your call to save/create.")
|
|
140
|
+
if data and self._next_data:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"Save data was provided to the model class by both passing in a dictionary and setting new values on the column attributes. This is not allowed. You will have to use just one method of specifying save data."
|
|
143
|
+
)
|
|
144
|
+
if not data:
|
|
145
|
+
data = {**self._next_data}
|
|
146
|
+
self._next_data = {}
|
|
147
|
+
|
|
148
|
+
save_columns = self.get_columns()
|
|
149
|
+
if columns is not None:
|
|
150
|
+
for column in columns.values():
|
|
151
|
+
save_columns[column.name] = column
|
|
152
|
+
|
|
153
|
+
old_data = self.get_raw_data()
|
|
154
|
+
data = self.columns_pre_save(data, save_columns)
|
|
155
|
+
data = self.pre_save(data)
|
|
156
|
+
if data is None:
|
|
157
|
+
raise ValueError("pre_save forgot to return the data array!")
|
|
158
|
+
|
|
159
|
+
[to_save, temporary_data] = self.columns_to_backend(data, save_columns)
|
|
160
|
+
to_save = self.to_backend(to_save, save_columns)
|
|
161
|
+
if self:
|
|
162
|
+
new_data = self.backend.update(self._data[self.id_column_name], to_save, self) # type: ignore
|
|
163
|
+
else:
|
|
164
|
+
new_data = self.backend.create(to_save, self) # type: ignore
|
|
165
|
+
id = self.backend.column_from_backend(save_columns[self.id_column_name], new_data[self.id_column_name]) # type: ignore
|
|
166
|
+
|
|
167
|
+
# if we had any temporary columns add them back in
|
|
168
|
+
new_data = {
|
|
169
|
+
**temporary_data,
|
|
170
|
+
**new_data,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
data = self.columns_post_save(data, id, save_columns)
|
|
174
|
+
self.post_save(data, id)
|
|
175
|
+
|
|
176
|
+
self.set_raw_data(new_data)
|
|
177
|
+
self._transformed_data = {}
|
|
178
|
+
self._previous_data = old_data
|
|
179
|
+
self._touched_columns = {key: True for key in data.keys()}
|
|
180
|
+
|
|
181
|
+
self.columns_save_finished(save_columns)
|
|
182
|
+
self.save_finished()
|
|
183
|
+
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def is_changing(self: Self, key: str, data: dict[str, Any]) -> bool:
|
|
187
|
+
"""
|
|
188
|
+
Return True/False to denote if the given column is being modified by the active save operation.
|
|
189
|
+
|
|
190
|
+
Pass in the name of the column to check and the data dictionary from the save in progress
|
|
191
|
+
"""
|
|
192
|
+
self.no_queries()
|
|
193
|
+
has_old_value = key in self._data
|
|
194
|
+
has_new_value = key in data
|
|
195
|
+
|
|
196
|
+
if not has_new_value:
|
|
197
|
+
return False
|
|
198
|
+
if not has_old_value:
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
return getattr(self, key) != data[key]
|
|
202
|
+
|
|
203
|
+
def latest(self: Self, key: str, data: dict[str, Any]) -> Any:
|
|
204
|
+
"""
|
|
205
|
+
Return the 'latest' value for a column during the save operation.
|
|
206
|
+
|
|
207
|
+
Return either the column value from the data dictionary or the current value stored in the model
|
|
208
|
+
Basically, shorthand for the optimized version of: `data.get(key, default=getattr(self, key))` (which is
|
|
209
|
+
less than ideal because it always builds the default value, even when not necessary)
|
|
210
|
+
|
|
211
|
+
Pass in the name of the column to check and the data dictionary from the save in progress
|
|
212
|
+
"""
|
|
213
|
+
self.no_queries()
|
|
214
|
+
if key in data:
|
|
215
|
+
return data[key]
|
|
216
|
+
return getattr(self, key)
|
|
217
|
+
|
|
218
|
+
def was_changed(self: Self, key: str) -> bool:
|
|
219
|
+
"""Return True/False to denote if a column was changed in the last save."""
|
|
220
|
+
self.no_queries()
|
|
221
|
+
if self._previous_data is None:
|
|
222
|
+
raise ValueError("was_changed was called before a save was finished - you must save something first")
|
|
223
|
+
if key not in self._touched_columns:
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
has_old_value = bool(self._previous_data.get(key))
|
|
227
|
+
has_new_value = bool(self._data.get(key))
|
|
228
|
+
|
|
229
|
+
if has_new_value != has_old_value:
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
if not has_old_value:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
columns = self.get_columns()
|
|
236
|
+
new_value = self._data[key]
|
|
237
|
+
old_value = self._previous_data[key]
|
|
238
|
+
if key not in columns:
|
|
239
|
+
return old_value != new_value
|
|
240
|
+
return not columns[key].values_match(old_value, new_value)
|
|
241
|
+
|
|
242
|
+
def previous_value(self: Self, key: str):
|
|
243
|
+
self.no_queries()
|
|
244
|
+
return getattr(self.__class__, key).transform(self._previous_data.get(key))
|
|
245
|
+
|
|
246
|
+
def delete(self: Self, except_if_not_exists=True) -> bool:
|
|
247
|
+
self.no_queries()
|
|
248
|
+
if not self:
|
|
249
|
+
if except_if_not_exists:
|
|
250
|
+
raise ValueError("Cannot delete model that already exists")
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
columns = self.get_columns()
|
|
254
|
+
self.columns_pre_delete(columns)
|
|
255
|
+
self.pre_delete()
|
|
256
|
+
|
|
257
|
+
self.backend.delete(self._data[self.id_column_name], self) # type: ignore
|
|
258
|
+
|
|
259
|
+
self.columns_post_delete(columns)
|
|
260
|
+
self.post_delete()
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
def columns_pre_save(self: Self, data: dict[str, Any], columns) -> dict[str, Any]:
|
|
264
|
+
"""Use the column information present in the model to make any necessary changes before saving."""
|
|
265
|
+
iterate = True
|
|
266
|
+
changed = {}
|
|
267
|
+
while iterate:
|
|
268
|
+
iterate = False
|
|
269
|
+
for column in columns.values():
|
|
270
|
+
data = column.pre_save(data, self)
|
|
271
|
+
if data is None:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"Column {column.name} of type {column.__class__.__name__} did not return any data for pre_save"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# if we have newly chnaged data then we want to loop through the pre-saves again
|
|
277
|
+
if data and column.name not in changed:
|
|
278
|
+
changed[column.name] = True
|
|
279
|
+
iterate = True
|
|
280
|
+
return data
|
|
281
|
+
|
|
282
|
+
def columns_to_backend(self: Self, data: dict[str, Any], columns) -> Any:
|
|
283
|
+
backend_data = {**data}
|
|
284
|
+
temporary_data = {}
|
|
285
|
+
for column in columns.values():
|
|
286
|
+
if column.is_temporary:
|
|
287
|
+
if column.name in backend_data:
|
|
288
|
+
temporary_data[column.name] = backend_data[column.name]
|
|
289
|
+
del backend_data[column.name]
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
backend_data = self.backend.column_to_backend(column, backend_data) # type: ignore
|
|
293
|
+
if backend_data is None:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"Column {column.name} of type {column.__class__.__name__} did not return any data for to_database"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return [backend_data, temporary_data]
|
|
299
|
+
|
|
300
|
+
def to_backend(self: Self, data: dict[str, Any], columns) -> dict[str, Any]:
|
|
301
|
+
return data
|
|
302
|
+
|
|
303
|
+
def columns_post_save(self: Self, data: dict[str, Any], id: str | int, columns) -> dict[str, Any]:
|
|
304
|
+
"""Use the column information present in the model to make additional changes as needed after saving."""
|
|
305
|
+
for column in columns.values():
|
|
306
|
+
column.post_save(data, self, id)
|
|
307
|
+
return data
|
|
308
|
+
|
|
309
|
+
def columns_save_finished(self: Self, columns) -> None:
|
|
310
|
+
"""Call the save_finished method on all of our columns."""
|
|
311
|
+
for column in columns.values():
|
|
312
|
+
column.save_finished(self)
|
|
313
|
+
|
|
314
|
+
def post_save(self: Self, data: dict[str, Any], id: str | int) -> None:
|
|
315
|
+
"""
|
|
316
|
+
Create a hook to extend so you can provide additional pre-save logic as needed.
|
|
317
|
+
|
|
318
|
+
It is passed in the data being saved as well as the id. It should take action as needed and then return
|
|
319
|
+
either the original data array or an adjusted one if appropriate.
|
|
320
|
+
"""
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
|
|
324
|
+
"""
|
|
325
|
+
Create a hook to extend so you can provide additional pre-save logic as needed.
|
|
326
|
+
|
|
327
|
+
It is passed in the data being saved and it should return the same data with adjustments as needed
|
|
328
|
+
"""
|
|
329
|
+
return data
|
|
330
|
+
|
|
331
|
+
def save_finished(self: Self) -> None:
|
|
332
|
+
"""
|
|
333
|
+
Create a hook to extend so you can provide additional logic after a save operation has fully completed.
|
|
334
|
+
|
|
335
|
+
It has no retrun value and is passed no data. By the time this fires the model has already been
|
|
336
|
+
updated with the new data. You can decide on the necessary actions using the `was_changed` and
|
|
337
|
+
the `previous_value` functions.
|
|
338
|
+
"""
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
def columns_pre_delete(self: Self, columns: dict[str, Column]) -> None:
|
|
342
|
+
"""Use the column information present in the model to make any necessary changes before deleting."""
|
|
343
|
+
for column in columns.values():
|
|
344
|
+
column.pre_delete(self)
|
|
345
|
+
|
|
346
|
+
def pre_delete(self: Self) -> None:
|
|
347
|
+
"""Create a hook to extend so you can provide additional pre-delete logic as needed."""
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
def columns_post_delete(self: Self, columns: dict[str, Column]) -> None:
|
|
351
|
+
"""Use the column information present in the model to make any necessary changes after deleting."""
|
|
352
|
+
for column in columns.values():
|
|
353
|
+
column.post_delete(self)
|
|
354
|
+
|
|
355
|
+
def post_delete(self: Self) -> None:
|
|
356
|
+
"""Create a hook to extend so you can provide additional post-delete logic as needed."""
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
def where_for_request(
|
|
360
|
+
self: Self,
|
|
361
|
+
models: Self,
|
|
362
|
+
routing_data: dict[str, str],
|
|
363
|
+
authorization_data: dict[str, Any],
|
|
364
|
+
input_output: Any,
|
|
365
|
+
overrides: dict[str, Column] = {},
|
|
366
|
+
) -> Self:
|
|
367
|
+
"""Create a hook to automatically apply filtering whenever the model makes an appearance in a get/update/list/search handler."""
|
|
368
|
+
for column in self.get_columns(overrides=overrides).values():
|
|
369
|
+
models = column.where_for_request(models, routing_data, authorization_data, input_output) # type: ignore
|
|
370
|
+
return models
|
|
371
|
+
|
|
372
|
+
##############################################################
|
|
373
|
+
### From here down is functionality related to list/search ###
|
|
374
|
+
##############################################################
|
|
375
|
+
def has_query(self) -> bool:
|
|
376
|
+
"""Whether or not this model instance represents a query."""
|
|
377
|
+
return bool(self._query)
|
|
378
|
+
|
|
379
|
+
def get_query(self) -> Query:
|
|
380
|
+
"""Fetch the query object in the model."""
|
|
381
|
+
return self._query if self._query else Query(self.__class__)
|
|
382
|
+
|
|
383
|
+
def as_query(self) -> Self:
|
|
384
|
+
"""
|
|
385
|
+
Make the model queryable.
|
|
386
|
+
|
|
387
|
+
This is used to remove the ambiguity of attempting execute a query against a model object that stores a record.
|
|
388
|
+
|
|
389
|
+
The reason this exists is because the model class is used both to query as well as to operate on single records, which can cause
|
|
390
|
+
subtle bugs if a developer accidentally confuses the two usages. Consider the following (partial) example:
|
|
391
|
+
|
|
392
|
+
```python
|
|
393
|
+
def some_function(models):
|
|
394
|
+
model = models.find("id=5")
|
|
395
|
+
if model:
|
|
396
|
+
models.save({"test": "example"})
|
|
397
|
+
other_record = model.find("id=6")
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
In the above example it seems likely that the intention was to use `model.save()`, not `models.save()`. Similarly, the last line
|
|
401
|
+
should be `models.find()`, not `model.find()`. To minimize these kinds of issues, clearskies won't let you execute a query against
|
|
402
|
+
an individual model record, nor will it let you execute a save against a model being used to make a query. In both cases, you'll
|
|
403
|
+
get an exception from clearskies, as the models track exactly how they are being used.
|
|
404
|
+
|
|
405
|
+
In some rare cases though, you may want to start a new query aginst a model that represents a single record. This is most common
|
|
406
|
+
if you have a function that was passed an individual model, and you'd like to use it to fetch more records without having to
|
|
407
|
+
inject the model class more generally. That's where the `as_query()` method comes in. It's basically just a way of telling clearskies
|
|
408
|
+
"yes, I really do want to start a query using a model that represents a record". So, for example:
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
def some_function(models):
|
|
412
|
+
model = models.find("id=5")
|
|
413
|
+
more_models = model.where("test=example") # throws an exception.
|
|
414
|
+
more_models = model.as_query().where("test=example") # works as expected.
|
|
415
|
+
```
|
|
416
|
+
"""
|
|
417
|
+
new_model = self._di.build(self.__class__, cache=False)
|
|
418
|
+
new_model.set_query(Query(self.__class__))
|
|
419
|
+
return new_model
|
|
420
|
+
|
|
421
|
+
def set_query(self, query: Query) -> Self:
|
|
422
|
+
"""Set the query object."""
|
|
423
|
+
self._query = query
|
|
424
|
+
self._query_executed = False
|
|
425
|
+
return self
|
|
426
|
+
|
|
427
|
+
def with_query(self, query: Query) -> Self:
|
|
428
|
+
return self._di.build(self.__class__, cache=False).set_query(query)
|
|
429
|
+
|
|
430
|
+
def select(self: Self, select: str) -> Self:
|
|
431
|
+
"""
|
|
432
|
+
Add some additional columns to the select part of the query.
|
|
433
|
+
|
|
434
|
+
This method returns a new object with the updated query. The original model object is unmodified.
|
|
435
|
+
Multiple calls to this method add together. The following:
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
models.select("column_1 column_2").select("column_3")
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
will select column_1, column_2, column_3 in the final query.
|
|
442
|
+
"""
|
|
443
|
+
self.no_single_model()
|
|
444
|
+
return self.with_query(self.get_query().add_select(select))
|
|
445
|
+
|
|
446
|
+
def select_all(self: Self, select_all=True) -> Self:
|
|
447
|
+
"""
|
|
448
|
+
Set whether or not to select all columns with the query.
|
|
449
|
+
|
|
450
|
+
This method returns a new object with the updated query. The original model object is unmodified.
|
|
451
|
+
"""
|
|
452
|
+
self.no_single_model()
|
|
453
|
+
return self.with_query(self.get_query().set_select_all(select_all))
|
|
454
|
+
|
|
455
|
+
def where(self: Self, where: str | Condition) -> Self:
|
|
456
|
+
"""
|
|
457
|
+
Add the given condition to the query.
|
|
458
|
+
|
|
459
|
+
This method returns a new object with the updated query. The original model object is unmodified.
|
|
460
|
+
|
|
461
|
+
Conditions should be an SQL-like string of the form [column][operator][value] with an optional table prefix.
|
|
462
|
+
You can safely inject user input into the value. The column name will also be checked against the searchable
|
|
463
|
+
columns for the model class, and an exception will be thrown if the column doesn't exist or is not searchable.
|
|
464
|
+
|
|
465
|
+
Multiple conditions are always joined with AND. There is no explicit option for OR. The closest is using an
|
|
466
|
+
IN condition.
|
|
467
|
+
|
|
468
|
+
Examples:
|
|
469
|
+
```python
|
|
470
|
+
for record in (
|
|
471
|
+
models.where("order_id=5").where("status IN ('ACTIVE','PENDING')").where("other_table.id=asdf")
|
|
472
|
+
):
|
|
473
|
+
print(record.id)
|
|
474
|
+
```
|
|
475
|
+
"""
|
|
476
|
+
self.no_single_model()
|
|
477
|
+
return self.with_query(self.get_query().add_where(where if isinstance(where, Condition) else Condition(where)))
|
|
478
|
+
|
|
479
|
+
def join(self: Self, join: str) -> Self:
|
|
480
|
+
"""Add a join clause to the query."""
|
|
481
|
+
self.no_single_model()
|
|
482
|
+
return self.with_query(self.get_query().add_join(Join(join)))
|
|
483
|
+
|
|
484
|
+
def is_joined(self: Self, table_name: str, alias: str = "") -> bool:
|
|
485
|
+
"""
|
|
486
|
+
Check if a given table was already joined.
|
|
487
|
+
|
|
488
|
+
If you provide an alias then it will also verify if the table was joined with the specific alias name.
|
|
489
|
+
"""
|
|
490
|
+
for join in self.get_query().joins:
|
|
491
|
+
if join.unaliased_table_name != table_name:
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
if alias and join.alias != alias:
|
|
495
|
+
continue
|
|
496
|
+
|
|
497
|
+
return True
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
def group_by(self: Self, group_by_column_name: str) -> Self:
|
|
501
|
+
self.no_single_model()
|
|
502
|
+
return self.with_query(self.get_query().set_group_by(group_by_column_name))
|
|
503
|
+
|
|
504
|
+
def sort_by(
|
|
505
|
+
self: Self,
|
|
506
|
+
primary_column_name: str,
|
|
507
|
+
primary_direction: str,
|
|
508
|
+
primary_table_name: str = "",
|
|
509
|
+
secondary_column_name: str = "",
|
|
510
|
+
secondary_direction: str = "",
|
|
511
|
+
secondary_table_name: str = "",
|
|
512
|
+
) -> Self:
|
|
513
|
+
self.no_single_model()
|
|
514
|
+
sort = Sort(primary_table_name, primary_column_name, primary_direction)
|
|
515
|
+
secondary_sort = None
|
|
516
|
+
if secondary_column_name and secondary_direction:
|
|
517
|
+
secondary_sort = Sort(secondary_table_name, secondary_column_name, secondary_direction)
|
|
518
|
+
return self.with_query(self.get_query().set_sort(sort, secondary_sort))
|
|
519
|
+
|
|
520
|
+
def limit(self: Self, limit: int) -> Self:
|
|
521
|
+
self.no_single_model()
|
|
522
|
+
return self.with_query(self.get_query().set_limit(limit))
|
|
523
|
+
|
|
524
|
+
def pagination(self: Self, **pagination_data) -> Self:
|
|
525
|
+
self.no_single_model()
|
|
526
|
+
error = self.backend.validate_pagination_data(pagination_data, str)
|
|
527
|
+
if error:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
f"Invalid pagination data for model {self.__class__.__name__} with backend "
|
|
530
|
+
+ f"{self.backend.__class__.__name__}. {error}"
|
|
531
|
+
)
|
|
532
|
+
return self.with_query(self.get_query().set_pagination(pagination_data))
|
|
533
|
+
|
|
534
|
+
def find(self: Self, where: str | Condition) -> Self:
|
|
535
|
+
"""
|
|
536
|
+
Return the first model matching a given where condition.
|
|
537
|
+
|
|
538
|
+
This is just shorthand for `models.where("column=value").find()`. Example:
|
|
539
|
+
|
|
540
|
+
```python
|
|
541
|
+
model = models.find("column=value")
|
|
542
|
+
print(model.id)
|
|
543
|
+
```
|
|
544
|
+
"""
|
|
545
|
+
self.no_single_model()
|
|
546
|
+
return self.where(where).first()
|
|
547
|
+
|
|
548
|
+
def __len__(self: Self): # noqa: D105
|
|
549
|
+
self.no_single_model()
|
|
550
|
+
if self._count is None:
|
|
551
|
+
self._count = self.backend.count(self.get_query())
|
|
552
|
+
return self._count
|
|
553
|
+
|
|
554
|
+
def __iter__(self: Self) -> Iterator[Self]: # noqa: D105
|
|
555
|
+
self.no_single_model()
|
|
556
|
+
self._next_page_data = {}
|
|
557
|
+
raw_rows = self.backend.records(
|
|
558
|
+
self.get_query(),
|
|
559
|
+
next_page_data=self._next_page_data,
|
|
560
|
+
)
|
|
561
|
+
return iter([self.model(row) for row in raw_rows])
|
|
562
|
+
|
|
563
|
+
def paginate_all(self: Self) -> list[Self]:
|
|
564
|
+
"""
|
|
565
|
+
Loop through all available pages of results and returns a list of all models that match the query.
|
|
566
|
+
|
|
567
|
+
NOTE: this loads up all records in memory before returning (e.g. it isn't using generators yet), so
|
|
568
|
+
expect delays for large record sets.
|
|
569
|
+
|
|
570
|
+
```python
|
|
571
|
+
for model in models.where("column=value").paginate_all():
|
|
572
|
+
print(model.id)
|
|
573
|
+
```
|
|
574
|
+
"""
|
|
575
|
+
self.no_single_model()
|
|
576
|
+
next_models = self.with_query(self.get_query())
|
|
577
|
+
results = list(next_models.__iter__())
|
|
578
|
+
next_page_data = next_models.next_page_data()
|
|
579
|
+
while next_page_data:
|
|
580
|
+
next_models = self.pagination(**next_page_data)
|
|
581
|
+
results.extend(next_models.__iter__())
|
|
582
|
+
next_page_data = next_models.next_page_data()
|
|
583
|
+
return results
|
|
584
|
+
|
|
585
|
+
def model(self: Self, data: dict[str, Any] = {}) -> Self:
|
|
586
|
+
"""
|
|
587
|
+
Create a new model object and populates it with the data in `data`.
|
|
588
|
+
|
|
589
|
+
NOTE: the difference between this and `model.create` is that model.create() actually saves a record in the backend,
|
|
590
|
+
while this method just creates a model object populated with the given data.
|
|
591
|
+
"""
|
|
592
|
+
model = self._di.build(self.__class__, cache=False)
|
|
593
|
+
model.set_raw_data(data)
|
|
594
|
+
return model
|
|
595
|
+
|
|
596
|
+
def empty(self: Self) -> Self:
|
|
597
|
+
"""
|
|
598
|
+
An alias for self.model({})
|
|
599
|
+
"""
|
|
600
|
+
return self.model({})
|
|
601
|
+
|
|
602
|
+
def create(self: Self, data: dict[str, Any] = {}, columns: dict[str, Column] = {}, no_data=False) -> Self:
|
|
603
|
+
"""
|
|
604
|
+
Create a new record in the backend using the information in `data`.
|
|
605
|
+
|
|
606
|
+
new_model = models.create({"column": "value"})
|
|
607
|
+
"""
|
|
608
|
+
empty = self.model()
|
|
609
|
+
empty.save(data, columns=columns, no_data=no_data)
|
|
610
|
+
return empty
|
|
611
|
+
|
|
612
|
+
def first(self: Self) -> Self:
|
|
613
|
+
"""
|
|
614
|
+
Return the first model matching the given query.
|
|
615
|
+
|
|
616
|
+
```python
|
|
617
|
+
model = models.where("column=value").sort_by("age", "DESC").first()
|
|
618
|
+
print(model.id)
|
|
619
|
+
```
|
|
620
|
+
"""
|
|
621
|
+
self.no_single_model()
|
|
622
|
+
iter = self.__iter__()
|
|
623
|
+
try:
|
|
624
|
+
return iter.__next__()
|
|
625
|
+
except StopIteration:
|
|
626
|
+
return self.model()
|
|
627
|
+
|
|
628
|
+
def allowed_pagination_keys(self: Self) -> list[str]:
|
|
629
|
+
return self.backend.allowed_pagination_keys()
|
|
630
|
+
|
|
631
|
+
def validate_pagination_data(self, kwargs: dict[str, Any], case_mapping: Callable[[str], str]) -> str:
|
|
632
|
+
return self.backend.validate_pagination_data(kwargs, case_mapping)
|
|
633
|
+
|
|
634
|
+
def next_page_data(self: Self):
|
|
635
|
+
return self._next_page_data
|
|
636
|
+
|
|
637
|
+
def documentation_pagination_next_page_response(self: Self, case_mapping: Callable) -> list[Any]:
|
|
638
|
+
return self.backend.documentation_pagination_next_page_response(case_mapping)
|
|
639
|
+
|
|
640
|
+
def documentation_pagination_next_page_example(self: Self, case_mapping: Callable) -> dict[str, Any]:
|
|
641
|
+
return self.backend.documentation_pagination_next_page_example(case_mapping)
|
|
642
|
+
|
|
643
|
+
def documentation_pagination_parameters(self: Self, case_mapping: Callable) -> list[tuple[AutoDocSchema, str]]:
|
|
644
|
+
return self.backend.documentation_pagination_parameters(case_mapping)
|
|
645
|
+
|
|
646
|
+
def no_queries(self) -> None:
|
|
647
|
+
if self._query:
|
|
648
|
+
raise ValueError(
|
|
649
|
+
"You attempted to save/read record data for a model being used to make a query. This is not allowed, as it is typically a sign of a bug in your application code."
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
def no_single_model(self):
|
|
653
|
+
if self._data:
|
|
654
|
+
raise ValueError(
|
|
655
|
+
"You have attempted to execute a query against a model that represents an individual record. This is not allowed, as it is typically a sign of a bug in your application code. If this is intentional, call model.as_query() before executing your query."
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class ModelClassReference:
|
|
660
|
+
@abstractmethod
|
|
661
|
+
def get_model_class(self) -> type[Model]:
|
|
662
|
+
pass
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
import wrapt # type: ignore
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@wrapt.decorator
|
|
7
|
+
def parameters_to_properties(wrapped, instance, args, kwargs):
|
|
8
|
+
if not instance:
|
|
9
|
+
raise ValueError(
|
|
10
|
+
"The parameters_to_properties decorator only works for methods in classes, not plain functions"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
if args:
|
|
14
|
+
wrapped_args = inspect.getfullargspec(wrapped)
|
|
15
|
+
for key, value in zip(wrapped_args.args[1:], args):
|
|
16
|
+
# if it's a dictionary or a list then copy it to avoid linking data
|
|
17
|
+
if isinstance(value, dict):
|
|
18
|
+
value = {**value}
|
|
19
|
+
if isinstance(value, list):
|
|
20
|
+
value = [*value]
|
|
21
|
+
setattr(instance, key, value)
|
|
22
|
+
|
|
23
|
+
for key, value in kwargs.items():
|
|
24
|
+
# if it's a dictionary or a list then copy it to avoid linking data
|
|
25
|
+
if isinstance(value, dict):
|
|
26
|
+
value = {**value}
|
|
27
|
+
if isinstance(value, list):
|
|
28
|
+
value = [*value]
|
|
29
|
+
setattr(instance, key, value)
|
|
30
|
+
|
|
31
|
+
wrapped(*args, **kwargs)
|
clearskies/py.typed
ADDED
|
File without changes
|