Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed issue #314 - include information about view/serializer in warnings #322

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion drf_spectacular/drainage.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import contextlib
import sys
from collections import defaultdict
from typing import DefaultDict
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions drf_spectacular/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
95 changes: 48 additions & 47 deletions drf_spectacular/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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. <int:{variable}>) or annotating '
f'the parameter type with @extend_schema. defaulting to "string".'
f'{self.view.__class__.__name__}: could not derive type of path parameter '
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefix can be dropped again (see other comment)

f'"{variable}" because it is untyped and obtaining queryset from the viewset '
f'failed. Consider adding a type to the path (e.g. <int:{variable}>) or '
f'annotating the parameter type with @extend_schema. Defaulting to "string".'
)
else:
try:
Expand All @@ -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 '
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefix can be dropped again (see other comment)

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(
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 '
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefix can be dropped again (see other comment)

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})'
)
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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__):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my OCD complains here due to increasing indent and obfuscating the git blame, but what can you do if that is the proper thing to do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, me too, but I couldn't see a way around it. This consideration was in fact one of the reasons why I didn't want to put add_trace_message round the big block in the generators.py file. However I found in the end I only needed to put in around one line there. See #327

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
16 changes: 8 additions & 8 deletions drf_spectacular/plumbing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefix can be dropped again (see other comment)

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})'
)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
16 changes: 11 additions & 5 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
)
Expand Down Expand Up @@ -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):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usage of the context manager and manual traces can be dropped in favor of the other solution. this also keeps the tests closer to reality

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'):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading