base-api-utils 1.1.115__tar.gz → 1.1.117__tar.gz
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.
- {base_api_utils-1.1.115/src/base_api_utils.egg-info → base_api_utils-1.1.117}/PKG-INFO +1 -1
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/pyproject.toml +1 -1
- base_api_utils-1.1.117/src/base_api_utils/__init__.py +14 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/filter_utils_mixin.py +12 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/abstract_serializer.py +21 -1
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/amqp_service.py +34 -8
- base_api_utils-1.1.117/src/base_api_utils/tests/test_task_status_endpoints.py +104 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/tasks_result_view.py +5 -5
- {base_api_utils-1.1.115 → base_api_utils-1.1.117/src/base_api_utils.egg-info}/PKG-INFO +1 -1
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils.egg-info/SOURCES.txt +4 -1
- base_api_utils-1.1.117/tests/test_amqp_service.py +218 -0
- base_api_utils-1.1.117/tests/test_lazy_imports.py +136 -0
- base_api_utils-1.1.117/tests/test_v2_abstract_serializer.py +290 -0
- base_api_utils-1.1.115/src/base_api_utils/__init__.py +0 -1
- base_api_utils-1.1.115/tests/test_lazy_imports.py +0 -58
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/LICENSE +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/README.md +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/setup.cfg +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/commands/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/commands/failed_task_management_mixin.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/domain/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/domain/event_message.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/domain/event_message_factory.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/domain/events.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/dtos/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/dtos/authz_user.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/fields/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/fields/email_list_field.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/base_filter.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/custom_ordering_filter.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/base_event_handler.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/sponsor_event_handler.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/sponsorship_addon_event_handler.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/sponsorship_event_handler.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/handlers/summit_event_handler.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/ioc/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/ioc/container.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/models/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/models/item_image.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/abstract_access_token_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/abstract_oauth2_api_client.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/access_token_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/check_sponsor_user_permissions.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/group_required.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/oauth2_authentication.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/oauth2_client_factory.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/oauth2_scope_required.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/permission_event_handler.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/shared.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/security/user_permissions_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/base_model_serializer.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/email_list_char_field.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/nullable_slug_related_field.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/serializers_registry.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/task_result_serializer.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/timestamp_field.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/base_model_serializer.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/expands.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/query_params.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/serializers/v2/spectacular.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_domain_events_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_event_dispatcher.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_images_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_sponsor_metadata_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_storage_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/abstract_summit_metadata_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/celery_failed.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/event_dispatcher.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/images_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/s3_service.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/strategies/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/strategies/abstract_task_runner_strategy.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/strategies/publish_domain_event_strategy.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/tests/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/tests/test_failed_celery_task_management.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/urls.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/async_helpers.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/circuit_breaker.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/config.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/custom_auto_schema.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/db_connection_helpers.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/debounce_helpers.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/enums.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/env_var_eval.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/exceptions.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/expand_queryset_optimization_mixin.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/external_errors.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/file_lock.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/fingerprint_helpers.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/http_helpers.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/inject_job.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/look_up_value_mixin.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/model_helpers.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/oauth2_custom_schema_base.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/pagination.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/rate_utils.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/read_write_serializer_mixin.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/serializers.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/service_utils.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/string.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/utils/tests.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/__init__.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/base_view.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/parent_children_crud.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/root_entity_crud.py +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils.egg-info/dependency_links.txt +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils.egg-info/requires.txt +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils.egg-info/top_level.txt +0 -0
- {base_api_utils-1.1.115 → base_api_utils-1.1.117}/tests/test_circuit_breaker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: base-api-utils
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.117
|
|
4
4
|
Summary: Django Rest Framework micro services common utilities
|
|
5
5
|
Author-email: Román Gutierrez <roman@tipit.net>, Sebastian Marcet <sebastian@tipit.net>
|
|
6
6
|
Project-URL: Homepage, https://github.com/fntechgit/base-api-utils
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
__all__ = ["private_tasks_admin_patterns"]
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def __getattr__(name: str):
|
|
5
|
+
# Lazy import: loading private_tasks_admin_patterns eagerly chains through
|
|
6
|
+
# views → tasks_result_view → @extend_schema → DEFAULT_SCHEMA_CLASS,
|
|
7
|
+
# which causes a circular import when any consumer of this package is
|
|
8
|
+
# itself imported as DEFAULT_SCHEMA_CLASS (e.g. a custom AutoSchema
|
|
9
|
+
# subclass that inherits from base_api_utils.utils.CustomAutoSchema).
|
|
10
|
+
if name == "private_tasks_admin_patterns":
|
|
11
|
+
from .urls import private_tasks_admin_patterns
|
|
12
|
+
|
|
13
|
+
return private_tasks_admin_patterns
|
|
14
|
+
raise AttributeError(f"module 'base_api_utils' has no attribute {name!r}")
|
{base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/filters/filter_utils_mixin.py
RENAMED
|
@@ -56,3 +56,15 @@ class FilterUtilsMixin:
|
|
|
56
56
|
def filter_isnull(self, queryset, field_name, value):
|
|
57
57
|
is_null = str(value).lower() == 'true'
|
|
58
58
|
return Q(**{f"{field_name}__isnull": is_null})
|
|
59
|
+
|
|
60
|
+
def get_and_filter(self, filter_name):
|
|
61
|
+
"""Returns (operator, value) for the named AND filter, or None if absent."""
|
|
62
|
+
for f in self.data.getlist(self.filters_param, []):
|
|
63
|
+
fname, fop, fval = self.parse_filter(f)
|
|
64
|
+
if fname == filter_name:
|
|
65
|
+
return fop, fval
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def has_and_filter(self, filter_name):
|
|
69
|
+
"""Returns True if the named filter is present in the AND filter list."""
|
|
70
|
+
return self.get_and_filter(filter_name) is not None
|
|
@@ -3,6 +3,7 @@ import copy
|
|
|
3
3
|
from .expands import ExpandMapping
|
|
4
4
|
from .query_params import normalize_none, parse_tree
|
|
5
5
|
|
|
6
|
+
|
|
6
7
|
class AbstractSerializer:
|
|
7
8
|
allowed_fields = None
|
|
8
9
|
allowed_relations = []
|
|
@@ -122,10 +123,28 @@ class AbstractSerializer:
|
|
|
122
123
|
ctx["relations_tree"] = {k: {} for k in dr}
|
|
123
124
|
|
|
124
125
|
def _filter_local_fields(self, fields):
|
|
126
|
+
fields = dict(fields)
|
|
125
127
|
ctx = self._own_context
|
|
126
128
|
ft = ctx.get("fields_tree")
|
|
127
129
|
if ft is None or (isinstance(ft, dict) and len(ft) == 0):
|
|
128
130
|
return fields
|
|
131
|
+
# Navigation-only fields_tree (every top-level key has a non-empty
|
|
132
|
+
# subtree pointing deeper) means the user only constrained nested
|
|
133
|
+
# levels and never listed a scalar at this one. Preserve defaults
|
|
134
|
+
# here and let the deeper constraints apply at child serializers.
|
|
135
|
+
has_explicit_leaf = any(not subtree for subtree in ft.values())
|
|
136
|
+
if not has_explicit_leaf:
|
|
137
|
+
af = self._merged_allowed_fields()
|
|
138
|
+
if af is not None:
|
|
139
|
+
allowed_keep = (
|
|
140
|
+
set(af)
|
|
141
|
+
| set(ctx.get("expand_tree", {}).keys())
|
|
142
|
+
| set(ctx.get("relations_tree", {}).keys())
|
|
143
|
+
)
|
|
144
|
+
for k in list(fields.keys()):
|
|
145
|
+
if k not in allowed_keep:
|
|
146
|
+
fields.pop(k, None)
|
|
147
|
+
return fields
|
|
129
148
|
keep = (
|
|
130
149
|
set(ft.keys())
|
|
131
150
|
| set(ctx.get("expand_tree", {}).keys())
|
|
@@ -169,4 +188,5 @@ class AbstractSerializer:
|
|
|
169
188
|
return ChildSerializer(context=self.get_child_context('field_name')).to_representation(obj)
|
|
170
189
|
"""
|
|
171
190
|
from .expands import _child_context
|
|
172
|
-
|
|
191
|
+
|
|
192
|
+
return _child_context(self._own_context, attr)
|
{base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/services/amqp_service.py
RENAMED
|
@@ -194,18 +194,44 @@ class AMQPService(AbstractDomainEventsService):
|
|
|
194
194
|
logging.getLogger('api').error(f'Error closing connection: {close_error}')
|
|
195
195
|
|
|
196
196
|
def subscribe(self):
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
"""Start listening for messages. Reconnects indefinitely on connection loss."""
|
|
198
|
+
if not self.queue_name:
|
|
199
|
+
raise ValueError('AMQPService::subscribe: queue_name is required')
|
|
200
|
+
attempt = 0
|
|
201
|
+
while True:
|
|
202
|
+
clean_return = False
|
|
203
|
+
try:
|
|
204
|
+
self.connection = None
|
|
205
|
+
self.channel = None
|
|
200
206
|
self._connect()
|
|
201
207
|
|
|
202
|
-
if self.queue_name:
|
|
203
208
|
self.channel.basic_consume(
|
|
204
209
|
queue=self.queue_name,
|
|
205
210
|
on_message_callback=self._callback
|
|
206
211
|
)
|
|
207
212
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
logging.getLogger('api').info("Waiting for messages...")
|
|
214
|
+
self.channel.start_consuming()
|
|
215
|
+
|
|
216
|
+
# start_consuming() returned — broker closed the connection (e.g. maintenance mode)
|
|
217
|
+
logging.getLogger('api').warning(
|
|
218
|
+
f'AMQPService::subscribe: Connection to {self.queue_name} closed by broker, reconnecting...')
|
|
219
|
+
attempt = 0 # reset backoff after a successful session
|
|
220
|
+
clean_return = True
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logging.getLogger('api').error(
|
|
223
|
+
f'AMQPService::subscribe: Failed to subscribe to {self.queue_name}: {e}')
|
|
224
|
+
finally:
|
|
225
|
+
try:
|
|
226
|
+
if self.connection and not self.connection.is_closed:
|
|
227
|
+
self.connection.close()
|
|
228
|
+
except Exception as close_err:
|
|
229
|
+
logging.getLogger('api').warning(
|
|
230
|
+
f'AMQPService::subscribe: could not close connection: {close_err}')
|
|
231
|
+
|
|
232
|
+
delay = self._calculate_delay(attempt)
|
|
233
|
+
logging.getLogger('api').info(
|
|
234
|
+
f'AMQPService::subscribe: Reconnecting to {self.queue_name} in {delay:.2f}s...')
|
|
235
|
+
time.sleep(delay)
|
|
236
|
+
if not clean_return:
|
|
237
|
+
attempt += 1
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from unittest.mock import patch, MagicMock
|
|
2
|
+
|
|
3
|
+
from django.core.management import call_command
|
|
4
|
+
from rest_framework.test import APITestCase
|
|
5
|
+
from django.urls import reverse
|
|
6
|
+
import json
|
|
7
|
+
from rest_framework import status
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TaskStatusEndpointsTest(APITestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.access_token = 'ACCESS_TOKEN'
|
|
13
|
+
call_command('seed_data')
|
|
14
|
+
|
|
15
|
+
def test_task_status_pending(self):
|
|
16
|
+
task_id = 'test-task-pending'
|
|
17
|
+
url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
|
|
18
|
+
|
|
19
|
+
mock_result = MagicMock()
|
|
20
|
+
mock_result.status = 'PENDING'
|
|
21
|
+
|
|
22
|
+
with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
|
|
23
|
+
response = self.client.get(f'{url}?access_token={self.access_token}')
|
|
24
|
+
|
|
25
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
26
|
+
json_response = json.loads(response.content)
|
|
27
|
+
self.assertEqual(json_response['task_id'], task_id)
|
|
28
|
+
self.assertEqual(json_response['status'], 'PENDING')
|
|
29
|
+
self.assertNotIn('result', json_response)
|
|
30
|
+
self.assertNotIn('error', json_response)
|
|
31
|
+
|
|
32
|
+
def test_task_status_started(self):
|
|
33
|
+
task_id = 'test-task-started'
|
|
34
|
+
url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
|
|
35
|
+
|
|
36
|
+
mock_result = MagicMock()
|
|
37
|
+
mock_result.status = 'STARTED'
|
|
38
|
+
|
|
39
|
+
with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
|
|
40
|
+
response = self.client.get(f'{url}?access_token={self.access_token}')
|
|
41
|
+
|
|
42
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
43
|
+
json_response = json.loads(response.content)
|
|
44
|
+
self.assertEqual(json_response['status'], 'STARTED')
|
|
45
|
+
self.assertNotIn('result', json_response)
|
|
46
|
+
self.assertNotIn('error', json_response)
|
|
47
|
+
|
|
48
|
+
def test_task_status_success(self):
|
|
49
|
+
task_id = 'test-task-success'
|
|
50
|
+
url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
|
|
51
|
+
|
|
52
|
+
mock_result = MagicMock()
|
|
53
|
+
mock_result.status = 'SUCCESS'
|
|
54
|
+
mock_result.result = {'imported': [1667], 'errors': []}
|
|
55
|
+
|
|
56
|
+
with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
|
|
57
|
+
response = self.client.get(f'{url}?access_token={self.access_token}')
|
|
58
|
+
|
|
59
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
60
|
+
json_response = json.loads(response.content)
|
|
61
|
+
self.assertEqual(json_response['task_id'], task_id)
|
|
62
|
+
self.assertEqual(json_response['status'], 'SUCCESS')
|
|
63
|
+
self.assertIn('result', json_response)
|
|
64
|
+
self.assertEqual(json_response['result'], {'imported': [1667], 'errors': []})
|
|
65
|
+
self.assertNotIn('error', json_response)
|
|
66
|
+
|
|
67
|
+
def test_task_status_success_with_partial_errors(self):
|
|
68
|
+
task_id = 'test-task-partial'
|
|
69
|
+
url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
|
|
70
|
+
|
|
71
|
+
mock_result = MagicMock()
|
|
72
|
+
mock_result.status = 'SUCCESS'
|
|
73
|
+
mock_result.result = {
|
|
74
|
+
'imported': [1667],
|
|
75
|
+
'errors': [{'user_id': 9999, 'error': 'There is no sponsor user registered with the id 9999'}]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
|
|
79
|
+
response = self.client.get(f'{url}?access_token={self.access_token}')
|
|
80
|
+
|
|
81
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
82
|
+
json_response = json.loads(response.content)
|
|
83
|
+
self.assertEqual(json_response['status'], 'SUCCESS')
|
|
84
|
+
self.assertEqual(len(json_response['result']['imported']), 1)
|
|
85
|
+
self.assertEqual(len(json_response['result']['errors']), 1)
|
|
86
|
+
self.assertEqual(json_response['result']['errors'][0]['user_id'], 9999)
|
|
87
|
+
|
|
88
|
+
def test_task_status_failure(self):
|
|
89
|
+
task_id = 'test-task-failure'
|
|
90
|
+
url = reverse('private-tasks-admin-endpoints:retrieve_async_task_status', kwargs={'task_id': task_id})
|
|
91
|
+
|
|
92
|
+
mock_result = MagicMock()
|
|
93
|
+
mock_result.status = 'FAILURE'
|
|
94
|
+
mock_result.result = Exception('Sponsor 999 not found')
|
|
95
|
+
|
|
96
|
+
with patch('base_api_utils.views.tasks_result_view.AsyncResult', return_value=mock_result):
|
|
97
|
+
response = self.client.get(f'{url}?access_token={self.access_token}')
|
|
98
|
+
|
|
99
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
100
|
+
json_response = json.loads(response.content)
|
|
101
|
+
self.assertEqual(json_response['task_id'], task_id)
|
|
102
|
+
self.assertEqual(json_response['status'], 'FAILURE')
|
|
103
|
+
self.assertIn('error', json_response)
|
|
104
|
+
self.assertNotIn('result', json_response)
|
{base_api_utils-1.1.115 → base_api_utils-1.1.117}/src/base_api_utils/views/tasks_result_view.py
RENAMED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
from celery.result import AsyncResult
|
|
2
|
+
from drf_spectacular.utils import extend_schema
|
|
2
3
|
from rest_framework import viewsets, status
|
|
3
4
|
from rest_framework.response import Response
|
|
4
5
|
from django_celery_results.models import TaskResult
|
|
5
6
|
from celery import current_app
|
|
6
7
|
|
|
7
|
-
from ..security import oauth2_scope_required, group_required
|
|
8
|
+
from ..security import oauth2_scope_required, group_required, OAuth2Authentication
|
|
8
9
|
from ..serializers import TaskResultSerializer
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
@extend_schema(tags=["Celery Tasks"])
|
|
11
13
|
class TasksResultAPIView(viewsets.ReadOnlyModelViewSet):
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"""
|
|
14
|
+
authentication_classes = [OAuth2Authentication]
|
|
15
|
+
|
|
15
16
|
queryset = TaskResult.objects.filter(status='FAILURE')
|
|
16
17
|
serializer_class = TaskResultSerializer
|
|
17
18
|
|
|
18
19
|
@oauth2_scope_required()
|
|
19
20
|
@group_required()
|
|
20
21
|
def retry(self, request, task_id, *args, **kwargs):
|
|
21
|
-
"""Rerun a failed task manually"""
|
|
22
22
|
task_result = self.get_queryset().filter(task_id=task_id).first()
|
|
23
23
|
if not task_result:
|
|
24
24
|
return Response({"error": "Task not registered as failed"}, status=status.HTTP_404_NOT_FOUND)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: base-api-utils
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.117
|
|
4
4
|
Summary: Django Rest Framework micro services common utilities
|
|
5
5
|
Author-email: Román Gutierrez <roman@tipit.net>, Sebastian Marcet <sebastian@tipit.net>
|
|
6
6
|
Project-URL: Homepage, https://github.com/fntechgit/base-api-utils
|
|
@@ -74,6 +74,7 @@ src/base_api_utils/services/strategies/abstract_task_runner_strategy.py
|
|
|
74
74
|
src/base_api_utils/services/strategies/publish_domain_event_strategy.py
|
|
75
75
|
src/base_api_utils/tests/__init__.py
|
|
76
76
|
src/base_api_utils/tests/test_failed_celery_task_management.py
|
|
77
|
+
src/base_api_utils/tests/test_task_status_endpoints.py
|
|
77
78
|
src/base_api_utils/utils/__init__.py
|
|
78
79
|
src/base_api_utils/utils/async_helpers.py
|
|
79
80
|
src/base_api_utils/utils/circuit_breaker.py
|
|
@@ -105,5 +106,7 @@ src/base_api_utils/views/base_view.py
|
|
|
105
106
|
src/base_api_utils/views/parent_children_crud.py
|
|
106
107
|
src/base_api_utils/views/root_entity_crud.py
|
|
107
108
|
src/base_api_utils/views/tasks_result_view.py
|
|
109
|
+
tests/test_amqp_service.py
|
|
108
110
|
tests/test_circuit_breaker.py
|
|
109
|
-
tests/test_lazy_imports.py
|
|
111
|
+
tests/test_lazy_imports.py
|
|
112
|
+
tests/test_v2_abstract_serializer.py
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ── Pre-stub amqp_service.py's dependencies ────────────────────────────────────
|
|
10
|
+
# Must happen at module level before _load_module executes the file.
|
|
11
|
+
# Base classes must be real Python classes so AMQPService inherits correctly.
|
|
12
|
+
|
|
13
|
+
class _AbstractDomainEventsService:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _AbstractEventDispatcher:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _AMQPConnectionError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _AMQPChannelError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_stub_module(**attrs):
|
|
30
|
+
m = MagicMock()
|
|
31
|
+
for k, v in attrs.items():
|
|
32
|
+
setattr(m, k, v)
|
|
33
|
+
return m
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_mock_pika_exc = _make_stub_module(
|
|
37
|
+
AMQPConnectionError=_AMQPConnectionError,
|
|
38
|
+
AMQPChannelError=_AMQPChannelError,
|
|
39
|
+
)
|
|
40
|
+
_mock_pika = _make_stub_module(exceptions=_mock_pika_exc)
|
|
41
|
+
|
|
42
|
+
for _name, _mod in [
|
|
43
|
+
("pika", _mock_pika),
|
|
44
|
+
("pika.exceptions", _mock_pika_exc),
|
|
45
|
+
("base_api_utils", MagicMock()),
|
|
46
|
+
("base_api_utils.utils", MagicMock()),
|
|
47
|
+
("base_api_utils.domain", MagicMock()),
|
|
48
|
+
("base_api_utils.services", MagicMock()),
|
|
49
|
+
(
|
|
50
|
+
"base_api_utils.services.abstract_event_dispatcher",
|
|
51
|
+
_make_stub_module(AbstractEventDispatcher=_AbstractEventDispatcher),
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
"base_api_utils.services.abstract_domain_events_service",
|
|
55
|
+
_make_stub_module(AbstractDomainEventsService=_AbstractDomainEventsService),
|
|
56
|
+
),
|
|
57
|
+
]:
|
|
58
|
+
sys.modules.setdefault(_name, _mod)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_module(name, file_path):
|
|
62
|
+
"""Load a module directly from file path, bypassing package __init__.py."""
|
|
63
|
+
spec = importlib.util.spec_from_file_location(name, file_path)
|
|
64
|
+
module = importlib.util.module_from_spec(spec)
|
|
65
|
+
sys.modules[name] = module
|
|
66
|
+
spec.loader.exec_module(module)
|
|
67
|
+
return module
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_services_dir = (
|
|
71
|
+
Path(__file__).resolve().parent.parent / "src" / "base_api_utils" / "services"
|
|
72
|
+
)
|
|
73
|
+
_amqp_module = _load_module(
|
|
74
|
+
"base_api_utils.services.amqp_service",
|
|
75
|
+
_services_dir / "amqp_service.py",
|
|
76
|
+
)
|
|
77
|
+
AMQPService = _amqp_module.AMQPService
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def _make_service(**kwargs):
|
|
83
|
+
defaults = dict(
|
|
84
|
+
exchange_name="test-exchange",
|
|
85
|
+
queue_name="test-queue",
|
|
86
|
+
rabbit_host="localhost",
|
|
87
|
+
rabbit_port=5672,
|
|
88
|
+
rabbit_username="guest",
|
|
89
|
+
rabbit_password="guest",
|
|
90
|
+
virtual_host="/",
|
|
91
|
+
exchange_type="direct",
|
|
92
|
+
routing_keys=["test.event"],
|
|
93
|
+
base_delay=1.0,
|
|
94
|
+
max_delay=60.0,
|
|
95
|
+
jitter=False,
|
|
96
|
+
)
|
|
97
|
+
defaults.update(kwargs)
|
|
98
|
+
return AMQPService(**defaults)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Tests ─────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
@pytest.mark.unit
|
|
104
|
+
class TestAMQPServiceSubscribeReconnection:
|
|
105
|
+
|
|
106
|
+
def test_reconnects_when_broker_closes_connection(self):
|
|
107
|
+
"""start_consuming() returning (e.g. maintenance-mode close) triggers reconnect."""
|
|
108
|
+
service = _make_service()
|
|
109
|
+
connect_calls = 0
|
|
110
|
+
|
|
111
|
+
def fake_connect():
|
|
112
|
+
nonlocal connect_calls
|
|
113
|
+
connect_calls += 1
|
|
114
|
+
mock_conn = MagicMock(is_closed=True)
|
|
115
|
+
mock_ch = MagicMock()
|
|
116
|
+
# Second call raises to break the infinite loop
|
|
117
|
+
if connect_calls >= 2:
|
|
118
|
+
mock_ch.start_consuming.side_effect = KeyboardInterrupt
|
|
119
|
+
service.connection = mock_conn
|
|
120
|
+
service.channel = mock_ch
|
|
121
|
+
|
|
122
|
+
with patch.object(service, "_connect", side_effect=fake_connect), \
|
|
123
|
+
patch("base_api_utils.services.amqp_service.time.sleep"):
|
|
124
|
+
with pytest.raises(KeyboardInterrupt):
|
|
125
|
+
service.subscribe()
|
|
126
|
+
|
|
127
|
+
assert connect_calls == 2
|
|
128
|
+
|
|
129
|
+
def test_reconnects_after_connection_exception(self):
|
|
130
|
+
"""An exception during _connect triggers a retry after delay."""
|
|
131
|
+
service = _make_service()
|
|
132
|
+
connect_attempts = 0
|
|
133
|
+
|
|
134
|
+
def failing_connect():
|
|
135
|
+
nonlocal connect_attempts
|
|
136
|
+
connect_attempts += 1
|
|
137
|
+
if connect_attempts > 2:
|
|
138
|
+
raise KeyboardInterrupt
|
|
139
|
+
raise ConnectionError("broker unavailable")
|
|
140
|
+
|
|
141
|
+
with patch.object(service, "_connect", side_effect=failing_connect), \
|
|
142
|
+
patch("base_api_utils.services.amqp_service.time.sleep") as mock_sleep:
|
|
143
|
+
with pytest.raises(KeyboardInterrupt):
|
|
144
|
+
service.subscribe()
|
|
145
|
+
|
|
146
|
+
assert connect_attempts == 3
|
|
147
|
+
assert mock_sleep.call_count == 2
|
|
148
|
+
|
|
149
|
+
def test_resets_backoff_after_successful_session(self):
|
|
150
|
+
"""After start_consuming returns cleanly, backoff resets to base_delay."""
|
|
151
|
+
service = _make_service(base_delay=1.0, max_delay=60.0, jitter=False)
|
|
152
|
+
connect_calls = 0
|
|
153
|
+
|
|
154
|
+
def fake_connect():
|
|
155
|
+
nonlocal connect_calls
|
|
156
|
+
connect_calls += 1
|
|
157
|
+
if connect_calls == 1:
|
|
158
|
+
mock_ch = MagicMock()
|
|
159
|
+
mock_ch.start_consuming.return_value = None # returns cleanly
|
|
160
|
+
service.connection = MagicMock(is_closed=True)
|
|
161
|
+
service.channel = mock_ch
|
|
162
|
+
elif connect_calls == 2:
|
|
163
|
+
raise ConnectionError("first failure after clean session")
|
|
164
|
+
else:
|
|
165
|
+
raise KeyboardInterrupt
|
|
166
|
+
|
|
167
|
+
with patch.object(service, "_connect", side_effect=fake_connect), \
|
|
168
|
+
patch("base_api_utils.services.amqp_service.time.sleep") as mock_sleep:
|
|
169
|
+
with pytest.raises(KeyboardInterrupt):
|
|
170
|
+
service.subscribe()
|
|
171
|
+
|
|
172
|
+
# Clean session resets attempt to 0; the first failure after it must use base_delay, not 2×
|
|
173
|
+
delays = [c.args[0] for c in mock_sleep.call_args_list]
|
|
174
|
+
assert delays == pytest.approx([1.0, 1.0])
|
|
175
|
+
|
|
176
|
+
def test_cleans_up_open_connection_on_error(self):
|
|
177
|
+
"""An open connection is closed in the finally block after an error."""
|
|
178
|
+
service = _make_service()
|
|
179
|
+
mock_conn = MagicMock(is_closed=False)
|
|
180
|
+
|
|
181
|
+
def fake_connect():
|
|
182
|
+
service.connection = mock_conn
|
|
183
|
+
mock_ch = MagicMock()
|
|
184
|
+
mock_ch.start_consuming.side_effect = KeyboardInterrupt
|
|
185
|
+
service.channel = mock_ch
|
|
186
|
+
|
|
187
|
+
with patch.object(service, "_connect", side_effect=fake_connect), \
|
|
188
|
+
patch("base_api_utils.services.amqp_service.time.sleep"):
|
|
189
|
+
with pytest.raises(KeyboardInterrupt):
|
|
190
|
+
service.subscribe()
|
|
191
|
+
|
|
192
|
+
mock_conn.close.assert_called_once()
|
|
193
|
+
|
|
194
|
+
def test_delay_grows_exponentially_across_failures(self):
|
|
195
|
+
"""Reconnection delay doubles on each successive failure."""
|
|
196
|
+
service = _make_service(base_delay=1.0, max_delay=60.0, jitter=False)
|
|
197
|
+
connect_attempts = 0
|
|
198
|
+
|
|
199
|
+
def failing_connect():
|
|
200
|
+
nonlocal connect_attempts
|
|
201
|
+
connect_attempts += 1
|
|
202
|
+
if connect_attempts > 3:
|
|
203
|
+
raise KeyboardInterrupt
|
|
204
|
+
raise ConnectionError("broker unavailable")
|
|
205
|
+
|
|
206
|
+
with patch.object(service, "_connect", side_effect=failing_connect), \
|
|
207
|
+
patch("base_api_utils.services.amqp_service.time.sleep") as mock_sleep:
|
|
208
|
+
with pytest.raises(KeyboardInterrupt):
|
|
209
|
+
service.subscribe()
|
|
210
|
+
|
|
211
|
+
delays = [c.args[0] for c in mock_sleep.call_args_list]
|
|
212
|
+
assert delays == [1.0, 2.0, 4.0]
|
|
213
|
+
|
|
214
|
+
def test_subscribe_raises_when_queue_name_is_none(self):
|
|
215
|
+
"""subscribe() must fail fast when queue_name is not configured."""
|
|
216
|
+
service = _make_service(queue_name=None)
|
|
217
|
+
with pytest.raises(ValueError, match="queue_name is required"):
|
|
218
|
+
service.subscribe()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
import types
|
|
4
|
+
import unittest.mock
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_module(name, file_path):
|
|
11
|
+
"""Load a module directly from file path, bypassing package __init__.py."""
|
|
12
|
+
spec = importlib.util.spec_from_file_location(name, file_path)
|
|
13
|
+
if spec is None or spec.loader is None:
|
|
14
|
+
raise ImportError(f"Could not load module {name} from {file_path}")
|
|
15
|
+
module = importlib.util.module_from_spec(spec)
|
|
16
|
+
sys.modules[name] = module
|
|
17
|
+
spec.loader.exec_module(module)
|
|
18
|
+
return module
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_utils_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils" / "utils"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.unit
|
|
25
|
+
class TestExpandQuerysetOptimizationMixinImports:
|
|
26
|
+
"""Regression: ExpandQuerysetOptimizationMixin must not trigger
|
|
27
|
+
serializers/__init__.py at module load time, because that chain
|
|
28
|
+
reaches django_celery_results.models.TaskResult (a Django model
|
|
29
|
+
that requires the app registry to be ready).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def test_mixin_module_loads_without_triggering_serializers_init(self):
|
|
33
|
+
"""Loading the mixin module should NOT execute serializers/__init__.py."""
|
|
34
|
+
# Clear any cached state
|
|
35
|
+
mod_name = "base_api_utils.utils.expand_queryset_optimization_mixin"
|
|
36
|
+
for key in list(sys.modules):
|
|
37
|
+
if key.startswith("base_api_utils.serializers"):
|
|
38
|
+
del sys.modules[key]
|
|
39
|
+
if mod_name in sys.modules:
|
|
40
|
+
del sys.modules[mod_name]
|
|
41
|
+
|
|
42
|
+
module = _load_module(mod_name, _utils_dir / "expand_queryset_optimization_mixin.py")
|
|
43
|
+
|
|
44
|
+
# Module should load successfully and define the mixin class
|
|
45
|
+
assert hasattr(module, "ExpandQuerysetOptimizationMixin")
|
|
46
|
+
|
|
47
|
+
# serializers/__init__.py should NOT have been executed during load
|
|
48
|
+
assert "base_api_utils.serializers" not in sys.modules
|
|
49
|
+
assert "base_api_utils.serializers.task_result_serializer" not in sys.modules
|
|
50
|
+
|
|
51
|
+
def test_mixin_has_no_module_level_query_params_import(self):
|
|
52
|
+
"""The query_params import should be deferred to method level."""
|
|
53
|
+
mod_name = "base_api_utils.utils.expand_queryset_optimization_mixin"
|
|
54
|
+
if mod_name in sys.modules:
|
|
55
|
+
del sys.modules[mod_name]
|
|
56
|
+
|
|
57
|
+
module = _load_module(mod_name, _utils_dir / "expand_queryset_optimization_mixin.py")
|
|
58
|
+
|
|
59
|
+
# query_params should NOT be loaded at module level
|
|
60
|
+
assert "base_api_utils.serializers.v2.query_params" not in sys.modules
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.unit
|
|
64
|
+
class TestBaseApiUtilsInitLazyImport:
|
|
65
|
+
"""Regression: importing base_api_utils must NOT eagerly load
|
|
66
|
+
urls → views → tasks_result_view → @extend_schema → DEFAULT_SCHEMA_CLASS.
|
|
67
|
+
That chain causes a circular import when base_api_utils is imported by
|
|
68
|
+
a custom AutoSchema subclass that is itself registered as DEFAULT_SCHEMA_CLASS.
|
|
69
|
+
|
|
70
|
+
private_tasks_admin_patterns must be accessible but only materialised on
|
|
71
|
+
first attribute access, not at package-import time.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def test_import_base_api_utils_does_not_load_views(self):
|
|
75
|
+
"""Importing the package must not pull in views/tasks_result_view."""
|
|
76
|
+
for key in list(sys.modules):
|
|
77
|
+
if key.startswith("base_api_utils"):
|
|
78
|
+
del sys.modules[key]
|
|
79
|
+
|
|
80
|
+
_base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
|
|
81
|
+
_load_module("base_api_utils", _base_dir / "__init__.py")
|
|
82
|
+
|
|
83
|
+
assert "base_api_utils.views" not in sys.modules
|
|
84
|
+
assert "base_api_utils.views.tasks_result_view" not in sys.modules
|
|
85
|
+
assert "base_api_utils.urls" not in sys.modules
|
|
86
|
+
|
|
87
|
+
def test_unknown_attribute_raises_attribute_error(self):
|
|
88
|
+
"""__getattr__ must re-raise AttributeError for unknown names so that
|
|
89
|
+
normal attribute-missing semantics are preserved."""
|
|
90
|
+
for key in list(sys.modules):
|
|
91
|
+
if key.startswith("base_api_utils"):
|
|
92
|
+
del sys.modules[key]
|
|
93
|
+
|
|
94
|
+
_base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
|
|
95
|
+
module = _load_module("base_api_utils", _base_dir / "__init__.py")
|
|
96
|
+
|
|
97
|
+
with pytest.raises(AttributeError):
|
|
98
|
+
_ = module.no_such_attribute_xyz
|
|
99
|
+
|
|
100
|
+
def test_all_declares_private_tasks_admin_patterns(self):
|
|
101
|
+
"""__all__ must list private_tasks_admin_patterns so star imports work."""
|
|
102
|
+
for key in list(sys.modules):
|
|
103
|
+
if key.startswith("base_api_utils"):
|
|
104
|
+
del sys.modules[key]
|
|
105
|
+
|
|
106
|
+
_base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
|
|
107
|
+
module = _load_module("base_api_utils", _base_dir / "__init__.py")
|
|
108
|
+
|
|
109
|
+
assert hasattr(module, "__all__")
|
|
110
|
+
assert "private_tasks_admin_patterns" in module.__all__
|
|
111
|
+
|
|
112
|
+
def test_private_tasks_admin_patterns_lazy_resolution_with_stubbed_urls(self):
|
|
113
|
+
"""Actually triggering getattr resolves the attribute via __getattr__
|
|
114
|
+
without needing Django's app registry.
|
|
115
|
+
|
|
116
|
+
Stubs base_api_utils.urls in sys.modules so the relative import inside
|
|
117
|
+
__getattr__ never reaches the real urls.py → views → Django app-registry
|
|
118
|
+
chain. Verifies the lazy-import path executes correctly end-to-end.
|
|
119
|
+
"""
|
|
120
|
+
for key in list(sys.modules):
|
|
121
|
+
if key.startswith("base_api_utils"):
|
|
122
|
+
del sys.modules[key]
|
|
123
|
+
|
|
124
|
+
_base_dir = Path(__file__).resolve().parent.parent / "src" / "base_api_utils"
|
|
125
|
+
module = _load_module("base_api_utils", _base_dir / "__init__.py")
|
|
126
|
+
# Ensure the module is recognized as a package so relative imports work.
|
|
127
|
+
module.__package__ = "base_api_utils"
|
|
128
|
+
|
|
129
|
+
sentinel = object()
|
|
130
|
+
fake_urls = types.ModuleType("base_api_utils.urls")
|
|
131
|
+
setattr(fake_urls, "private_tasks_admin_patterns", sentinel)
|
|
132
|
+
|
|
133
|
+
with unittest.mock.patch.dict(sys.modules, {"base_api_utils.urls": fake_urls}):
|
|
134
|
+
result = getattr(module, "private_tasks_admin_patterns")
|
|
135
|
+
|
|
136
|
+
assert result is sentinel
|