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

Fix inheritance bugs with @extend_schema_view(). #554

Merged
merged 1 commit into from
Oct 12, 2021

Conversation

ngnpope
Copy link
Contributor

@ngnpope ngnpope commented Oct 7, 2021

When creating a copy of a method from a parent class we now:

  • Ensure that __qualname__ is defined correctly
    • i.e. Child.method instead of Parent.method.
    • This isn't essential but helps diagnosing issues when debugging.
  • Move application of the decorator to the last moment.
  • Deep copy the existing schema extensions before applying decorator.

This fixes #218 where two child classes with @extend_schema_view affect each other - schema extensions are applied to the parent such that the second child overwrites the changes applied to the first child.

This also fixes my case where a child with @extend_schema_view clobbered the schema extensions of the parent which also used @extend_schema_view.

@codecov
Copy link

codecov bot commented Oct 7, 2021

Codecov Report

Merging #554 (4aaaa81) into master (2a1f1ec) will decrease coverage by 0.00%.
The diff coverage is 97.22%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #554      +/-   ##
==========================================
- Coverage   98.77%   98.76%   -0.01%     
==========================================
  Files          58       58              
  Lines        6508     6540      +32     
==========================================
+ Hits         6428     6459      +31     
- Misses         80       81       +1     
Impacted Files Coverage Δ
tests/test_extend_schema_view.py 98.43% <88.88%> (-1.57%) ⬇️
drf_spectacular/utils.py 98.98% <100.00%> (+<0.01%) ⬆️
tests/test_regressions.py 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 2a1f1ec...4aaaa81. Read the comment docs.

Copy link
Contributor Author

@ngnpope ngnpope left a comment

Choose a reason for hiding this comment

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

@tfranzel This was causing quite a mess in the code that I've been converting. I hope this all makes sense?

drf_spectacular/utils.py Outdated Show resolved Hide resolved
drf_spectacular/utils.py Outdated Show resolved Hide resolved
Comment on lines +56 to +66
@extend_schema_view(
list=extend_schema(exclude=True),
retrieve=extend_schema(description='overridden description for child only'),
extended_action=extend_schema(responses={200: {'type': 'string', 'pattern': r'^[0-9]{4}(?:-[0-9]{2}){2}$'}}),
raw_action=extend_schema(summary="view raw action summary"),
)
class ZViewSet(XViewSet):
@extend_schema(tags=['child-tag'])
@action(detail=False, methods=['GET'])
def raw_action(self, request):
return Response('2019-03-01')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This tests the child with @extend_schema_view inheriting from a parent with @extend_schema_view scenario.

In this case:

  • We want list to be removed from the schema only for the child.
  • We want retrieve to have description overridden only for the child, but retain tags from the parent.
  • We want extended_action to have responses overridden only for the child, but retain description from the parent.
  • We want raw_action to override the tags via @extend_schema in the child and summary via @extend_schema_view on the child without affecting the parent.
    • The only thing I'm unsure about here is that we don't get the description from the parent's @extend_schema_view, but we would get the tags from the @extend_schema on the parent if we weren't overriding it. Perhaps this needs to be tweaked to ensure that we still get description?

tests/test_extend_schema_view.py Outdated Show resolved Hide resolved
@ngnpope
Copy link
Contributor Author

ngnpope commented Oct 11, 2021

Hi @tfranzel 👋🏻

Did you have any thoughts on this one?

@tfranzel
Copy link
Owner

hey @ngnpope! first of all thank you for tackling this issue! Somehow I never got around doing it. I had already set aside time today to review this in detail and I didn't want to give a partial review. As you likely noticed, the mechanics are tricky and I'd like to make sure that all corner cases are covered. On first sight it looks good to me.

ZViewSet in extend_schema_view.py is totally fine by me. However, i would like to remove the doctor/appointment stuff for conciseness and clarity. What I would like to see instead is a minimal regression tests in test_regressions that focuses on that issue with as little boilerplate as possible.

@ngnpope
Copy link
Contributor Author

ngnpope commented Oct 11, 2021

Somehow I never got around doing it.

I know the feeling! I tackled it out of necessity.

As you likely noticed, the mechanics are tricky and I'd like to make sure that all corner cases are covered.

💯

However, i would like to remove the doctor/appointment stuff for conciseness and clarity. What I would like to see instead is a minimal regression tests in test_regressions that focuses on that issue with as little boilerplate as possible.

I've had a go at producing a smaller regression test. I attempted to avoid defining a custom action by using list, but the test didn't fail - only when I defined a custom notes action.

@tfranzel
Copy link
Owner

Oh boy, that was a rookie mistake 🤦 I'm quite certain I finally understood the root cause. Thanks for pointing me in the right direction, otherwise I would have missed it again.

