From 473ae9cf71d3a99b3bcd05b7f4f2c94eb6c33881 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 12 Oct 2024 23:33:19 +0100 Subject: [PATCH] Make squashmigrations update max_migrations.txt --- CHANGELOG.rst | 4 + README.rst | 53 ++++--- .../management/commands/makemigrations.py | 3 +- .../management/commands/squashmigrations.py | 39 +++++ tests/compat.py | 39 +++++ tests/test_makemigrations.py | 22 +-- tests/test_squashmigrations.py | 141 ++++++++++++++++++ tests/utils.py | 25 ++++ 8 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 src/django_linear_migrations/management/commands/squashmigrations.py create mode 100644 tests/compat.py create mode 100644 tests/test_squashmigrations.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 03b81ae..60114a4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,10 @@ Changelog ========= +* Make ``squashmigrations`` update ``max_migration.txt`` files as well. + + Thanks to Gordon Wrigley for the report in `Issue #329 `__. + * Drop Python 3.8 support. * Support Python 3.13. diff --git a/README.rst b/README.rst index a66ad2c..1b22c37 100644 --- a/README.rst +++ b/README.rst @@ -54,20 +54,6 @@ Installation ..., ] -The app relies on overriding the built-in ``makemigrations`` command. -*If your project has a custom* ``makemigrations`` *command,* ensure the app containing your custom command is **above** ``django_linear_migrations``, and that your command subclasses its ``Command`` class: - -.. code-block:: python - - # myapp/management/commands/makemigrations.py - from django_linear_migrations.management.commands.makemigrations import ( - Command as BaseCommand, - ) - - - class Command(BaseCommand): - ... - **Third,** check the automatic detection of first-party apps. Run this command: @@ -86,7 +72,7 @@ If you see any apps listed that *aren’t* part of your project, define the list INSTALLED_APPS = FIRST_PARTY_APPS + ["django_linear_migrations", ...] -(Note: Django recommends you always list first-party apps first in your project so they can override things in third-party and contrib apps.) +Note: Django recommends you always list first-party apps first in your project so they can override things in third-party and contrib apps. **Fourth,** create the ``max_migration.txt`` files for your first-party apps by re-running the command without the dry run flag: @@ -94,23 +80,34 @@ If you see any apps listed that *aren’t* part of your project, define the list python manage.py create_max_migration_files -In the future, when you add a new app to your project, you’ll need to create its ``max_migration.txt`` file. -Add the new app to ``INSTALLED_APPS`` or ``FIRST_PARTY_APPS`` as appropriate, then rerun the creation command for the new app by specifying its label: - -.. code-block:: sh - - python manage.py create_max_migration_files my_new_app - Usage ===== django-linear-migrations helps you work on Django projects where several branches adding migrations may be in progress at any time. It enforces that your apps have a *linear* migration history, avoiding merge migrations and the problems they can cause from migrations running in different orders. -It does this by making ``makemigrations`` record the name of the latest migration in per-app ``max_migration.txt`` files. +It does this by making ``makemigrations`` and ``squashmigrations`` record the name of the latest migration in per-app ``max_migration.txt`` files. These files will then cause a merge conflicts in your source control tool (Git, Mercurial, etc.) in the case of migrations being developed in parallel. The first merged migration for an app will prevent the second from being merged, without addressing the conflict. The included ``rebase_migration`` command can help automatically such conflicts. +Custom commands +--------------- + +django-linear-migrations relies on overriding the built-in ``makemigrations`` and ``squashmigrations`` commands. +If your project has custom versions of these commands, ensure the app containing your custom commands is **above** ``django_linear_migrations``, and that your commands subclass its ``Command`` class. +For example, for ``makemigrations``: + +.. code-block:: python + + # myapp/management/commands/makemigrations.py + from django_linear_migrations.management.commands.makemigrations import ( + Command as BaseCommand, + ) + + + class Command(BaseCommand): + ... + System Checks ------------- @@ -138,6 +135,16 @@ Pass the ``--dry-run`` flag to only list the ``max_migration.txt`` files that wo Pass the ``--recreate`` flag to re-create files that already exist. This may be useful after altering migrations with merges or manually. +Adding new apps +^^^^^^^^^^^^^^^ + +When you add a new app to your project, you may need to create its ``max_migration.txt`` file to match any pre-created migrations. +Add the new app to ``INSTALLED_APPS`` or ``FIRST_PARTY_APPS`` as appropriate, then rerun the creation command for the new app by specifying its label: + +.. code-block:: sh + + python manage.py create_max_migration_files my_new_app + ``rebase_migration`` Command ---------------------------- diff --git a/src/django_linear_migrations/management/commands/makemigrations.py b/src/django_linear_migrations/management/commands/makemigrations.py index d8f2652..37f5d06 100644 --- a/src/django_linear_migrations/management/commands/makemigrations.py +++ b/src/django_linear_migrations/management/commands/makemigrations.py @@ -50,6 +50,5 @@ def _post_write_migration_files( # Reload required as we've generated changes migration_details = MigrationDetails(app_label, do_reload=True) - max_migration_name = app_migrations[-1].name max_migration_txt = migration_details.dir / "max_migration.txt" - max_migration_txt.write_text(max_migration_name + "\n") + max_migration_txt.write_text(f"{app_migrations[-1].name}\n") diff --git a/src/django_linear_migrations/management/commands/squashmigrations.py b/src/django_linear_migrations/management/commands/squashmigrations.py new file mode 100644 index 0000000..d0ac959 --- /dev/null +++ b/src/django_linear_migrations/management/commands/squashmigrations.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +from django.core.management.commands import squashmigrations +from django.core.management.commands.squashmigrations import Command as BaseCommand +from django.db.migrations import Migration +from django.db.migrations.writer import MigrationWriter + +from django_linear_migrations.apps import MigrationDetails +from django_linear_migrations.apps import first_party_app_configs + + +class Command(BaseCommand): + def handle(self, **options: Any) -> None: + # Temporarily wrap the call to MigrationWriter.__init__ to capture its first + # argument, the generated migration instance. + captured_migration = None + + def wrapper(migration: Migration, *args: Any, **kwargs: Any) -> MigrationWriter: + nonlocal captured_migration + captured_migration = migration + return MigrationWriter(migration, *args, **kwargs) + + squashmigrations.MigrationWriter = wrapper # type: ignore[attr-defined] + + try: + super().handle(**options) + finally: + squashmigrations.MigrationWriter = MigrationWriter # type: ignore[attr-defined] + + if captured_migration is not None and any( + captured_migration.app_label == app_config.label + for app_config in first_party_app_configs() + ): + # A squash migration was generated, update max_migration.txt. + migration_details = MigrationDetails(captured_migration.app_label) + max_migration_txt = migration_details.dir / "max_migration.txt" + max_migration_txt.write_text(f"{captured_migration.name}\n") diff --git a/tests/compat.py b/tests/compat.py new file mode 100644 index 0000000..64bc549 --- /dev/null +++ b/tests/compat.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import sys +import unittest +from collections.abc import Callable +from contextlib import AbstractContextManager +from typing import Any +from typing import TypeVar + +# TestCase.enterContext() backport, source: +# https://adamj.eu/tech/2022/11/14/unittest-context-methods-python-3-11-backports/ + +_T = TypeVar("_T") + +if sys.version_info < (3, 11): + + def _enter_context(cm: Any, addcleanup: Callable[..., None]) -> Any: + # We look up the special methods on the type to match the with + # statement. + cls = type(cm) + try: + enter = cls.__enter__ + exit = cls.__exit__ + except AttributeError: + raise TypeError( + f"'{cls.__module__}.{cls.__qualname__}' object does " + f"not support the context manager protocol" + ) from None + result = enter(cm) + addcleanup(exit, cm, None, None, None) + return result + + +class EnterContextMixin(unittest.TestCase): + if sys.version_info < (3, 11): + + def enterContext(self, cm: AbstractContextManager[_T]) -> _T: + result: _T = _enter_context(cm, self.addCleanup) + return result diff --git a/tests/test_makemigrations.py b/tests/test_makemigrations.py index 5eaa042..31c4115 100644 --- a/tests/test_makemigrations.py +++ b/tests/test_makemigrations.py @@ -1,34 +1,22 @@ from __future__ import annotations -import sys -import time import unittest from functools import partial from textwrap import dedent import django -import pytest from django.db import models from django.test import TestCase from django.test import override_settings +from tests.compat import EnterContextMixin from tests.utils import run_command +from tests.utils import temp_migrations_module -class MakeMigrationsTests(TestCase): - @pytest.fixture(autouse=True) - def tmp_path_fixture(self, tmp_path): - migrations_module_name = "migrations" + str(time.time()).replace(".", "") - self.migrations_dir = tmp_path / migrations_module_name - self.migrations_dir.mkdir() - sys.path.insert(0, str(tmp_path)) - try: - with override_settings( - MIGRATION_MODULES={"testapp": migrations_module_name} - ): - yield - finally: - sys.path.pop(0) +class MakeMigrationsTests(EnterContextMixin, TestCase): + def setUp(self): + self.migrations_dir = self.enterContext(temp_migrations_module()) call_command = partial(run_command, "makemigrations") diff --git a/tests/test_squashmigrations.py b/tests/test_squashmigrations.py new file mode 100644 index 0000000..44e7b8d --- /dev/null +++ b/tests/test_squashmigrations.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from functools import partial +from textwrap import dedent + +import pytest +from django.core.management import CommandError +from django.test import TestCase +from django.test import override_settings + +from tests.compat import EnterContextMixin +from tests.utils import run_command +from tests.utils import temp_migrations_module + + +class SquashMigrationsTests(EnterContextMixin, TestCase): + def setUp(self): + self.migrations_dir = self.enterContext(temp_migrations_module()) + + call_command = partial(run_command, "squashmigrations") + + def test_fail_already_squashed_migration(self): + (self.migrations_dir / "__init__.py").touch() + (self.migrations_dir / "0001_already_squashed.py").write_text( + dedent( + """\ + from django.db import migrations, models + + + class Migration(migrations.Migration): + replaces = [ + ('testapp', '0001_initial'), + ('testapp', '0002_second'), + ] + dependencies = [] + operations = [] + """ + ) + ) + (self.migrations_dir / "__init__.py").touch() + (self.migrations_dir / "0002_new_branch.py").write_text( + dedent( + """\ + from django.db import migrations, models + + + class Migration(migrations.Migration): + dependencies = [ + ('testapp', '0001_already_squashed'), + ] + operations = [] + """ + ) + ) + max_migration_txt = self.migrations_dir / "max_migration.txt" + max_migration_txt.write_text("0002_new_branch\n") + + with pytest.raises(CommandError) as excinfo: + self.call_command("testapp", "0002", "--no-input") + + assert excinfo.value.args[0].startswith( + "You cannot squash squashed migrations!" + ) + assert max_migration_txt.read_text() == "0002_new_branch\n" + + def test_success(self): + (self.migrations_dir / "__init__.py").touch() + (self.migrations_dir / "0001_initial.py").write_text( + dedent( + """\ + from django.db import migrations, models + + + class Migration(migrations.Migration): + intial = True + dependencies = [] + operations = [] + """ + ) + ) + (self.migrations_dir / "__init__.py").touch() + (self.migrations_dir / "0002_second.py").write_text( + dedent( + """\ + from django.db import migrations, models + + + class Migration(migrations.Migration): + dependencies = [ + ('testapp', '0001_initial'), + ] + operations = [] + """ + ) + ) + max_migration_txt = self.migrations_dir / "max_migration.txt" + max_migration_txt.write_text("0002_second\n") + + out, err, returncode = self.call_command("testapp", "0002", "--no-input") + + assert returncode == 0 + assert max_migration_txt.read_text() == "0001_squashed_0002_second\n" + + @override_settings(FIRST_PARTY_APPS=[]) + def test_skip_non_first_party_app(self): + (self.migrations_dir / "__init__.py").touch() + (self.migrations_dir / "0001_initial.py").write_text( + dedent( + """\ + from django.db import migrations, models + + + class Migration(migrations.Migration): + intial = True + dependencies = [] + operations = [] + """ + ) + ) + (self.migrations_dir / "__init__.py").touch() + (self.migrations_dir / "0002_second.py").write_text( + dedent( + """\ + from django.db import migrations, models + + + class Migration(migrations.Migration): + dependencies = [ + ('testapp', '0001_initial'), + ] + operations = [] + """ + ) + ) + max_migration_txt = self.migrations_dir / "max_migration.txt" + max_migration_txt.write_text("0002_second\n") + + out, err, returncode = self.call_command("testapp", "0002", "--no-input") + + assert returncode == 0 + assert max_migration_txt.read_text() == "0002_second\n" diff --git a/tests/utils.py b/tests/utils.py index 4ee2482..9060b73 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,9 +1,34 @@ from __future__ import annotations +import sys +import tempfile +import time +from contextlib import contextmanager from io import StringIO +from pathlib import Path from textwrap import dedent from django.core.management import call_command +from django.test import override_settings + + +@contextmanager +def temp_migrations_module(): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + migrations_module_name = "migrations" + str(time.time()).replace(".", "") + migrations_dir = tmp_path / migrations_module_name + + migrations_dir.mkdir() + sys.path.insert(0, str(tmp_path)) + try: + with override_settings( + MIGRATION_MODULES={"testapp": migrations_module_name} + ): + yield migrations_dir + finally: + sys.path.pop(0) def run_command(*args, **kwargs):