Unfortunately, it modifies the 'fields' variable as it iterates through
it:
''Line 694 of django.db.models.base.py:''
{{{
for field in fields:
if field in prefetched_objects_cache:
del prefetched_objects_cache[field]
fields.remove(field)
}}}
Modifying the list that we are iterating over causes some list items to be
skipped. For example here we would expected to see 'a', 'b', and 'c',
removed from dict {{{d}}} but 'b' is skipped:
{{{
In [8]: d = dict(a=1,b=2, c=3)
In [9]: fields = ['a','b','c']
In [10]: for f in fields:
...: print(f)
...: if f in d:
...: del d[f]
...: fields.remove(f)
...:
a
c
In [11]: fields
Out[11]: ['b']
In [12]: d
Out[12]: {'b': 2}
}}}
I beieve that all that needs to be done to correct this is to create a
copy of the list by wrapping fields in list(), like so:
{{{
In [13]: fields = ['a','b','c']
In [14]: d=dict(a=1,b=2, c=3)
In [15]: for f in list(fields):
...: print(f)
...: if f in d:
...: del d[f]
...: fields.remove(f)
...:
a
b
c
In [16]: d
Out[16]: {}
In [17]: fields
Out[17]: []
}}}
Therefore as far as I can tell the fix to the django code would be as easy
as wrapping fields in list():
''Line 694 of django.db.models.base.py:'' modified
{{{
for field in list(fields):
if field in prefetched_objects_cache:
del prefetched_objects_cache[field]
fields.remove(field)
}}}
--
Ticket URL: <https://code.djangoproject.com/ticket/34925>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.
* owner: nobody => Asfand Yar Khan
* status: new => assigned
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:1>
* version: 4.2 => 5.0
* stage: Unreviewed => Accepted
Comment:
Wow it's surprising that this flew under the radar for so long. The bug
has been around since we introduced prefetched relationship clearing in
#29625.
We should definitely not alter the provided `fields` and obviously not
remove members while iterating over it.
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:2>
Comment (by Asfand Yar Khan):
Was able to reproduce this on my end.
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:3>
Comment (by Andrew Cordery):
I searched through the codebase with some admitedly crap regex just
looking for similar instances of for x in y: ... y.remove(x), and I found
one other:
Line 93 of django/core/management/commands/compilemessages.py:
{{{
for dirpath, dirnames, filenames in os.walk(".", topdown=True):
for dirname in dirnames:
if is_ignored_path(
os.path.normpath(os.path.join(dirpath, dirname)),
ignore_patterns
):
dirnames.remove(dirname)
elif dirname == "locale":
basedirs.append(os.path.join(dirpath, dirname))
}}}
This behavior is explicitly encouraged by python
https://docs.python.org/3/library/os.html#os.walk, however in my tests
this will still lead to the same kind of member skipping behavior. This
particular block of code would still work correctly as long as there was
never a 'locale' dirname directly after an ignored dirname in the same
path.
For example, imagine the following directory structure, where we wanted to
extract the two 'locale' dirs (./locale and ./a/locale) and ignore the 'b'
dir.
{{{
root:
a/
locale/
b/
locale/
}}}
If you were to run the following code:
{{{
import os
locale_paths=[]
for dirpath, dirnames, filenames in os.walk(".", topdown=True):
# os.walk returns dirnames in arbitrary order
https://docs.python.org/3/library/os.html#os.listdir,
# so sort the dirnames to force the skip directory 'b' to come
directly before the 'locale' directory
dirnames.sort()
print(f'Iterating through {dirpath}/{dirnames}')
for dirname in dirnames:
path = os.path.join(dirpath, dirname)
print(f'Examining {path}...', end='')
if dirname == 'b':
print(f'Ignoring {path}')
dirnames.remove(dirname)
elif dirname == 'locale':
print(f'Found {path}')
locale_paths.append(path)
else:
print('')
}}}
You would get the following output:
{{{
Iterating through ./['a', 'b', 'locale']
Examining ./a...
Examining ./b...Ignoring ./b
Iterating through ./a/['locale']
Examining ./a/locale...Found ./a/locale
Iterating through ./a/locale/[]
Iterating through ./locale/[]
In [33]: locale_paths
Out[33]: ['./a/locale']
}}}
Notice that ./locale is never 'examined' nor does it appear in the
'locale_paths' output variable. It does however get traversed as shown by
the {{{'Iterating through ./locale/[]'}}} message, which makes sense as it
was not removed from dirnames. This does mean though that if ./locale/
had had a 'locale' subdirectory in it, ''that'' directory would have been
picked up, even though the parent was missed.
Naturally, changing the line to {{{for dirname in list(dirnames)}}} also
completely resolves this issue.
Not sure any of that matters or is useful to you guys, but it was
interesting to me. Moral of the story is don't ever mutate a list we are
iterating through, even if the python docs seem to imply that you can! I
think their docs should be amended to specifically state that you should
NOT mutate it during the initial iteration, and that instead you should
iterate through a copy (ex list(dirnames)) or only mutate it once you have
examined every member of the list that you are interested in, right before
your code exits.
* owner: Asfand Yar Khan => (none)
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:4>
* status: assigned => new
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:5>
Comment (by David Sanders):
Hi Andrew,
Thanks for you investigation, interested in submitting a patch & test? 🏆
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:4>
Comment (by trontelj):
Hello, I'm new to this community, but I have three years of experience
working with Django, and I'm eager to contribute. Can I work on this
ticket?
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:5>
Comment (by David Sanders):
@trontelj sure 👍 Submit a PR and assign yourself. If you need assistance
please head over to Discord to seek help from a community member.
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:6>
* owner: nobody => trontelj
* status: new => assigned
Comment:
[https://github.com/django/django/pull/17417 PR] is ready
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:7>
* needs_tests: 0 => 1
Comment:
All patches require tests.
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:8>
* has_patch: 0 => 1
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:9>
* needs_tests: 1 => 0
Comment:
I have added tests. The failed tests are still not related to the changes
I made in my code.
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:10>
* stage: Accepted => Ready for checkin
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:11>
* stage: Ready for checkin => Accepted
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:12>
* needs_tests: 0 => 1
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:11>
* needs_better_patch: 0 => 1
* needs_tests: 1 => 0
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:12>
* needs_better_patch: 1 => 0
* stage: Accepted => Ready for checkin
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:13>
* status: assigned => closed
* resolution: => fixed
Comment:
In [changeset:"b0ec87b8578147be4357c90eabcd2b916c780810" b0ec87b8]:
{{{
#!CommitTicketReference repository=""
revision="b0ec87b8578147be4357c90eabcd2b916c780810"
Fixed #34925 -- Prevented Model.refresh_from_db() from mutating list of
fields.
}}}
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:14>
Comment (by GitHub <noreply@…>):
In [changeset:"978680db224601e3e09f715c82f89f390c63a763" 978680d]:
{{{
#!CommitTicketReference repository=""
revision="978680db224601e3e09f715c82f89f390c63a763"
Refs #34925 -- Avoided altering passed by reference
refresh_from_db(fields).
Follow up to b0ec87b8578147be4357c90eabcd2b916c780810.
}}}
--
Ticket URL: <https://code.djangoproject.com/ticket/34925#comment:15>