The whole point of wrapping_decorator and that setattr is to prevent "The Clobbering". It's supposed to act as a circuit breaker so that decoration does not creep into foreign classes. I could not grasp why this worked perfectly the first go round, but not afterwards. Turns out the root cause is id(wrapped_method.kwargs) == id(method.kwargs). the deepcopy is actually superfluous as there is no issue with the Schema inheritance class.

wraps creates a shallow copy of __dict__ and so the kwargs dictionary is still the same object after decoration, which of course breaks the whole purpose of the circuit breaker. So we only need to make sure that each method has it's own kwargs dict object and thus the essential fix is this:

        if hasattr(wrapped_method, 'kwargs'):
            wrapped_method.kwargs = dict(wrapped_method.kwargs)
  • Please test if that works for all your cases.
  • Please roll back the __qualname__ change. I get your sentiment, but it is supposed to highlight the origin of the method, i.e. where implementation and docstring actually came from.
  • If you do the honors of putting in the fix, we can finally merge this. I'll just add 1 or 2 tests on top.

When creating a copy of a method from a parent class we now:

- Move application of the decorator to the last moment.
- Shallow copy the existing schema extensions before applying decorator.

This fixes tfranzel#218 where two child classes with @extend_schema_view affect
each other - schema extensions are applied to the parent such that the
second child overwrites the changes applied to the first child.

This also fixes my case where a child with @extend_schema_view clobbered
the schema extensions of the parent which also used @extend_schema_view.
@ngnpope
Copy link
Contributor Author

ngnpope commented Oct 11, 2021

Oh boy, that was a rookie mistake facepalm I'm quite certain I finally understood the root cause. Thanks for pointing me in the right direction, otherwise I would have missed it again.

No problem.

Turns out the root cause is id(wrapped_method.kwargs) == id(method.kwargs). the deepcopy is actually superfluous as there is no issue with the Schema inheritance class.

Yup. I think I was just hedging my bets with the deepcopy. The implementation is currently replacing, not merging, so we aren't concerned about deeper levels, e.g. if responses was set to {200: ...} on the parent and responses was set to {400: ...} on the child it wouldn't merge and give {200: ..., 400: ...}. While in some way that might be nice and convenient, it's hard to get right and might not always be desired.

wraps creates a shallow copy of __dict__ and so the kwargs dictionary is still the same object after decoration, which of course breaks the whole purpose of the circuit breaker.

Bingo. Everything is stored under kwargs, not directly on __dict__.

Out of interest, kwargs seems like a rather generic name to use when assigning as an attribute on classes/methods, so is there a particular reason for that? Perhaps _drf_spectacular would make sense to make it unlikely to collide with anything else?

        if hasattr(wrapped_method, 'kwargs'):
            wrapped_method.kwargs = dict(wrapped_method.kwargs)

I've opted for wrapped_method.kwargs.copy() to make a shallow copy.

  • Please test if that works for all your cases.

Seems to work fine for the test cases I added.

  • Please roll back the __qualname__ change. I get your sentiment, but it is supposed to highlight the origin of the method, i.e. where implementation and docstring actually came from.

Done.

@tfranzel
Copy link
Owner

I've opted for wrapped_method.kwargs.copy() to make a shallow copy.

👍

The implementation is currently replacing, not merging, so we aren't concerned about deeper levels,

I would rather call it layering (inheritance). The Schema classes are layered on top of each other. That behavior is intended like this and it follow one simple known rule: inheritance. i wouldn't know how to come up with another mechanic that is easier to understand.

Out of interest, kwargs seems like a rather generic name to use

Yes, it actually needs to be called like this. I did not come up with this naming or mechanic. It is the native DRF behaviour and DRF uses the kwargs to initialize the view methods. This comment contains more context:

# custom actions have kwargs in their context, others don't. create it so our create_view

Seems to work fine for the test cases I added.

Awesome!

@tfranzel tfranzel merged commit 4aaaa81 into tfranzel:master Oct 12, 2021
tfranzel added a commit that referenced this pull request Oct 12, 2021
@tfranzel
Copy link
Owner

hey @ngnpope, so I did some more investigating and found 2 more isolation issues.

  1. @extend_schema and extend_schema_view combinations breaking isolation
  2. @api_view with @extend_schema_view

Had to refactor the isolation code as it is required at multiple places actually. Please test these changes if something is behaving in a weird way, though i'm quite sure this eliminated a lot of weird behavior.

the amount of magic that is generated by@api_view is astounding. we half half a dozen checks all over the codebase only for when @api_view is doing weird stuff.

@ngnpope ngnpope deleted the fix-extend-schema-view branch October 12, 2021 21:21
@ngnpope
Copy link
Contributor Author

ngnpope commented Oct 12, 2021

Thanks @tfranzel, that's great. It looks good for me so far.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ViewSet inheritance issue
2 participants