Skip to content

Commit

Permalink
Inital commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
charettes committed Apr 14, 2020
0 parents commit ff0668f
Show file tree
Hide file tree
Showing 18 changed files with 477 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
syntax:glob
*.py[co]
dist/
*.egg-info/*
.coverage
.tox
35 changes: 35 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
dist: bionic
sudo: false
language: python
cache: pip
python:
- 3.6
- 3.7
- 3.8
stages:
- lint
- test
jobs:
fast_finish: true
include:
- { stage: lint, env: TOXENV=flake8, python: 3.6 }
- { stage: lint, env: TOXENV=isort, python: 3.6 }
- stage: deploy
if: tag IS present
python: 3.6
deploy:
skip_existing: true
provider: pypi
user: charettes
distributions: sdist bdist_wheel
password:
secure: P3/8C47HKEp0iQohltLjgFIK9jPGt5ZTeyNU9ayRa9J3C3yWgKGLJrTYNsxhoTT60ZGkLuOnpNioIDwZ3oBjm6OrONYZZ7xwXPxPaHa/Uxu91Bqkb/0IMSXWAKnjGCvE8DtDaDkBEOd52wyVinrR2YLp11oJR/cVUikFVFHpSBrxEl5SxgaulL61R4EmEVT1Y7y+dO5XqOBygb8/n2HWaCXw8Jd/gxgG/vYQy5zOC8S1sbRU6zR4DnpkDTDbLskuIDL/MySh8k793S/vqRWXeXMw8XGu6b5E3z69Yr7fB1UqUkwbr/GWnfkkjUyRxP8q8xk/5pvoJ1IUpLM9yFQTrOHtoJfFGaFpb/7kwuWdePL2+y5Er3YqNInrTvDg0yEyvGMPBLxv7D+tfpnNYoCvwfvspFkXLwl1w3CbM57FkPezHboEY+CPHWis1UnDNk/0g4Yn72NFcMcBHmnGOARrBXWEx345IU7QqJsFucFoYFLZHnYayiGu2S43j9UNk+2/5PUBve6Z0yQ5VKq4Obme9DS3LN3pbcBAoSpo5zszs2/xUaRO9KZ/7a6GScWCB9tmwFvfmsg5XAQQLD+aC49ZTjeFa3DeY/zufnah69eZgGcM1OKS8jOQrv9h5nAFvExV+gldAtVbC5FgEYTPn06E3oB0J3IMBqXEts5f9xzQSj0=
on:
tags: true

install:
- pip install tox coveralls tox-travis
script:
- tox
after_success:
- if [ -f .coverage ]; then coveralls; fi
19 changes: 19 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Copyright (c) 2020, Simon Charette

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
95 changes: 95 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
django-syzygy
=============

.. image:: https://travis-ci.org/charettes/django-syzygy.svg?branch=master
:target: https://travis-ci.org/charettes/django-syzygy
:alt: Build Status

.. image:: https://coveralls.io/repos/github/charettes/django-syzygy/badge.svg?branch=master
:target: https://coveralls.io/github/charettes/django-syzygy?branch=master
:alt: Coverage status


Django application providing database migration tooling to automate their deployment.

Installation
------------

.. code:: sh
pip install django-syzygy
Concept
-------

Syzygy introduces a notion of _prerequisite_ and _postponed_ migrations with
regards to deployment.

A migration is assumed to be a _prerequisite_ unless it contains a destructive
operation or the migration has its `postpone` class attribute set to `True`.
When this boolean attribute is defined it will bypass `operations` based
heuristics.

e.g. this migration would be considered a _prerequisite_

.. code:: python
class Migration(migrations.Migration):
operations = [
AddField('model', 'field', models.IntegerField(null=True))
]
while the following migrations would be _postponed_

.. code:: python
class Migration(migrations.Migration):
operations = [
RemoveField('model', 'field'),
]
In order to prevent the creation of migrations mixing operations of different
nature this package registers system checks. These checks will generate an error
for every migration not explicitly tagged using the `postpone` class attribute
that contains an ambiguous sequence of operations.

e.g. this migration would result in a check error

.. code:: python
class Migration(migrations.Migration):
operations = [
AddField('model', 'other_field', models.IntegerField(null=True)),
RemoveField('model', 'field'),
]
Migration revert are also supported and result in inverting the nature of
migrations. A migration that is normally considered a _prerequisite_ would then
be _postponed_ when reverted.

Usage
-----

Add `'syzygy'` to your `INSTALLED_APPS`

.. code:: python
# settings.py
INSTALLED_APPS = [
...
'syzygy',
...
]
Setup you deployment pipeline to run `migrate --prerequisite` before rolling
out your code changes and `migrate` afterwards to apply the postponed
migrations.

Development
-----------

Make your changes, and then run tests via tox:

.. code:: sh
tox
15 changes: 15 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[isort]
combine_as_imports=true
include_trailing_comma=true
multi_line_output=5
not_skip=__init__.py

[coverage:run]
source = syzygy
branch = 1

[flake8]
max-line-length = 119

[wheel]
universal = 1
34 changes: 34 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env python
from __future__ import unicode_literals

from setuptools import find_packages, setup

with open("README.rst") as file_:
long_description = file_.read()

setup(
name="django-syzygy",
version="0.0.1",
description="",
long_description=long_description,
url="https://github.com/charettes/django-syzygy",
author="Simon Charette",
author_email="charette.s@gmail.com",
install_requires=["Django>=2.2"],
packages=find_packages(exclude=["tests", "tests.*"]),
license="MIT License",
classifiers=[
"Environment :: Web Environment",
"Framework :: Django",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.0",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Topic :: Software Development :: Libraries :: Python Modules",
],
)
1 change: 1 addition & 0 deletions syzygy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = "syzygy.apps.SyzygyConfig"
11 changes: 11 additions & 0 deletions syzygy/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.apps import AppConfig
from django.core import checks

from syzygy.checks import check_migrations


class SyzygyConfig(AppConfig):
name = __package__

def ready(self):
checks.register(check_migrations, "migrations")
49 changes: 49 additions & 0 deletions syzygy/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import os
from importlib import import_module

from django.apps import apps
from django.core.checks import Error
from django.db.migrations.loader import MigrationLoader
from django.utils.module_loading import import_string

from syzygy.plan import must_postpone_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."
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:
continue
try:
module = import_module(module_name)
except ImportError:
# This is not the place to deal with migration issues.
continue
directory = os.path.dirname(module.__file__)
migration_names = set()
for name in os.listdir(directory):
if name.endswith(".py"):
import_name = name.rsplit(".", 1)[0]
migration_names.add(import_name)
for migration_name in migration_names:
try:
migration_class = import_string(
f"{module_name}.{migration_name}.Migration"
)
except ImportError:
# This is not the place to deal with migration issues.
continue
migration = migration_class(migration_name, app_config.label)
try:
must_postpone_migration(migration)
except ValueError as e:
errors.append(
Error(str(e), hint=hint, obj=migration, id="migrations.0001")
)
return errors
Empty file added syzygy/management/__init__.py
Empty file.
Empty file.
22 changes: 22 additions & 0 deletions syzygy/management/commands/migrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.core.management import CommandError
from django.core.management.commands.migrate import Command as MigrateCommand
from django.db.models.signals import pre_migrate

from syzygy.plan import get_prerequisite_plan


class Command(MigrateCommand):
def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument("--prerequisite", action="store_true")

def migrate_prerequisite(self, plan, **kwargs):
try:
plan[:] = get_prerequisite_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)
super().handle(*args, **options)
78 changes: 78 additions & 0 deletions syzygy/plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from typing import List, NewType, Optional, Tuple

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

Plan = NewType("Plan", List[Tuple[Migration, bool]])


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


def must_postpone_migration(
migration: Migration, backward: bool = False
) -> Optional[bool]:
"""
Return whether or not migration must be postponed.
If not specified through a `postpone` :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:
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


def get_prerequisite_plan(plan: Plan) -> Plan:
"""
Trim provided plan to its leading contiguous prerequisite sequence.
If the plan contains non-contiguous sequence of prerequisite migrations
or migrations with ambiguous prerequisite nature a `ValueError` is raised.
"""
prerequisite_plan = []
postpone = False
for migration, backward in plan:
if must_postpone_migration(migration, backward):
postpone = True
else:
if postpone:
raise ValueError(
"Plan contains a non-contiguous sequence of prerequisite "
"migrations."
)
prerequisite_plan.append((migration, backward))
return prerequisite_plan
Empty file added tests/__init__.py
Empty file.
Empty file added tests/migrations/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions tests/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
SECRET_KEY = "not-secret-anymore"

TIME_ZONE = "America/Montreal"

DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}}

INSTALLED_APPS = ["syzygy", "tests"]

0 comments on commit ff0668f

Please sign in to comment.