diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py index 943bd35d34..96aa230b24 100644 --- a/django/db/migrations/autodetector.py +++ b/django/db/migrations/autodetector.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import datetime +import functools import re from itertools import chain @@ -63,6 +64,8 @@ class MigrationAutodetector(object): key: self.deep_deconstruct(value) for key, value in obj.items() } + elif isinstance(obj, functools.partial): + return (obj.func, self.deep_deconstruct(obj.args), self.deep_deconstruct(obj.keywords)) elif isinstance(obj, COMPILED_REGEX_TYPE): return RegexObject(obj) elif isinstance(obj, type): diff --git a/docs/releases/1.9.6.txt b/docs/releases/1.9.6.txt index c22e99f48e..8b48f803c7 100644 --- a/docs/releases/1.9.6.txt +++ b/docs/releases/1.9.6.txt @@ -15,3 +15,6 @@ Bugfixes * Fixed ``TimeField`` microseconds round-tripping on MySQL and SQLite (:ticket:`26498`). + +* Prevented ``makemigrations`` from generating infinite migrations for a model + field that references a ``functools.partial`` (:ticket:`26475`). diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py index ba9b4c8caa..25b6a3190b 100644 --- a/tests/migrations/test_autodetector.py +++ b/tests/migrations/test_autodetector.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import functools import re from django.apps import apps @@ -657,6 +658,59 @@ class AutodetectorTests(TestCase): self.assertOperationTypes(changes, 'testapp', 0, ["AlterField"]) self.assertOperationAttributes(changes, "testapp", 0, 0, name="name", preserve_default=True) + def test_supports_functools_partial(self): + def _content_file_name(instance, filename, key, **kwargs): + return '{}/{}'.format(instance, filename) + + def content_file_name(key, **kwargs): + return functools.partial(_content_file_name, key, **kwargs) + + # An unchanged partial reference. + before = self.make_project_state([ModelState("testapp", "Author", [ + ("id", models.AutoField(primary_key=True)), + ("file", models.FileField(max_length=200, upload_to=content_file_name('file'))), + ])]) + after = self.make_project_state([ModelState("testapp", "Author", [ + ("id", models.AutoField(primary_key=True)), + ("file", models.FileField(max_length=200, upload_to=content_file_name('file'))), + ])]) + autodetector = MigrationAutodetector(before, after) + changes = autodetector._detect_changes() + self.assertNumberMigrations(changes, 'testapp', 0) + + # A changed partial reference. + args_changed = self.make_project_state([ModelState("testapp", "Author", [ + ("id", models.AutoField(primary_key=True)), + ("file", models.FileField(max_length=200, upload_to=content_file_name('other-file'))), + ])]) + autodetector = MigrationAutodetector(before, args_changed) + changes = autodetector._detect_changes() + self.assertNumberMigrations(changes, 'testapp', 1) + self.assertOperationTypes(changes, 'testapp', 0, ['AlterField']) + # Can't use assertOperationFieldAttributes because we need the + # deconstructed version, i.e., the exploded func/args/keywords rather + # than the partial: we don't care if it's not the same instance of the + # partial, only if it's the same source function, args, and keywords. + value = changes['testapp'][0].operations[0].field.upload_to + self.assertEqual( + (_content_file_name, ('other-file',), {}), + (value.func, value.args, value.keywords) + ) + + kwargs_changed = self.make_project_state([ModelState("testapp", "Author", [ + ("id", models.AutoField(primary_key=True)), + ("file", models.FileField(max_length=200, upload_to=content_file_name('file', spam='eggs'))), + ])]) + autodetector = MigrationAutodetector(before, kwargs_changed) + changes = autodetector._detect_changes() + self.assertNumberMigrations(changes, 'testapp', 1) + self.assertOperationTypes(changes, 'testapp', 0, ['AlterField']) + value = changes['testapp'][0].operations[0].field.upload_to + self.assertEqual( + (_content_file_name, ('file',), {'spam': 'eggs'}), + (value.func, value.args, value.keywords) + ) + @mock.patch('django.db.migrations.questioner.MigrationQuestioner.ask_not_null_alteration', side_effect=AssertionError("Should not have prompted for not null addition")) def test_alter_field_to_not_null_with_default(self, mocked_ask_method):