From a41cebe6aae438cab005c286a6c00242828c54db Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Tue, 16 Jul 2024 14:32:36 -0500 Subject: [PATCH] Support after_deploy() migrations after the delay has passed (#53) * Convert Safe to a custom class rather than an enum. This allows us to specify and store the delay associated with after_deploy. This also adds a check to determine if migrations are using the enum style when it should be using the method definition. * Render a new message for delayed after_deploy migrations. * Support running the tests against multiple databases. This likely needs work around the coverage aggregation. * Add SafeMigration model with migration. * Support Safe.after_deploy() with a delay. * Limit mysql and postgres tests to python 3.10-3.12 as per tox limits * Use future type annotations until 3.8 is retired. --- .github/workflows/build.yml | 156 +++++++- HISTORY.rst | 8 + README.rst | 22 +- src/django_safemigrate/__init__.py | 24 +- src/django_safemigrate/check.py | 25 +- .../management/commands/safemigrate.py | 118 ++++-- .../migrations/0001_initial.py | 45 +++ src/django_safemigrate/migrations/__init__.py | 0 src/django_safemigrate/models.py | 41 +++ tests/models_test.py | 25 ++ tests/safemigrate_test.py | 347 ++++++++++++++++-- tests/testproject/settings.py | 13 + tox.ini | 48 ++- 13 files changed, 803 insertions(+), 69 deletions(-) create mode 100644 src/django_safemigrate/migrations/0001_initial.py create mode 100644 src/django_safemigrate/migrations/__init__.py create mode 100644 src/django_safemigrate/models.py create mode 100644 tests/models_test.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4d3852..72fdd44 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,156 @@ name: Build on: push jobs: - tests: + mysql: + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + + services: + mariadb: + image: mariadb + env: + MARIADB_ROOT_PASSWORD: django_safemigrate + options: >- + --health-cmd "mariadb-admin ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + + - name: Cache pip + uses: actions/cache@v3 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + + - name: Install Tox + run: python -m pip install --upgrade pip tox tox-gh-actions coverage[toml] + + - name: Run tox + env: + DB_BACKEND: mysql + DB_USER: root + DB_PASSWORD: django_safemigrate + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + run: | + tox + python -m coverage combine + python -m coverage xml + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + name: Python ${{ matrix.python-version }} MySQL + files: "coverage.xml" + fail_ci_if_error: true + verbose: true + + + postgres: + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + database: [postgresql] + # Add psycopg3 to our matrix for modern python versions + include: + - python-version: '3.10' + database: psycopg3 + - python-version: '3.11' + database: psycopg3 + - python-version: '3.12' + database: psycopg3 + + services: + postgres: + image: postgres + env: + POSTGRES_DB: django_safemigrate + POSTGRES_USER: django_safemigrate + POSTGRES_PASSWORD: django_safemigrate + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + + - name: Cache pip + uses: actions/cache@v3 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + + - name: Install Tox + run: python -m pip install --upgrade pip tox tox-gh-actions coverage[toml] + + - name: Run tox + env: + DB_BACKEND: ${{ matrix.database }} + DB_HOST: localhost + DB_PORT: 5432 + run: | + tox + python -m coverage combine + python -m coverage xml + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + name: Python ${{ matrix.python-version }} ${{ matrix.database }} + files: "coverage.xml" + fail_ci_if_error: true + verbose: true + + + sqlite: strategy: fail-fast: false matrix: @@ -40,6 +189,9 @@ jobs: run: python -m pip install --upgrade pip tox tox-gh-actions coverage[toml] - name: Run tox + env: + DB_BACKEND: sqlite3 + DB_NAME: ":memory:" run: | tox python -m coverage combine @@ -49,7 +201,7 @@ jobs: uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Python ${{ matrix.python-version }} + name: Python ${{ matrix.python-version }} SQLite files: "coverage.xml" fail_ci_if_error: true verbose: true diff --git a/HISTORY.rst b/HISTORY.rst index 879191f..11a4e9f 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,14 @@ Pending * Add support for Django 5.1. * Drop support for Django 3.2, 4.0, 4.1. +* Convert ``Safe`` to be a custom class rather than an ``Enum``. +* The valid values for ``safe`` are: + + * ``Safe.before_deploy()`` + * ``Safe.after_deploy()`` + * ``Safe.always()`` +* Add support for allowing a ``Safe.after_deploy(delay=timedelta())`` + migration to be migrated after the delay has passed. 4.3 (2024-03-28) ++++++++++++++++ diff --git a/README.rst b/README.rst index 761b723..e0d61cb 100644 --- a/README.rst +++ b/README.rst @@ -45,7 +45,7 @@ such as a migration to add a column. from django_safemigrate import Safe class Migration(migrations.Migration): - safe = Safe.before_deploy + safe = Safe.before_deploy() At this point you can run the ``safemigrate`` Django command to run the migrations, and only these migrations will run. @@ -66,18 +66,32 @@ Safety Options There are three options for the value of the ``safe`` property of the migration. -* ``Safe.before_deploy`` +* ``Safe.before_deploy()`` This migration is only safe to run before the code change is deployed. For example, a migration that adds a new field to a model. -* ``Safe.after_deploy`` +* ``Safe.after_deploy(delay=None)`` This migration is only safe to run after the code change is deployed. This is the default that is applied if no ``safe`` property is given. For example, a migration that removes a field from a model. -* ``Safe.always`` + By specifying a ``delay`` parameter, you can specify when a + ``Safe.after_deploy()`` migration can be run with the ``safemigrate`` + command. For example, if it's desired to wait a week before applying + a migration, you can specify ``Safe.after_deploy(delay=timedelta(days=7))``. + + The ``delay`` is used with the datetime when the migration is first detected. + The detection datetime is when the ``safemigrate`` command detects the + migration in a plan that successfully runs. If the migration plan is blocked, + such when a ``Safe.after_deploy(delay=None)`` is in front of a + ``Safe.before_deploy()``, no migrations are marked as detected. + + Note that a ``Safe.after_deploy()`` migration will not run the first + time it's encountered. + +* ``Safe.always()`` This migration is safe to run before *and* after the code change is deployed. diff --git a/src/django_safemigrate/__init__.py b/src/django_safemigrate/__init__.py index 066b2b6..cc1cf1c 100644 --- a/src/django_safemigrate/__init__.py +++ b/src/django_safemigrate/__init__.py @@ -1,7 +1,29 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta from enum import Enum -class Safe(Enum): +class SafeEnum(Enum): always = "always" before_deploy = "before_deploy" after_deploy = "after_deploy" + + +@dataclass +class Safe: + safe: SafeEnum + delay: timedelta | None = None + + @classmethod + def always(cls): + return cls(safe=SafeEnum.always) + + @classmethod + def before_deploy(cls): + return cls(safe=SafeEnum.before_deploy) + + @classmethod + def after_deploy(cls, *, delay: timedelta = None): + return cls(safe=SafeEnum.after_deploy, delay=delay) diff --git a/src/django_safemigrate/check.py b/src/django_safemigrate/check.py index c4c3960..99a9210 100644 --- a/src/django_safemigrate/check.py +++ b/src/django_safemigrate/check.py @@ -12,19 +12,25 @@ MISSING_SAFE_MESSAGE = ( "{file_path}: {migration_class} is missing the 'safe' attribute.\n" ) +UPGRADE_SAFE_DEFINITION_MESSAGE = "{file_path}: {migration_class} is using `{definition}` when it should be using `{corrected}`\n" FAILURE_MESSAGE = ( "\n" "Add the following to the migration class:\n" "\n" "from django_safemigrate import Safe\n" "class Migration(migrations.Migration):\n" - " safe = Safe.before_deploy\n" + " safe = Safe.before_deploy()\n" "\n" "You can also use the following:\n" - " safe = Safe.always\n" - " safe = Safe.after_deploy\n" + " safe = Safe.always()\n" + " safe = Safe.after_deploy()\n" "\n" ) +ENUM_DEFINITIONS = [ + "safe = Safe.always\n", + "safe = Safe.after_deploy\n", + "safe = Safe.before_deploy\n", +] def validate_migrations(files): @@ -43,6 +49,19 @@ def validate_migrations(files): file_path=file_path, migration_class=migration_class ) ) + for definition in ENUM_DEFINITIONS: + if definition in content: + definition = definition.replace("\n", "") + corrected = f"{definition}()" + success = False + sys.stdout.write( + UPGRADE_SAFE_DEFINITION_MESSAGE.format( + file_path=file_path, + migration_class=migration_class, + definition=definition, + corrected=corrected, + ) + ) if not success: sys.stdout.write(FAILURE_MESSAGE) return success diff --git a/src/django_safemigrate/management/commands/safemigrate.py b/src/django_safemigrate/management/commands/safemigrate.py index 3475767..054e1ce 100644 --- a/src/django_safemigrate/management/commands/safemigrate.py +++ b/src/django_safemigrate/management/commands/safemigrate.py @@ -2,14 +2,20 @@ Migration safety is enforced by a pre_migrate signal receiver. """ +from __future__ import annotations + from enum import Enum from django.conf import settings from django.core.management.base import CommandError from django.core.management.commands import migrate +from django.db.migrations import Migration from django.db.models.signals import pre_migrate +from django.utils import timezone +from django.utils.timesince import timeuntil -from django_safemigrate import Safe +from django_safemigrate import Safe, SafeEnum +from django_safemigrate.models import SafeMigration class SafeMigrate(Enum): @@ -18,9 +24,9 @@ class SafeMigrate(Enum): disabled = "disabled" -def safety(migration): +def safety(migration: Migration): """Determine the safety status of a migration.""" - return getattr(migration, "safe", Safe.after_deploy) + return getattr(migration, "safe", Safe.after_deploy()) def safemigrate(): @@ -35,13 +41,51 @@ def safemigrate(): ) from e +def filter_migrations( + migrations: list[Migration], +) -> tuple[list[Migration], list[Migration]]: + """ + Filter migrations into ready and protected migrations. + + A protected migration is one that's marked Safe.after_deploy() + and has not yet passed its delay value. + """ + now = timezone.now() + + detected_map = SafeMigration.objects.get_detected_map( + [(m.app_label, m.name) for m in migrations] + ) + + def is_protected(migration): + migration_safe = safety(migration) + detected = detected_map.get((migration.app_label, migration.name)) + # A migration is protected if detected is None or delay is not specified. + return migration_safe.safe == SafeEnum.after_deploy and ( + detected is None + or migration_safe.delay is None + or now < (detected + migration_safe.delay) + ) + + ready = [] + protected = [] + + for migration in migrations: + if is_protected(migration): + protected.append(migration) + else: + ready.append(migration) + return ready, protected + + class Command(migrate.Command): """Run database migrations that are safe to run before deployment.""" help = "Run database migrations that are safe to run before deployment." receiver_has_run = False + fake = False def handle(self, *args, **options): + self.fake = options.get("fake", False) # Only connect the handler when this command is run to # avoid running for tests. pre_migrate.connect( @@ -75,7 +119,8 @@ def pre_migrate_receiver(self, *, plan, **_): invalid = [ migration for migration in migrations - if not isinstance(safety(migration), Safe) or safety(migration) not in Safe + if not isinstance(safety(migration), Safe) + or safety(migration).safe not in SafeEnum ] if invalid: self.stdout.write(self.style.MIGRATE_HEADING("Invalid migrations:")) @@ -85,11 +130,7 @@ def pre_migrate_receiver(self, *, plan, **_): "Aborting due to migrations with invalid safe properties." ) - protected = [ - migration - for migration in migrations - if safety(migration) == Safe.after_deploy - ] + ready, protected = filter_migrations(migrations) if not protected: return # No migrations to protect. @@ -99,11 +140,6 @@ def pre_migrate_receiver(self, *, plan, **_): for migration in protected: self.stdout.write(f" {migration.app_label}.{migration.name}") - ready = [ - migration - for migration in migrations - if safety(migration) != Safe.after_deploy - ] delayed = [] blocked = [] @@ -122,7 +158,7 @@ def pre_migrate_receiver(self, *, plan, **_): for migration in block: ready.remove(migration) - if safety(migration) == Safe.before_deploy: + if safety(migration).safe == SafeEnum.before_deploy: blocked.append(migration) else: delayed.append(migration) @@ -131,21 +167,51 @@ def pre_migrate_receiver(self, *, plan, **_): delayed = [m for m in migrations if m in delayed] blocked = [m for m in migrations if m in blocked] - # Display delayed migrations if they exist: - if delayed: - self.stdout.write(self.style.MIGRATE_HEADING("Delayed migrations:")) - for migration in delayed: - self.stdout.write(f" {migration.app_label}.{migration.name}") - - # Display blocked migrations if they exist. - if blocked: - self.stdout.write(self.style.MIGRATE_HEADING("Blocked migrations:")) - for migration in blocked: - self.stdout.write(f" {migration.app_label}.{migration.name}") + self.delayed(delayed) + self.blocked(blocked) if blocked and strict: raise CommandError("Aborting due to blocked migrations.") + # Only mark migrations as detected if not faking + if not self.fake: + # The detection datetime is what's used to determine if an + # after_deploy() with a delay can be migrated or not. + for migration in migrations: + SafeMigration.objects.get_or_create( + app=migration.app_label, name=migration.name + ) + # Swap out the items in the plan with the safe migrations. # None are backward, so we can always set backward to False. plan[:] = [(migration, False) for migration in ready] + + def delayed(self, migrations): + """Handle delayed migrations.""" + # Display delayed migrations if they exist: + if migrations: + self.stdout.write(self.style.MIGRATE_HEADING("Delayed migrations:")) + for migration in migrations: + migration_safe = safety(migration) + if ( + migration_safe.safe == SafeEnum.after_deploy + and migration_safe.delay is not None + ): + now = timezone.localtime() + migrate_date = now + migration_safe.delay + humanized_date = timeuntil(migrate_date, now=now, depth=2) + self.stdout.write( + f" {migration.app_label}.{migration.name} " + f"(can automatically migrate in {humanized_date} " + f"- {migrate_date.isoformat()})" + ) + else: + self.stdout.write(f" {migration.app_label}.{migration.name}") + + def blocked(self, migrations): + """Handle blocked migrations.""" + # Display blocked migrations if they exist. + if migrations: + self.stdout.write(self.style.MIGRATE_HEADING("Blocked migrations:")) + for migration in migrations: + self.stdout.write(f" {migration.app_label}.{migration.name}") diff --git a/src/django_safemigrate/migrations/0001_initial.py b/src/django_safemigrate/migrations/0001_initial.py new file mode 100644 index 0000000..46e7049 --- /dev/null +++ b/src/django_safemigrate/migrations/0001_initial.py @@ -0,0 +1,45 @@ +import django.utils.timezone +from django.db import migrations, models + +from django_safemigrate import Safe + + +class Migration(migrations.Migration): + safe = Safe.before_deploy() + + dependencies = [ + ("contenttypes", "__first__"), + ] + + operations = [ + migrations.CreateModel( + name="SafeMigration", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("app", models.CharField(max_length=255)), + ("name", models.CharField(max_length=255)), + ( + "detected", + models.DateTimeField( + default=django.utils.timezone.now, + help_text="The time the migration was detected. This is used to determine when a migration with Safe.after_deploy() should be migrated.", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="safemigration", + constraint=models.UniqueConstraint( + fields=("app", "name"), name="safemigration_unique" + ), + ), + ] diff --git a/src/django_safemigrate/migrations/__init__.py b/src/django_safemigrate/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/django_safemigrate/models.py b/src/django_safemigrate/models.py new file mode 100644 index 0000000..a5eaff4 --- /dev/null +++ b/src/django_safemigrate/models.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from django.db import models +from django.db.models.functions import Concat +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class SafeMigrationManager(models.Manager): + def get_detected_map(self, app_model_pairs: list[tuple[str, str]]): + detection_qs = ( + self.get_queryset() + .annotate( + identifer=Concat(models.F("app"), models.Value("."), models.F("name")) + ) + .filter(identifer__in=[".".join(pair) for pair in app_model_pairs]) + ) + detected_map = { + (obj.app, obj.name): obj.detected for obj in detection_qs.iterator() + } + return detected_map + + +class SafeMigration(models.Model): + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["app", "name"], name="safemigration_unique" + ), + ] + + created = models.DateTimeField(auto_now_add=True) + app = models.CharField(max_length=255) + name = models.CharField(max_length=255) + detected = models.DateTimeField( + help_text=_( + "The time the migration was detected. This is used to determine when a migration with Safe.after_deploy() should be migrated." + ), + default=timezone.now, + ) + objects = SafeMigrationManager() diff --git a/tests/models_test.py b/tests/models_test.py new file mode 100644 index 0000000..c219c8e --- /dev/null +++ b/tests/models_test.py @@ -0,0 +1,25 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone + +from django_safemigrate.models import SafeMigration + +pytestmark = pytest.mark.django_db + + +class TestSafeMigrationManager: + def test_get_detected_map(self): + m1 = SafeMigration.objects.create( + app="spam", name="0001", detected=timezone.now() - timedelta(days=1) + ) + m2 = SafeMigration.objects.create( + app="spam", name="0002", detected=timezone.now() + ) + mapping = SafeMigration.objects.get_detected_map( + [("spam", "0001"), ("spam", "0002")] + ) + assert mapping == {("spam", "0001"): m1.detected, ("spam", "0002"): m2.detected} + + mapping = SafeMigration.objects.get_detected_map([("spam", "0001")]) + assert mapping == {("spam", "0001"): m1.detected} diff --git a/tests/safemigrate_test.py b/tests/safemigrate_test.py index 89e49ec..357b25d 100644 --- a/tests/safemigrate_test.py +++ b/tests/safemigrate_test.py @@ -1,10 +1,15 @@ """Unit tests for the safemigrate command.""" +from datetime import timedelta +from io import StringIO + import pytest from django.core.management.base import CommandError +from django.utils import timezone from django_safemigrate import Safe from django_safemigrate.check import validate_migrations from django_safemigrate.management.commands.safemigrate import Command +from django_safemigrate.models import SafeMigration class Migration: @@ -27,6 +32,9 @@ def __repr__(self): class TestSafeMigrate: """Unit tests for the safemigrate command.""" + # Identify all tests in this class as pytest.mark.django_db + pytestmark = pytest.mark.django_db + @pytest.fixture def receiver(self): """A bound receiver to test against.""" @@ -66,19 +74,19 @@ def default_after(self, receiver): def test_all_before(self, receiver): """Before migrations will remain in the plan.""" - plan = [(Migration(safe=Safe.before_deploy), False)] + plan = [(Migration(safe=Safe.before_deploy()), False)] receiver(plan=plan) assert len(plan) == 1 def test_final_after(self, receiver): """Run everything except the final migration.""" plan = [ - (Migration("spam", "0001_initial", safe=Safe.before_deploy), False), + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), ( Migration( "spam", "0002_followup", - safe=Safe.after_deploy, + safe=Safe.after_deploy(), dependencies=[("spam", "0001_initial")], ), False, @@ -90,20 +98,192 @@ def test_final_after(self, receiver): def test_multiple_after(self, receiver): """Run up to the first after migration.""" plan = [ - (Migration("spam", "0001_initial", safe=Safe.before_deploy), False), + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), + ( + Migration( + "spam", + "0002_followup", + safe=Safe.after_deploy(), + dependencies=[("spam", "0001_initial")], + ), + False, + ), + (Migration("eggs", "0001_followup", safe=Safe.after_deploy()), False), + ] + receiver(plan=plan) + assert len(plan) == 1 + + def test_after_with_passed_delay(self, receiver): + """Run through the delayed after_deploy migration.""" + SafeMigration.objects.create( + app="spam", name="0001_initial", detected=timezone.now() - timedelta(days=1) + ) + SafeMigration.objects.create( + app="spam", + name="0002_followup", + detected=timezone.now() - timedelta(days=1), + ) + plan = [ + ( + Migration( + "spam", + "0001_initial", + safe=Safe.after_deploy(delay=timedelta(hours=12)), + ), + False, + ), ( Migration( "spam", "0002_followup", - safe=Safe.after_deploy, + safe=Safe.after_deploy(delay=timedelta(days=2)), dependencies=[("spam", "0001_initial")], ), False, ), - (Migration("eggs", "0001_followup", safe=Safe.after_deploy), False), ] receiver(plan=plan) assert len(plan) == 1 + assert plan[0][0].name == "0001_initial" + + def test_after_blocks_passed_delay(self, receiver): + """ + An after_deploy migration without a delay blocks a after_deploy + migration with a passed delay. + """ + SafeMigration.objects.create( + app="spam", name="0001_initial", detected=timezone.now() - timedelta(days=1) + ) + SafeMigration.objects.create( + app="spam", + name="0002_followup", + detected=timezone.now() - timedelta(days=1), + ) + plan = [ + ( + Migration( + "spam", + "0001_initial", + safe=Safe.after_deploy(delay=timedelta(days=2)), + ), + False, + ), + ( + Migration( + "spam", + "0002_followup", + safe=Safe.after_deploy(delay=timedelta(hours=12)), + dependencies=[("spam", "0001_initial")], + ), + False, + ), + ] + receiver(plan=plan) + assert len(plan) == 0 + + def test_after_with_no_detected_blocks_passed_delay(self, receiver): + """ + An after_deploy migration without a detected blocks a after_deploy + migration with a passed delay. + """ + SafeMigration.objects.create( + app="spam", + name="0002_followup", + detected=timezone.now() - timedelta(days=1), + ) + plan = [ + ( + Migration( + "spam", + "0001_initial", + safe=Safe.after_deploy(delay=timedelta(days=2)), + ), + False, + ), + ( + Migration( + "spam", + "0002_followup", + safe=Safe.after_deploy(delay=timedelta(hours=12)), + dependencies=[("spam", "0001_initial")], + ), + False, + ), + ] + receiver(plan=plan) + assert len(plan) == 0 + + def test_after_with_no_delay_blocks_passed_delay(self, receiver): + """ + An after_deploy migration without a delay blocks a after_deploy + migration with a passed delay. + """ + SafeMigration.objects.create( + app="spam", name="0001_initial", detected=timezone.now() - timedelta(days=1) + ) + SafeMigration.objects.create( + app="spam", + name="0002_followup", + detected=timezone.now() - timedelta(days=1), + ) + plan = [ + (Migration("spam", "0001_initial", safe=Safe.after_deploy()), False), + ( + Migration( + "spam", + "0002_followup", + safe=Safe.after_deploy(delay=timedelta(hours=12)), + dependencies=[("spam", "0001_initial")], + ), + False, + ), + ] + receiver(plan=plan) + assert len(plan) == 0 + + def test_after_doesnt_apply_on_first_run(self, receiver): + """An after_deploy migration with a passed delay only can't run + on the same command it was detected.""" + plan = [ + ( + Migration( + "spam", + "0001_initial", + safe=Safe.after_deploy(delay=timedelta(hours=-1)), + ), + False, + ), + ] + receiver(plan=plan) + assert len(plan) == 0 + + def test_after_message(self, receiver): + """ + Confirm the delayed messaging of a migration with + an after_deploy safety. + """ + migrations = [ + Migration( + "spam", + "0001_initial", + safe=Safe.after_deploy(delay=(timedelta(days=8))), + ), + Migration( + "spam", + "0002_followup", + safe=Safe.after_deploy(), + dependencies=[("spam", "0001_initial")], + ), + ] + out = StringIO() + Command(stdout=out).delayed(migrations) + result = out.getvalue().strip() + header, migration1, migration2 = result.split("\n", maxsplit=2) + assert header == "Delayed migrations:" + assert migration1.startswith( + " spam.0001_initial (can automatically migrate in 1\xa0week, 1\xa0day - " + ) + assert migration2 == " spam.0002_followup" def test_blocked_by_after(self, receiver): """Blocked before migrations indicate a failure state. @@ -114,12 +294,12 @@ def test_blocked_by_after(self, receiver): block to avoid release failures. """ plan = [ - (Migration("spam", "0001_initial", safe=Safe.before_deploy), False), + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), ( Migration( "spam", "0002_followup", - safe=Safe.after_deploy, + safe=Safe.after_deploy(), dependencies=[("spam", "0001_initial")], ), False, @@ -128,7 +308,7 @@ def test_blocked_by_after(self, receiver): Migration( "spam", "0003_safety", - safe=Safe.before_deploy, + safe=Safe.before_deploy(), dependencies=[("spam", "0002_followup")], ), False, @@ -145,18 +325,18 @@ def test_blocked_by_after_run_before(self, receiver): your control to add a dependency to directly. """ plan = [ - (Migration("spam", "0001_initial", safe=Safe.before_deploy), False), + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), ( Migration( "spam", "0002_followup", - safe=Safe.after_deploy, + safe=Safe.after_deploy(), dependencies=[("spam", "0001_initial")], run_before=[("eggs", "0001_safety")], ), False, ), - (Migration("eggs", "0001_safety", safe=Safe.before_deploy), False), + (Migration("eggs", "0001_safety", safe=Safe.before_deploy()), False), ] with pytest.raises(CommandError): receiver(plan=plan) @@ -164,12 +344,12 @@ def test_blocked_by_after_run_before(self, receiver): def test_consecutive_after(self, receiver): """Consecutive after migrations are ok.""" plan = [ - (Migration("spam", "0001_initial", safe=Safe.before_deploy), False), + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), ( Migration( "spam", "0002_followup", - safe=Safe.after_deploy, + safe=Safe.after_deploy(), dependencies=[("spam", "0001_initial")], ), False, @@ -178,7 +358,7 @@ def test_consecutive_after(self, receiver): Migration( "spam", "0003_safety", - safe=Safe.after_deploy, + safe=Safe.after_deploy(), dependencies=[("spam", "0002_followup")], ), False, @@ -190,12 +370,12 @@ def test_consecutive_after(self, receiver): def test_always_before_after(self, receiver): """Always migrations will run before after migrations.""" plan = [ - (Migration("spam", "0001_initial", safe=Safe.always), False), + (Migration("spam", "0001_initial", safe=Safe.always()), False), ( Migration( "spam", "0002_followup", - safe=Safe.after_deploy, + safe=Safe.after_deploy(), dependencies=[("spam", "0001_initial")], ), False, @@ -207,12 +387,12 @@ def test_always_before_after(self, receiver): def test_always_after_after(self, receiver): """Always migrations will not block after deployments.""" plan = [ - (Migration("spam", "0001_initial", safe=Safe.after_deploy), False), + (Migration("spam", "0001_initial", safe=Safe.after_deploy()), False), ( Migration( "spam", "0002_followup", - safe=Safe.always, + safe=Safe.always(), dependencies=[("spam", "0001_initial")], ), False, @@ -232,12 +412,12 @@ def test_blocked_by_after_nonstrict(self, settings, receiver): """ settings.SAFEMIGRATE = "nonstrict" plan = [ - (Migration("spam", "0001_initial", safe=Safe.before_deploy), False), + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), ( Migration( "spam", "0002_followup", - safe=Safe.after_deploy, + safe=Safe.after_deploy(), dependencies=[("spam", "0001_initial")], ), False, @@ -246,7 +426,7 @@ def test_blocked_by_after_nonstrict(self, settings, receiver): Migration( "spam", "0003_safety", - safe=Safe.before_deploy, + safe=Safe.before_deploy(), dependencies=[("spam", "0002_followup")], ), False, @@ -273,7 +453,7 @@ def test_with_non_safe_migration_nonstrict(self, settings, receiver): Migration( "spam", "0001_initial", - safe=Safe.before_deploy, + safe=Safe.before_deploy(), dependencies=[("auth", "0001_initial")], ), False, @@ -297,7 +477,7 @@ def test_with_non_safe_migration_disabled(self, settings, receiver): Migration( "spam", "0001_initial", - safe=Safe.before_deploy, + safe=Safe.before_deploy(), dependencies=[("auth", "0001_initial")], ), False, @@ -306,7 +486,7 @@ def test_with_non_safe_migration_disabled(self, settings, receiver): Migration( "spam", "0002_followup", - safe=Safe.after_deploy, + safe=Safe.after_deploy(), dependencies=[("spam", "0001_initial")], ), False, @@ -315,7 +495,7 @@ def test_with_non_safe_migration_disabled(self, settings, receiver): Migration( "spam", "0003_safety", - safe=Safe.before_deploy, + safe=Safe.before_deploy(), dependencies=[("spam", "0002_followup")], ), False, @@ -343,16 +523,105 @@ def test_boolean_invalid(self, receiver): with pytest.raises(CommandError): receiver(plan=plan) + def test_migrations_not_detected_when_blocked(self, receiver): + """If the plan can't advance, the migrations shouldn't be marked as detected.""" + plan = [ + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), + ( + Migration( + "spam", + "0002_followup", + safe=Safe.after_deploy(), + dependencies=[("spam", "0001_initial")], + ), + False, + ), + ( + Migration( + "spam", + "0003_safety", + safe=Safe.before_deploy(), + dependencies=[("spam", "0002_followup")], + ), + False, + ), + ] + with pytest.raises(CommandError): + receiver(plan=plan) + assert not SafeMigration.objects.exists() -class TestCheck: - """Exercise the check command.""" + def test_migrations_not_detected_when_faked(self, receiver): + """If migrate command is faked, the migrations shouldn't be marked as detected.""" + plan = [ + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), + ( + Migration( + "spam", + "0002_followup", + safe=Safe.after_deploy(), + dependencies=[("spam", "0001_initial")], + ), + False, + ), + ( + Migration( + "spam", + "0003_safety", + safe=Safe.after_deploy(), + dependencies=[("spam", "0002_followup")], + ), + False, + ), + ] + command = Command() + command.fake = True + command.pre_migrate_receiver(plan=plan) + assert SafeMigration.objects.count() == 0 + + def test_migrations_are_detected(self, receiver): + """Migrations should be marked as detected during the happy flow.""" + existing = SafeMigration.objects.create( + app="spam", name="0001_initial", detected=timezone.now() - timedelta(days=1) + ) + plan = [ + (Migration("spam", "0001_initial", safe=Safe.before_deploy()), False), + ( + Migration( + "spam", + "0002_followup", + safe=Safe.after_deploy(), + dependencies=[("spam", "0001_initial")], + ), + False, + ), + ( + Migration( + "spam", + "0003_safety", + safe=Safe.after_deploy(), + dependencies=[("spam", "0002_followup")], + ), + False, + ), + ] + receiver(plan=plan) + assert SafeMigration.objects.count() == 3 + # Confirm the existing value is not updated + assert SafeMigration.objects.filter(detected__gt=existing.detected).count() == 2 + + +class TestCheckMissingSafe: + """ + Test the check command for migrations + missing the safe attribute + """ MARKED = """ from django.db import migrations from django_safemigrate import Safe class Migration(migrations.Migration): - safe = Safe.always + safe = Safe.always() """ UNMARKED = """ @@ -376,3 +645,23 @@ def test_validate_migrations_falsematch(self, tmp_path): with open(tmp_path / "0001_initial.py", "w") as f: f.write("THIS IS NOT A MIGRATION") assert validate_migrations([tmp_path / "0001_initial.py"]) + + +class TestCheckEnumAttribute: + """ + Test the check command for migrations + missing the safe attribute + """ + + ENUM_DEFINITION = """ +from django.db import migrations +from django_safemigrate import Safe + +class Migration(migrations.Migration): + safe = Safe.always +""" + + def test_validate_migrations_failure(self, tmp_path): + with open(tmp_path / "0001_initial.py", "w") as f: + f.write(self.ENUM_DEFINITION) + assert not validate_migrations([tmp_path / "0001_initial.py"]) diff --git a/tests/testproject/settings.py b/tests/testproject/settings.py index b79c9b7..339046e 100644 --- a/tests/testproject/settings.py +++ b/tests/testproject/settings.py @@ -15,3 +15,16 @@ ] MIDDLEWARE = [] ROOT_URLCONF = "testproject.urls" +DATABASES = { + "default": { + "ENGINE": "django.db.backends.{}".format(os.getenv("DB_BACKEND", "sqlite3")), + "NAME": os.getenv("DB_NAME", ":memory:"), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST", ""), + "PORT": os.getenv("DB_PORT", ""), + "TEST": { + "USER": "default_test", + }, + }, +} diff --git a/tox.ini b/tox.ini index d0a1def..8c29a81 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,10 @@ [tox] isolated_build = True envlist = - py{38,39,310,311,312}-dj42 - py{310,311,312}-dj50 - py{310,311,312}-dj51 - py{310,311,312}-djmain + py{38,39,310,311,312}-dj42-sqlite + py{310,311,312}-dj50-{sqlite,postgresql,psycopg3,mysql} + py{310,311,312}-dj51-sqlite + py{310,311,312}-djmain-sqlite [testenv] pip_pre = True @@ -17,16 +17,49 @@ deps = pytest-cov pytest-django pytest-mock + postgresql: psycopg2-binary + psycopg3: psycopg[binary] + mysql: mysqlclient passenv = CI GITHUB_* + DB_BACKEND + DB_NAME + DB_USER + DB_PASSWORD + DB_HOST setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = d + DB_NAME = {env:DB_NAME:django_safemigrate} + DB_USER = {env:DB_USER:django_safemigrate} + DB_HOST = {env:DB_HOST:localhost} + DB_PASSWORD = {env:DB_PASSWORD:django_safemigrate} DJANGO_SETTINGS_MODULE = tests.testproject.settings commands = python -m coverage run -m pytest {posargs} + +[testenv:py{38,39,310,311,312}-dj{42,50,51,main}-{postgresql,psycopg3}] +setenv = + {[testenv]setenv} + DB_BACKEND = postgresql + DB_PORT = {env:DB_PORT:5432} + + +[testenv:py{38,39,310,311,312}-dj{42,50,51,main}-mysql] +setenv = + {[testenv]setenv} + DB_BACKEND = mysql + DB_PORT = {env:DB_PORT:3306} + + +[testenv:py{38,39,310,311,312}-dj{42,50,51,main}-sqlite] +setenv = + {[testenv]setenv} + DB_BACKEND = sqlite3 + DB_NAME = ":memory: + [gh-actions] python = 3.8: py38 @@ -34,3 +67,10 @@ python = 3.10: py310 3.11: py311 3.12: py312 + +[gh-actions:env] +DB_BACKEND = + mysql: mysql + postgresql: postgresql + psycopg3: psycopg3 + sqlite3: sqlite