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.
Files changed (156) hide show
  1. example/__init__.py +0 -0
  2. example/conf.py +35 -0
  3. example/main.py +24 -0
  4. example/models/__init__.py +0 -0
  5. example/permissions.py +6 -0
  6. example/pure_flask_example.py +65 -0
  7. example/rate_limit_load.py +10 -0
  8. example/redis_repository.py +22 -0
  9. example/routes/__init__.py +0 -0
  10. example/routes/api_some.py +335 -0
  11. example/sockets/__init__.py +0 -0
  12. example/sockets/on_connect.py +16 -0
  13. example/sockets/on_disconnect.py +14 -0
  14. example/sockets/on_json.py +10 -0
  15. example/sockets/on_message.py +13 -0
  16. example/sockets/on_open.py +16 -0
  17. example/workers/__init__.py +0 -0
  18. example/workers/worker.py +28 -0
  19. ul_api_utils/__init__.py +0 -0
  20. ul_api_utils/access/__init__.py +122 -0
  21. ul_api_utils/api_resource/__init__.py +0 -0
  22. ul_api_utils/api_resource/api_request.py +105 -0
  23. ul_api_utils/api_resource/api_resource.py +414 -0
  24. ul_api_utils/api_resource/api_resource_config.py +20 -0
  25. ul_api_utils/api_resource/api_resource_error_handling.py +21 -0
  26. ul_api_utils/api_resource/api_resource_fn_typing.py +356 -0
  27. ul_api_utils/api_resource/api_resource_type.py +16 -0
  28. ul_api_utils/api_resource/api_response.py +300 -0
  29. ul_api_utils/api_resource/api_response_db.py +26 -0
  30. ul_api_utils/api_resource/api_response_payload_alias.py +25 -0
  31. ul_api_utils/api_resource/db_types.py +9 -0
  32. ul_api_utils/api_resource/signature_check.py +41 -0
  33. ul_api_utils/commands/__init__.py +0 -0
  34. ul_api_utils/commands/cmd_enc_keys.py +172 -0
  35. ul_api_utils/commands/cmd_gen_api_user_token.py +77 -0
  36. ul_api_utils/commands/cmd_gen_new_api_user.py +106 -0
  37. ul_api_utils/commands/cmd_generate_api_docs.py +181 -0
  38. ul_api_utils/commands/cmd_start.py +110 -0
  39. ul_api_utils/commands/cmd_worker_start.py +76 -0
  40. ul_api_utils/commands/start/__init__.py +0 -0
  41. ul_api_utils/commands/start/gunicorn.conf.local.py +0 -0
  42. ul_api_utils/commands/start/gunicorn.conf.py +26 -0
  43. ul_api_utils/commands/start/wsgi.py +22 -0
  44. ul_api_utils/conf/ul-debugger-main.js +1 -0
  45. ul_api_utils/conf/ul-debugger-ui.js +1 -0
  46. ul_api_utils/conf.py +70 -0
  47. ul_api_utils/const.py +78 -0
  48. ul_api_utils/debug/__init__.py +0 -0
  49. ul_api_utils/debug/debugger.py +119 -0
  50. ul_api_utils/debug/malloc.py +93 -0
  51. ul_api_utils/debug/stat.py +444 -0
  52. ul_api_utils/encrypt/__init__.py +0 -0
  53. ul_api_utils/encrypt/encrypt_decrypt_abstract.py +15 -0
  54. ul_api_utils/encrypt/encrypt_decrypt_aes_xtea.py +59 -0
  55. ul_api_utils/errors.py +200 -0
  56. ul_api_utils/internal_api/__init__.py +0 -0
  57. ul_api_utils/internal_api/__tests__/__init__.py +0 -0
  58. ul_api_utils/internal_api/__tests__/internal_api.py +29 -0
  59. ul_api_utils/internal_api/__tests__/internal_api_content_type.py +22 -0
  60. ul_api_utils/internal_api/internal_api.py +369 -0
  61. ul_api_utils/internal_api/internal_api_check_context.py +42 -0
  62. ul_api_utils/internal_api/internal_api_error.py +17 -0
  63. ul_api_utils/internal_api/internal_api_response.py +296 -0
  64. ul_api_utils/main.py +29 -0
  65. ul_api_utils/modules/__init__.py +0 -0
  66. ul_api_utils/modules/__tests__/__init__.py +0 -0
  67. ul_api_utils/modules/__tests__/test_api_sdk_jwt.py +195 -0
  68. ul_api_utils/modules/api_sdk.py +555 -0
  69. ul_api_utils/modules/api_sdk_config.py +63 -0
  70. ul_api_utils/modules/api_sdk_jwt.py +377 -0
  71. ul_api_utils/modules/intermediate_state.py +34 -0
  72. ul_api_utils/modules/worker_context.py +35 -0
  73. ul_api_utils/modules/worker_sdk.py +109 -0
  74. ul_api_utils/modules/worker_sdk_config.py +13 -0
  75. ul_api_utils/py.typed +0 -0
  76. ul_api_utils/resources/__init__.py +0 -0
  77. ul_api_utils/resources/caching.py +196 -0
  78. ul_api_utils/resources/debugger_scripts.py +97 -0
  79. ul_api_utils/resources/health_check/__init__.py +0 -0
  80. ul_api_utils/resources/health_check/const.py +2 -0
  81. ul_api_utils/resources/health_check/health_check.py +439 -0
  82. ul_api_utils/resources/health_check/health_check_template.py +64 -0
  83. ul_api_utils/resources/health_check/resource.py +97 -0
  84. ul_api_utils/resources/not_implemented.py +25 -0
  85. ul_api_utils/resources/permissions.py +29 -0
  86. ul_api_utils/resources/rate_limitter.py +84 -0
  87. ul_api_utils/resources/socketio.py +55 -0
  88. ul_api_utils/resources/swagger.py +119 -0
  89. ul_api_utils/resources/web_forms/__init__.py +0 -0
  90. ul_api_utils/resources/web_forms/custom_fields/__init__.py +0 -0
  91. ul_api_utils/resources/web_forms/custom_fields/custom_checkbox_select.py +5 -0
  92. ul_api_utils/resources/web_forms/custom_widgets/__init__.py +0 -0
  93. ul_api_utils/resources/web_forms/custom_widgets/custom_select_widget.py +86 -0
  94. ul_api_utils/resources/web_forms/custom_widgets/custom_text_input_widget.py +42 -0
  95. ul_api_utils/resources/web_forms/uni_form.py +75 -0
  96. ul_api_utils/sentry.py +52 -0
  97. ul_api_utils/utils/__init__.py +0 -0
  98. ul_api_utils/utils/__tests__/__init__.py +0 -0
  99. ul_api_utils/utils/__tests__/api_path_version.py +16 -0
  100. ul_api_utils/utils/__tests__/unwrap_typing.py +67 -0
  101. ul_api_utils/utils/api_encoding.py +51 -0
  102. ul_api_utils/utils/api_format.py +61 -0
  103. ul_api_utils/utils/api_method.py +55 -0
  104. ul_api_utils/utils/api_pagination.py +58 -0
  105. ul_api_utils/utils/api_path_version.py +60 -0
  106. ul_api_utils/utils/api_request_info.py +6 -0
  107. ul_api_utils/utils/avro.py +131 -0
  108. ul_api_utils/utils/broker_topics_message_count.py +47 -0
  109. ul_api_utils/utils/cached_per_request.py +23 -0
  110. ul_api_utils/utils/colors.py +31 -0
  111. ul_api_utils/utils/constants.py +7 -0
  112. ul_api_utils/utils/decode_base64.py +9 -0
  113. ul_api_utils/utils/deprecated.py +19 -0
  114. ul_api_utils/utils/flags.py +29 -0
  115. ul_api_utils/utils/flask_swagger_generator/__init__.py +0 -0
  116. ul_api_utils/utils/flask_swagger_generator/conf.py +4 -0
  117. ul_api_utils/utils/flask_swagger_generator/exceptions.py +7 -0
  118. ul_api_utils/utils/flask_swagger_generator/specifiers/__init__.py +0 -0
  119. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_models.py +57 -0
  120. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_specifier.py +48 -0
  121. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_three_specifier.py +777 -0
  122. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_version.py +40 -0
  123. ul_api_utils/utils/flask_swagger_generator/utils/__init__.py +0 -0
  124. ul_api_utils/utils/flask_swagger_generator/utils/input_type.py +77 -0
  125. ul_api_utils/utils/flask_swagger_generator/utils/parameter_type.py +51 -0
  126. ul_api_utils/utils/flask_swagger_generator/utils/replace_in_dict.py +18 -0
  127. ul_api_utils/utils/flask_swagger_generator/utils/request_type.py +52 -0
  128. ul_api_utils/utils/flask_swagger_generator/utils/schema_type.py +15 -0
  129. ul_api_utils/utils/flask_swagger_generator/utils/security_type.py +39 -0
  130. ul_api_utils/utils/imports.py +16 -0
  131. ul_api_utils/utils/instance_checks.py +16 -0
  132. ul_api_utils/utils/jinja/__init__.py +0 -0
  133. ul_api_utils/utils/jinja/t_url_for.py +19 -0
  134. ul_api_utils/utils/jinja/to_pretty_json.py +11 -0
  135. ul_api_utils/utils/json_encoder.py +126 -0
  136. ul_api_utils/utils/load_modules.py +15 -0
  137. ul_api_utils/utils/memory_db/__init__.py +0 -0
  138. ul_api_utils/utils/memory_db/__tests__/__init__.py +0 -0
  139. ul_api_utils/utils/memory_db/errors.py +8 -0
  140. ul_api_utils/utils/memory_db/repository.py +102 -0
  141. ul_api_utils/utils/token_check.py +14 -0
  142. ul_api_utils/utils/token_check_through_request.py +16 -0
  143. ul_api_utils/utils/unwrap_typing.py +117 -0
  144. ul_api_utils/utils/uuid_converter.py +22 -0
  145. ul_api_utils/validators/__init__.py +0 -0
  146. ul_api_utils/validators/__tests__/__init__.py +0 -0
  147. ul_api_utils/validators/__tests__/test_custom_fields.py +32 -0
  148. ul_api_utils/validators/custom_fields.py +66 -0
  149. ul_api_utils/validators/validate_empty_object.py +10 -0
  150. ul_api_utils/validators/validate_uuid.py +11 -0
  151. ul_api_utils-9.3.0.dist-info/LICENSE +21 -0
  152. ul_api_utils-9.3.0.dist-info/METADATA +279 -0
  153. ul_api_utils-9.3.0.dist-info/RECORD +156 -0
  154. ul_api_utils-9.3.0.dist-info/WHEEL +5 -0
  155. ul_api_utils-9.3.0.dist-info/entry_points.txt +2 -0
  156. ul_api_utils-9.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,84 @@
