Fixed #30427, Fixed #16176 -- Corrected setting descriptor in Field.contribute_to_class().#11337
Fixed #30427, Fixed #16176 -- Corrected setting descriptor in Field.contribute_to_class().#11337jarekwg wants to merge 4 commits intodjango:mainfrom
Conversation
carltongibson
left a comment
There was a problem hiding this comment.
Hi @jarekwg. Thanks for this.
Mailing list discussion: https://groups.google.com/d/topic/django-developers/zXB0oJ8tD3E/discussion
The general approach seems... OK. 🙂
I've pinged your thread to ask for input. Why is it as it is? (What is this "class method with the same name as a field" use-case?)
|
Any updates related to this issue? As far as I see has Jarek updated his PR. jazzband/django-model-utils#382 is e.g. waiting for a fix. Thanks! |
There was a problem hiding this comment.
Hi @jarekwg.
I'm still trying to get to the very bottom of this. I think that's quite a long way down. 🙂
I'm toying with the idea that we need to always set the descriptor for fields, so:
if self.column:
setattr(cls, self.attname, self.descriptor_class(self))
We already advertise that Field inheritance does not work (cannot work) exactly
like Python inheritance. This would be more of the same.
It was a possibility @carljm discussed on ticket-16176:
[The] suggestion to set all fields as class attributes so they override descriptors from a parent class is certainly worth considering, as it would make models behave more like regular Python classes. This could result in some backwards-incompatibility; would need to look into it more to get a clearer sense of what cases might cause trouble, but I think it's probably acceptable if documented in the release notes.
Making that change, only three existing tests break. The one we're discussing here, plus a couple related to checks that can be adjusted. (In particular there's nothing I can find for the Why? of not overriding existing attributes.)
If you have capacity to work on this, can I ask you to add test cases for:
- This ticket (you have one).
- jazzband/django-model-utils#382
- ticket-16176
- ticket-28198
- ticket-27807
Question: are these all resolved by changing the behaviour here, without breaking other tests? (If not, which one's not?) If we could show that then we could feasibly think about whether the MRO change (which only affects the diamond-like case, I think) would be acceptable.
I'll mark this as Patch needs improvement on the ticket, but if you can keep working on this I'd be interested to help you, since it would be nice to resolve if we can. Thanks.
|
Awesome. Thanks for the detailed review!
Cheers, |
I've stuck to the wording 'shadow', since that's what was in there already, though i think shadowing refers more to cross-scope variable overrides, and might not entirely be correct in this case.. lemme know if you think it should be changed. As for the diamond case, I can see what's going on. |
bd6508f to
a0d465d
Compare
|
Hi @jarekwg. Good stuff. I had a quick look and rebased and squashed.
OK, do that. I agree. I was just being optimistic I think 🙂 I need to go through your comments and the failures one by one to determine if they're acceptable to change. |
|
Removed Leaving the other failing tests as they are until you get a look at them first.
|
|
OK. Great stuff @jarekwg. I’ll look at this again next week now. But we’ll definitely need a release note for the breaking change. Then we’ll need to approach the list and ask for extra feedback. I think the bug we’re fixing (the User case being the clearest in my mind) is probably worth it but it’s not something I’d take in without consultation first. (So, release notes, my review, edit tests, mailing list...) |
d753119 to
eef5f9d
Compare
There was a problem hiding this comment.
Hi @jarekwg.
I added release notes, adjusted the tests and rebased on latest master.
Mailing list post asking for comments on the proposed change here:
https://groups.google.com/d/topic/django-developers/BNXorfBeNSk/discussion
It's quite long. :)
Let's see what folks have to say.
Thanks for you work here. It feels like we've been scratching at this for a long time now.
felixxm
left a comment
There was a problem hiding this comment.
Thanks both 🚀 Great detective job 🕵️♂️ 🕵️♀️
We should mention ticket-16176 in the commit message, e.g. "Fixed #30427, Fixed #16176 -- Corrected setting descriptor in Field.contribute_to_class().". Also we can mark ticket-28198 as a duplicate.
jarekwg
left a comment
There was a problem hiding this comment.
Thanks for review, will update shortly.
|
Let me know if you'd rather have the commit squashed into previous. I'm leaving it separate to make it more obvious where I've made changes. e: spellcheck doesn't approve of "truthy"! :o |
|
Hey @jarekwg. Thanks for picking this up. Please do leave separate commits for now. (Exactly makes it easier to review.) |
3414de0 to
5fc4884
Compare
|
Right, "truthy" is a word, so I added it to the spelling wordlist. I added an example to the release notes. It's a bit long but I don't see that it can be much shorter and still get the point across? (@felixxm @adamchainz?) If we're happy, I think we're there... 🤞 Thanks for your work on this one @jarekwg. It's a monster. 👹 |
I can't see anything to cut out. I'd even consider adding a sentence that |
5fc4884 to
51b69e6
Compare
carltongibson
left a comment
There was a problem hiding this comment.
So after 4bbe826 this looks much less controversial: there's no effective change in behaviour (bar the two checks tests adjustments). It's just a fix.
Still a bit 😬, because it's contribute_to_class() but, I think it should be good to go.
@felixxm @adamchainz I'd appreciate that extra look-over. Thanks :)
|
Ah, didn't actually run against the new test from 4bbe826 ... Let me work out what it did change here. (Sigh 🙂) |
|
Right, this is the pull request that never dies. Putting the old test back in, multiply inheriting fields is enabled by this change. The test added in 4bbe826 fails, and the old test needs adjusting, as we had before, since normal Python MRO is not respected. @jarekwg Do you have capacity to play with this and work out exactly what the ordering is etc?
On |
|
@carltongibson sure!
Resummarising my previous comment in short: abstract base classes are traversed in mro() order, but the way we then add fields from them onto the new class is reckless -- we don't check whether the fields are defined directly on the classes we're traversing, or on one of their ancestors. I'll:
Will try to have a crack this long easter weekend. |
|
Hi Jared. I want to be 100% clear on the state of the model at each point. The old test looked like it was working. Except whilst it had a single Deferred descriptor, there were two identically named fields added (to local_fields) which is why, when we added the model check() call the error was raised. It wasn’t really the case that the fields were overridden in the sense of normal inheritance. Rather when you fetch the field you’d get the one from the base class, not the DescendantTwo. With the change, I want to make sure that the resultant model is sane. Maybe an added assert or two on the state of the _meta object makes sense. (As I say, I want to fire it up in the debugger myself as well.) This is such a central part of the framework that we must tread very carefully. Unless we have no option, I don’t think further changes to |
|
With my latest read-through of django/django/db/models/base.py Line 208 in 6485a5f So there really is absolutely no way of supporting diamond inheritance in the current state, because even if we did tell It desperately needs a rewrite; dancing around it is just going to continue causing pain. Soooo, I went a little more aggressive on these abstract inheritance tests, adding some utils for quick class creation, then applying a more rigorous covering of test cases. The PRO of this approach is we can write a tonne of complex inheritance cases in a readable way. The CON is a pattern like this could itself potentially introduce issues, or be a little harder to maintain. Please let me know if I've gone too far and I can revert the changes back to an expanded form. Please also let me know what you think of this suite of test cases; whether they're all sensible, or somewhy superfluous. Also, what do you think about playing things out this way:
I made a commit changing tests only, cause would like to know what you think; but happy to update docs too. re the sanity you were referring to, I had a quick look with:
|
There was a problem hiding this comment.
Appreciate what you've done with the tests but I think they're harder to comprehend now. As a Django dev I can jump in and read model classes, but the helpers are obscuring things a bit to me.
Also you now have multiple tests per test method now - ideally there should just be a handful of classes per test and some assertions. I guess you can split based on overriding, non-overriding, etc.
There was a problem hiding this comment.
Thanks for taking a look!
Yeah it is more obscure, I was just hoping to find a way to avoid the verbosity of
Meta:
abstract = True
over and over again.
Happy to revert, I guess the exercise helped list out several additional cases that weren't being covered before, like making sure each level pulls through from the previous in the absence of overrides.
I agree that test methods should hold minimal assertions that tie more directly to one specific use case, but I found it hard to break these down into multiple methods without having even more repetition/verbosity. Splitting on overriding/non-overriding still fans out a fair amount (if we care for each branch of the fan).
There was a problem hiding this comment.
Also, question: Is there a pattern for any kind of "short-form" class creation in the django test suite currently? I had a quick look but couldn't find anything, probs due to the "readability" argument.
Could be a worthwhile exercise to implement a standard for "quickbuild" setup functions for model classes -- would be more understandable if such things were used across the board, and much easier to scan than tonnes of vertical scrolling.
There was a problem hiding this comment.
I'm pretty sure you can make the abstract bases once in the test case class level:
class ...Tests(...):
class SomeBase(models.Model):
...
Then reuse them appropriately in the individual test methods, which should only need to create the "bottom" classes?
I grepped for \btype\( and found a few uses, but none are for "quick" building.
I personally think the "tonnes of vertical scrolling" is a small concern compared to having to grok how type() works.
There was a problem hiding this comment.
I'm pretty sure you can make the abstract bases once in the test case class level:
class ...Tests(...): class SomeBase(models.Model): ...Then reuse them appropriately in the individual test methods, which should only need to create the "bottom" classes?
Ok, I like that. Wasn't aware you could declare there without having isolation or referencing issues, but had a quick play and looks pretty solid.
I grepped for
\btype\(and found a few uses, but none are for "quick" building.I personally think the "tonnes of vertical scrolling" is a small concern compared to having to grok how
type()works.
Well the tradeoff is you learn the extra bit of lingo (don't necessarily have to read up on type; docstring on util function gives arguably sufficient blackbox-level insight) to then be able to sweep through a whole bunch of class definitions quickly. Having them defined so close together allows for easy comparison between them, as well as between their usage across tests.
I think if someone were to visit this file in the future to work on a single test, then having everything splayed out as normal django class definitions is much more accessible. However, when looking at the tests holistically (to identify all the cases they're covering and how), a compact perspective can be useful.
I've seen a lot of django tests where multiple contributors have added test cases to the same suite, with approaches that are all quite different, with high potential for covering the same functionality multiple times. Seeing more neighbouring tests on the screen when writing one's own can lead to more consistency, both in test layout, and in ensuring the tests all work together to provide maximal non-overlapping coverage.
So I think there's at least some merit to it. But I understand trying to forge a new pattern on a random PR probs serves only to detract from what the PR's discussion should actually be about.
Thanks for having a look anyway, I'll expand it out again after Carlton drops his thoughts on some of the other stuff I mentioned in my main comment (blocking diamond inheritance altogether).
There was a problem hiding this comment.
we should block users from building diamond cases at all, rather than testing for the converse.
But we don't block, right? It's not normally worth leaving comments about things Django "should" do.
Also I think this test can be split into a few different diamond cases.
There was a problem hiding this comment.
we should block users from building diamond cases at all, rather than testing for the converse.
But we don't block, right? It's not normally worth leaving comments about things Django "should" do.
Yeah fair. I'll update this comment once we decide whether we go ahead and address the should bit prior to merging this PR.
Also I think this test can be split into a few different diamond cases.
There are 4 cases (A=Abstract, *=defines field). I think all are worth checking, based on :
A* A* A* A*
/ \ / \ / \ / \
A* A A* A A A* A A*
\ / \ / \ / \ /
D* D D* D
So four separate test cases for diamond inheritance? Feels very verbose. Possibly no need to test in cases where Derived overrides with its own value since the earlier multi inheritance test already covers this, but given the complexity of the underlying metaclass logic, might be worth keeping still?
There was a problem hiding this comment.
I think it's fine. Separate test cases are normally a good thing since they can fail individually.
There was a problem hiding this comment.
Hi @jarekwg — You still with me on this one? 🙂
Sorry for the slow follow-up: in the first-place, 2020, but it's taken me a while to have a window to sit down with this again, and to get to stare at ModelBase.__new__() for sufficiently long. 😳
With the proposed change if you do not override the field yourself the a copy of the field from the first base that declares it will be added, via the adjustment in contribute_to_class().
The flow is ModelBase.__new__() -> .add_to_class() -> Field.contribute_to_class().
The first two steps are unchanged. They were always called. It's only that contribute_to_class() would previously filter the field. (This was the Don't override classmethods with the descriptor that we never found a cause for.)
The change in behaviour is just two-fold.
- Checks which would previously have ruled out a model now pass.
- The diamond-like MRO order is changed but checks (if run) would have previously failed on such a model.
On master, calling check() for the models defined in the test_multi_parent_diamond case:
[<Error: level=40, msg="The field 'foo' clashes with the field 'foo' from model 'model_inheritance.derivedfromdescendantsnofirstno'.", hint=None, obj=<django.db.models.fields.CharField: foo>, id='models.E006'>]
[<Error: level=40, msg="The field 'foo' clashes with the field 'foo' from model 'model_inheritance.derivedfromdescendantsofirstno'.", hint=None, obj=<django.db.models.fields.CharField: foo>, id='models.E006'>]
With the change, those checks don't fail, so this kind of inheritance would be allowed now.
There's the example that doesn't respect Python MRO:
self.assertCorrectInheritance('DerivedFromDescendantsNOFirstNO', 40) # just this one that fails.
The reason is it's not really MRO: a copy of the field is put onto the child class, so bases aren't consulted.
I continue to think that what's going on here is compatible with what we say elsewhere about model inheritance. (i.e. that it's not really like Python inheritance) and that the cases this fixes justify making the change. People are really hitting those cases, whereas the MRO example wouldn't currently make it past the check framework, so you'd have to be going some to run into it.
Code it written once and read many times: @jarekwg could you rewrite the tests to explicitly define the classes, as per discussion with @adamchainz? — It's a bit more verbose but much easier to follow in the end. (I imagine someone opening these tests in 6 years time: it would be good if they spelled out the situation at the first pass.)
Make sense?
Thanks for your effort here.
Comment based on 724d1cb9bada7adea5248bce14bdda29588ebe55 (in case that gets lost).
Heh, no worries. Thanks for circling back round to this. I'll need to refresh my thoughts a bit again, but if you're saying that diamond-inheritance-wrist-slapping already occurs currently (not sure how i failed to notice this..), then we're good to go with pushing out this change. I'll try to find some time to expand out the tests this weekend. Cheers, |
|
The key point is that such a model fails the |
Ahh, ok that makes more sense. Thanks! |
…ield.contribute_to_class(). Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
jarekwg
left a comment
There was a problem hiding this comment.
Ok, I've rebased the branch, and expanded out the tests.
Let me know if there's anything else!
| NOTE: We need a rewrite of `ModelBase.__new__()` to support this properly. | ||
| Until that happens, one of the tests here is marked as expected failure. Startup checks will | ||
| attempt to prevent users from building these cases anyway. |
There was a problem hiding this comment.
@carltongibson I've updated this note in response to @adamchainz' feedback, as well given what you said about the diamond check above. I think it's correct now and useful (?) to explain why we've marked one of the tests with @expectedfailure, but can remove it altogether if it still feels more like a ticket that's been shoved into the code.
|
Great work on this @jarekwg ... @carltongibson how's it looking now? |
|
@aidanlister it's just waiting for another chance to review again. It's on my long list for 3.2. |
|
No worries! I'm just happy to see it get addresed. |
Ticket #30427
Previously: DeferredAttributes would not get stapled onto models where the model (or an ancestor) already had an existing truthy attribute (despite comment only mentioning protecting against overriding classmethods).
With this change: DeferredAttributes will
not get stapled onto models where the model (or an ancestor) already has a callable attributealways get stapled on, regardless of other attributes on the models or their ancestors.This means we have the following changes:
Aside from the change, this PR includes:
A failing test that this change fixes.An update to an existing test, covering the new overriding order of DeferredAttributes. Arguably, what was previously being tested for didn't make sense.Problems:
@propertydecorators throw a fly in the ointment. They look like methods, but the decorator makescallablereturnFalsefor them. It's why the testsuite is failing presently. We can add an additional check for the existence of__isabstractmethod__on the attribute, but it starts to feel rabbitholey. Perhaps we should think about a more aggressive change here, such as always overriding everything. Why do we protect classmethods anyway?Happy to discuss ideas on how to solve this here or on the ticket.
[2020-03-04] edit: updated to reflect most recent decisions/discussion.