ul-api-utils 9.3.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.
- example/__init__.py +0 -0
- example/conf.py +35 -0
- example/main.py +24 -0
- example/models/__init__.py +0 -0
- example/permissions.py +6 -0
- example/pure_flask_example.py +65 -0
- example/rate_limit_load.py +10 -0
- example/redis_repository.py +22 -0
- example/routes/__init__.py +0 -0
- example/routes/api_some.py +335 -0
- example/sockets/__init__.py +0 -0
- example/sockets/on_connect.py +16 -0
- example/sockets/on_disconnect.py +14 -0
- example/sockets/on_json.py +10 -0
- example/sockets/on_message.py +13 -0
- example/sockets/on_open.py +16 -0
- example/workers/__init__.py +0 -0
- example/workers/worker.py +28 -0
- ul_api_utils/__init__.py +0 -0
- ul_api_utils/access/__init__.py +122 -0
- ul_api_utils/api_resource/__init__.py +0 -0
- ul_api_utils/api_resource/api_request.py +105 -0
- ul_api_utils/api_resource/api_resource.py +414 -0
- ul_api_utils/api_resource/api_resource_config.py +20 -0
- ul_api_utils/api_resource/api_resource_error_handling.py +21 -0
- ul_api_utils/api_resource/api_resource_fn_typing.py +356 -0
- ul_api_utils/api_resource/api_resource_type.py +16 -0
- ul_api_utils/api_resource/api_response.py +300 -0
- ul_api_utils/api_resource/api_response_db.py +26 -0
- ul_api_utils/api_resource/api_response_payload_alias.py +25 -0
- ul_api_utils/api_resource/db_types.py +9 -0
- ul_api_utils/api_resource/signature_check.py +41 -0
- ul_api_utils/commands/__init__.py +0 -0
- ul_api_utils/commands/cmd_enc_keys.py +172 -0
- ul_api_utils/commands/cmd_gen_api_user_token.py +77 -0
- ul_api_utils/commands/cmd_gen_new_api_user.py +106 -0
- ul_api_utils/commands/cmd_generate_api_docs.py +181 -0
- ul_api_utils/commands/cmd_start.py +110 -0
- ul_api_utils/commands/cmd_worker_start.py +76 -0
- ul_api_utils/commands/start/__init__.py +0 -0
- ul_api_utils/commands/start/gunicorn.conf.local.py +0 -0
- ul_api_utils/commands/start/gunicorn.conf.py +26 -0
- ul_api_utils/commands/start/wsgi.py +22 -0
- ul_api_utils/conf/ul-debugger-main.js +1 -0
- ul_api_utils/conf/ul-debugger-ui.js +1 -0
- ul_api_utils/conf.py +70 -0
- ul_api_utils/const.py +78 -0
- ul_api_utils/debug/__init__.py +0 -0
- ul_api_utils/debug/debugger.py +119 -0
- ul_api_utils/debug/malloc.py +93 -0
- ul_api_utils/debug/stat.py +444 -0
- ul_api_utils/encrypt/__init__.py +0 -0
- ul_api_utils/encrypt/encrypt_decrypt_abstract.py +15 -0
- ul_api_utils/encrypt/encrypt_decrypt_aes_xtea.py +59 -0
- ul_api_utils/errors.py +200 -0
- ul_api_utils/internal_api/__init__.py +0 -0
- ul_api_utils/internal_api/__tests__/__init__.py +0 -0
- ul_api_utils/internal_api/__tests__/internal_api.py +29 -0
- ul_api_utils/internal_api/__tests__/internal_api_content_type.py +22 -0
- ul_api_utils/internal_api/internal_api.py +369 -0
- ul_api_utils/internal_api/internal_api_check_context.py +42 -0
- ul_api_utils/internal_api/internal_api_error.py +17 -0
- ul_api_utils/internal_api/internal_api_response.py +296 -0
- ul_api_utils/main.py +29 -0
- ul_api_utils/modules/__init__.py +0 -0
- ul_api_utils/modules/__tests__/__init__.py +0 -0
- ul_api_utils/modules/__tests__/test_api_sdk_jwt.py +195 -0
- ul_api_utils/modules/api_sdk.py +555 -0
- ul_api_utils/modules/api_sdk_config.py +63 -0
- ul_api_utils/modules/api_sdk_jwt.py +377 -0
- ul_api_utils/modules/intermediate_state.py +34 -0
- ul_api_utils/modules/worker_context.py +35 -0
- ul_api_utils/modules/worker_sdk.py +109 -0
- ul_api_utils/modules/worker_sdk_config.py +13 -0
- ul_api_utils/py.typed +0 -0
- ul_api_utils/resources/__init__.py +0 -0
- ul_api_utils/resources/caching.py +196 -0
- ul_api_utils/resources/debugger_scripts.py +97 -0
- ul_api_utils/resources/health_check/__init__.py +0 -0
- ul_api_utils/resources/health_check/const.py +2 -0
- ul_api_utils/resources/health_check/health_check.py +439 -0
- ul_api_utils/resources/health_check/health_check_template.py +64 -0
- ul_api_utils/resources/health_check/resource.py +97 -0
- ul_api_utils/resources/not_implemented.py +25 -0
- ul_api_utils/resources/permissions.py +29 -0
- ul_api_utils/resources/rate_limitter.py +84 -0
- ul_api_utils/resources/socketio.py +55 -0
- ul_api_utils/resources/swagger.py +119 -0
- ul_api_utils/resources/web_forms/__init__.py +0 -0
- ul_api_utils/resources/web_forms/custom_fields/__init__.py +0 -0
- ul_api_utils/resources/web_forms/custom_fields/custom_checkbox_select.py +5 -0
- ul_api_utils/resources/web_forms/custom_widgets/__init__.py +0 -0
- ul_api_utils/resources/web_forms/custom_widgets/custom_select_widget.py +86 -0
- ul_api_utils/resources/web_forms/custom_widgets/custom_text_input_widget.py +42 -0
- ul_api_utils/resources/web_forms/uni_form.py +75 -0
- ul_api_utils/sentry.py +52 -0
- ul_api_utils/utils/__init__.py +0 -0
- ul_api_utils/utils/__tests__/__init__.py +0 -0
- ul_api_utils/utils/__tests__/api_path_version.py +16 -0
- ul_api_utils/utils/__tests__/unwrap_typing.py +67 -0
- ul_api_utils/utils/api_encoding.py +51 -0
- ul_api_utils/utils/api_format.py +61 -0
- ul_api_utils/utils/api_method.py +55 -0
- ul_api_utils/utils/api_pagination.py +58 -0
- ul_api_utils/utils/api_path_version.py +60 -0
- ul_api_utils/utils/api_request_info.py +6 -0
- ul_api_utils/utils/avro.py +131 -0
- ul_api_utils/utils/broker_topics_message_count.py +47 -0
- ul_api_utils/utils/cached_per_request.py +23 -0
- ul_api_utils/utils/colors.py +31 -0
- ul_api_utils/utils/constants.py +7 -0
- ul_api_utils/utils/decode_base64.py +9 -0
- ul_api_utils/utils/deprecated.py +19 -0
- ul_api_utils/utils/flags.py +29 -0
- ul_api_utils/utils/flask_swagger_generator/__init__.py +0 -0
- ul_api_utils/utils/flask_swagger_generator/conf.py +4 -0
- ul_api_utils/utils/flask_swagger_generator/exceptions.py +7 -0
- ul_api_utils/utils/flask_swagger_generator/specifiers/__init__.py +0 -0
- ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_models.py +57 -0
- ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_specifier.py +48 -0
- ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_three_specifier.py +777 -0
- ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_version.py +40 -0
- ul_api_utils/utils/flask_swagger_generator/utils/__init__.py +0 -0
- ul_api_utils/utils/flask_swagger_generator/utils/input_type.py +77 -0
- ul_api_utils/utils/flask_swagger_generator/utils/parameter_type.py +51 -0
- ul_api_utils/utils/flask_swagger_generator/utils/replace_in_dict.py +18 -0
- ul_api_utils/utils/flask_swagger_generator/utils/request_type.py +52 -0
- ul_api_utils/utils/flask_swagger_generator/utils/schema_type.py +15 -0
- ul_api_utils/utils/flask_swagger_generator/utils/security_type.py +39 -0
- ul_api_utils/utils/imports.py +16 -0
- ul_api_utils/utils/instance_checks.py +16 -0
- ul_api_utils/utils/jinja/__init__.py +0 -0
- ul_api_utils/utils/jinja/t_url_for.py +19 -0
- ul_api_utils/utils/jinja/to_pretty_json.py +11 -0
- ul_api_utils/utils/json_encoder.py +126 -0
- ul_api_utils/utils/load_modules.py +15 -0
- ul_api_utils/utils/memory_db/__init__.py +0 -0
- ul_api_utils/utils/memory_db/__tests__/__init__.py +0 -0
- ul_api_utils/utils/memory_db/errors.py +8 -0
- ul_api_utils/utils/memory_db/repository.py +102 -0
- ul_api_utils/utils/token_check.py +14 -0
- ul_api_utils/utils/token_check_through_request.py +16 -0
- ul_api_utils/utils/unwrap_typing.py +117 -0
- ul_api_utils/utils/uuid_converter.py +22 -0
- ul_api_utils/validators/__init__.py +0 -0
- ul_api_utils/validators/__tests__/__init__.py +0 -0
- ul_api_utils/validators/__tests__/test_custom_fields.py +32 -0
- ul_api_utils/validators/custom_fields.py +66 -0
- ul_api_utils/validators/validate_empty_object.py +10 -0
- ul_api_utils/validators/validate_uuid.py +11 -0
- ul_api_utils-9.3.0.dist-info/LICENSE +21 -0
- ul_api_utils-9.3.0.dist-info/METADATA +279 -0
- ul_api_utils-9.3.0.dist-info/RECORD +156 -0
- ul_api_utils-9.3.0.dist-info/WHEEL +5 -0
- ul_api_utils-9.3.0.dist-info/entry_points.txt +2 -0
- ul_api_utils-9.3.0.dist-info/top_level.txt +2 -0
ul_api_utils/errors.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from typing import Dict, List, Any
|
|
2
|
+
|
|
3
|
+
from ul_api_utils.internal_api.internal_api_error import InternalApiResponseErrorObj
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AbstractApiError(Exception):
|
|
7
|
+
"""
|
|
8
|
+
Ошибки которые наследуются от этого класса используются
|
|
9
|
+
- для отделения ошибок НАШЕГО приложения от всех других.
|
|
10
|
+
АБСТРАКТНЫЙ КЛАСС = НАПРЯМУЮ НЕ ИСПОЛЬЗУЕТСЯ для raise
|
|
11
|
+
"""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AbstractInternalApiError(AbstractApiError):
|
|
16
|
+
"""
|
|
17
|
+
Ошибки которые наследуются от этого класса используются
|
|
18
|
+
- ТОЛЬКО для отделения ошибок запроса во внешнее API
|
|
19
|
+
АБСТРАКТНЫЙ КЛАСС = НАПРЯМУЮ НЕ ИСПОЛЬЗУЕТСЯ для raise
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RequestAbstractInternalApiError(AbstractInternalApiError):
|
|
25
|
+
"""
|
|
26
|
+
Ошибки которые наследуются от этого класса используются
|
|
27
|
+
- для отделения ошибок ДО обработки запроса сторонним сервером
|
|
28
|
+
АБСТРАКТНЫЙ КЛАСС = НАПРЯМУЮ НЕ ИСПОЛЬЗУЕТСЯ для raise
|
|
29
|
+
"""
|
|
30
|
+
def __init__(self, message: str, error: Exception) -> None:
|
|
31
|
+
assert isinstance(message, str), f'message must be str. "{type(message).__name__}" was given'
|
|
32
|
+
assert isinstance(error, Exception), f'error must be Exception. "{type(error).__name__}" was given'
|
|
33
|
+
super(RequestAbstractInternalApiError, self).__init__(f'{message} :: {error}')
|
|
34
|
+
self.error = error
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NotFinishedRequestInternalApiError(RequestAbstractInternalApiError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ResponseAbstractInternalApiError(AbstractInternalApiError):
|
|
42
|
+
"""
|
|
43
|
+
Ошибки которые наследуются от этого класса используются
|
|
44
|
+
- для фильтрации ошибок произошедших После успешного получения информации от стороннеего АПИ
|
|
45
|
+
- но произошедшего по причине некорркетного статуса/формата пэйлоада и пр
|
|
46
|
+
АБСТРАКТНЫЙ КЛАСС = НАПРЯМУЮ НЕ ИСПОЛЬЗУЕТСЯ для raise
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotCheckedResponseInternalApiError(ResponseAbstractInternalApiError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ResponseFormatInternalApiError(ResponseAbstractInternalApiError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ResponseDataAbstractInternalApiError(ResponseAbstractInternalApiError):
|
|
60
|
+
"""
|
|
61
|
+
Ошибки которые наследуются от этого класса используются
|
|
62
|
+
- для фильтрации ошибок произошедших После успешного получения информации от стороннеего АПИ
|
|
63
|
+
- но произошедшего по причине некорркетного статуса/формата пэйлоада и пр
|
|
64
|
+
АБСТРАКТНЫЙ КЛАСС = НАПРЯМУЮ НЕ ИСПОЛЬЗУЕТСЯ для raise
|
|
65
|
+
"""
|
|
66
|
+
def __init__(self, message: str, error: Exception) -> None:
|
|
67
|
+
assert isinstance(message, str), f'message must be str. "{type(message).__name__}" was given'
|
|
68
|
+
assert isinstance(error, Exception), f'error must be Exception. "{type(error).__name__}" was given'
|
|
69
|
+
super(ResponseDataAbstractInternalApiError, self).__init__(f'{message} :: {error}')
|
|
70
|
+
self.error = error
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ResponseJsonInternalApiError(ResponseDataAbstractInternalApiError):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ResponseJsonSchemaInternalApiError(ResponseDataAbstractInternalApiError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ResponsePayloadTypeInternalApiError(ResponseDataAbstractInternalApiError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ResponseStatusAbstractInternalApiError(ResponseAbstractInternalApiError):
|
|
86
|
+
"""
|
|
87
|
+
Ошибки которые наследуются от этого класса используются
|
|
88
|
+
- если сервер выдал ошибку со статусом (явным или неявным) >= 400
|
|
89
|
+
АБСТРАКТНЫЙ КЛАСС = НАПРЯМУЮ НЕ ИСПОЛЬЗУЕТСЯ для raise
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, status_code: int, errors: List[InternalApiResponseErrorObj]) -> None:
|
|
93
|
+
assert isinstance(status_code, int), f'status_code must be int. "{type(status_code).__name__}" was given'
|
|
94
|
+
assert status_code >= 400
|
|
95
|
+
super(ResponseStatusAbstractInternalApiError, self).__init__(f'status code error :: {status_code} :: {[e.model_dump() for e in errors]}')
|
|
96
|
+
self.status_code = status_code
|
|
97
|
+
self.errors = errors
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Server5XXInternalApiError(ResponseStatusAbstractInternalApiError):
|
|
101
|
+
"""
|
|
102
|
+
Ошибки которые наследуются от этого класса используются
|
|
103
|
+
- если сервер выдал ошибку со статусом (явным или неявным) >= 500
|
|
104
|
+
= ЧТОТО ПОШЛО НЕ ТАК на сервере
|
|
105
|
+
"""
|
|
106
|
+
def __init__(self, status_code: int, errors: List[InternalApiResponseErrorObj]) -> None:
|
|
107
|
+
assert 500 <= status_code
|
|
108
|
+
super(Server5XXInternalApiError, self).__init__(status_code, errors)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Client4XXInternalApiError(ResponseStatusAbstractInternalApiError):
|
|
112
|
+
"""
|
|
113
|
+
Ошибки которые наследуются от этого класса используются
|
|
114
|
+
- если сервер выдал ошибку со статусом (явным или неявным) >= 400 < 500
|
|
115
|
+
= ЧТО ТО ОТПРАВИЛ НЕ ТО с клиента
|
|
116
|
+
"""
|
|
117
|
+
def __init__(self, status_code: int, errors: List[InternalApiResponseErrorObj]) -> None:
|
|
118
|
+
assert 400 <= status_code < 500
|
|
119
|
+
super(Client4XXInternalApiError, self).__init__(status_code, errors)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class UserAbstractApiError(AbstractApiError):
|
|
123
|
+
"""
|
|
124
|
+
Ошибки которые наследуются от этого класса используются
|
|
125
|
+
- ТОЛЬКО для остановки обработки запроса с мгновенным выходом
|
|
126
|
+
- используются СТРОГО в обработчиках ресурсов (роутах) API-приложения
|
|
127
|
+
АБСТРАКТНЫЙ КЛАСС = НАПРЯМУЮ НЕ ИСПОЛЬЗУЕТСЯ для raise
|
|
128
|
+
"""
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ValidationListApiError(UserAbstractApiError):
|
|
133
|
+
def __init__(self, errors: List[Dict[str, str]]):
|
|
134
|
+
super().__init__(f'validation errors: {errors}')
|
|
135
|
+
self.errors = errors
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ValidateApiError(UserAbstractApiError):
|
|
139
|
+
def __init__(self, code: str, location: list[Any], msg_template: str, input: Any = None):
|
|
140
|
+
self.code = code
|
|
141
|
+
self.location = location
|
|
142
|
+
self.msg_template = msg_template
|
|
143
|
+
self.input = input
|
|
144
|
+
|
|
145
|
+
def __str__(self): # type: ignore
|
|
146
|
+
return (
|
|
147
|
+
f"{self.msg_template} (code={self.code}, location={self.location}, "
|
|
148
|
+
f"input={self.input})"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class SimpleValidateApiError(UserAbstractApiError):
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AccessApiError(UserAbstractApiError):
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class PermissionDeniedApiError(UserAbstractApiError):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class NoResultFoundApiError(UserAbstractApiError):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class HasAlreadyExistsApiError(UserAbstractApiError):
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class InvalidContentTypeError(UserAbstractApiError):
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class RuntimeAbstractApiError(AbstractApiError):
|
|
177
|
+
"""
|
|
178
|
+
Ошибки которые наследуются от этого класса используются
|
|
179
|
+
- ТОЛЬКО для обозначения внештатной ситуации
|
|
180
|
+
- НЕ используется для пользовательского кода.
|
|
181
|
+
- СТРОГО внутрення ошибка сервера (назначенная АПИ-УТИЛС)
|
|
182
|
+
= обозначает то что чтото пошло не так
|
|
183
|
+
АБСТРАКТНЫЙ КЛАСС = НАПРЯМУЮ НЕ ИСПОЛЬЗУЕТСЯ для raise
|
|
184
|
+
"""
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ResourceRuntimeApiError(RuntimeAbstractApiError):
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ResponseTypeRuntimeApiError(RuntimeAbstractApiError):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class WrapInternalApiError(Exception):
|
|
197
|
+
|
|
198
|
+
def __init__(self, message: str, error: AbstractInternalApiError) -> None:
|
|
199
|
+
super().__init__(message)
|
|
200
|
+
self.error = error
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from ul_api_utils.internal_api.internal_api import InternalApi
|
|
2
|
+
from ul_api_utils.internal_api.internal_api_response import InternalApiResponseCheckLevel
|
|
3
|
+
from ul_api_utils.utils.api_method import ApiMethod
|
|
4
|
+
from ul_api_utils.utils.api_path_version import ApiPathVersion
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_internal_api() -> None:
|
|
8
|
+
ia = InternalApi('http://someurl.com')
|
|
9
|
+
|
|
10
|
+
assert ia._path_prefix == '/api'
|
|
11
|
+
assert ia._entry_point == 'http://someurl.com'
|
|
12
|
+
|
|
13
|
+
ia = InternalApi('http://someurl.com/with-prefix/some')
|
|
14
|
+
assert ia._path_prefix == '/with-prefix/some'
|
|
15
|
+
assert ia._entry_point == 'http://someurl.com'
|
|
16
|
+
|
|
17
|
+
ia = InternalApi('http://someurl.com/', path_prefix='')
|
|
18
|
+
assert ia._path_prefix == ''
|
|
19
|
+
assert ia._entry_point == 'http://someurl.com'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_internal_api_google() -> None:
|
|
23
|
+
ia = InternalApi('https://google.com')
|
|
24
|
+
|
|
25
|
+
ia.test_override(ApiMethod.GET, '/search', 200, response_json={"value": "123123"}, v=ApiPathVersion.NO_PREFIX)
|
|
26
|
+
|
|
27
|
+
resp = ia.request_get('/search', v=ApiPathVersion.NO_PREFIX).check(level=InternalApiResponseCheckLevel.STATUS_CODE)
|
|
28
|
+
|
|
29
|
+
assert resp.payload_raw == {"value": "123123"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from ul_api_utils.const import MIME__MSGPCK, MIME__JSON
|
|
2
|
+
from ul_api_utils.utils.api_format import ApiFormat
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_serialize() -> None:
|
|
6
|
+
cn = {"a": "b", "c": "рудддщ"}
|
|
7
|
+
|
|
8
|
+
assert ApiFormat.MESSAGE_PACK.serialize_bytes(cn)
|
|
9
|
+
|
|
10
|
+
assert ApiFormat.JSON.mime == MIME__JSON
|
|
11
|
+
assert ApiFormat.MESSAGE_PACK.mime == MIME__MSGPCK
|
|
12
|
+
assert ApiFormat.accept_mimes() == (MIME__MSGPCK, MIME__JSON)
|
|
13
|
+
|
|
14
|
+
format_msgpck = ApiFormat.from_mime(MIME__MSGPCK)
|
|
15
|
+
assert format_msgpck is not None
|
|
16
|
+
format_json = ApiFormat.from_mime(MIME__JSON)
|
|
17
|
+
assert format_json is not None
|
|
18
|
+
|
|
19
|
+
assert (
|
|
20
|
+
format_msgpck.parse_bytes(ApiFormat.MESSAGE_PACK.serialize_bytes(cn))
|
|
21
|
+
== format_json.parse_bytes(ApiFormat.JSON.serialize_bytes(cn))
|
|
22
|
+
)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import urllib.parse
|
|
3
|
+
from json import dumps
|
|
4
|
+
from typing import Optional, Dict, Any, Tuple, Callable
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from flask import g
|
|
8
|
+
from werkzeug.datastructures import FileStorage
|
|
9
|
+
|
|
10
|
+
from ul_api_utils.const import REQUEST_HEADER__INTERNAL, RESPONSE_HEADER__AUTHORIZATION, RESPONSE_HEADER__CONTENT_TYPE, INTERNAL_API__DEFAULT_PATH_PREFIX, REQUEST_HEADER__ACCEPT, \
|
|
11
|
+
RESPONSE_PROP_OK, RESPONSE_PROP_STATUS, RESPONSE_PROP_PAYLOAD, RESPONSE_PROP_COUNT, RESPONSE_PROP_TOTAL, RESPONSE_PROP_ERRORS, REQUEST_HEADER__CONTENT_TYPE, \
|
|
12
|
+
REQUEST_HEADER__CONTENT_ENCODING, REQUEST_HEADER__ACCEPT_CONTENT_ENCODING
|
|
13
|
+
from ul_api_utils.debug import stat
|
|
14
|
+
from ul_api_utils.errors import NotFinishedRequestInternalApiError, ResponseFormatInternalApiError
|
|
15
|
+
from ul_api_utils.internal_api.internal_api_check_context import internal_api_check_context_add_response
|
|
16
|
+
from ul_api_utils.internal_api.internal_api_response import InternalApiResponse, InternalApiOverriddenResponse
|
|
17
|
+
from ul_api_utils.utils.api_encoding import ApiEncoding
|
|
18
|
+
from ul_api_utils.utils.api_format import ApiFormat
|
|
19
|
+
from ul_api_utils.utils.api_method import ApiMethod, NO_REQUEST_BODY_METHODS
|
|
20
|
+
from ul_api_utils.utils.api_path_version import ApiPathVersion
|
|
21
|
+
from ul_api_utils.utils.json_encoder import CustomJSONEncoder
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_or_create_session(testing_mode: bool) -> requests.Session:
|
|
25
|
+
if testing_mode:
|
|
26
|
+
return requests.Session()
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return g.internal_api_requests_session # type: ignore
|
|
30
|
+
except AttributeError:
|
|
31
|
+
s = g.internal_api_requests_session = requests.Session() # type: ignore
|
|
32
|
+
return s
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
internal_api_registry: Dict[str, 'InternalApi'] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InternalApi:
|
|
39
|
+
__slots__ = (
|
|
40
|
+
'_path_prefix',
|
|
41
|
+
'_entry_point',
|
|
42
|
+
'_default_auth_token',
|
|
43
|
+
'_auth_method',
|
|
44
|
+
'_override',
|
|
45
|
+
'_testing_mode',
|
|
46
|
+
'_force_content_type',
|
|
47
|
+
'_force_encoding',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
entry_point: str,
|
|
53
|
+
*,
|
|
54
|
+
force_content_type: Optional[ApiFormat] = None,
|
|
55
|
+
force_encoding: Optional[ApiEncoding] = None,
|
|
56
|
+
default_auth_token: Optional[str] = None,
|
|
57
|
+
auth_method: str = 'Bearer',
|
|
58
|
+
path_prefix: str = INTERNAL_API__DEFAULT_PATH_PREFIX,
|
|
59
|
+
) -> None:
|
|
60
|
+
r = urllib.parse.urlparse(entry_point)
|
|
61
|
+
has_path_pref_in_entry_point = not (r.path == '' or r.path == '/')
|
|
62
|
+
assert '?' not in path_prefix, f'restricted symbol "?" was found in path_prefix="{path_prefix}"'
|
|
63
|
+
assert '&' not in path_prefix, f'restricted symbol "&" was found in path_prefix="{path_prefix}"'
|
|
64
|
+
assert r.query == ''
|
|
65
|
+
assert r.fragment == ''
|
|
66
|
+
assert r.scheme in {'http', 'https'}
|
|
67
|
+
assert not has_path_pref_in_entry_point or (has_path_pref_in_entry_point and path_prefix == INTERNAL_API__DEFAULT_PATH_PREFIX)
|
|
68
|
+
self._path_prefix = path_prefix if not has_path_pref_in_entry_point else r.path
|
|
69
|
+
self._entry_point = urllib.parse.urlunparse(r._replace(path=''))
|
|
70
|
+
self._default_auth_token = default_auth_token
|
|
71
|
+
self._auth_method = auth_method
|
|
72
|
+
internal_api_registry[self._entry_point] = self
|
|
73
|
+
self._override: Dict[str, Dict[ApiMethod, Callable[[bool], InternalApiResponse[Any]]]] = dict()
|
|
74
|
+
self._testing_mode = False
|
|
75
|
+
|
|
76
|
+
self._force_content_type = force_content_type
|
|
77
|
+
self._force_encoding = force_encoding
|
|
78
|
+
|
|
79
|
+
def test_override(
|
|
80
|
+
self,
|
|
81
|
+
method: ApiMethod,
|
|
82
|
+
path: str,
|
|
83
|
+
status_code: int,
|
|
84
|
+
*,
|
|
85
|
+
v: ApiPathVersion = ApiPathVersion.V01,
|
|
86
|
+
headers: Optional[Dict[str, str]] = None,
|
|
87
|
+
infinite: bool = False,
|
|
88
|
+
response_json: Any,
|
|
89
|
+
insert_std_schema: bool = True,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._testing_mode = True
|
|
92
|
+
|
|
93
|
+
path = v.compile_path(path, self._path_prefix)
|
|
94
|
+
|
|
95
|
+
if path not in self._override:
|
|
96
|
+
self._override[path] = dict()
|
|
97
|
+
|
|
98
|
+
if method not in self._override[path]:
|
|
99
|
+
if insert_std_schema:
|
|
100
|
+
response_data = {
|
|
101
|
+
RESPONSE_PROP_OK: 200 >= status_code > 400,
|
|
102
|
+
RESPONSE_PROP_COUNT: len(response_json) if isinstance(response_json, (list, tuple)) else (1 if response_json is not None else 0),
|
|
103
|
+
RESPONSE_PROP_TOTAL: len(response_json) if isinstance(response_json, (list, tuple)) else (1 if response_json is not None else 0),
|
|
104
|
+
RESPONSE_PROP_ERRORS: [],
|
|
105
|
+
RESPONSE_PROP_STATUS: status_code,
|
|
106
|
+
RESPONSE_PROP_PAYLOAD: response_json,
|
|
107
|
+
}
|
|
108
|
+
else:
|
|
109
|
+
response_data = response_json
|
|
110
|
+
text = dumps(response_data, cls=CustomJSONEncoder)
|
|
111
|
+
|
|
112
|
+
def _override(has_std_schema: bool) -> InternalApiResponse[Any]:
|
|
113
|
+
if not infinite:
|
|
114
|
+
self._override[path].pop(method)
|
|
115
|
+
resp = InternalApiOverriddenResponse(status_code=status_code, text=text, headers=headers or {})
|
|
116
|
+
return InternalApiResponse(f'{method.value} {path}', resp, has_std_schema, None)
|
|
117
|
+
|
|
118
|
+
self._override[path][method] = _override
|
|
119
|
+
return
|
|
120
|
+
raise OverflowError(f'method "{method.value}" has already defined for override path response "{path}"')
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def default_auth(self) -> Optional[Tuple[str, str]]:
|
|
124
|
+
if self._auth_method is None or self._default_auth_token is None:
|
|
125
|
+
return None
|
|
126
|
+
return self._auth_method, self._default_auth_token
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def entry_point(self) -> str:
|
|
130
|
+
return self._entry_point
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def path_prefix(self) -> str:
|
|
134
|
+
return self._path_prefix
|
|
135
|
+
|
|
136
|
+
def request_get( # Explicit params for mypy checking
|
|
137
|
+
self,
|
|
138
|
+
path: str,
|
|
139
|
+
*,
|
|
140
|
+
v: ApiPathVersion = ApiPathVersion.V01,
|
|
141
|
+
q: Optional[Dict[str, Any]] = None,
|
|
142
|
+
private: bool = True,
|
|
143
|
+
access_token: Optional[str] = None,
|
|
144
|
+
headers: Optional[Dict[str, str]] = None,
|
|
145
|
+
has_std_schema: bool = True,
|
|
146
|
+
) -> InternalApiResponse[Any]:
|
|
147
|
+
return self.request(
|
|
148
|
+
ApiMethod.GET,
|
|
149
|
+
path,
|
|
150
|
+
v=v,
|
|
151
|
+
q=q,
|
|
152
|
+
private=private,
|
|
153
|
+
access_token=access_token,
|
|
154
|
+
headers=headers,
|
|
155
|
+
has_std_schema=has_std_schema,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def request_post( # Explicit params for mypy checking
|
|
159
|
+
self,
|
|
160
|
+
path: str,
|
|
161
|
+
*,
|
|
162
|
+
v: ApiPathVersion = ApiPathVersion.V01,
|
|
163
|
+
q: Optional[Dict[str, Any]] = None,
|
|
164
|
+
json: Optional[Any] = None,
|
|
165
|
+
files: Optional[Dict[str, FileStorage]] = None,
|
|
166
|
+
private: bool = True,
|
|
167
|
+
access_token: Optional[str] = None,
|
|
168
|
+
headers: Optional[Dict[str, str]] = None,
|
|
169
|
+
has_std_schema: bool = True,
|
|
170
|
+
) -> InternalApiResponse[Any]:
|
|
171
|
+
return self.request(
|
|
172
|
+
ApiMethod.POST,
|
|
173
|
+
path,
|
|
174
|
+
v=v,
|
|
175
|
+
q=q,
|
|
176
|
+
json=json,
|
|
177
|
+
files=files,
|
|
178
|
+
private=private,
|
|
179
|
+
access_token=access_token,
|
|
180
|
+
headers=headers,
|
|
181
|
+
has_std_schema=has_std_schema,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def request_patch( # Explicit params for mypy checking
|
|
185
|
+
self,
|
|
186
|
+
path: str,
|
|
187
|
+
*,
|
|
188
|
+
v: ApiPathVersion = ApiPathVersion.V01,
|
|
189
|
+
q: Optional[Dict[str, Any]] = None,
|
|
190
|
+
json: Optional[Any] = None,
|
|
191
|
+
private: bool = True,
|
|
192
|
+
access_token: Optional[str] = None,
|
|
193
|
+
headers: Optional[Dict[str, str]] = None,
|
|
194
|
+
has_std_schema: bool = True,
|
|
195
|
+
) -> InternalApiResponse[Any]:
|
|
196
|
+
return self.request(
|
|
197
|
+
ApiMethod.PATCH,
|
|
198
|
+
path,
|
|
199
|
+
v=v,
|
|
200
|
+
q=q,
|
|
201
|
+
json=json,
|
|
202
|
+
private=private,
|
|
203
|
+
access_token=access_token,
|
|
204
|
+
headers=headers,
|
|
205
|
+
has_std_schema=has_std_schema,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def request_delete( # Explicit params for mypy checking
|
|
209
|
+
self,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
v: ApiPathVersion = ApiPathVersion.V01,
|
|
213
|
+
q: Optional[Dict[str, Any]] = None,
|
|
214
|
+
json: Optional[Any] = None,
|
|
215
|
+
private: bool = True,
|
|
216
|
+
access_token: Optional[str] = None,
|
|
217
|
+
headers: Optional[Dict[str, str]] = None,
|
|
218
|
+
has_std_schema: bool = True,
|
|
219
|
+
) -> InternalApiResponse[Any]:
|
|
220
|
+
return self.request(
|
|
221
|
+
ApiMethod.DELETE,
|
|
222
|
+
path,
|
|
223
|
+
v=v,
|
|
224
|
+
q=q,
|
|
225
|
+
json=json,
|
|
226
|
+
private=private,
|
|
227
|
+
access_token=access_token,
|
|
228
|
+
headers=headers,
|
|
229
|
+
has_std_schema=has_std_schema,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def request_put( # Explicit params for mypy checking
|
|
233
|
+
self,
|
|
234
|
+
path: str,
|
|
235
|
+
*,
|
|
236
|
+
v: ApiPathVersion = ApiPathVersion.V01,
|
|
237
|
+
q: Optional[Dict[str, Any]] = None,
|
|
238
|
+
json: Optional[Any] = None,
|
|
239
|
+
private: bool = True,
|
|
240
|
+
access_token: Optional[str] = None,
|
|
241
|
+
headers: Optional[Dict[str, str]] = None,
|
|
242
|
+
has_std_schema: bool = True,
|
|
243
|
+
) -> InternalApiResponse[Any]:
|
|
244
|
+
return self.request(
|
|
245
|
+
ApiMethod.PUT,
|
|
246
|
+
path,
|
|
247
|
+
v=v,
|
|
248
|
+
q=q,
|
|
249
|
+
json=json,
|
|
250
|
+
private=private,
|
|
251
|
+
access_token=access_token,
|
|
252
|
+
headers=headers,
|
|
253
|
+
has_std_schema=has_std_schema,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def request(
|
|
257
|
+
self,
|
|
258
|
+
method: ApiMethod,
|
|
259
|
+
path: str,
|
|
260
|
+
*,
|
|
261
|
+
v: ApiPathVersion = ApiPathVersion.V01,
|
|
262
|
+
q: Optional[Dict[str, Any]] = None,
|
|
263
|
+
json: Optional[Any] = None,
|
|
264
|
+
files: Optional[Dict[str, FileStorage]] = None,
|
|
265
|
+
private: bool = True,
|
|
266
|
+
access_token: Optional[str] = None,
|
|
267
|
+
headers: Optional[Dict[str, str]] = None,
|
|
268
|
+
has_std_schema: bool = True,
|
|
269
|
+
) -> InternalApiResponse[Any]:
|
|
270
|
+
assert isinstance(method, ApiMethod), f'method must be ApiMethod. "{type(method).__name__}" was given'
|
|
271
|
+
|
|
272
|
+
path = v.compile_path(path, self._path_prefix)
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
self._testing_mode
|
|
276
|
+
and path in self._override
|
|
277
|
+
and method in self._override[path]
|
|
278
|
+
):
|
|
279
|
+
return self._override[path][method](has_std_schema)
|
|
280
|
+
|
|
281
|
+
started_at = time.perf_counter()
|
|
282
|
+
q = ApiPathVersion.cleanup_q(q)
|
|
283
|
+
url = f'{self._entry_point.rstrip("/")}{path}'
|
|
284
|
+
|
|
285
|
+
req_headers = {
|
|
286
|
+
REQUEST_HEADER__INTERNAL: REQUEST_HEADER__INTERNAL,
|
|
287
|
+
REQUEST_HEADER__ACCEPT: ', '.join(ApiFormat.accept_mimes(self._force_content_type)),
|
|
288
|
+
**stat.get_stats_request_headers(),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if accept_enc := ', '.join(ApiEncoding.accept_mimes(self._force_encoding)):
|
|
292
|
+
req_headers[REQUEST_HEADER__ACCEPT_CONTENT_ENCODING] = accept_enc
|
|
293
|
+
|
|
294
|
+
req_headers.update(headers or {})
|
|
295
|
+
|
|
296
|
+
data: Optional[bytes] = None
|
|
297
|
+
debug_data: Optional[str] = None
|
|
298
|
+
if json is not None:
|
|
299
|
+
assert method not in NO_REQUEST_BODY_METHODS, f'{method.value} {url} :: must have no body'
|
|
300
|
+
content_type = ApiFormat.JSON if self._force_content_type is None else self._force_content_type
|
|
301
|
+
data = content_type.serialize_bytes(json)
|
|
302
|
+
|
|
303
|
+
if stat.collecting_enabled():
|
|
304
|
+
debug_data = (ApiFormat.JSON.serialize_bytes(json) if content_type is not ApiFormat.JSON else data).decode('utf-8')
|
|
305
|
+
req_headers[REQUEST_HEADER__CONTENT_TYPE] = content_type.mime
|
|
306
|
+
|
|
307
|
+
if data is not None:
|
|
308
|
+
if self._force_encoding is None:
|
|
309
|
+
pass
|
|
310
|
+
# if len(data) > AUTO_GZIP_THRESHOLD_LENGTH:
|
|
311
|
+
# data = ApiEncoding.GZIP.encode(data)
|
|
312
|
+
# req_headers[REQUEST_HEADER__CONTENT_ENCODING] = ApiEncoding.GZIP.mime
|
|
313
|
+
else:
|
|
314
|
+
if self._force_encoding is not ApiEncoding.NONE:
|
|
315
|
+
data = self._force_encoding.encode(data)
|
|
316
|
+
req_headers[REQUEST_HEADER__CONTENT_ENCODING] = self._force_encoding.mime
|
|
317
|
+
|
|
318
|
+
if private and (access_token or self._default_auth_token):
|
|
319
|
+
req_headers[RESPONSE_HEADER__AUTHORIZATION] = f'Bearer {access_token or self._default_auth_token}'
|
|
320
|
+
|
|
321
|
+
response_text = ''
|
|
322
|
+
status_code = None
|
|
323
|
+
internal_stat = []
|
|
324
|
+
error = None
|
|
325
|
+
requests_response = None
|
|
326
|
+
try:
|
|
327
|
+
requests_response = get_or_create_session(self._testing_mode).request(
|
|
328
|
+
method.value,
|
|
329
|
+
url=url,
|
|
330
|
+
files=({name: (fs.filename, fs.stream, fs.content_type, fs.headers) for name, fs in files.items()} if files is not None else None),
|
|
331
|
+
headers=req_headers,
|
|
332
|
+
data=data,
|
|
333
|
+
params=q,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if self._force_content_type is None:
|
|
337
|
+
self._force_content_type = ApiFormat.MESSAGE_PACK if requests_response.headers.get(RESPONSE_HEADER__CONTENT_TYPE, '') == ApiFormat.MESSAGE_PACK.mime else None
|
|
338
|
+
|
|
339
|
+
status_code = requests_response.status_code
|
|
340
|
+
except Exception as e: # noqa: B902
|
|
341
|
+
error = e
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
if requests_response is not None:
|
|
345
|
+
internal_response: InternalApiResponse[Any] = InternalApiResponse(f'{method} {requests_response.url}', requests_response, has_std_schema, None)
|
|
346
|
+
|
|
347
|
+
internal_api_check_context_add_response(internal_response)
|
|
348
|
+
|
|
349
|
+
if has_std_schema:
|
|
350
|
+
response_text = requests_response.text
|
|
351
|
+
internal_stat = internal_response.internal_use__debug_stats
|
|
352
|
+
except Exception as e: # noqa: B902
|
|
353
|
+
error = ResponseFormatInternalApiError(str(e))
|
|
354
|
+
|
|
355
|
+
if stat.collecting_enabled():
|
|
356
|
+
stat.add_http_request_stat(
|
|
357
|
+
started_at=started_at,
|
|
358
|
+
method=method,
|
|
359
|
+
url=requests_response.url if requests_response else url,
|
|
360
|
+
status_code=status_code,
|
|
361
|
+
internal_stats=internal_stat,
|
|
362
|
+
request=debug_data,
|
|
363
|
+
response=response_text,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if error:
|
|
367
|
+
raise NotFinishedRequestInternalApiError('request not finished', error)
|
|
368
|
+
|
|
369
|
+
return internal_response
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
from typing import Generator, Any, TYPE_CHECKING
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from flask import g
|
|
6
|
+
|
|
7
|
+
from ul_api_utils.errors import NotCheckedResponseInternalApiError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ul_api_utils.internal_api.internal_api_response import InternalApiResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextlib.contextmanager
|
|
14
|
+
def internal_api_check_context() -> Generator[None, None, None]:
|
|
15
|
+
g._api_utils_internal_api_context = [] # type: ignore
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
yield
|
|
19
|
+
|
|
20
|
+
invalid_resp = []
|
|
21
|
+
for resp in g._api_utils_internal_api_context: # type: ignore
|
|
22
|
+
if not resp._internal_use__checked_once:
|
|
23
|
+
invalid_resp.append(resp)
|
|
24
|
+
|
|
25
|
+
if len(invalid_resp) > 0:
|
|
26
|
+
info = ", ".join(f"\"{r._internal_use__info}\"" for r in invalid_resp)
|
|
27
|
+
raise NotCheckedResponseInternalApiError(
|
|
28
|
+
f'internal api responses must be checked once at least :: [{info}]',
|
|
29
|
+
)
|
|
30
|
+
finally:
|
|
31
|
+
g._api_utils_internal_api_context.clear() # type: ignore
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def internal_api_check_context_add_response(resp: 'InternalApiResponse[Any]') -> None:
|
|
35
|
+
if hasattr(g, '_api_utils_internal_api_context'):
|
|
36
|
+
g._api_utils_internal_api_context.append(resp) # type: ignore
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def internal_api_check_context_rm_response(id: UUID) -> None:
|
|
40
|
+
if hasattr(g, '_api_utils_internal_api_context'):
|
|
41
|
+
prev = g._api_utils_internal_api_context # type: ignore
|
|
42
|
+
g._api_utils_internal_api_context = [r for r in prev if r.id != id] # type: ignore
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Optional, Any, Dict, List, Tuple, Union
|
|
2
|
+
|
|
3
|
+
from pydantic import ConfigDict, BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InternalApiResponseErrorObj(BaseModel):
|
|
7
|
+
error_type: str
|
|
8
|
+
error_message: str
|
|
9
|
+
error_location: Optional[Union[List[str], str, Tuple[str, ...]]] = None
|
|
10
|
+
error_kind: Optional[str] = None
|
|
11
|
+
error_input: Optional[Any] = None
|
|
12
|
+
other: Optional[Dict[str, Any]] = None
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(
|
|
15
|
+
extra="ignore",
|
|
16
|
+
frozen=True,
|
|
17
|
+
)
|