Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make squashmigrations update max_migrations.txt #360

Merged
merged 2 commits into from
Oct 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: # pragma: no cover
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