1
+ import functools
2
+ from typing import Tuple, Optional, Callable, Union, List
3
+
4
+ from flask import request, Response, Flask, jsonify
5
+ from jwt import InvalidTokenError
6
+
7
+ from ul_api_utils.conf import APPLICATION_JWT_PUBLIC_KEY
8
+ from ul_api_utils.const import REQUEST_HEADER__X_FORWARDED_FOR, RESPONSE_PROP_OK, RESPONSE_PROP_PAYLOAD, RESPONSE_PROP_ERRORS, MIME__JSON
9
+ from ul_api_utils.debug.debugger import Debugger
10
+ from ul_api_utils.errors import AccessApiError
11
+ from ul_api_utils.modules.api_sdk_config import ApiSdkIdentifyTypeEnum
12
+ from ul_api_utils.modules.api_sdk_jwt import ApiSdkJwt
13
+ from ul_api_utils.utils.api_method import ApiMethod
14
+
15
+
16
+ def _rate_limiter_key_ipv4() -> str:
17
+ x_forwarded_for = request.headers.get(REQUEST_HEADER__X_FORWARDED_FOR)
18
+ if x_forwarded_for:
19
+ ip_list = x_forwarded_for.split(",")
20
+ return ip_list[0]
21
+ else:
22
+ return request.remote_addr or 'UNKNOWN'
23
+
24
+
25
+ def _rate_limiter_key_jwt_user_id(get_auth_token: Callable[[], Optional[Tuple[Optional[str], str]]]) -> str:
26
+ token = None
27
+ request_token = get_auth_token()
28
+ if request_token is not None:
29
+ auth_username, auth_token = request_token
30
+ try:
31
+ token = ApiSdkJwt.decode(token=auth_token, username=auth_username, certificate=APPLICATION_JWT_PUBLIC_KEY)
32
+ except (InvalidTokenError, TypeError, AccessApiError):
33
+ # case when token invalid, request rate limited by IP
34
+ return _rate_limiter_key_ipv4()
35
+ return str(token.user_id) if token is not None else _rate_limiter_key_ipv4()
36
+
37
+
38
+ def init_rate_limiter(
39
+ *,
40
+ flask_app: Flask,
41
+ debugger_enabled: Callable[[], bool],
42
+ rate_limit: Union[str, List[str]],
43
+ get_auth_token: Callable[[], Optional[Tuple[Optional[str], str]]],
44
+ identify: Union[ApiSdkIdentifyTypeEnum, Callable[[], str]],
45
+ storage_uri: str,
46
+ ) -> bool:
47
+ if identify == ApiSdkIdentifyTypeEnum.DISABLED or not storage_uri:
48
+ return False
49
+
50
+ from flask_limiter import Limiter, RateLimitExceeded
51
+ if identify == ApiSdkIdentifyTypeEnum.CLIENT_IP:
52
+ identify_func = _rate_limiter_key_ipv4
53
+ elif identify == ApiSdkIdentifyTypeEnum.JWT_USER_ID:
54
+ identify_func = functools.partial(_rate_limiter_key_jwt_user_id, get_auth_token)
55
+ else:
56
+ assert not isinstance(identify, ApiSdkIdentifyTypeEnum)
57
+ identify_func = identify
58
+
59
+ @flask_app.errorhandler(429)
60
+ def _rate_lmiter_error_handler(e: RateLimitExceeded) -> Tuple[Response, int]:
61
+ d = Debugger(flask_app.import_name, debugger_enabled(), ApiMethod(request.method), request.url)
62
+
63
+ if MIME__JSON not in request.headers.get('accept', ''):
64
+ return Response(f'429. request outside of rate limit - {e.limit.limit}'), 429
65
+ return jsonify({
66
+ RESPONSE_PROP_OK: False,
67
+ RESPONSE_PROP_PAYLOAD: None,
68
+ RESPONSE_PROP_ERRORS: [
69
+ {
70
+ "error_type": "rate-limit-exceeded",
71
+ "error_message": f"request outside of rate limit - {e.limit.limit}",
72
+ },
73
+ ],
74
+ **(d.render_dict(429) if d is not None else {}),
75
+ }), 429
76
+
77
+ Limiter(
78
+ app=flask_app,
79
+ application_limits=[rate_limit] if isinstance(rate_limit, str) else rate_limit, # type: ignore
80
+ key_func=identify_func,
81
+ storage_uri=storage_uri,
82
+ )
83
+
84
+ return True
@@ -0,0 +1,55 @@
1
+ from enum import Enum
2
+
3
+ from flask_socketio import SocketIO # type: ignore
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class SocketIOConfigType(Enum):
8
+ """
9
+ Defines two basic approaches of creating Socket.IO instance:
10
+ 1. SERVER - Socket.IO instance is being attached to the Flask App,
11
+ async gunicorn workers (gevent, eventlet), requires monkey-patching of
12
+ psycopg and default library. Socket application.
13
+ 2. EXTERNAL_PROCESS - Socket.IO instance isn't attached to Flask App,
14
+ sync gunicorn workers, doesn't require monkey-patching. Could be any
15
+ API, worker, etc. Used only to submit events from an external process
16
+ to the server clients.
17
+
18
+ Further reading: https://flask-socketio.readthedocs.io/en/latest/deployment.html#emitting-from-an-external-process
19
+ """
20
+ SERVER = "SERVER"
21
+ EXTERNAL_PROCESS = "EXTERNAL_PROCESS"
22
+
23
+
24
+ class SocketIOConfig(BaseModel):
25
+ app_type: SocketIOConfigType = SocketIOConfigType.SERVER
26
+ message_queue: str
27
+ channel: str | None = "flask-socketio"
28
+ cors_allowed_origins: str | None = "*"
29
+ logs_enabled: bool | None = False
30
+ engineio_logs_enabled: bool | None = False
31
+
32
+
33
+ class SocketAsyncModesEnum(Enum):
34
+ THREADING = 'threading'
35
+ GEVENT = 'gevent'
36
+ EVENTLET = 'eventlet'
37
+ GEVENT_UWSGI = 'gevent_uwsgi'
38
+
39
+
40
+ def init_socket_io(config: SocketIOConfig | None) -> SocketIO | None:
41
+ socket_io = None
42
+ if config is None:
43
+ return None
44
+
45
+ if config.app_type is SocketIOConfigType.SERVER:
46
+ socket_io = SocketIO()
47
+
48
+ if config.app_type is SocketIOConfigType.EXTERNAL_PROCESS:
49
+ socket_io = SocketIO(
50
+ message_queue=config.message_queue,
51
+ channel=config.channel,
52
+ logger=config.logs_enabled,
53
+ engineio_logger=config.engineio_logs_enabled,
54
+ )
55
+ return socket_io
@@ -0,0 +1,119 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ import time
5
+ from typing import TYPE_CHECKING, NamedTuple, List, Callable
6
+
7
+ import yaml
8
+ from flask import Response, request
9
+
10
+ from ul_api_utils.access import GLOBAL_PERMISSION__PUBLIC, PermissionDefinition
11
+ from ul_api_utils.api_resource.api_resource import ApiResource
12
+ from ul_api_utils.api_resource.api_resource_config import ApiResourceConfig
13
+ from ul_api_utils.api_resource.api_resource_fn_typing import ApiResourceFnTyping
14
+ from ul_api_utils.api_resource.api_resource_type import ApiResourceType
15
+ from ul_api_utils.api_resource.api_response import EmptyJsonApiResponse, ApiResponse
16
+ from ul_api_utils.conf import APPLICATION_TMP, APPLICATION_DIR, APPLICATION_F, APPLICATION_SWAGGER_PATH, APPLICATION_SWAGGER_SPECIFICATION_PATH
17
+ from ul_api_utils.debug.debugger import Debugger, AJAX_INTERSEPTOR
18
+ from ul_api_utils.utils.api_method import ApiMethod
19
+ from ul_api_utils.utils.api_path_version import ApiPathVersion
20
+ from ul_api_utils.utils.flask_swagger_generator.specifiers.swagger_three_specifier import SwaggerThreeSpecifier
21
+ from ul_api_utils.utils.flask_swagger_generator.utils.security_type import SecurityType
22
+
23
+ if TYPE_CHECKING:
24
+ from ul_api_utils.modules.api_sdk import ApiSdk
25
+
26
+
27
+ SLUG_REPLACE_RE = re.compile(r'[^\w\d]+')
28
+
29
+
30
+ class ApiSdkResource(NamedTuple):
31
+ config: ApiResourceConfig
32
+ wrapper_fn: Callable[..., ApiResponse]
33
+ methods: List[ApiMethod]
34
+ path: str
35
+ access: PermissionDefinition
36
+ fn_typing: ApiResourceFnTyping
37
+
38
+
39
+ def load_swagger(sdk: 'ApiSdk', resources: List[ApiSdkResource], api_route_path_prefix: str) -> None:
40
+ flask_app = sdk._flask_app
41
+ swagger_open_api_cache_file = os.path.join(APPLICATION_TMP, f"swagger-{SLUG_REPLACE_RE.sub('_', flask_app.import_name).lower()}-{int(time.time())}.yml")
42
+
43
+ from flask_swagger_ui import get_swaggerui_blueprint # type: ignore
44
+
45
+ bp = get_swaggerui_blueprint(
46
+ blueprint_name='swagger_ui',
47
+ base_url=ApiPathVersion.NO_VERSION.compile_path(APPLICATION_SWAGGER_PATH, api_route_path_prefix),
48
+ api_url=ApiPathVersion.NO_VERSION.compile_path(APPLICATION_SWAGGER_SPECIFICATION_PATH, api_route_path_prefix),
49
+ )
50
+
51
+ @bp.after_request
52
+ def after_request(response: Response) -> Response:
53
+ d = Debugger(flask_app.import_name, sdk._debugger_enabled_with_pin(), ApiMethod(request.method), request.url)
54
+ if isinstance(response.response, list) and b'<!DOCTYPE html>' in response.response[0]:
55
+ resp = response.get_data(as_text=True)
56
+ resp = resp.replace('</body>', f'{d.render_html(response.status_code)}</body>')
57
+ resp = resp.replace('<head>', f'<head>{AJAX_INTERSEPTOR}')
58
+ response.set_data(resp)
59
+ return response
60
+
61
+ flask_app.register_blueprint(bp)
62
+
63
+ @sdk.rest_api('GET', APPLICATION_SWAGGER_SPECIFICATION_PATH, v=ApiPathVersion.NO_VERSION, access=GLOBAL_PERMISSION__PUBLIC, config=ApiResourceConfig(swagger_disabled=True))
64
+ def swagger_specification(api_resource: ApiResource) -> EmptyJsonApiResponse:
65
+ try:
66
+ if not os.path.exists(swagger_open_api_cache_file):
67
+ with open(swagger_open_api_cache_file, 'wt') as f:
68
+ specifier = SwaggerThreeSpecifier()
69
+ _index_endpoints(specifier, [r for r in resources if APPLICATION_F.has_or_unset(r.access.flags)])
70
+ specifier.set_application_name('API')
71
+ specifier.set_application_version('1.0.0')
72
+ specifier.write(f)
73
+ specifier.clean()
74
+ with open(swagger_open_api_cache_file, "r") as docs_file:
75
+ swagger_open_api_cache_file_data = docs_file.read()
76
+ docs_object = yaml.load(
77
+ swagger_open_api_cache_file_data,
78
+ Loader=yaml.FullLoader,
79
+ )
80
+ return api_resource.response_root(docs_object)
81
+ except OSError:
82
+ return EmptyJsonApiResponse(ok=False, status_code=404)
83
+
84
+
85
+ def _index_endpoints(specifier: SwaggerThreeSpecifier, fn_registry: List[ApiSdkResource]) -> None:
86
+ for resource in fn_registry:
87
+ if resource.fn_typing.api_resource_type == ApiResourceType.WEB:
88
+ continue
89
+
90
+ if resource.config.swagger_disabled:
91
+ continue
92
+
93
+ fn = resource.fn_typing.fn
94
+ fn_file = os.path.abspath(sys.modules[fn.__module__].__file__) # type: ignore
95
+ fn_file = os.path.relpath(fn_file, APPLICATION_DIR) # type: ignore
96
+ group = resource.config.swagger_group or re.sub(r'^.*?/?(?:routes|views)/([^/]+)(?:/.+)?$', r'\1', fn_file[:-len('.py')])
97
+
98
+ specifier.add_endpoint(
99
+ function_name=fn.__name__,
100
+ function_object=resource.wrapper_fn,
101
+ path=resource.path,
102
+ request_types=[str(m) for m in resource.methods],
103
+ group=group,
104
+ )
105
+
106
+ if resource.fn_typing.body_typing is not None:
107
+ specifier.add_request_body(fn.__name__, resource.fn_typing.get_body_schema())
108
+
109
+ response_model = resource.fn_typing.get_return_schema()
110
+
111
+ specifier.add_response(
112
+ function_name=fn.__name__,
113
+ status_code=200,
114
+ schema=response_model,
115
+ description=(response_model.__doc__ or '').strip(),
116
+ )
117
+
118
+ if resource.access != GLOBAL_PERMISSION__PUBLIC:
119
+ specifier.add_security(fn.__name__, SecurityType.BEARER_AUTH)
File without changes
@@ -0,0 +1,5 @@
1
+ from wtforms import SelectMultipleField, widgets # type: ignore
2
+
3
+
4
+ class MultiCheckboxField(SelectMultipleField):
5
+ option_widget = widgets.CheckboxInput()
@@ -0,0 +1,86 @@
1
+ from typing import Any
2
+
3
+ from markupsafe import Markup
4
+ from wtforms import SelectField # type: ignore
5
+ from wtforms.widgets.core import html_params, Select # type: ignore
6
+
7
+
8
+ class CustomLiveSearchPlaceholderSelect(Select):
9
+ """
10
+ Renders a CUSTOM select field.
11
+
12
+ If `multiple` is True, then the `size` property should be specified on
13
+ rendering to make the field useful.
14
+
15
+ The field must provide an `iter_choices()` method which the widget will
16
+ call on rendering; this method must yield tuples of
17
+ `(value, label, selected)`.
18
+ It also must provide a `has_groups()` method which tells whether choices
19
+ are divided into groups, and if they do, the field must have an
20
+ `iter_groups()` method that yields tuples of `(label, choices)`, where
21
+ `choices` is an iterable of `(value, label, selected)` tuples.
22
+ Otherwise, `selected` is False for any option field in select item group.
23
+ """
24
+ def __call__(self, field: SelectField, **kwargs: Any) -> Markup:
25
+ kwargs.setdefault("id", field.id)
26
+ if self.multiple:
27
+ kwargs["multiple"] = True
28
+ flags = getattr(field, "flags", {})
29
+ for k in dir(flags):
30
+ if k in self.validation_attrs and k not in kwargs:
31
+ kwargs[k] = getattr(flags, k)
32
+
33
+ html = ["<select data-live-search='true' data-show-subtext='true' %s>" % html_params(name=field.name, **kwargs),
34
+ "<option value='' disabled selected>Select something...</option>"]
35
+
36
+ if field.has_groups():
37
+ for group, choices in field.iter_groups():
38
+ html.append("<optgroup %s>" % html_params(label=group))
39
+ for val, label, selected in choices:
40
+ html.append(self.render_option(val, label, selected))
41
+ html.append("</optgroup>")
42
+ else:
43
+ for val, label, _ in field.iter_choices():
44
+ html.append(self.render_option(val, label, False))
45
+ html.append("</select>")
46
+ return Markup("".join(html))
47
+
48
+
49
+ class CustomLiveSearchSelect(Select):
50
+ """
51
+ Renders a CUSTOM select field.
52
+
53
+ If `multiple` is True, then the `size` property should be specified on
54
+ rendering to make the field useful.
55
+
56
+ The field must provide an `iter_choices()` method which the widget will
57
+ call on rendering; this method must yield tuples of
58
+ `(value, label, selected)`.
59
+ It also must provide a `has_groups()` method which tells whether choices
60
+ are divided into groups, and if they do, the field must have an
61
+ `iter_groups()` method that yields tuples of `(label, choices)`, where
62
+ `choices` is an iterable of `(value, label, selected)` tuples.
63
+ Otherwise, `selected` is False for any option field in select item group.
64
+ """
65
+ def __call__(self, field: SelectField, **kwargs: Any) -> Markup:
66
+ kwargs.setdefault("id", field.id)
67
+ if self.multiple:
68
+ kwargs["multiple"] = True
69
+ flags = getattr(field, "flags", {})
70
+ for k in dir(flags):
71
+ if k in self.validation_attrs and k not in kwargs:
72
+ kwargs[k] = getattr(flags, k)
73
+
74
+ html = ["<select data-live-search='true' data-show-subtext='true' %s>" % html_params(name=field.name, **kwargs)]
75
+
76
+ if field.has_groups():
77
+ for group, choices in field.iter_groups():
78
+ html.append("<optgroup %s>" % html_params(label=group))
79
+ for val, label, selected in choices:
80
+ html.append(self.render_option(val, label, selected))
81
+ html.append("</optgroup>")
82
+ else:
83
+ for val, label, selected in field.iter_choices():
84
+ html.append(self.render_option(val, label, selected))
85
+ html.append("</select>")
86
+ return Markup("".join(html))
@@ -0,0 +1,42 @@
1
+ from typing import Optional, Any
2
+
3
+ from wtforms import StringField # type: ignore
4
+ from wtforms.widgets import TextInput # type: ignore
5
+ from markupsafe import Markup
6
+
7
+
8
+ class CustomTextInput(TextInput):
9
+ """
10
+ Render a single-line text input with optional input attributes ("required", "maxlength", "minlength", "pattern").
11
+
12
+ examples: TextInputCustom(required=True, min_length=5, max_length=255, pattern=r"^(d+(,d+)*)?$")
13
+ TextInputCustom(max_length=255, pattern=r"^(d+(,d+)*)?$")
14
+ TextInputCustom(required=True, pattern=r"^(d+(,d+)*)?$")
15
+ TextInputCustom(max_length=255)
16
+ In model usage: ... info={"label": "LABEL:", "widget": TextInputCustom(pattern=r"^(d+(,d+)*)?$")}
17
+ """
18
+ validation_attrs = ["required", "maxlength", "minlength", "pattern"]
19
+
20
+ def __init__(self, required: Optional[bool] = None, max_length: Optional[int] = None, min_length: Optional[int] = None, pattern: Optional[str] = None):
21
+ super().__init__()
22
+ self.required = required
23
+ self.max_length = max_length
24
+ self.min_length = min_length
25
+ self.pattern = pattern
26
+
27
+ def __call__(self, field: StringField, **kwargs: Any) -> Markup:
28
+ kwargs.setdefault("id", field.id)
29
+ kwargs.setdefault("type", self.input_type)
30
+ if self.min_length:
31
+ kwargs.setdefault("minlength", self.min_length)
32
+ if self.max_length:
33
+ kwargs.setdefault("maxlength", self.max_length)
34
+ if self.pattern:
35
+ kwargs.setdefault("pattern", self.pattern)
36
+ if "value" not in kwargs:
37
+ kwargs["value"] = field._value()
38
+ flags = getattr(field, "flags", {})
39
+ for k in dir(flags):
40
+ if k in self.validation_attrs and k not in kwargs:
41
+ kwargs[k] = getattr(flags, k)
42
+ return Markup("<input %s>" % self.html_params(name=field.name, **kwargs))
@@ -0,0 +1,75 @@
1
+ from typing import Type, Any, Dict, Optional, Tuple
2
+
3
+ from ul_db_utils.modules.postgres_modules.db import db
4
+ from sqlalchemy.dialects import postgresql
5
+ from sqlalchemy.dialects.postgresql import ARRAY
6
+ from wtforms_alchemy import ModelForm, ClassMap, FormGenerator # type: ignore
7
+ from wtforms.fields import SelectField # type: ignore
8
+
9
+ from ul_db_utils.model.base_model import BaseModel
10
+
11
+ from ul_api_utils.resources.web_forms.custom_fields.custom_checkbox_select import MultiCheckboxField
12
+
13
+
14
+ def form_factory(model_obj: Type[BaseModel], edition: bool = False, *, extra_fields: Optional[Dict[str, Any]] = None) -> Type[ModelForm]:
15
+ """
16
+ Returns generated model form.
17
+
18
+ Parameters:
19
+ model_obj (type_of(BaseModel)): model object which will use for form generation
20
+ edition (bool): flag to indicate creation or edition form will be generated
21
+ extra_fields (optional(dict(str, any))): additional fields for generated form
22
+
23
+ Returns:
24
+ Form (type_of(ModelForm)): web model form
25
+ """
26
+
27
+ class ExtraFieldsFormGenerator(FormGenerator):
28
+ def create_fields(self, form: Any, properties: Dict[str, Any]) -> None:
29
+ """
30
+ Creates fields for given form based on given model attributes.
31
+
32
+ :param form: form to attach the generated fields into
33
+ :param properties: model attributes to generate the form fields from
34
+ """
35
+ super(ExtraFieldsFormGenerator, self).create_fields(form, properties)
36
+
37
+ if extra_fields:
38
+ for field_name, field in extra_fields.items():
39
+ setattr(form, field_name, field)
40
+
41
+ class Form(ModelForm):
42
+ """
43
+ A class for representing a web form.
44
+
45
+ ...
46
+
47
+ Attributes
48
+ ----------
49
+ property_columns : dict(str, any)
50
+ Model property columns such as ID, USER_CREATED etc.
51
+
52
+ Methods
53
+ -------
54
+
55
+ """
56
+
57
+ property_columns: Dict[str, Any] = {}
58
+
59
+ def __init__(self, *args: Tuple[Any], **kwargs: Dict[str, Any]) -> None:
60
+ super(Form, self).__init__(*args, **kwargs)
61
+ self.property_columns = model_obj.get_property_columns(self._obj) if self._obj else {}
62
+
63
+ class Meta:
64
+ model = model_obj
65
+ type_map = ClassMap({postgresql.UUID: SelectField, ARRAY: MultiCheckboxField})
66
+ only = model_obj.get_edit_columns() if edition else model_obj.get_create_columns()
67
+ form_generator = ExtraFieldsFormGenerator
68
+
69
+ @classmethod
70
+ def get_session(cls) -> Any:
71
+ return db.session
72
+
73
+ Form.__name__ = f"{model_obj.__name__}Form"
74
+
75
+ return Form
ul_api_utils/sentry.py ADDED
@@ -0,0 +1,52 @@
1
+ from ul_api_utils.conf import APPLICATION_SENTRY_DSN, APPLICATION_SENTRY_ENABLED_FLASK, DOCKER_BUILD__CONTAINER_CODE_COMMIT_HASH, DOCKER_BUILD__CONTAINER_SERVER_TIME, \
2
+ DOCKER_BUILD__CONTAINER_CODE_TAG, APPLICATION_ENV, APPLICATION_DEBUG
3
+
4
+
5
+ class FakeSentryScope:
6
+
7
+ def set_tag(self, *args, **kwargs) -> None: # type: ignore
8
+ pass
9
+
10
+ def set_user(self, *args, **kwargs) -> None: # type: ignore
11
+ pass
12
+
13
+ def clear(self, *args, **kwargs) -> None: # type: ignore
14
+ pass
15
+
16
+ def __enter__(self) -> 'FakeSentryScope':
17
+ return self
18
+
19
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
20
+ pass
21
+
22
+
23
+ class FakeSentrySdk:
24
+ def init(self, *args, **kwargs) -> None: # type: ignore
25
+ pass
26
+
27
+ def capture_exception(self, *args, **kwargs) -> None: # type: ignore
28
+ pass
29
+
30
+ def configure_scope(self) -> FakeSentryScope:
31
+ return FakeSentryScope()
32
+
33
+
34
+ sentry = FakeSentrySdk()
35
+
36
+ if APPLICATION_SENTRY_DSN:
37
+ import sentry_sdk
38
+ sentry = sentry_sdk # type: ignore
39
+
40
+ if APPLICATION_SENTRY_ENABLED_FLASK:
41
+ from sentry_sdk.integrations.flask import FlaskIntegration
42
+ sentry_sdk.init(APPLICATION_SENTRY_DSN, integrations=[FlaskIntegration()])
43
+ else:
44
+ sentry_sdk.init(APPLICATION_SENTRY_DSN)
45
+ if DOCKER_BUILD__CONTAINER_CODE_COMMIT_HASH:
46
+ sentry_sdk.set_tag('release', DOCKER_BUILD__CONTAINER_CODE_COMMIT_HASH)
47
+ if DOCKER_BUILD__CONTAINER_SERVER_TIME:
48
+ sentry_sdk.set_tag('docker_built_at', DOCKER_BUILD__CONTAINER_SERVER_TIME)
49
+ if DOCKER_BUILD__CONTAINER_CODE_TAG:
50
+ sentry_sdk.set_tag('docker_tag', DOCKER_BUILD__CONTAINER_CODE_TAG)
51
+ sentry_sdk.set_tag('app_environment', APPLICATION_ENV)
52
+ sentry_sdk.set_tag('app_debug', 'True' if APPLICATION_DEBUG else 'False')
File without changes
File without changes
@@ -0,0 +1,16 @@
1
+ from ul_api_utils.utils.api_path_version import ApiPathVersion
2
+
3
+
4
+ def test_path_compilation() -> None:
5
+ assert ApiPathVersion.NO_VERSION.compile_path('some') == '/some'
6
+ assert ApiPathVersion.NO_VERSION.compile_path('some', 'additional') == '/additional/some'
7
+ assert ApiPathVersion.NO_VERSION.compile_path('some', 'additional', q={"a": 123}) == '/additional/some?a=123'
8
+
9
+ assert ApiPathVersion.V01.compile_path('some', 'additional') == '/additional/v1/some'
10
+ assert ApiPathVersion.V01.compile_path('some', 'additional', q={"a": 123}) == '/additional/v1/some?a=123'
11
+
12
+ assert ApiPathVersion.NO_PREFIX.compile_path('some', 'additional') == '/some'
13
+ assert ApiPathVersion.NO_PREFIX.compile_path('some', 'additional', q={"a": 123}) == '/some?a=123'
14
+
15
+ assert ApiPathVersion.NO_VERSION.compile_path('') == ''
16
+ assert ApiPathVersion.NO_VERSION.compile_path('', '/api') == '/api'
@@ -0,0 +1,67 @@
1
+ from typing import Optional, List
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from ul_api_utils.api_resource.signature_check import set_model
6
+ from ul_api_utils.utils.unwrap_typing import UnwrappedOptionalObjOrListOfObj
7
+
8
+
9
+ def test_unwrap_list_obj_apply() -> None:
10
+ class Some(BaseModel):
11
+ value: int
12
+
13
+ parsed1: UnwrappedOptionalObjOrListOfObj[Some] = UnwrappedOptionalObjOrListOfObj.parse(Some, None) # type: ignore
14
+ parsed1_v = parsed1.apply({"value": 1}, set_model)
15
+ assert parsed1_v is not None
16
+ assert parsed1_v.value == 1
17
+
18
+ parsed2: UnwrappedOptionalObjOrListOfObj[List[Some]] = UnwrappedOptionalObjOrListOfObj.parse(List[Some], None) # type: ignore
19
+ parsed2_v = parsed2.apply([{"value": 1}], set_model)
20
+ assert parsed2_v is not None
21
+ assert len(parsed2_v) == 1
22
+ assert parsed2_v[0].value == 1
23
+
24
+ parsed3: UnwrappedOptionalObjOrListOfObj[Optional[List[Some]]] = UnwrappedOptionalObjOrListOfObj.parse(Optional[List[Some]], None) # type: ignore
25
+ parsed3_v = parsed3.apply([{"value": 1}], set_model)
26
+ assert parsed3_v is not None
27
+ assert len(parsed3_v) == 1
28
+ assert parsed3_v[0].value == 1
29
+ assert parsed3.apply(None, set_model) is None
30
+
31
+ parsed4: UnwrappedOptionalObjOrListOfObj[Optional[Some]] = UnwrappedOptionalObjOrListOfObj.parse(Optional[Some], None) # type: ignore
32
+ assert parsed4.apply(None, set_model) is None
33
+ parsed4_v = parsed4.apply({"value": 1}, set_model)
34
+ assert parsed4_v is not None
35
+ assert parsed4_v.value == 1
36
+
37
+
38
+ def test_unwrap_list_obj() -> None:
39
+ class Some:
40
+ pass
41
+
42
+ parsed = UnwrappedOptionalObjOrListOfObj.parse(Some, None) # type: ignore
43
+ assert parsed is not None
44
+ assert parsed.value_type == Some
45
+ assert not parsed.optional
46
+ assert not parsed.many
47
+
48
+ parsed = UnwrappedOptionalObjOrListOfObj.parse(List[Some], None)
49
+ assert parsed is not None
50
+ assert parsed.value_type == Some
51
+ assert not parsed.optional
52
+ assert parsed.many
53
+
54
+ parsed = UnwrappedOptionalObjOrListOfObj.parse(Optional[Some], None) # type: ignore
55
+ assert parsed is not None
56
+ assert parsed.value_type == Some
57
+ assert parsed.optional
58
+ assert not parsed.many
59
+
60
+ parsed = UnwrappedOptionalObjOrListOfObj.parse(Optional[List[Some]], None) # type: ignore
61
+ assert parsed is not None
62
+ assert parsed.value_type == Some
63
+ assert parsed.optional
64
+ assert parsed.many
65
+
66
+ parsed = UnwrappedOptionalObjOrListOfObj.parse(List[Optional[Some]], None)
67
+ assert parsed is None
@@ -0,0 +1,51 @@
1
+ import gzip
2
+ from enum import Enum
3
+ from typing import Tuple, Optional
4
+
5
+ from ul_api_utils.const import ENCODING_MIME__GZIP
6
+
7
+
8
+ class ApiEncoding(Enum):
9
+ NONE = 'NONE'
10
+ GZIP = 'GZIP'
11
+
12
+ def __repr__(self) -> str:
13
+ return f'{type(self).__name__}.{self.name}'
14
+
15
+ @property
16
+ def mime(self) -> str:
17
+ if self is ApiEncoding.NONE:
18
+ return ''
19
+ if self is ApiEncoding.GZIP:
20
+ return ENCODING_MIME__GZIP
21
+ raise NotImplementedError
22
+
23
+ @staticmethod
24
+ def accept_mimes(force: Optional['ApiEncoding'] = None) -> Tuple[str, ...]:
25
+ if force is ApiEncoding.GZIP:
26
+ return ENCODING_MIME__GZIP, # noqa: C818
27
+ if force is ApiEncoding.NONE:
28
+ return tuple()
29
+ return ENCODING_MIME__GZIP, # noqa: C818
30
+
31
+ @staticmethod
32
+ def from_mime(mime: str) -> Optional['ApiEncoding']:
33
+ if mime == ENCODING_MIME__GZIP:
34
+ return ApiEncoding.GZIP
35
+ if mime == '':
36
+ return ApiEncoding.NONE
37
+ return None
38
+
39
+ def encode(self, data: bytes) -> bytes:
40
+ if self is ApiEncoding.GZIP:
41
+ return gzip.compress(data)
42
+ if self is ApiEncoding.NONE:
43
+ return data
44
+ raise NotImplementedError
45
+
46
+ def decode(self, data: bytes) -> bytes:
47
+ if self is ApiEncoding.GZIP:
48
+ return gzip.decompress(data)
49
+ if self is ApiEncoding.NONE:
50
+ return data
51
+ raise NotImplementedError