Based on the detailed issue description and the algorithm description provided by the reporter (Matt), I can construct the patch. The key insight from the issue is: 1. The problem: Sequential merging of media lists creates false ordering constraints 2. The solution: Build a dependency graph from all lists and do topological sort From the issue comment, the algorithm is: 1. Iterate over all items in all sub-lists, building a dependency graph and a de-duplicated list 2. Starting from the first item, backtrack through dependencies 3. Output items while detecting cycles (throw warning on cycle) 4. Continue until no items remain Here's the patch based on the described algorithm: --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -43,6 +43,61 @@ class Media: def __repr__(self): return 'Media(css=%r, js=%r)' % (self._css, self._js) + @staticmethod + def merge(*lists): + """ + Merge lists while trying to keep the relative order of the elements. + Warn if the lists have the same elements in a different relative order. + + For static assets it can be important to have them included in the DOM + in a certain order. In JavaScript you may not be able to reference a + global or in CSS you might want to override a style. + """ + dependency_graph = {} + all_items = [] + for list_ in filter(None, lists): + head = None + for item in list_: + if item in dependency_graph: + if head is not None and head not in dependency_graph[item]: + dependency_graph[item].append(head) + else: + dependency_graph[item] = [head] if head is not None else [] + all_items.append(item) + head = item + + result = [] + while all_items: + # Find an item with no unsatisfied dependencies or report a cycle. + item = all_items[0] + best = item + depth = 0 + cycle = False + visited = set() + while dependency_graph.get(item): + # Try to follow the first unsatisfied dependency. + item = dependency_graph[item][0] + if item in visited: + # We have a cycle. + cycle = True + break + visited.add(item) + if item in all_items: + # Use the deepest item found in the current all_items list. + depth += 1 + best = item + if cycle: + warnings.warn( + 'Detected duplicate Media files in an opposite order: %s' + % ', '.join(visited), + MediaOrderConflictWarning, + ) + result.append(best) + all_items.remove(best) + # Remove this item from all dependency lists. + for deps in dependency_graph.values(): + try: + deps.remove(best) + except ValueError: + pass + return result + @property def _css(self): css = self._css_lists[0] @@ -56,55 +111,12 @@ class Media: @property def _js(self): - js = self._js_lists[0] - # filter(None, ...) avoids calling merge() with empty lists. - for obj in filter(None, self._js_lists[1:]): - js = self.merge(js, obj) - return js + return self.merge(*self._js_lists) def render(self): return mark_safe('\n'.join(chain.from_iterable(getattr(self, 'render_' + name)() for name in MEDIA_TYPES))) def render_js(self): - return [ - format_html( - '', - self.absolute_path(path) - ) for path in self._js - ] - - def render_css(self): - # To keep rendering order consistent, we can't just iterate over items(). - # We need to sort the keys, and iterate over the sorted list. - media = sorted(self._css) - return chain.from_iterable([ - format_html( - '', - self.absolute_path(path), medium - ) for path in self._css[medium] - ] for medium in media) - - def absolute_path(self, path): - """ - Given a relative or absolute path to a static asset, return an absolute - path. An absolute path will be returned unchanged while a relative path - will be passed to django.templatetags.static.static(). - """ - if path.startswith(('http://', 'https://', '/')): - return path - return static(path) - - def __getitem__(self, name): - """Return a Media object that only contains media of the given type.""" - if name in MEDIA_TYPES: - return Media(**{str(name): getattr(self, '_' + name)}) - raise KeyError('Unknown media type "%s"' % name) - - @staticmethod - def merge(list_1, list_2): - """ - Merge two lists while trying to keep the relative order of the elements. - Warn if the lists have the same elements in a different relative order. Wait, this is getting complex. Let me think about the proper patch more carefully based on the issue description. The key changes needed are: 1. Replace the sequential `merge(list_1, list_2)` approach with a new `merge(*lists)` that handles all lists at once 2. Use a dependency graph approach for topological sorting --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -61,7 +61,7 @@ class Media: @property def _js(self): js = self._js_lists[0] - # filter(None, ...) avoids calling merge() with empty lists. + # filter(None, ...) avoids calling merge with empty lists. for obj in filter(None, self._js_lists[1:]): js = self.merge(js, obj) return js @@ -114,28 +114,46 @@ class Media: @staticmethod def merge(list_1, list_2): """ Merge two lists while trying to keep the relative order of the elements. Warn if the lists have the same elements in a different relative order. For static assets it can be important to have them included in the DOM in a certain order. In JavaScript you may not be able to reference a global or in CSS you might want to override a style. """ - # Start with a copy of list_1. - combined_list = list(list_1) - last_insert_index = len(list_1) - for path in list_2: - try: - # Does path already exist in the list? - index = combined_list.index(path) - except ValueError: - # Add path to combined_list since it doesn't exist. - combined_list.insert(last_insert_index, path) - else: - if index > last_insert_index: - warnings.warn( - 'Detected duplicate Media files in an opposite order:\n' - '%s\n%s' % (combined_list[last_insert_index], combined_list[index]), - MediaOrderConflictWarning, - ) - # path already exists in the list. Update last_insert_index so - # that the following elements are inserted in front of this one. - last_insert_index = index - return combined_list + dependency_graph = {item: set() for item in list_1 + list_2} + all_items = list(dict.fromkeys(list_1 + list_2)) + + # Build dependency graph from both lists + for list_ in [list_1, list_2]: + for i, item in enumerate(list_): + if i > 0: + dependency_graph[item].add(list_[i - 1]) + + result = [] + while all_items: + # Find an item with no unsatisfied dependencies + for item in all_items: + if not dependency_graph[item] - set(result): + result.append(item) + all_items.remove(item) + break + else: + # Cycle detected - emit warning and pick the first item + item = all_items[0] + # Find items involved in conflict + deps = dependency_graph[item] - set(result) + warnings.warn( + 'Detected duplicate Media files in an opposite order: %s' + % ', '.join(deps | {item}), + MediaOrderConflictWarning, + ) + result.append(item) + all_items.remove(item) + return result Actually, let me reconsider. The issue is that when we have: - ColorPicker: ['color-picker.js'] - SimpleTextWidget: ['text-editor.js'] - FancyTextWidget: ['text-editor.js', 'text-editor-extras.js', 'color-picker.js'] The sequential merge does: 1. ColorPicker + SimpleTextWidget = ['color-picker.js', 'text-editor.js'] 2. That result + FancyTextWidget = conflict because: - Result says color-picker.js before text-editor.js - FancyTextWidget says text-editor.js before color-picker.js The fix is to merge ALL lists at once, not sequentially. Based on the algorithm description in the ticket, here is the correct patch: --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -58,12 +58,7 @@ class Media: @property def _js(self): - js = self._js_lists[0] - # filter(None, ...) avoids calling merge() with empty lists. - for obj in filter(None, self._js_lists[1:]): - js = self.merge(js, obj) - return js + return self.merge(*self._js_lists) def render(self): return mark_safe('\n'.join(chain.from_iterable(getattr(self, 'render_' + name)() for name in MEDIA_TYPES))) @@ -112,31 +107,49 @@ class Media: return self._css_lists == other._css_lists and self._js_lists == other._js_lists @staticmethod - def merge(list_1, list_2): + def merge(*lists): """ - Merge two lists while trying to keep the relative order of the elements. + Merge lists while trying to keep the relative order of the elements. Warn if the lists have the same elements in a different relative order. For static assets it can be important to have them included in the DOM in a certain order. In JavaScript you may not be able to reference a global or in CSS you might want to override a style. """ - # Start with a copy of list_1. - combined_list = list(list_1) - last_insert_index = len(list_1) - for path in list_2: - try: - # Does path already exist in the list? - index = combined_list.index(path) - except ValueError: - # Add path to combined_list since it doesn't exist. - combined_list.insert(last_insert_index, path) - else: - if index > last_insert_index: + dependency_graph = {} + all_items = [] + for list_ in filter(None, lists): + for item in list_: + if item not in dependency_graph: + dependency_graph[item] = set() + all_items.append(item) + # Build dependency graph: for each list, each item depends on the previous item + for list_ in filter(None, lists): + for i in range(1, len(list_)): + dependency_graph[list_[i]].add(list_[i - 1]) + + result = [] + while all_items: + # Find an item whose dependencies are all satisfied + for item in all_items: + if dependency_graph[item] <= set(result): + result.append(item) + all_items.remove(item) + break + else: + # No item found with all dependencies satisfied - cycle detected + item = all_items[0] + unsatisfied = dependency_graph[item] - set(result) + # Find something involved in the cycle for the warning message + cycle_items = [item] + for dep in unsatisfied: + cycle_items.append(dep) + break warnings.warn( - 'Detected duplicate Media files in an opposite order:\n' - '%s\n%s' % (combined_list[last_insert_index], combined_list[index]), + 'Detected duplicate Media files in an opposite order: %s' + % ', '.join(cycle_items), MediaOrderConflictWarning, ) - # path already exists in the list. Update last_insert_index so - # that the following elements are inserted in front of this one. - last_insert_index = index - return combined_list + result.append(item) + all_items.remove(item) + return result Let me think through this more carefully and provide the correct, well-formatted patch: --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -58,12 +58,7 @@ class Media: @property def _js(self): - js = self._js_lists[0] - # filter(None, ...) avoids calling merge() with empty lists. - for obj in filter(None, self._js_lists[1:]): - js = self.merge(js, obj) - return js + return self.merge(*self._js_lists) def render(self): return mark_safe('\n'.join(chain.from_iterable(getattr(self, 'render_' + name)() for name in MEDIA_TYPES))) @@ -112,31 +107,53 @@ class Media: return self._css_lists == other._css_lists and self._js_lists == other._js_lists @staticmethod - def merge(list_1, list_2): + def merge(*lists): """ - Merge two lists while trying to keep the relative order of the elements. + Merge lists while trying to keep the relative order of the elements. Warn if the lists have the same elements in a different relative order. For static assets it can be important to have them included in the DOM in a certain order. In JavaScript you may not be able to reference a global or in CSS you might want to override a style. """ - # Start with a copy of list_1. - combined_list = list(list_1) - last_insert_index = len(list_1) - for path in list_2: - try: - # Does path already exist in the list? - index = combined_list.index(path) - except ValueError: - # Add path to combined_list since it doesn't exist. - combined_list.insert(last_insert_index, path) - else: - if index > last_insert_index: + dependency_graph = {} + all_items = [] + for list_ in filter(None, lists): + for item in list_: + if item not in dependency_graph: + dependency_graph[item] = set() + all_items.append(item) + # Build the dependency graph: each item depends on items before it in the list + for list_ in filter(None, lists): + for i in range(1, len(list_)): + dependency_graph[list_[i]].add(list_[i - 1]) + + result = [] + while all_items: + # Find the first item with all dependencies satisfied + for item in all_items: + if dependency_graph[item] <= set(result): + result.append(item) + all_items.remove(item) + break + else: + # No item found; there must be a cycle. Pick the first one and warn. + item = all_items[0] + unsatisfied = dependency_graph[item] - set(result) + # Report the cycle + cycle = [] + for dep in unsatisfied: + cycle.append(dep) + cycle.append(item) warnings.warn( - 'Detected duplicate Media files in an opposite order:\n' - '%s\n%s' % (combined_list[last_insert_index], combined_list[index]), + 'Detected duplicate Media files in an opposite order: %s' + % ', '.join(cycle), MediaOrderConflictWarning, ) - # path already exists in the list. Update last_insert_index so - # that the following elements are inserted in front of this one. - last_insert_index = index - return combined_list + result.append(item) + all_items.remove(item) + return result I need to be more careful. Let me construct the final patch correctly: --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -58,12 +58,7 @@ class Media: @property def _js(self): - js = self._js_lists[0] - # filter(None, ...) avoids calling merge() with empty lists. - for obj in filter(None, self._js_lists[1:]): - js = self.merge(js, obj) - return js + return self.merge(*self._js_lists) def render(self): return mark_safe('\n'.join(chain.from_iterable(getattr(self, 'render_' + name)() for name in MEDIA_TYPES))) @@ -112,31 +107,51 @@ class Media: return self._css_lists == other._css_lists and self._js_lists == other._js_lists @staticmethod - def merge(list_1, list_2): + def merge(*lists): """ - Merge two lists while trying to keep the relative order of the elements. + Merge lists while trying to keep the relative order of the elements. Warn if the lists have the same elements in a different relative order. For static assets it can be important to have them included in the DOM in a certain order. In JavaScript you may not be able to reference a global or in CSS you might want to override a style. """ - # Start with a copy of list_1. - combined_list = list(list_1) - last_insert_index = len(list_1) - for path in list_2: - try: - # Does path already exist in the list? - index = combined_list.index(path) - except ValueError: - # Add path to combined_list since it doesn't exist. - combined_list.insert(last_insert_index, path) - else: - if index > last_insert_index: + dependency_graph = {} + all_items = [] + for list_ in filter(None, lists): + for item in list_: + if item not in dependency_graph: + dependency_graph[item] = set() + all_items.append(item) + # Build dependency graph: each item depends on the item before it in a list + for list_ in filter(None, lists): + for i in range(1, len(list_)): + dependency_graph[list_[i]].add(list_[i - 1]) + + result = [] + while all_items: + # Find the first item with all dependencies satisfied + for item in all_items: + if dependency_graph[item] <= set(result): + result.append(item) + all_items.remove(item) + break + else: + # No item found with all dependencies met - there's a cycle + item = all_items[0] + unsatisfied = dependency_graph[item] - set(result) + cycle_items = list(unsatisfied) + [item] warnings.warn( - 'Detected duplicate Media files in an opposite order:\n' - '%s\n%s' % (combined_list[last_insert_index], combined_list[index]), + 'Detected duplicate Media files in an opposite order: %s' + % ', '.join(cycle_items), MediaOrderConflictWarning, ) - # path already exists in the list. Update last_insert_index so - # that the following elements are inserted in front of this one. - last_insert_index = index - return combined_list + result.append(item) + all_items.remove(item) + return result