Skip to content

Commit

Permalink
Merge b5fa7ed into 9906dfd
Browse files Browse the repository at this point in the history
  • Loading branch information
charettes committed Apr 21, 2020
2 parents 9906dfd + b5fa7ed commit 20d5165
Show file tree
Hide file tree
Showing 17 changed files with 286 additions and 98 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist/
.coverage
.tox
.mypy_cache/
htmlcov/
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[isort]
combine_as_imports=true
include_trailing_comma=true
multi_line_output=5
multi_line_output=3
not_skip=__init__.py

[coverage:run]
Expand Down
5 changes: 5 additions & 0 deletions syzygy/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
from .constants import Stage

__all__ = ("Stage",)


default_app_config = "syzygy.apps.SyzygyConfig"
18 changes: 13 additions & 5 deletions syzygy/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@
from django.db.migrations.loader import MigrationLoader
from django.utils.module_loading import import_string

from syzygy.plan import must_postpone_migration
from syzygy.plan import must_post_deploy_migration


def check_migrations(app_configs, **kwargs):
if app_configs is None:
app_configs = apps.get_app_configs()
errors = []
hint = "Assign `Migration.postpone` to denote whether or not the migration should be postponed."
hint = (
"Assign an explicit stage to it or break its operation into multiple "
"migrations if it's not already applied."
)
for app_config in app_configs:
# Most of the following code is taken from MigrationLoader.load_disk
# while allowing non-global app_configs to be used.
module_name, _explicit = MigrationLoader.migrations_module(app_config.label)
if module_name is None:
if module_name is None: # pragma: no cover
continue
try:
module = import_module(module_name)
Expand All @@ -41,9 +44,14 @@ def check_migrations(app_configs, **kwargs):
continue
migration = migration_class(migration_name, app_config.label)
try:
must_postpone_migration(migration)
must_post_deploy_migration(migration)
except ValueError as e:
errors.append(
Error(str(e), hint=hint, obj=migration, id="migrations.0001")
Error(
str(e),
hint=hint,
obj=(migration.app_label, migration.name),
id="migrations.0001",
)
)
return errors
6 changes: 6 additions & 0 deletions syzygy/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from enum import Enum


class Stage(Enum):
PRE_DEPLOY = "pre_deploy"
POST_DEPLOY = "post_deployment"
19 changes: 12 additions & 7 deletions syzygy/management/commands/migrate.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
from django.apps import apps
from django.core.management import CommandError
from django.core.management.commands.migrate import ( # type: ignore
Command as MigrateCommand,
)
from django.db.models.signals import pre_migrate

from syzygy.plan import get_prerequisite_plan
from syzygy.plan import get_pre_deploy_plan


class Command(MigrateCommand):
def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument("--prerequisite", action="store_true")
parser.add_argument(
"--pre-deploy",
action="store_true",
help="Only run migrations staged for pre-deployment.",
)

def migrate_prerequisite(self, plan, **kwargs):
def migrate_pre_deploy(self, plan, **kwargs):
try:
plan[:] = get_prerequisite_plan(plan)
plan[:] = get_pre_deploy_plan(plan)
except ValueError as exc:
raise CommandError(str(exc)) from exc

def handle(self, *args, prerequisite, **options):
if prerequisite:
pre_migrate.connect(self.migrate_prerequisite)
def handle(self, *args, pre_deploy, **options):
if pre_deploy:
pre_migrate.connect(self.migrate_pre_deploy, sender=apps.get_app_config('syzygy'))
super().handle(*args, **options)
1 change: 1 addition & 0 deletions syzygy/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Empty models file in order to allow the pre_migrate signal to be sent.
110 changes: 63 additions & 47 deletions syzygy/plan.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,94 @@
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple

from django.conf import settings
from django.db.migrations import DeleteModel, Migration, RemoveField
from django.db.migrations.operations.base import Operation

from .constants import Stage

MigrationStagesSetting = Dict[str, Stage]
Plan = List[Tuple[Migration, bool]]


def must_postpone_operation(
operation: Operation, backward: bool = False
) -> Optional[bool]:
"""Return whether not operation must be postponed."""
def get_operation_stage(operation: Operation) -> Stage:
"""Return the heuristically determined `Stage` of the operation."""
if isinstance(operation, (DeleteModel, RemoveField)):
return not backward
# All other operations are assumed to be prerequisite.
return backward
return Stage.POST_DEPLOY
return Stage.PRE_DEPLOY


def _get_configured_migration_stage(migration: Migration) -> Optional[Stage]:
"""Return the `Stage` configured through setting:`MIGRATION_STAGES` of the migration."""
setting: MigrationStagesSetting = getattr(settings, "MIGRATION_STAGES", None)
if setting is None:
return
return setting.get(f"{migration.app_label}.{migration.name}")


