├── migration_snapshots ├── tests │ ├── __init__.py │ ├── test_model.py │ ├── test_core.py │ └── test_visualizer.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── create_snapshot.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── __init__.py ├── apps.py ├── settings.py ├── admin.py ├── models.py └── utils.py ├── docs ├── readme.rst ├── contributing.rst ├── migration_snapshot.jpeg ├── installation.rst ├── usage.rst ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── requirements.txt ├── pytest.ini ├── conftest.py ├── requirements_dev.txt ├── MANIFEST.in ├── .gitignore ├── setup.cfg ├── manage.py ├── .github └── ISSUE_TEMPLATE.md ├── Makefile ├── .pre-commit-config.yaml ├── LICENSE ├── setup.py ├── CONTRIBUTING.rst └── README.rst /migration_snapshots/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migration_snapshots/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migration_snapshots/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /migration_snapshots/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /migration_snapshots/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.1" 2 | -------------------------------------------------------------------------------- /migration_snapshots/tests/test_model.py: -------------------------------------------------------------------------------- 1 | # TODO: test MigrationSnapshot.save() 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx 2 | sphinx-autobuild 3 | graphviz 4 | python-dateutil 5 | -------------------------------------------------------------------------------- /docs/migration_snapshot.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lenders-Cooperative/django-migration-snapshots/HEAD/docs/migration_snapshot.jpeg -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 6.0 3 | addopts = -ra -q -p no:django 4 | DJANGO_SETTINGS_MODULE = migration_snaphots.settings 5 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | # conftest.py 2 | from pytest_djangoapp import configure_djangoapp_plugin 3 | 4 | pytest_plugins = configure_djangoapp_plugin() 5 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | invoke 2 | twine 3 | wheel 4 | zest.releaser 5 | black 6 | isort 7 | django 8 | pytest 9 | pytest-django 10 | pytest-djangoapp 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTING.rst 2 | include HISTORY.rst 3 | include LICENSE 4 | include README.rst 5 | include requirements.txt 6 | recursive-include migration_snapshots *py 7 | -------------------------------------------------------------------------------- /migration_snapshots/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MigrationSnapshotsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "migration_snapshots" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | .pytest_cache/ 4 | 5 | # Packages 6 | *.egg 7 | *.egg-info 8 | dist 9 | build 10 | eggs 11 | parts 12 | bin 13 | var 14 | sdist 15 | 16 | # Sphinx 17 | docs/_build 18 | 19 | venv/ 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | ignore = D203 6 | exclude = 7 | migration_snapshots/migrations, 8 | .git, 9 | docs/conf.py, 10 | build, 11 | dist 12 | max-line-length = 119 13 | 14 | [zest.releaser] 15 | python-file-with-version = migration_snapshots/__init__.py 16 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | $ easy_install django-migration-snapshots 8 | 9 | Or, if you have virtualenvwrapper installed:: 10 | 11 | $ mkvirtualenv django-migration-snapshots 12 | $ pip install django-migration-snapshots 13 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use Django Migration Snapshots in a project, add it to your `INSTALLED_APPS`: 6 | 7 | .. code-block:: python 8 | 9 | INSTALLED_APPS = ( 10 | ... 11 | 'migration_snapshots', 12 | ... 13 | ) 14 | 15 | Add Django Migration Snapshots's URL patterns: 16 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "migration_snapshots.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Django Migration Snapshots version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /migration_snapshots/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | MIGRATION_SNAPSHOT_FILENAME = getattr( 4 | settings, "MIGRATION_SNAPSHOT_FILENAME", "migration_snapshot" 5 | ) 6 | MIGRATION_SNAPSHOT_DIR = getattr( 7 | settings, "MIGRATION_SNAPSHOT_DIR", "migration_snapshots/" 8 | ) 9 | 10 | MIGRATION_SNAPSHOT_MODEL = getattr(settings, "MIGRATION_SNAPSHOT_MODEL", False) 11 | DEFAULT_SNAPSHOT_FORMAT = getattr(settings, "DEFAULT_SNAPSHOT_FORMAT", "pdf") 12 | -------------------------------------------------------------------------------- /migration_snapshots/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from . import models, settings 4 | 5 | if settings.MIGRATION_SNAPSHOT_MODEL is True: 6 | 7 | @admin.register(models.MigrationSnapshot) 8 | class MigrationSnapshot(admin.ModelAdmin): 9 | list_display = ["id", "output_format", "created_at", "modified_at"] 10 | list_filter = ["output_format"] 11 | readonly_fields = ("graph_source", "output_file", "created_at", "modified_at") 12 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. complexity documentation master file, created by 2 | sphinx-quickstart on Tue Jul 9 22:26:36 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django Migration Snapshots's documentation! 7 | ================================================================= 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | readme 15 | installation 16 | usage 17 | contributing 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | python -m pip install --upgrade pip 3 | pip install -r requirements.txt -r requirements_dev.txt 4 | pre-commit install 5 | 6 | .PHONY: build ## Build bundle 7 | build: 8 | python setup.py sdist 9 | 10 | deploy: 11 | twine upload dist/* 12 | 13 | lint: 14 | black --exclude "/migrations/,venv/" migration_snapshots setup.py 15 | 16 | .PHONY: docs ## Generate docs 17 | docs: install 18 | cd docs && make html 19 | 20 | .PHONY: docs-live ## Generate docs with live reloading 21 | docs-live: install 22 | cd docs && make livehtml 23 | 24 | test: 25 | pytest 26 | -------------------------------------------------------------------------------- /migration_snapshots/tests/test_core.py: -------------------------------------------------------------------------------- 1 | from migration_snapshots import settings 2 | 3 | 4 | def test_default_migration_snapshot_filename(): 5 | assert settings.MIGRATION_SNAPSHOT_FILENAME == "migration_snapshot" 6 | 7 | 8 | def test_default_migration_snapshot_dir(): 9 | assert settings.MIGRATION_SNAPSHOT_DIR == "migration_snapshots/" 10 | 11 | 12 | def test_default_migration_snapshot_model_bool(): 13 | assert settings.MIGRATION_SNAPSHOT_MODEL is False 14 | 15 | 16 | def test_default_snapshot_format(): 17 | assert settings.DEFAULT_SNAPSHOT_FORMAT == "pdf" 18 | 19 | -------------------------------------------------------------------------------- /migration_snapshots/tests/test_visualizer.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from django.test import TestCase 4 | 5 | from migration_snapshots.utils import MigrationHistoryUtil 6 | 7 | 8 | class TestMigrationHistoryUtil(TestCase): 9 | def setUp(self): 10 | super().setUp() 11 | self.util_cls = MigrationHistoryUtil 12 | self.util = self.util_cls() 13 | 14 | def test_format_label(self): 15 | tupled_node = ("foo", "bar") 16 | label = self.util._format_label(tupled_node) 17 | assert label == "foo/bar" 18 | 19 | def test_get_node_details(self): 20 | mock = MagicMock() 21 | mock.app_label = "fizz" 22 | mock.name = "buzz" 23 | 24 | node_details = self.util_cls._get_node_details(mock) 25 | assert node_details == ("fizz", "buzz") 26 | -------------------------------------------------------------------------------- /migration_snapshots/management/commands/create_snapshot.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.core.management.base import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | def add_arguments(self, parser): 7 | parser.add_argument( 8 | "--format", help="output format for digraph", required=False, default="pdf" 9 | ) 10 | parser.add_argument( 11 | "--date", 12 | help="end date for migration history output", 13 | required=False, 14 | default="", 15 | ) 16 | 17 | def handle(self, *args, **options): 18 | """ 19 | Create migration snapshot using input arguments. 20 | Default format: 'pdf' 21 | """ 22 | MigrationSnapshot = apps.get_model("migration_snapshots", "MigrationSnapshot") 23 | snapshot = MigrationSnapshot( 24 | output_format=options.get("format", MigrationSnapshot.PDF), 25 | ) 26 | snapshot._date_end = options.get("date") 27 | snapshot.save() 28 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: "docs|migrations|.git" 2 | default_stages: [commit] 3 | fail_fast: true 4 | 5 | repos: 6 | - repo: https://github.com/psf/black 7 | rev: 22.3.0 8 | hooks: 9 | - id: black 10 | exclude: ^.*\b(migrations)\b.*$ 11 | args: ["--target-version", "py310"] 12 | 13 | - repo: https://github.com/asottile/pyupgrade 14 | rev: v3.1.0 15 | hooks: 16 | - id: pyupgrade 17 | 18 | - repo: https://github.com/pre-commit/pre-commit-hooks 19 | rev: v4.3.0 20 | hooks: 21 | - id: trailing-whitespace 22 | - id: end-of-file-fixer 23 | - id: check-yaml 24 | 25 | - repo: https://github.com/PyCQA/isort 26 | rev: 5.10.1 27 | hooks: 28 | - id: isort 29 | 30 | - repo: https://github.com/PyCQA/flake8 31 | rev: 4.0.1 32 | hooks: 33 | - id: flake8 34 | args: ["--ignore=E501,W503,F403,F405,E203"] 35 | additional_dependencies: [flake8-isort] 36 | 37 | # sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date 38 | ci: 39 | autoupdate_schedule: weekly 40 | skip: [] 41 | submodules: false 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, MM 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | import sys 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | 13 | def get_version(*file_paths): 14 | """Retrieves the version from migration_snapshots/__init__.py""" 15 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 16 | version_file = open(filename).read() 17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 18 | if version_match: 19 | return version_match.group(1) 20 | raise RuntimeError("Unable to find version string.") 21 | 22 | 23 | version = get_version("migration_snapshots", "__init__.py") 24 | 25 | 26 | if sys.argv[-1] == "publish": 27 | try: 28 | import wheel 29 | 30 | print("Wheel version: ", wheel.__version__) 31 | except ImportError: 32 | print('Wheel library missing. Please run "pip install wheel"') 33 | sys.exit() 34 | os.system("python setup.py sdist upload") 35 | os.system("python setup.py bdist_wheel upload") 36 | sys.exit() 37 | 38 | if sys.argv[-1] == "tag": 39 | print("Tagging the version on git:") 40 | os.system("git tag -a %s -m 'version %s'" % (version, version)) 41 | os.system("git push --tags") 42 | sys.exit() 43 | 44 | readme = open("README.rst").read() 45 | requirements = open("requirements.txt").readlines() 46 | 47 | setup( 48 | name="django-migration-snapshots", 49 | version=version, 50 | description="""Capture django migration history snapshots""", 51 | long_description=readme, 52 | author="Lenders-Cooperative", 53 | author_email="mmcclelland@thesummitgrp.com", 54 | url="https://github.com/Lenders-Cooperative/django-migration-snapshots", 55 | packages=[ 56 | "migration_snapshots", 57 | ], 58 | include_package_data=True, 59 | install_requires=requirements, 60 | license="BSD", 61 | zip_safe=False, 62 | keywords="django-migration-snapshots", 63 | classifiers=[ 64 | "Development Status :: 3 - Alpha", 65 | "Framework :: Django :: 3.0", 66 | "Framework :: Django :: 3.1", 67 | "Framework :: Django :: 3.2", 68 | "Framework :: Django :: 4.0", 69 | "Framework :: Django :: 4.1", 70 | "Intended Audience :: Developers", 71 | "License :: OSI Approved :: BSD License", 72 | "Natural Language :: English", 73 | "Programming Language :: Python :: 3", 74 | "Programming Language :: Python :: 3.8", 75 | "Programming Language :: Python :: 3.9", 76 | "Programming Language :: Python :: 3.10", 77 | ], 78 | ) 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/Lenders-Cooperative/django-migration-snapshots/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Your operating system name and version. 21 | * Any details about your local setup that might be helpful in troubleshooting. 22 | * Detailed steps to reproduce the bug. 23 | 24 | Fix Bugs 25 | ~~~~~~~~ 26 | 27 | Look through the GitHub issues for bugs. Anything tagged with "bug" 28 | is open to whoever wants to implement it. 29 | 30 | Implement Features 31 | ~~~~~~~~~~~~~~~~~~ 32 | 33 | Look through the GitHub issues for features. Anything tagged with "feature" 34 | is open to whoever wants to implement it. 35 | 36 | Write Documentation 37 | ~~~~~~~~~~~~~~~~~~~ 38 | 39 | Django Migration Snapshots could always use more documentation, whether as part of the 40 | official Django Migration Snapshots docs, in docstrings, or even on the web in blog posts, 41 | articles, and such. 42 | 43 | Submit Feedback 44 | ~~~~~~~~~~~~~~~ 45 | 46 | The best way to send feedback is to file an issue at https://github.com/Lenders-Cooperative/django-migration-snapshots/issues. 47 | 48 | If you are proposing a feature: 49 | 50 | * Explain in detail how it would work. 51 | * Keep the scope as narrow as possible, to make it easier to implement. 52 | * Remember that this is a volunteer-driven project, and that contributions 53 | are welcome :) 54 | 55 | Get Started! 56 | ------------ 57 | 58 | Ready to contribute? Here's how to set up `django-migration-snapshots` for local development. 59 | 60 | 1. Fork the `django-migration-snapshots` repo on GitHub. 61 | 2. Clone your fork locally:: 62 | 63 | $ git clone git@github.com:your_name_here/django-migration-snapshots.git 64 | 65 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 66 | 67 | $ mkvirtualenv django-migration-snapshots 68 | $ cd django-migration-snapshots/ 69 | $ python setup.py develop 70 | 71 | 4. Create a branch for local development:: 72 | 73 | $ git checkout -b name-of-your-bugfix-or-feature 74 | 75 | Now you can make your changes locally. 76 | 77 | 5. When you're done making changes, check that your changes pass flake8 and the 78 | tests, including testing other Python versions with tox:: 79 | 80 | $ flake8 migration_snapshots tests 81 | $ python setup.py test 82 | $ tox 83 | 84 | To get flake8 and tox, just pip install them into your virtualenv. 85 | 86 | 6. Commit your changes and push your branch to GitHub:: 87 | 88 | $ git add . 89 | $ git commit -m "Your detailed description of your changes." 90 | $ git push origin name-of-your-bugfix-or-feature 91 | 92 | 7. Submit a pull request through the GitHub website. 93 | 94 | Pull Request Guidelines 95 | ----------------------- 96 | 97 | Before you submit a pull request, check that it meets these guidelines: 98 | 99 | 1. The pull request should include tests. 100 | 2. If the pull request adds functionality, the docs should be updated. Put 101 | your new functionality into a function with a docstring, and add the 102 | feature to the list in README.rst. 103 | -------------------------------------------------------------------------------- /migration_snapshots/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-10-14 01:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="MigrationSnapshot", 15 | fields=[ 16 | ( 17 | "id", 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ( 26 | "output_format", 27 | models.CharField( 28 | choices=[ 29 | ("bmp", "BMP"), 30 | ("cgimage", "CGIMAGE"), 31 | ("canon", "CANON"), 32 | ("dot", "DOT"), 33 | ("gv", "GV"), 34 | ("xdot", "XDOT"), 35 | ("xdot1.2", "XDOT1.2"), 36 | ("xdot1.4", "XDOT1.4"), 37 | ("eps", "EPS"), 38 | ("exr", "EXR"), 39 | ("fig", "FIG"), 40 | ("gd", "GD"), 41 | ("gd2", "GD2"), 42 | ("gif", "GIF"), 43 | ("gtk", "GTK"), 44 | ("ico", "ICO"), 45 | ("cmap", "CMAP"), 46 | ("ismap", "ISMAP"), 47 | ("imap", "IMAP"), 48 | ("cmapx", "CMAPX"), 49 | ("imap_np", "IMAP_NP"), 50 | ("cmapx_np", "CMAPX_NP"), 51 | ("jpg", "JPG"), 52 | ("jpeg", "JPEG"), 53 | ("jpe", "JPE"), 54 | ("jp2", "JP2"), 55 | ("json", "JSON"), 56 | ("json0", "JSON0"), 57 | ("dot_json", "DOT_JSON"), 58 | ("xdot_json", "XDOT_JSON"), 59 | ("pdf", "PDF"), 60 | ("pic", "PIC"), 61 | ("pct", "PCT"), 62 | ("pict", "PICT"), 63 | ("plain", "PLAIN"), 64 | ("plain-ext", "PLAIN-EXT"), 65 | ("png", "PNG"), 66 | ("pov", "POV"), 67 | ("ps2", "PS2"), 68 | ("psd", "PSD"), 69 | ("sgi", "SGI"), 70 | ("svg", "SVG"), 71 | ("svgz", "SVGZ"), 72 | ("tga", "TGA"), 73 | ("tif", "TIF"), 74 | ("tiff", "TIFF"), 75 | ("tk", "TK"), 76 | ("vml", "VML"), 77 | ("vmlz", "VMLZ"), 78 | ("vrml", "VRML"), 79 | ("wbmp", "WBMP"), 80 | ("webp", "WEBP"), 81 | ("xlib", "XLIB"), 82 | ("x11", "X11"), 83 | ], 84 | default="gv", 85 | max_length=10, 86 | verbose_name="Visualization File Output Format", 87 | ), 88 | ), 89 | ("graph_source", models.TextField(blank=True, null=True)), 90 | ( 91 | "output_file", 92 | models.FileField(blank=True, null=True, upload_to="migration_vis/"), 93 | ), 94 | ("created_at", models.DateField(auto_now_add=True)), 95 | ("modified_at", models.DateField(auto_now=True)), 96 | ], 97 | ), 98 | ] 99 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Django Migration Snapshots 3 | ============================= 4 | 5 | .. image:: https://img.shields.io/badge/license-BSD-blue.svg 6 | :target: https://github.com/Lenders-Cooperative/django-migration-snapshots/blob/main/LICENSE 7 | 8 | .. image:: https://readthedocs.org/projects/django-migration-snapshots/badge/?version=stable&style=flat 9 | :target: https://django-migration-snapshots.readthedocs.io 10 | 11 | .. image:: https://img.shields.io/pypi/v/django-migration-snapshots.svg 12 | :target: https://pypi.org/project/django-migration-snapshots/ 13 | 14 | .. image:: https://img.shields.io/pypi/pyversions/django-migration-snapshots 15 | :target: https://pypi.org/project/django-migration-snapshots/ 16 | 17 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 18 | :target: https://github.com/psf/black 19 | 20 | 21 | Capture snapshots of your django project's migration history. These snapshots are represented as a directed graph using ``pygraphviz`` in both textual and graphical formats. 22 | 23 | Documentation 24 | ------------- 25 | 26 | The full documentation is at https://django-migration-snapshots.readthedocs.io. 27 | 28 | Quickstart 29 | ---------- 30 | 31 | Install Django Migration Snapshots:: 32 | 33 | pip install django-migration-snapshots 34 | 35 | Add it to your ``INSTALLED_APPS``: 36 | 37 | .. code-block:: python 38 | 39 | INSTALLED_APPS = ( 40 | ... 41 | "migration_snapshots", 42 | ... 43 | ) 44 | 45 | 1) Execute management command to create snapshot 46 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 47 | .. code-block:: python 48 | 49 | # creates snapshot of entire migration history 50 | python manage.py create_snapshot 51 | 52 | # filter migrations before applied date (YYYY-MM-DD) 53 | python manage.py create_snapshot --date="2022-10-15" 54 | 55 | 2) Create object programmatically or from the admin panel 56 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 57 | .. code-block:: python 58 | 59 | MigrationSnapshot.objects.create(output_format="pdf") 60 | 61 | 3) Automatically create migration snapshots with the `post_migrate` signal 62 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 63 | .. code-block:: python 64 | 65 | from django.apps import AppConfig 66 | from django.db.models.signals import post_migrate 67 | 68 | def my_snapshot_callback(sender, **kwargs): 69 | # Create migration snapshot 70 | MigrationSnapshot.objects.create(output_format="pdf") 71 | 72 | class MyAppConfig(AppConfig): 73 | ... 74 | 75 | def ready(self): 76 | # send signal only once after all migrations execute 77 | post_migrate.connect(my_snapshot_callback, sender=self) 78 | 79 | 80 | Text Snapshot 81 | ------------- 82 | 83 | .. code-block:: python 84 | 85 | digraph { 86 | "admin/0001_initial" -> "auth/0001_initial" 87 | "admin/0001_initial" -> "contenttypes/0001_initial" 88 | "admin/0002_logentry_remove_auto_add" -> "admin/0001_initial" 89 | "admin/0003_logentry_add_action_flag_choices" -> "admin/0002_logentry_remove_auto_add" 90 | "auth/0001_initial" -> "contenttypes/0001_initial" 91 | "auth/0002_alter_permission_name_max_length" -> "auth/0001_initial" 92 | ... 93 | } 94 | 95 | 96 | Graphical Snapshot 97 | ------------------ 98 | 99 | .. image:: docs/migration_snapshot.jpeg 100 | :width: 600 101 | :alt: JPEG visual representation of migration history 102 | 103 | 104 | Features 105 | -------- 106 | * ``MigrationSnapshot`` data model 107 | * Supported output formats 108 | 109 | * *BMP, CGIMAGE, DOT_CANON, DOT, GV, XDOT, XDOT12, XDOT14, EPS, EXR, FIG, GD, GIF, GTK, ICO, CMAP, ISMAP, IMAP, CMAPX, IMAGE_NP, CMAPX_NP, JPG, JPEG, JPE, JPEG_2000, JSON, JSON0, DOT_JSON, XDOT_JSON, PDF, PIC, PICT, APPLE_PICT, PLAIN_TEXT, PLAIN_EXT, PNG, POV_RAY, PS_PDF, PSD, SGI, SVG, SVGZ, TGA, TIF, TIFF, TK, VML, VMLZ, VRML, WBMP, WEBP, XLIB, X11* 110 | * View migration history based on the miigration's applied timestamp 111 | 112 | 113 | TODO's 114 | ------- 115 | * Additional test coverage 116 | * Setup tox 117 | * Additional filters in management command (ie; per app, per model, etc.) 118 | * More documentation 119 | 120 | 121 | Local Development 122 | ----------------- 123 | 124 | :: 125 | 126 | make install 127 | make test 128 | 129 | 130 | Deployment 131 | ---------- 132 | 133 | :: 134 | 135 | make build 136 | make deploy 137 | 138 | 139 | License 140 | ------- 141 | 142 | This project is provided under the `BSD License `_. 143 | 144 | -------------------------------------------------------------------------------- /migration_snapshots/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dateutil.parser import parse 4 | from django.core.files import File 5 | from django.db import models 6 | from django.db.models.signals import post_save 7 | from django.dispatch import receiver 8 | from django.utils.translation import gettext as _ 9 | 10 | from . import settings 11 | from .utils import MigrationHistoryUtil 12 | 13 | if settings.MIGRATION_SNAPSHOT_MODEL is True: 14 | 15 | class MigrationSnapshot(models.Model): 16 | BMP = "bmp" 17 | CGIMAGE = "cgimage" 18 | DOT_CANON = "canon" 19 | DOT = "dot" 20 | GV = "gv" 21 | XDOT = "xdot" 22 | XDOT12 = "xdot1.2" 23 | XDOT14 = "xdot1.4" 24 | EPS = "eps" 25 | EXR = "exr" 26 | FIG = "fig" 27 | GD = "gd" 28 | GD2 = "gd2" 29 | GIF = "gif" 30 | GTK = "gtk" 31 | ICO = "ico" 32 | CMAP = "cmap" 33 | ISMAP = "ismap" 34 | IMAP = "imap" 35 | CMAPX = "cmapx" 36 | IMAGE_NP = "imap_np" 37 | CMAPX_NP = "cmapx_np" 38 | JPG = "jpg" 39 | JPEG = "jpeg" 40 | JPE = "jpe" 41 | JPEG_2000 = "jp2" 42 | JSON = "json" 43 | JSON0 = "json0" 44 | DOT_JSON = "dot_json" 45 | XDOT_JSON = "xdot_json" 46 | PDF = "pdf" 47 | PIC = "pic" 48 | PICT = "pct" 49 | APPLE_PICT = "pict" 50 | PLAIN_TEXT = "plain" 51 | PLAIN_EXT = "plain-ext" 52 | PNG = "png" 53 | POV_RAY = "pov" 54 | PS_PDF = "ps2" 55 | PSD = "psd" 56 | SGI = "sgi" 57 | SVG = "svg" 58 | SVGZ = "svgz" 59 | TGA = "tga" 60 | TIF = "tif" 61 | TIFF = "tiff" 62 | TK = "tk" 63 | VML = "vml" 64 | VMLZ = "vmlz" 65 | VRML = "vrml" 66 | WBMP = "wbmp" 67 | WEBP = "webp" 68 | XLIB = "xlib" 69 | X11 = "x11" 70 | 71 | FORMAT_CHOICES = [ 72 | (BMP, BMP.upper()), 73 | (CGIMAGE, CGIMAGE.upper()), 74 | (DOT_CANON, DOT_CANON.upper()), 75 | (DOT, DOT.upper()), 76 | (GV, GV.upper()), 77 | (XDOT, XDOT.upper()), 78 | (XDOT12, XDOT12.upper()), 79 | (XDOT14, XDOT14.upper()), 80 | (EPS, EPS.upper()), 81 | (EXR, EXR.upper()), 82 | (FIG, FIG.upper()), 83 | (GD, GD.upper()), 84 | (GD2, GD2.upper()), 85 | (GIF, GIF.upper()), 86 | (GTK, GTK.upper()), 87 | (ICO, ICO.upper()), 88 | (CMAP, CMAP.upper()), 89 | (ISMAP, ISMAP.upper()), 90 | (IMAP, IMAP.upper()), 91 | (CMAPX, CMAPX.upper()), 92 | (IMAGE_NP, IMAGE_NP.upper()), 93 | (CMAPX_NP, CMAPX_NP.upper()), 94 | (JPG, JPG.upper()), 95 | (JPEG, JPEG.upper()), 96 | (JPE, JPE.upper()), 97 | (JPEG_2000, JPEG_2000.upper()), 98 | (JSON, JSON.upper()), 99 | (JSON0, JSON0.upper()), 100 | (DOT_JSON, DOT_JSON.upper()), 101 | (XDOT_JSON, XDOT_JSON.upper()), 102 | (PDF, PDF.upper()), 103 | (PIC, PIC.upper()), 104 | (PICT, PICT.upper()), 105 | (APPLE_PICT, APPLE_PICT.upper()), 106 | (PLAIN_TEXT, PLAIN_TEXT.upper()), 107 | (PLAIN_EXT, PLAIN_EXT.upper()), 108 | (PNG, PNG.upper()), 109 | (POV_RAY, POV_RAY.upper()), 110 | (PS_PDF, PS_PDF.upper()), 111 | (PSD, PSD.upper()), 112 | (SGI, SGI.upper()), 113 | (SVG, SVG.upper()), 114 | (SVGZ, SVGZ.upper()), 115 | (TGA, TGA.upper()), 116 | (TIF, TIF.upper()), 117 | (TIFF, TIFF.upper()), 118 | (TK, TK.upper()), 119 | (VML, VML.upper()), 120 | (VMLZ, VMLZ.upper()), 121 | (VRML, VRML.upper()), 122 | (WBMP, WBMP.upper()), 123 | (WEBP, WEBP.upper()), 124 | (XLIB, XLIB.upper()), 125 | (X11, X11.upper()), 126 | ] 127 | output_format = models.CharField( 128 | _("Visualization File Output Format"), 129 | max_length=10, 130 | choices=FORMAT_CHOICES, 131 | default=GV, 132 | ) 133 | graph_source = models.TextField(blank=True, null=True) 134 | output_file = models.FileField( 135 | upload_to=settings.MIGRATION_SNAPSHOT_DIR, blank=True, null=True 136 | ) 137 | created_at = models.DateField(auto_now_add=True) 138 | modified_at = models.DateField(auto_now=True) 139 | 140 | def __str__(self): 141 | return f"Snapshot #:{self.pk}" 142 | 143 | def record_snapshot(self): 144 | graph_name = settings.MIGRATION_SNAPSHOT_FILENAME 145 | if self.output_format is None: 146 | self.output_format = settings.DEFAULT_SNAPSHOT_FORMAT 147 | 148 | file_name = f"{graph_name}.{self.output_format}" 149 | date_end = getattr(self, "_date_end", None) 150 | 151 | try: 152 | visualizer = MigrationHistoryUtil( 153 | output_format=self.output_format, date_end=parse(date_end) 154 | ) 155 | visualizer.create_snapshot() 156 | self.graph_source = str(visualizer.source) 157 | with open(file_name, "rb") as f: 158 | self.output_file.save(file_name, File(f)) 159 | finally: 160 | os.remove(graph_name) 161 | os.remove(file_name) 162 | 163 | @receiver(post_save, sender=MigrationSnapshot) 164 | def record_snapshot_signal(sender, instance, **kwargs): 165 | post_save.disconnect(record_snapshot_signal, sender=MigrationSnapshot) 166 | instance.record_snapshot() 167 | post_save.connect(record_snapshot_signal, sender=MigrationSnapshot) 168 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # complexity documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | import sys 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | # sys.path.insert(0, os.path.abspath('.')) 21 | 22 | cwd = os.getcwd() 23 | parent = os.path.dirname(cwd) 24 | sys.path.append(parent) 25 | 26 | import migration_snapshots 27 | 28 | # -- General configuration ----------------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be extensions 34 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ["_templates"] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = ".rst" 42 | 43 | # The encoding of source files. 44 | # source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = "index" 48 | 49 | # General information about the project. 50 | project = "Django Migration Snapshots" 51 | copyright = "2022, Lenders Cooperative" 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = migration_snapshots.__version__ 59 | # The full version, including alpha/beta/rc tags. 60 | release = migration_snapshots.__version__ 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | # today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | # today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ["_build"] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all documents. 77 | # default_role = None 78 | 79 | # If true, '()' will be appended to :func: etc. cross-reference text. 80 | # add_function_parentheses = True 81 | 82 | # If true, the current module name will be prepended to all description 83 | # unit titles (such as .. function::). 84 | # add_module_names = True 85 | 86 | # If true, sectionauthor and moduleauthor directives will be shown in the 87 | # output. They are ignored by default. 88 | # show_authors = False 89 | 90 | # The name of the Pygments (syntax highlighting) style to use. 91 | pygments_style = "sphinx" 92 | 93 | # A list of ignored prefixes for module index sorting. 94 | # modindex_common_prefix = [] 95 | 96 | # If true, keep warnings as "system message" paragraphs in the built documents. 97 | # keep_warnings = False 98 | 99 | 100 | # -- Options for HTML output --------------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = "default" 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | # html_theme_options = {} 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | # html_theme_path = [] 113 | 114 | # The name for this set of Sphinx documents. If None, it defaults to 115 | # " v documentation". 116 | # html_title = None 117 | 118 | # A shorter title for the navigation bar. Default is the same as html_title. 119 | # html_short_title = None 120 | 121 | # The name of an image file (relative to this directory) to place at the top 122 | # of the sidebar. 123 | # html_logo = None 124 | 125 | # The name of an image file (within the static path) to use as favicon of the 126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 127 | # pixels large. 128 | # html_favicon = None 129 | 130 | # Add any paths that contain custom static files (such as style sheets) here, 131 | # relative to this directory. They are copied after the builtin static files, 132 | # so a file named "default.css" will overwrite the builtin "default.css". 133 | html_static_path = ["_static"] 134 | 135 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 136 | # using the given strftime format. 137 | # html_last_updated_fmt = '%b %d, %Y' 138 | 139 | # If true, SmartyPants will be used to convert quotes and dashes to 140 | # typographically correct entities. 141 | # html_use_smartypants = True 142 | 143 | # Custom sidebar templates, maps document names to template names. 144 | # html_sidebars = {} 145 | 146 | # Additional templates that should be rendered to pages, maps page names to 147 | # template names. 148 | # html_additional_pages = {} 149 | 150 | # If false, no module index is generated. 151 | # html_domain_indices = True 152 | 153 | # If false, no index is generated. 154 | # html_use_index = True 155 | 156 | # If true, the index is split into individual pages for each letter. 157 | # html_split_index = False 158 | 159 | # If true, links to the reST sources are added to the pages. 160 | # html_show_sourcelink = True 161 | 162 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 163 | # html_show_sphinx = True 164 | 165 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 166 | # html_show_copyright = True 167 | 168 | # If true, an OpenSearch description file will be output, and all pages will 169 | # contain a tag referring to it. The value of this option must be the 170 | # base URL from which the finished HTML is served. 171 | # html_use_opensearch = '' 172 | 173 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 174 | # html_file_suffix = None 175 | 176 | # Output file base name for HTML help builder. 177 | htmlhelp_basename = "django-migration-snapshotsdoc" 178 | 179 | 180 | # -- Options for LaTeX output -------------------------------------------------- 181 | 182 | latex_elements = { 183 | # The paper size ('letterpaper' or 'a4paper'). 184 | #'papersize': 'letterpaper', 185 | # The font size ('10pt', '11pt' or '12pt'). 186 | #'pointsize': '10pt', 187 | # Additional stuff for the LaTeX preamble. 188 | #'preamble': '', 189 | } 190 | 191 | # Grouping the document tree into LaTeX files. List of tuples 192 | # (source start file, target name, title, author, documentclass [howto/manual]). 193 | latex_documents = [ 194 | ( 195 | "index", 196 | "django-migration-snapshots.tex", 197 | "Django Migration Snapshots Documentation", 198 | "Lenders Cooperative", 199 | "manual", 200 | ), 201 | ] 202 | 203 | # The name of an image file (relative to this directory) to place at the top of 204 | # the title page. 205 | # latex_logo = None 206 | 207 | # For "manual" documents, if this is true, then toplevel headings are parts, 208 | # not chapters. 209 | # latex_use_parts = False 210 | 211 | # If true, show page references after internal links. 212 | # latex_show_pagerefs = False 213 | 214 | # If true, show URL addresses after external links. 215 | # latex_show_urls = False 216 | 217 | # Documents to append as an appendix to all manuals. 218 | # latex_appendices = [] 219 | 220 | # If false, no module index is generated. 221 | # latex_domain_indices = True 222 | 223 | 224 | # -- Options for manual page output -------------------------------------------- 225 | 226 | # One entry per manual page. List of tuples 227 | # (source start file, name, description, authors, manual section). 228 | man_pages = [ 229 | ( 230 | "index", 231 | "django-migration-snapshots", 232 | "Django Migration Snapshots Documentation", 233 | ["Lenders Cooperative"], 234 | 1, 235 | ) 236 | ] 237 | 238 | # If true, show URL addresses after external links. 239 | # man_show_urls = False 240 | 241 | 242 | # -- Options for Texinfo output ------------------------------------------------ 243 | 244 | # Grouping the document tree into Texinfo files. List of tuples 245 | # (source start file, target name, title, author, 246 | # dir menu entry, description, category) 247 | texinfo_documents = [ 248 | ( 249 | "index", 250 | "django-migration-snapshots", 251 | "Django Migration Snapshots Documentation", 252 | "Lenders Cooperative", 253 | "django-migration-snapshots", 254 | "One line description of project.", 255 | "Miscellaneous", 256 | ), 257 | ] 258 | 259 | # Documents to append as an appendix to all manuals. 260 | # texinfo_appendices = [] 261 | 262 | # If false, no module index is generated. 263 | # texinfo_domain_indices = True 264 | 265 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 266 | # texinfo_show_urls = 'footnote' 267 | 268 | # If true, do not generate a @detailmenu in the "Top" node's menu. 269 | # texinfo_no_detailmenu = False 270 | -------------------------------------------------------------------------------- /migration_snapshots/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from tempfile import NamedTemporaryFile 4 | from typing import Any, Optional, Tuple, Union 5 | 6 | from django.db import connection 7 | from django.db.migrations.exceptions import NodeNotFoundError 8 | from django.db.migrations.graph import MigrationGraph, Node 9 | from django.db.migrations.loader import MigrationLoader 10 | from django.db.migrations.recorder import MigrationRecorder 11 | from django.utils import timezone 12 | from graphviz import Digraph 13 | 14 | 15 | class TimeBasedMigrationLoader(MigrationLoader): 16 | """ 17 | `TimeBasedMigrationLoader` inherits from Django's default `MigrationLoader` 18 | overriding the queryset from the `MigrationRecorder` - allowing the filtering of recorded migrations by any provided timestamp. 19 | The default is the current timezone-aware timestamp which will fetch all recorded migrations. 20 | """ 21 | 22 | def __init__( 23 | self, 24 | *args: Any, 25 | date_end: Optional[datetime], 26 | **kwargs: bool, 27 | ) -> None: 28 | if not isinstance(date_end, datetime): 29 | raise ValueError("Datetime argument must be a datetime object") 30 | 31 | self.date_end = date_end or timezone.now() 32 | super().__init__(*args, **kwargs) 33 | 34 | def build_graph(self) -> None: 35 | """ 36 | Build a migration dependency graph using both the disk and database. 37 | You'll need to rebuild the graph if you apply migrations. This isn't 38 | usually a problem as generally migration stuff runs in a one-shot process. 39 | """ 40 | # Load disk data 41 | self.load_disk() 42 | # Load database data 43 | recorder = MigrationRecorder(self.connection) 44 | filtered_migration_qs = recorder.migration_qs.filter(applied__lt=self.date_end) 45 | 46 | self.applied_migrations = ( 47 | { 48 | (migration.app, migration.name): migration 49 | for migration in filtered_migration_qs 50 | } 51 | if recorder.has_table() 52 | else {} 53 | ) 54 | 55 | # To start, populate the migration graph with nodes for ALL migrations 56 | # and their dependencies. Also make note of replacing migrations at this step. 57 | self.graph = MigrationGraph() 58 | self.replacements = {} 59 | for key, migration in self.disk_migrations.copy().items(): 60 | if key not in self.applied_migrations: 61 | del self.disk_migrations[key] 62 | continue 63 | 64 | self.graph.add_node(key, migration) 65 | 66 | # Replacing migrations. 67 | if migration.replaces: 68 | self.replacements[key] = migration 69 | for key, migration in self.disk_migrations.items(): 70 | # Internal (same app) dependencies. 71 | self.add_internal_dependencies(key, migration) 72 | # Add external dependencies now that the internal ones have been resolved. 73 | for key, migration in self.disk_migrations.items(): 74 | self.add_external_dependencies(key, migration) 75 | # Carry out replacements where possible and if enabled. 76 | if self.replace_migrations: 77 | for key, migration in self.replacements.items(): 78 | # Get applied status of each of this migration's replacement 79 | # targets. 80 | applied_statuses = [ 81 | (target in self.applied_migrations) for target in migration.replaces 82 | ] 83 | # The replacing migration is only marked as applied if all of 84 | # its replacement targets are. 85 | if all(applied_statuses): 86 | self.applied_migrations[key] = migration 87 | else: 88 | self.applied_migrations.pop(key, None) 89 | # A replacing migration can be used if either all or none of 90 | # its replacement targets have been applied. 91 | if all(applied_statuses) or (not any(applied_statuses)): 92 | self.graph.remove_replaced_nodes(key, migration.replaces) 93 | else: 94 | # This replacing migration cannot be used because it is 95 | # partially applied. Remove it from the graph and remap 96 | # dependencies to it (#25945). 97 | self.graph.remove_replacement_node(key, migration.replaces) 98 | # Ensure the graph is consistent. 99 | try: 100 | self.graph.validate_consistency() 101 | except NodeNotFoundError as excp: 102 | # Check if the missing node could have been replaced by any squash 103 | # migration but wasn't because the squash migration was partially 104 | # applied before. In that case raise a more understandable exception 105 | # (#23556). 106 | # Get reverse replacements. 107 | reverse_replacements = {} 108 | for key, migration in self.replacements.items(): 109 | for replaced in migration.replaces: 110 | reverse_replacements.setdefault(replaced, set()).add(key) 111 | # Try to reraise exception with more detail. 112 | if excp.node in reverse_replacements: 113 | candidates = reverse_replacements.get(excp.node, set()) 114 | is_replaced = any( 115 | candidate in self.graph.nodes for candidate in candidates 116 | ) 117 | if not is_replaced: 118 | tries = ", ".join("%s.%s" % c for c in candidates) 119 | raise NodeNotFoundError( 120 | "Migration {0} depends on nonexistent node ('{1}', '{2}'). " 121 | "Django tried to replace migration {1}.{2} with any of [{3}] " 122 | "but wasn't able to because some of the replaced migrations " 123 | "are already applied.".format( 124 | excp.origin, excp.node[0], excp.node[1], tries 125 | ), 126 | excp.node, 127 | ) from excp 128 | raise 129 | self.graph.ensure_not_cyclic() 130 | 131 | 132 | class MigrationHistoryUtil: 133 | def __init__( 134 | self, 135 | *, 136 | output_format: str = "pdf", 137 | filename: str = "migration_snapshot", 138 | delimiter: str = "/", 139 | **options: dict, 140 | ) -> None: 141 | """ 142 | Initialize TimeBaseMigrationLoader based on timestamp 143 | and set object attributes 144 | """ 145 | self.migration_loader = TimeBasedMigrationLoader( 146 | options.get("connection", connection), 147 | date_end=options.get("date_end", timezone.now()), 148 | ) 149 | 150 | self.delimiter = delimiter 151 | self.filename = os.path.splitext(filename)[0] 152 | self.digraph = Digraph(format=output_format) 153 | 154 | def _format_label(self, tupled_node: Tuple[str, str]) -> str: 155 | """ 156 | Hook to provide custom formatting if desired 157 | """ 158 | return f"{self.delimiter}".join(tupled_node) 159 | 160 | @staticmethod 161 | def _get_node_details(node: Node) -> Tuple[str, str]: 162 | """ 163 | Create node details tuple. 164 | """ 165 | return (node.app_label, node.name) 166 | 167 | def construct_digraph(self) -> None: 168 | """ 169 | Construct digraph by adding nodes and node dependencies to digraph object. 170 | """ 171 | for node in sorted(self.graph.nodes.values(), key=self._get_node_details): 172 | self.add_node(node) 173 | self.add_nested_edges(node) 174 | 175 | def add_node(self, node: Node) -> None: 176 | """ 177 | Create node label and add formatted node tuple. 178 | """ 179 | node_label = self._format_label(self._get_node_details(node)) 180 | self.digraph.node(node_label, node_label) 181 | 182 | def add_edges(self, node_to: Node, node_from: Node) -> None: 183 | """ 184 | Add digraph edges between two nodes with formatted labels. 185 | """ 186 | self.digraph.edge(self._format_label(node_from), self._format_label(node_to)) 187 | 188 | def add_nested_edges(self, node: Node) -> None: 189 | """ 190 | Loop over node dependencies and add respective edges. 191 | """ 192 | for dep in node.dependencies: 193 | if dep[-1] == "__first__": 194 | self.add_edges( 195 | self.graph.root_nodes(dep[0])[0], self._get_node_details(node) 196 | ) 197 | elif dep[-1] == "__latest__": 198 | self.add_edges( 199 | self.graph.leaf_nodes(dep[0])[0], self._get_node_details(node) 200 | ) 201 | else: 202 | self.add_edges(dep, self._get_node_details(node)) 203 | 204 | def create_snapshot( 205 | self, *, view: bool = False, temp_file: bool = False, **kwargs: Union[bool, str] 206 | ) -> str: 207 | """ 208 | Construct digraph and create either: 209 | 1.) a temporary file for view-only 210 | 2.) graphical output to disk. 211 | """ 212 | self.construct_digraph() 213 | if temp_file is True: 214 | with NamedTemporaryFile() as temp: 215 | filename = self.digraph.render(temp.name, view=True, **kwargs) 216 | else: 217 | filename = self.digraph.render(self.filename, view=view, **kwargs) 218 | 219 | return filename 220 | 221 | @property 222 | def graph(self) -> MigrationGraph: 223 | return self.migration_loader.graph 224 | 225 | @property 226 | def source(self) -> str: 227 | """ 228 | Textual format output of a digraph structure. 229 | """ 230 | return self.digraph.source 231 | --------------------------------------------------------------------------------