tfranzel / drf-spectacular

Sane and flexible OpenAPI 3 schema generation for Django REST framework.
https://drf-spectacular.readthedocs.io
BSD 3-Clause "New" or "Revised" License
2.42k stars 266 forks source link

Schema generation fails with `AttributeError: 'NoneType' object has no attribute 'copy'` #1342

Open salomvary opened 8 hours ago

salomvary commented 8 hours ago

Using Version: 0.28.0.

Describe the bug

As in title. Both when using the management command to generate the schema or the schema view.

The stack trace looks like this:

Traceback (most recent call last):
  File "./manage.py", line 23, in <module>
    main()
  File "./manage.py", line 19, in main
    execute_from_command_line(sys.argv)
  File ".venv/lib/python3.12/site-packages/django/core/management/__init__.py", line 442, in execute_from_command_line
    utility.execute()
  File ".venv/lib/python3.12/site-packages/django/core/management/__init__.py", line 436, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File ".venv/lib/python3.12/site-packages/django/core/management/base.py", line 413, in run_from_argv
    self.execute(*args, **cmd_options)
  File ".venv/lib/python3.12/site-packages/django/core/management/base.py", line 459, in execute
    output = self.handle(*args, **options)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/management/commands/spectacular.py", line 72, in handle
    schema = generator.get_schema(request=None, public=True)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/generators.py", line 285, in get_schema
    paths=self.parse(request, public),
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/generators.py", line 256, in parse
    operation = view.schema.get_operation(
                ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/utils.py", line 451, in get_operation
    return super().get_operation(path, path_regex, path_prefix, method, registry)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/openapi.py", line 100, in get_operation
    request_body = self._get_request_body()
                   ^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/openapi.py", line 1321, in _get_request_body
    schema, partial_request_body_required = self._get_request_for_media_type(serializer, direction)
                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/openapi.py", line 1353, in _get_request_for_media_type
    component = self.resolve_serializer(serializer, direction)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/openapi.py", line 1648, in resolve_serializer
    component.schema = self._map_serializer(serializer, direction, bypass_extensions)
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/openapi.py", line 949, in _map_serializer
    schema = self._map_basic_serializer(serializer, direction)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/openapi.py", line 1048, in _map_basic_serializer
    schema = self._map_serializer_field(field, direction)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/openapi.py", line 658, in _map_serializer_field
    return append_meta(schema, meta)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".venv/lib/python3.12/site-packages/drf_spectacular/plumbing.py", line 542, in append_meta
    schema = schema.copy()
             ^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'copy'

To Reproduce I've tried but failed to create a snippet, the problem only occurs a specific, rather complex project.

The change that triggered the issue looks something like this:

    class X(models.Model):
        position = models.IntegerField()

    class ParentSerializer(serializers.Serializer):
        field = serializers.SerializerMethodField()

        @extend_schema_field(int)
        def get_field(self, instance):
            return None

    @extend_schema_field(ParentSerializer)
    class XChildSerializer(ParentSerializer):
        pass

    class XSerializer(serializers.ModelSerializer):
        field = XChildSerializer(read_only=True, source="*")

        class Meta:
            model = X
            fields = ("id", "position", "field")

    class XView(views.APIView):
        serializer_class = XSerializer

        def get(self, request):
            pass  # pragma: no cover

        def post(self, request):
            pass  # pragma: no cover

The interesting part is annotating XChildSerializer with @extend_schema_field(ParentSerializer). If I remove the annotation, the problem goes away. In my real project I also have another YChildSerializer with the same annotation, and that does not have this problem.

Expected behavior

Schema should be generated without exception.

I've been trying to figure out what might be wrong using the debugger, but I don't understand the internals of drf-spectacular enough to have any clue. Any pointers would be appreciated.

salomvary commented 8 hours ago

This is how far I got trying to reproduce the problem by adding a test case to test_regressions.py, but this test passes:

@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0')
def test_extend_schema_field_on_serializer(no_warnings):
    class X(models.Model):
        position = models.IntegerField()

    class ParentSerializer(serializers.Serializer):
        field = serializers.SerializerMethodField()

        @extend_schema_field(int)
        def get_field(self, instance):
            return None

    @extend_schema_field(ParentSerializer)
    class XChildSerializer(ParentSerializer):
        pass

    class XSerializer(serializers.ModelSerializer):
        field = XChildSerializer(read_only=True, source="*")

        class Meta:
            model = X
            fields = ("id", "position", "field")

    class XView(views.APIView):
        serializer_class = XSerializer

        def get(self, request):
            pass  # pragma: no cover

        def post(self, request):
            pass  # pragma: no cover

    @extend_schema_field(ParentSerializer)
    class YChildSerializer(ParentSerializer):
        pass

    class YSerializer(serializers.ModelSerializer):
        field = YChildSerializer()

        class Meta:
            model = X
            fields = ("id", "position", "field")

    class YView(views.APIView):
        serializer_class = YSerializer

        def get(self, request):
            pass  # pragma: no cover

        def post(self, request):
            pass  # pragma: no cover

    schema = generate_schema(None, patterns=[path('x', XView.as_view()), path('y', YView.as_view())])
    assert 'X' in schema['components']['schemas']
    assert 'Y' in schema['components']['schemas']
    assert 'Parent' in schema['components']['schemas']
    assert 'XChild' not in schema['components']['schemas']