def must_postpone_migration(
def get_migration_stage(migration: Migration) -> Optional[Stage]:
"""
Return the `Stage` of the migration.
If not specified through setting:`MIGRATION_STAGES` or a `stage`
:class:`django.db.migrations.Migration` class attribute it will be
tentatively deduced from its list of
attr:`django.db.migrations.Migration.operations`.
If the migration doesn't have any `operations` then `None` will be returned
and a `ValueError` will be raised if its contains operations of different
stages.
"""
stage = getattr(migration, "stage", None) or _get_configured_migration_stage(
migration
)
if stage is not None:
return stage
for operation in migration.operations:
operation_stage = get_operation_stage(operation)
if stage is None:
stage = operation_stage
elif operation_stage != stage:
raise ValueError(f"Cannot automatically determine stage of {migration}.")
return stage


def must_post_deploy_migration(
migration: Migration, backward: bool = False
) -> Optional[bool]:
"""
Return whether or not migration must be postponed.
Return whether or not migration must be run after deployment.
If not specified through a `postpone` :class:`django.db.migrations.Migration`
If not specified through a `stage` :class:`django.db.migrations.Migration`
class attribute it will be tentatively deduced from its list of
attr:`django.db.migrations.Migration.operations`.
In cases of ambiguity a `ValueError` will be raised.
"""
# Postponed migrations are considered prerequisite when they are reverted.
try:
setting = settings.SYZYGY_POSTPONE
except AttributeError:
global_postpone = None
else:
key = (migration.app_label, migration.name)
global_postpone = setting.get(key)
postpone = getattr(migration, "postpone", global_postpone)
if postpone is True:
return not backward
elif postpone is False:
migration_stage = get_migration_stage(migration)
if migration_stage is None:
return None
if migration_stage is Stage.PRE_DEPLOY:
return backward
# Migrations without operations such as merges are never postponed.
if not migration.operations:
return False
for operation in migration.operations:
postpone_operation = must_postpone_operation(operation, backward)
if postpone is None:
postpone = postpone_operation
elif postpone_operation != postpone:
raise ValueError(
f"Cannot determine whether or not {migration} should be postponed."
)
return postpone
return not backward


def get_prerequisite_plan(plan: Plan) -> Plan:
def get_pre_deploy_plan(plan: Plan) -> Plan:
"""
Trim provided plan to its leading contiguous prerequisite sequence.
Trim provided plan to its leading contiguous pre-deployment sequence.
If the plan contains non-contiguous sequence of prerequisite migrations
or migrations with ambiguous prerequisite nature a `ValueError` is raised.
If the plan contains non-contiguous sequence of pre-deployment migrations
or migrations with ambiguous deploy stage a `ValueError` is raised.
"""
prerequisite_plan: Plan = []
postpone = False
pre_deploy_plan: Plan = []
post_deploy = False
for migration, backward in plan:
if must_postpone_migration(migration, backward):
postpone = True
if must_post_deploy_migration(migration, backward):
post_deploy = True
else:
if postpone:
if post_deploy:
raise ValueError(
"Plan contains a non-contiguous sequence of prerequisite "
"Plan contains a non-contiguous sequence of pre-deployment "
"migrations."
)
prerequisite_plan.append((migration, backward))
return prerequisite_plan
pre_deploy_plan.append((migration, backward))
return pre_deploy_plan
29 changes: 29 additions & 0 deletions tests/test_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from django.apps import apps
from django.core.checks import run_checks
from django.core.checks.messages import Error
from django.test import SimpleTestCase


class ChecksTests(SimpleTestCase):
hint = (
"Assign an explicit stage to it or break its operation into multiple "
"migrations if it's not already applied."
)

def test_ambiguous_stage(self):
with self.settings(
MIGRATION_MODULES={"tests": "tests.test_migrations.ambiguous"}
):
checks = run_checks(
app_configs=[apps.get_app_config("tests")], tags={"migrations"}
)
self.assertEqual(len(checks), 1)
self.assertEqual(
checks[0],
Error(
msg="Cannot automatically determine stage of tests.0001_initial.",
hint=self.hint,
obj=("tests", "0001_initial"),
id="migrations.0001",
),
)
41 changes: 41 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from django.core.management import CommandError, call_command
from django.db import connection
from django.db.migrations.recorder import MigrationRecorder
from django.test import TransactionTestCase


class MigrateTests(TransactionTestCase):
def get_applied_migrations(self):
return {
name
for (app_label, name) in MigrationRecorder(connection).applied_migrations()
if app_label == "tests"
}

def assert_not_applied(self, name):
self.assertIn(("tests", name), self.recorder.applied_migrations())

def test_pre_deploy_forward(self):
with self.settings(
MIGRATION_MODULES={"tests": "tests.test_migrations.functional"}
):
call_command("migrate", "tests", pre_deploy=True, verbosity=0)
self.assertEqual(self.get_applied_migrations(), {"0001_pre_deploy"})

def test_pre_deploy_backward(self):
with self.settings(
MIGRATION_MODULES={"tests": "tests.test_migrations.functional"}
):
call_command("migrate", "tests", verbosity=0)
self.assertEqual(
self.get_applied_migrations(), {"0001_pre_deploy", "0002_post_deploy"}
)
call_command("migrate", "tests", "zero", pre_deploy=True, verbosity=0)
self.assertEqual(self.get_applied_migrations(), {"0001_pre_deploy"})

def test_ambiguous(self):
with self.settings(
MIGRATION_MODULES={"tests": "tests.test_migrations.ambiguous"}
):
with self.assertRaisesMessage(CommandError, 'Cannot automatically determine stage of tests.0001_initial.'):
call_command("migrate", "tests", pre_deploy=True, verbosity=0)
File renamed without changes.
5 changes: 5 additions & 0 deletions tests/test_migrations/ambiguous/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.db import migrations


class Migration(migrations.Migration):
operations = [migrations.CreateModel("Foo", []), migrations.DeleteModel("Bar")]
Empty file.
9 changes: 9 additions & 0 deletions tests/test_migrations/functional/0001_pre_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.db import migrations

from syzygy import Stage


class Migration(migrations.Migration):
initial = True
stage = Stage.PRE_DEPLOY
atomic = False
9 changes: 9 additions & 0 deletions tests/test_migrations/functional/0002_post_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.db import migrations

from syzygy import Stage


class Migration(migrations.Migration):
dependencies = [("tests", "0001_pre_deploy")]
stage = Stage.POST_DEPLOY
atomic = False
Empty file.

0 comments on commit 20d5165

Please sign in to comment.