diff --git a/drf_spectacular/drainage.py b/drf_spectacular/drainage.py index a99e1380..73408239 100644 --- a/drf_spectacular/drainage.py +++ b/drf_spectacular/drainage.py @@ -1,3 +1,4 @@ +import contextlib import sys from collections import defaultdict from typing import DefaultDict @@ -20,7 +21,7 @@ def emit(self, msg, severity): if spectacular_settings.DISABLE_ERRORS_AND_WARNINGS: return assert severity in ['warning', 'error'] - msg = str(msg) + msg = _get_current_trace() + str(msg) cache = self._warn_cache if severity == 'warning' else self._error_cache if msg not in cache: print(f'{severity.capitalize()} #{len(cache)}: {msg}', file=sys.stderr) @@ -51,6 +52,23 @@ def reset_generator_stats(): GENERATOR_STATS.reset() +_TRACES = [] + + +@contextlib.contextmanager +def add_trace_message(trace_message): + """ + Adds a message to be used as a prefix when emitting warnings and errors. + """ + _TRACES.append(trace_message) + yield + _TRACES.pop() + + +def _get_current_trace(): + return ''.join(f"{trace}: " for trace in _TRACES if trace) + + def has_override(obj, prop): if not hasattr(obj, '_spectacular_annotation'): return False diff --git a/drf_spectacular/hooks.py b/drf_spectacular/hooks.py index 8ea118ed..5d774497 100644 --- a/drf_spectacular/hooks.py +++ b/drf_spectacular/hooks.py @@ -82,15 +82,15 @@ def create_enum_component(name, schema): enum_name = f'{camelize(prop_name)}{prop_hash[:3].capitalize()}Enum' warn( f'enum naming encountered a non-optimally resolvable collision for fields ' - f'named "{prop_name}". the same name has been used for multiple choice sets ' - f'in multiple components. the collision was resolved with "{enum_name}". ' + f'named "{prop_name}". The same name has been used for multiple choice sets ' + f'in multiple components. The collision was resolved with "{enum_name}". ' f'add an entry to ENUM_NAME_OVERRIDES to fix the naming.' ) if enum_name_mapping.get(prop_hash, enum_name) != enum_name: warn( - f'encountered multiple names for the same choice set ({enum_name}). this ' + f'encountered multiple names for the same choice set ({enum_name}). This ' f'may be unwanted even though the generated schema is technically correct. ' - f'add an entry to ENUM_NAME_OVERRIDES to fix the naming.' + f'Add an entry to ENUM_NAME_OVERRIDES to fix the naming.' ) del enum_name_mapping[prop_hash] else: diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index 0c6c90fe..c147934a 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -18,7 +18,7 @@ from drf_spectacular.authentication import OpenApiAuthenticationExtension from drf_spectacular.contrib import * # noqa: F403, F401 -from drf_spectacular.drainage import get_override, has_override +from drf_spectacular.drainage import add_trace_message, get_override, has_override from drf_spectacular.extensions import ( OpenApiFilterExtension, OpenApiSerializerExtension, OpenApiSerializerFieldExtension, ) @@ -161,7 +161,7 @@ def _process_override_parameters(self): required=property_name in mapped.get('required', []), ) else: - warn(f'could not resolve parameter annotation {parameter}. skipping.') + warn(f'could not resolve parameter annotation {parameter}. Skipping.') return result def _get_format_parameters(self): @@ -324,10 +324,10 @@ def _resolve_path_parameters(self, variables): schema = resolved_parameter['schema'] elif get_view_model(self.view) is None: warn( - f'could not derive type of path parameter "{variable}" because because it ' - f'is untyped and obtaining queryset from {self.view.__class__} failed. ' - f'consider adding a type to the path (e.g. ) or annotating ' - f'the parameter type with @extend_schema. defaulting to "string".' + f'{self.view.__class__.__name__}: could not derive type of path parameter ' + f'"{variable}" because it is untyped and obtaining queryset from the viewset ' + f'failed. Consider adding a type to the path (e.g. ) or ' + f'annotating the parameter type with @extend_schema. Defaulting to "string".' ) else: try: @@ -338,9 +338,10 @@ def _resolve_path_parameters(self, variables): description = get_pk_description(model, model_field) except django_exceptions.FieldDoesNotExist: warn( - f'could not derive type of path parameter "{variable}" because ' - f'model "{model}" did contain no such field. consider annotating ' - f'parameter with @extend_schema. defaulting to "string".' + f'{self.view.__class__.__name__}: could not derive type of path ' + f'parameter "{variable}" for view because ' + f'model "{model}" did contain no such field. Consider annotating ' + f'parameter with @extend_schema. Defaulting to "string".' ) parameters.append(build_parameter_type( @@ -417,15 +418,15 @@ def _map_model_field(self, model_field, direction): if not field_cls: warn( f'model field "{model_field.get_internal_type()}" has no mapping in ' - f'ModelSerializer. it may be a deprecated field. defaulting to "string"' + f'ModelSerializer. It may be a deprecated field. Defaulting to "string"' ) return build_basic_type(OpenApiTypes.STR) return self._map_serializer_field(field_cls(), direction) else: error( - f'could not resolve model field "{model_field}". failed to resolve through ' + f'could not resolve model field "{model_field}". Failed to resolve through ' f'serializer_field_mapping, get_internal_type(), or any override mechanism. ' - f'defaulting to "string"' + f'Defaulting to "string"' ) return build_basic_type(OpenApiTypes.STR) @@ -642,7 +643,7 @@ def _map_serializer_field(self, field, direction): schema = self._map_model_field(field.model_field, direction) return append_meta(schema, meta) - warn(f'could not resolve serializer field "{field}". defaulting to "string"') + warn(f'could not resolve serializer field "{field}". Defaulting to "string"') return append_meta(build_basic_type(OpenApiTypes.STR), meta) def _map_min_max(self, field, content): @@ -794,8 +795,8 @@ def _map_response_type_hint(self, method): return resolve_type_hint(hint) except UnableToProceedError: warn( - f'unable to resolve type hint for function "{method.__name__}". consider ' - f'using a type hint or @extend_schema_field. defaulting to string.' + f'unable to resolve type hint for function "{method.__name__}". Consider ' + f'using a type hint or @extend_schema_field. Defaulting to string.' ) return build_basic_type(OpenApiTypes.STR) @@ -835,15 +836,15 @@ def _get_serializer(self): return view.serializer_class else: error( - f'Unable to guess serializer for {view.__class__.__name__}. This is graceful ' + f'{view.__class__.__name__}: unable to guess serializer. This is graceful ' f'fallback handling for APIViews. Consider using GenericAPIView as view base ' - f'class, if view is under your control. ignoring view for now. ' + f'class, if view is under your control. Ignoring view for now. ' ) else: - error('Encountered unknown view base class. please report this issue. ignoring for now') + error('Encountered unknown view base class. Please report this issue. Ignoring for now') except Exception as exc: error( - f'Exception raised while getting serializer from {view.__class__.__name__}. Hint: ' + f'exception raised while getting serializer. Hint: ' f'Is get_serializer_class() returning None or is get_queryset() not working without ' f'a request? Ignoring the view for now. (Exception: {exc})' ) @@ -939,8 +940,8 @@ def _get_request_for_media_type(self, serializer): request_body_required = False else: warn( - f'could not resolve request body for {self.method} {self.path}. defaulting to generic ' - 'free-form object. (maybe annotate a Serializer class?)' + f'could not resolve request body for {self.method} {self.path}. Defaulting to generic ' + 'free-form object. (Maybe annotate a Serializer class?)' ) schema = build_generic_type() schema['description'] = 'Unspecified request body' @@ -974,7 +975,7 @@ def _get_response_bodies(self): warn( f'could not resolve "{response_serializers}" for {self.method} {self.path}. ' f'Expected either a serializer or some supported override mechanism. ' - f'defaulting to generic free-form object.' + f'Defaulting to generic free-form object.' ) schema = build_basic_type(OpenApiTypes.OBJECT) schema['description'] = _('Unspecified response body') @@ -1005,7 +1006,7 @@ def _get_response_for_code(self, serializer, status_code, media_types=None): else: warn( f'could not resolve "{serializer}" for {self.method} {self.path}. Expected either ' - f'a serializer or some supported override mechanism. defaulting to ' + f'a serializer or some supported override mechanism. Defaulting to ' f'generic free-form object.' ) schema = build_basic_type(OpenApiTypes.OBJECT) @@ -1123,27 +1124,27 @@ def resolve_serializer(self, serializer, direction) -> ResolvedComponent: f'https://github.com/tfranzel/drf-spectacular/issues ' ) serializer = force_instance(serializer) - - component = ResolvedComponent( - name=self._get_serializer_name(serializer, direction), - type=ResolvedComponent.SCHEMA, - object=serializer, - ) - if component in self.registry: - return self.registry[component] # return component with schema - - self.registry.register(component) - component.schema = self._map_serializer(serializer, direction) - # 4 cases: - # 1. polymorphic container component -> use - # 2. concrete component with properties -> use - # 3. concrete component without properties -> prob. transactional so discard - # 4. explicit list component -> demultiplexed at usage location so discard - keep_component = ( - any(nest_tag in component.schema for nest_tag in ['oneOf', 'allOf', 'anyOf']) - or component.schema.get('properties', {}) - ) - if not keep_component: - del self.registry[component] - return ResolvedComponent(None, None) # sentinel - return component + with add_trace_message(serializer.__class__.__name__): + component = ResolvedComponent( + name=self._get_serializer_name(serializer, direction), + type=ResolvedComponent.SCHEMA, + object=serializer, + ) + if component in self.registry: + return self.registry[component] # return component with schema + + self.registry.register(component) + component.schema = self._map_serializer(serializer, direction) + # 4 cases: + # 1. polymorphic container component -> use + # 2. concrete component with properties -> use + # 3. concrete component without properties -> prob. transactional so discard + # 4. explicit list component -> demultiplexed at usage location so discard + keep_component = ( + any(nest_tag in component.schema for nest_tag in ['oneOf', 'allOf', 'anyOf']) + or component.schema.get('properties', {}) + ) + if not keep_component: + del self.registry[component] + return ResolvedComponent(None, None) # sentinel + return component diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index 3bfb1374..70365f3a 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -119,9 +119,9 @@ def get_view_model(view): return view.get_queryset().model except Exception as exc: warn( - f'failed to obtain model through view\'s queryset due to raised exception. ' - f'prevent this either by setting "queryset = Model.objects.none()" on the view, ' - f'having an empty fallback in get_queryset() or by using @extend_schema. ' + f'{view.__class__.__name__}: failed to obtain model through view\'s queryset due to ' + f'raised exception. Prevent this either by setting "queryset = Model.objects.none()" ' + f'on the view, having an empty fallback in get_queryset() or by using @extend_schema. ' f'(Exception: {exc})' ) @@ -389,8 +389,8 @@ def follow_field_source(model, path): except Exception as exc: warn( f'could not resolve field on model {model} with path "{".".join(path)}". ' - f'this is likely a custom field that does some unknown magic. maybe ' - f'consider annotating the field/property? defaulting to "string". (Exception: {exc})' + f'This is likely a custom field that does some unknown magic. Maybe ' + f'consider annotating the field/property? Defaulting to "string". (Exception: {exc})' ) def dummy_property(obj) -> str: @@ -581,8 +581,8 @@ def load_enum_name_overrides(): if len(spectacular_settings.ENUM_NAME_OVERRIDES) != len(overrides): error( - 'ENUM_NAME_OVERRIDES has duplication issues. encountered multiple names ' - 'for the same choice set. enum naming might be unexpected.' + 'ENUM_NAME_OVERRIDES has duplication issues. Encountered multiple names ' + 'for the same choice set. Enum naming might be unexpected.' ) return overrides @@ -660,7 +660,7 @@ def modify_for_versioning(patterns, method, path, view, requested_version): urlconf=tuple(detype_pattern(p) for p in patterns) ).resolve(path) except Resolver404: - error(f"namespace versioning path resolution failed for {path}. path will be ignored.") + error(f"namespace versioning path resolution failed for {path}. Path will be ignored.") elif issubclass(view.versioning_class, versioning.AcceptHeaderVersioning): # Append the version into request accepted_media_type. # e.g "application/json; version=1.0" diff --git a/tests/__init__.py b/tests/__init__.py index d1b7bfc7..e0bb5069 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,7 +20,7 @@ def assert_schema(schema, reference_filename, transforms=None): if not os.path.exists(reference_filename): raise RuntimeError( - f'{reference_filename} was not found for comparison. carefully inspect ' + f'{reference_filename} was not found for comparison. Carefully inspect ' f'the generated {reference_filename.replace(".yml", "_out.yml")} and ' f'copy it to {reference_filename} to serve as new ground truth.' ) @@ -51,23 +51,29 @@ def generate_schema(route, viewset=None, view=None, view_function=None): from rest_framework import routers from rest_framework.viewsets import ViewSetMixin + from drf_spectacular.drainage import add_trace_message from drf_spectacular.generators import SchemaGenerator patterns = [] + trace_message = '' if viewset: assert issubclass(viewset, ViewSetMixin) router = routers.SimpleRouter() router.register(route, viewset, basename=route) patterns = router.urls + trace_message = viewset.__name__ elif view: patterns = [path(route, view.as_view())] + trace_message = view.__name__ elif view_function: patterns = [path(route, view_function)] + trace_message = view_function.__name__ - generator = SchemaGenerator(patterns=patterns) - schema = generator.get_schema(request=None, public=True) - validate_schema(schema) # make sure generated schemas are always valid - return schema + with add_trace_message(trace_message): + generator = SchemaGenerator(patterns=patterns) + schema = generator.get_schema(request=None, public=True) + validate_schema(schema) # make sure generated schemas are always valid + return schema def get_response_schema(operation, status=None, content_type='application/json'): diff --git a/tests/test_command.py b/tests/test_command.py index e0b6d47e..110399d7 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -49,5 +49,5 @@ def test_command_fail(capsys): '--urlconf=tests.test_command', ) stderr = capsys.readouterr().err - assert 'Error #0: Unable to guess serializer' in stderr + assert 'Error #0: func: unable to guess serializer' in stderr assert 'Schema generation summary:' in stderr diff --git a/tests/test_warnings.py b/tests/test_warnings.py index 8caa21ba..34954aec 100644 --- a/tests/test_warnings.py +++ b/tests/test_warnings.py @@ -79,7 +79,7 @@ class X1Viewset(viewsets.ReadOnlyModelViewSet): generate_schema('x1', X1Viewset) stderr = capsys.readouterr().err assert 'obtaining queryset from' in stderr # warning 1 - assert 'failed to obtain model through view\'s queryset' in stderr # warning 2 + assert 'X1Viewset: failed to obtain model through view\'s queryset' in stderr # warning 2 def test_path_param_not_in_model(capsys): @@ -92,7 +92,9 @@ def meta_param(self, request, ephemeral, pk): pass # pragma: no cover generate_schema('x1', XViewset) - assert 'no such field' in capsys.readouterr().err + stderr = capsys.readouterr().err + assert 'no such field' in stderr + assert 'XViewset' in stderr def test_no_authentication_scheme_registered(capsys): @@ -107,7 +109,9 @@ class XViewset(mixins.ListModelMixin, viewsets.GenericViewSet): authentication_classes = [XAuth] generate_schema('x', XViewset) - assert 'no OpenApiAuthenticationExtension registered' in capsys.readouterr().err + stderr = capsys.readouterr().err + assert 'no OpenApiAuthenticationExtension registered' in stderr + assert 'XViewset' in stderr def test_serializer_not_found(capsys): @@ -115,7 +119,7 @@ class XViewset(mixins.ListModelMixin, viewsets.GenericViewSet): pass # pragma: no cover generate_schema('x', XViewset) - assert 'Exception raised while getting serializer' in capsys.readouterr().err + assert 'XViewset: exception raised while getting serializer.' in capsys.readouterr().err def test_extend_schema_unknown_class(capsys): @@ -150,7 +154,7 @@ def get(self, request): pass # pragma: no cover generate_schema('x', view=XView) - assert 'Unable to guess serializer for' in capsys.readouterr().err + assert 'XView: unable to guess serializer.' in capsys.readouterr().err def test_unable_to_follow_field_source_through_intermediate_property_warning(capsys): @@ -173,7 +177,7 @@ def get(self, request): generate_schema('x', view=XAPIView) assert ( - 'could not follow field source through intermediate property' + 'XAPIView: XSerializer: could not follow field source through intermediate property' ) in capsys.readouterr().err @@ -201,8 +205,8 @@ def get(self, request): generate_schema('x', view=XAPIView) stderr = capsys.readouterr().err - assert 'unable to resolve type hint for function "x"' in stderr - assert 'unable to resolve type hint for function "get_y"' in stderr + assert 'XAPIView: XSerializer: unable to resolve type hint for function "x"' in stderr + assert 'XAPIView: XSerializer: unable to resolve type hint for function "get_y"' in stderr def test_operation_id_collision_resolution(capsys):