Skip to content

Commit

Permalink
Make squashmigrations update max_migrations.txt
Browse files Browse the repository at this point in the history
  • Loading branch information
adamchainz committed Oct 12, 2024
1 parent b57d62b commit 473ae9c
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 42 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
Changelog
=========

* Make ``squashmigrations`` update ``max_migration.txt`` files as well.

Thanks to Gordon Wrigley for the report in `Issue #329 <https://github.com/adamchainz/django-linear-migrations/issues/329>`__.

* Drop Python 3.8 support.

* Support Python 3.13.
Expand Down
53 changes: 30 additions & 23 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -86,31 +72,42 @@ 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:

.. code-block:: sh
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
-------------

Expand Down Expand Up @@ -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
----------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
@@ -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")
39 changes: 39 additions & 0 deletions tests/compat.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 5 additions & 17 deletions tests/test_makemigrations.py
Original file line number Diff line number Diff line change
@@ -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")

Expand Down
141 changes: 141 additions & 0 deletions tests/test_squashmigrations.py
Original file line number Diff line number Diff line change
@@ -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"
Loading

0 comments on commit 473ae9c

Please sign in to comment.