Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
93b0187
Adds command to check for circular migration dependencies
kingbuzzman Jan 30, 2024
f94a497
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Feb 2, 2024
3c5d168
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 1, 2024
0ae7572
.
kingbuzzman Mar 1, 2024
c562576
Doesnt crash
kingbuzzman Mar 1, 2024
199868c
Clean up
kingbuzzman Mar 1, 2024
84b2791
Fixes ignore apps
kingbuzzman Mar 2, 2024
74c7f80
linter
kingbuzzman Mar 2, 2024
3f7d369
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 2, 2024
f254ae4
Adds ignore app
kingbuzzman Mar 2, 2024
27ac7f3
Merge branch 'add-circular-migration-finder' of github.com:kingbuzzma…
kingbuzzman Mar 2, 2024
0c5689b
.
kingbuzzman Mar 2, 2024
24fa8b3
If there is a previously ignored app, we remove it if --only is used
kingbuzzman Mar 2, 2024
821126e
.
kingbuzzman Mar 3, 2024
06524af
linter
kingbuzzman Mar 3, 2024
9041631
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 4, 2024
cb4611f
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 5, 2024
a8344a9
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 5, 2024
d5aaac0
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 20, 2024
48b3468
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 21, 2024
db6a177
Fixes test
kingbuzzman Mar 21, 2024
15e5b48
Going back to master..
kingbuzzman Mar 21, 2024
fe0073a
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 21, 2024
ffd70ad
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 21, 2024
51b1f87
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 21, 2024
13b42ec
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Mar 26, 2024
4ad20a0
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Apr 14, 2024
f499111
...dunno
kingbuzzman Aug 6, 2024
5a77e91
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Aug 11, 2024
ba4d949
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Sep 17, 2024
f5e7e02
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Nov 5, 2024
c11ede2
Update circularmigrations.py
kingbuzzman Nov 25, 2024
13dbf41
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Jan 23, 2025
7167ee5
wip
kingbuzzman Mar 26, 2025
aae51ad
Merge branch 'add-circular-migration-finder' of github.com:kingbuzzma…
kingbuzzman Mar 26, 2025
eebfa6b
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Jul 29, 2025
d6895f1
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Jul 30, 2025
dda05c1
Gettings missing test coverage always
kingbuzzman Jul 30, 2025
94def18
Always the diff
kingbuzzman Jul 30, 2025
3629ef0
Merge remote-tracking branch 'origin/master' into add-circular-migrat…
kingbuzzman Jul 30, 2025
fe53f60
Merge branch 'dev/get-missing-lines-always' into add-circular-migrati…
kingbuzzman Jul 30, 2025
e023128
Fix import
kingbuzzman Jul 30, 2025
601b84f
Merge branch 'master' into add-circular-migration-finder
kingbuzzman Jul 30, 2025
042f3a4
.
kingbuzzman Jul 30, 2025
7250802
Merge branch 'add-circular-migration-finder' of github.com:kingbuzzma…
kingbuzzman Jul 30, 2025
c1ee24e
.
kingbuzzman Jul 30, 2025
a647cd7
.
kingbuzzman Jul 30, 2025
fc9cef7
.
kingbuzzman Jul 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions django_squash/db/migrations/autodetector.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from django.db.migrations.autodetector import MigrationAutodetector as MigrationAutodetectorBase

from django_squash.contrib import postgres
from django_squash.db.migrations import utils

from . import utils

RESERVED_MIGRATION_KEYWORDS = ("_deleted", "_dependencies_change", "_replaces_change", "_original_migration")

Expand Down Expand Up @@ -83,13 +84,10 @@
continue

if isinstance(operation, dj_migrations.RunSQL):
operation._original_migration = migration
new_operations.append(operation)
elif isinstance(operation, dj_migrations.RunPython):
operation._original_migration = migration
new_operations.append(operation)
elif isinstance(operation, postgres.PGCreateExtension):
operation._original_migration = migration
new_operations_bubble_top.append(operation)
elif isinstance(operation, dj_migrations.SeparateDatabaseAndState):
# A valid use case for this should be given before any work is done.
Expand Down Expand Up @@ -160,7 +158,7 @@
for dependency in migration.dependencies:
dep_app_label, dep_migration = dependency
if dep_app_label in ignore_apps:
new_dependencies.append(original.graph.leaf_nodes(dep_app_label)[0])
new_dependencies.append(original.graph.leaf_nodes(dependency[0])[0])
continue

if dep_app_label == "__setting__":
Expand All @@ -174,10 +172,10 @@
# Leave as is, the django's migration writer will handle this by default
new_dependencies.append(dependency)
continue
# Technically, the terms '__first__' and '__latest__' could apply to dependencies. However, these
# are not labels that Django assigns automatically. Instead, they would be manually specified by
# the developer after Django has generated the necessary files. Given that our focus is solely
# on handling migrations created by Django, there is no practical need to account for these.
elif dep_migration == "__first__":
dependency = original.graph.root_nodes(dep_app_label)[0]

Check warning on line 176 in django_squash/db/migrations/autodetector.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 176 missing coverage
elif dep_migration == "__latest__":
dependency = original.graph.leaf_nodes(dep_app_label)[0]

Check warning on line 178 in django_squash/db/migrations/autodetector.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 178 missing coverage

migration_id = dependency
if migration_id not in migrations_by_name:
Expand Down
34 changes: 13 additions & 21 deletions django_squash/db/migrations/loader.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import logging
import inspect
import os
import tempfile
from contextlib import ExitStack
Expand All @@ -7,38 +7,30 @@
from django.conf import settings
from django.db.migrations.loader import MigrationLoader

from django_squash.db.migrations import utils

logger = logging.getLogger(__name__)


class SquashMigrationLoader(MigrationLoader):
def __init__(self, *args, **kwargs):
# keep a copy of the original migration modules to restore it later
original_migration_modules = settings.MIGRATION_MODULES
# make a copy of the migration modules so we can modify it
settings.MIGRATION_MODULES = settings.MIGRATION_MODULES.copy()
site_packages_path = utils.site_packages_path()
project_path = os.path.abspath(os.curdir)

with ExitStack() as stack:
# Find each app that belongs to the user and are not in the site-packages. Create a fake temporary
# directory inside each app that will tell django we don't have any migrations at all.
for app_config in apps.get_app_configs():
# absolute path to the app
app_path = utils.source_directory(app_config.module)

if app_path.startswith(site_packages_path):
# ignore any apps in inside site-packages
logger.debug("Ignoring app %s inside site-packages: %s", app_config.label, app_path)
continue

temp_dir = stack.enter_context(tempfile.TemporaryDirectory(prefix="migrations_", dir=app_path))
# Need to make this directory a proper python module otherwise django will refuse to recognize it
open(os.path.join(temp_dir, "__init__.py"), "a").close()
settings.MIGRATION_MODULES[app_config.label] = "%s.%s" % (
app_config.module.__name__,
os.path.basename(temp_dir),
)
module = app_config.module
app_path = os.path.dirname(os.path.abspath(inspect.getsourcefile(module)))

if app_path.startswith(project_path):
temp_dir = stack.enter_context(tempfile.TemporaryDirectory(prefix="migrations_", dir=app_path))
# Need to make this directory a proper python module otherwise django will refuse to recognize it
open(os.path.join(temp_dir, "__init__.py"), "a").close()
settings.MIGRATION_MODULES[app_config.label] = "%s.%s" % (
app_config.module.__name__,
os.path.basename(temp_dir),
)

super().__init__(*args, **kwargs)

Expand Down
62 changes: 27 additions & 35 deletions django_squash/db/migrations/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import ast
import functools
import hashlib
import importlib
import inspect
Expand All @@ -9,6 +8,7 @@
import sysconfig
import types
from collections import defaultdict
import functools

from django.db import migrations
from django.utils.module_loading import import_string
Expand Down Expand Up @@ -45,9 +45,6 @@


def source_directory(module):
"""
Return the absolute path of a module
"""
return os.path.dirname(os.path.abspath(inspect.getsourcefile(module)))


Expand Down Expand Up @@ -75,37 +72,39 @@
if inspect.ismethod(func) or inspect.signature(func).parameters.get("self") is not None:
raise ValueError("func cannot be part of an instance")

name = func.__qualname__
name = original_name = func.__qualname__
if "." in name:
parent_name, actual_name = name.rsplit(".", 1)
parent = getattr(import_string(func.__module__), parent_name)
if issubclass(parent, migrations.Migration):
name = actual_name

if func in self.functions:
name = name = original_name = actual_name
already_accounted = func in self.functions
if already_accounted:
return self.functions[func]

name = self.naming_function(name, {**self.context, "type_": "function", "func": func})
new_name = self.functions[func] = self.uniq(name)

return new_name

def uniq(self, name, original_name=None):
original_name = original_name or name
# Endless loop that will try different combinations until it finds a unique name
for i, _ in enumerate(itertools.count(), 2):
if self.names[name] == 0:
self.functions[func] = name
self.names[name] += 1
break

name = "%s_%s" % (original_name, i)

self.functions[func] = name

return name

def __call__(self, name, force_number=False):
original_name = name
if force_number:
name = f"{name}_1"
return self.uniq(name, original_name)
self.names[name] += 1
count = self.names[name]
if not force_number and count == 1:
return name
else:
new_name = "%s_%s" % (name, count)
# Make sure that the function name is fully unique
# You can potentially have the same name already defined.
return self(new_name)


def get_imports(module):
Expand All @@ -130,10 +129,9 @@


def normalize_function_name(name):
_, _, function_name = name.rpartition(".")
if function_name[0].isdigit():
# Functions CANNOT start with a number
function_name = "f_" + function_name
class_name, _, function_name = name.rpartition(".")
if class_name and not function_name:
function_name = class_name

Check warning on line 134 in django_squash/db/migrations/utils.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 134 missing coverage
return function_name


Expand All @@ -145,10 +143,10 @@
func.__qualname__ = name
func.__original__ = f
func.__source__ = re.sub(
pattern=rf"(def\s+){normalize_function_name(f.__qualname__)}",
repl=rf"\1{name}",
string=inspect.getsource(f),
count=1,
rf"(def\s+){normalize_function_name(f.__qualname__)}",
rf"\1{normalize_function_name(name)}",
inspect.getsource(f),
1,
)
return func

Expand All @@ -168,18 +166,12 @@

def is_code_in_site_packages(module_name):
# Find the module in the site-packages directory
site_packages_path_ = site_packages_path()
site_packages_path = sysconfig.get_path("purelib") # returns the "../site-packages" directory
try:
loader = importlib.util.find_spec(module_name)
return site_packages_path in loader.origin
except ImportError:
return False
return loader.origin.startswith(site_packages_path_)


@functools.lru_cache(maxsize=1)
def site_packages_path():
# returns the "../site-packages" directory
return sysconfig.get_path("purelib")


def replace_migration_attribute(source, attr, value):
Expand Down
20 changes: 3 additions & 17 deletions django_squash/db/migrations/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,8 @@
return self.replace_in_migration()

variables = []
custom_naming_function = utils.get_custom_rename_function()
unique_names = utils.UniqueVariableName(
{"app": self.migration.app_label}, naming_function=custom_naming_function
)
unique_names = utils.UniqueVariableName()
for operation in self.migration.operations:
unique_names.update_context(
{
"new_migration": self.migration,
"operation": operation,
"migration": (
operation._original_migration if hasattr(operation, "_original_migration") else self.migration
),
}
)
operation._deconstruct = operation.__class__.deconstruct

def deconstruct(self):
Expand All @@ -191,14 +179,12 @@
# Bind the deconstruct() to the instance to get the elidable
operation.deconstruct = deconstruct.__get__(operation, operation.__class__)
if not utils.is_code_in_site_packages(operation.code.__module__):
code_name = utils.normalize_function_name(unique_names.function(operation.code))
code_name = unique_names.function(operation.code)

Check warning on line 182 in django_squash/db/migrations/writer.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 182 missing coverage
operation.code = utils.copy_func(operation.code, code_name)
operation.code.__in_migration_file__ = True
if operation.reverse_code:
if not utils.is_code_in_site_packages(operation.reverse_code.__module__):
reversed_code_name = utils.normalize_function_name(
unique_names.function(operation.reverse_code)
)
reversed_code_name = unique_names.function(operation.reverse_code)

Check warning on line 187 in django_squash/db/migrations/writer.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 187 missing coverage
operation.reverse_code = utils.copy_func(operation.reverse_code, reversed_code_name)
operation.reverse_code.__in_migration_file__ = True
elif isinstance(operation, dj_migrations.RunSQL):
Expand Down
86 changes: 86 additions & 0 deletions django_squash/management/commands/circularmigrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from django.apps import apps
from django.conf import settings
from django.core.management.base import BaseCommand, no_translations, CommandError
from django.db.migrations.state import ProjectState

Check warning on line 4 in django_squash/management/commands/circularmigrations.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 1-4 missing coverage

from django_squash.db.migrations.autodetector import SquashMigrationAutodetector
from django_squash.db.migrations.loader import SquashMigrationLoader
from django_squash.db.migrations.questioner import NonInteractiveMigrationQuestioner

Check warning on line 8 in django_squash/management/commands/circularmigrations.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 6-8 missing coverage


class Command(BaseCommand):
def add_arguments(self, parser):
pass

Check warning on line 13 in django_squash/management/commands/circularmigrations.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 11-13 missing coverage

@no_translations
def handle(self, **kwargs):
self.verbosity = 1
self.include_header = False

Check warning on line 18 in django_squash/management/commands/circularmigrations.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 15-18 missing coverage

dependency_list = settings.INSTALLED_APPS
apps.all_models

Check warning on line 21 in django_squash/management/commands/circularmigrations.py

View workflow job for this annotation

GitHub Actions / Tests / Coverage

Missing Coverage

Line 20-21 missing coverage

questioner = NonInteractiveMigrationQuestioner(specified_apps=None, dry_run=False)
squash_loader = SquashMigrationLoader(None, ignore_no_migrations=True)
autodetector = SquashMigrationAutodetector(
squash_loader.project_state(),
ProjectState.from_apps(apps),
questioner,
)

changes = autodetector.changes(
squash_loader.graph,
trim_to_apps=None,
convert_apps=None,
migration_name=None,
)
for app_label, migrations in changes.items():
app_label = apps.get_app_config(app_label).name
try:
app_ranking = dependency_list.index(app_label)
except ValueError:
print(f"{app_label} not found")
continue
for migration in migrations:
bad_dependencies = []
for depends_on_app_label, _ in migration.dependencies:
if depends_on_app_label == "__setting__":
continue
depends_on_app_label = apps.get_app_config(depends_on_app_label).name
depends_on_app_ranking = dependency_list.index(depends_on_app_label)
if depends_on_app_ranking > app_ranking:
print(f"* {app_label} ({app_ranking}) > {depends_on_app_label} ({depends_on_app_ranking})")
bad_dependencies.append(depends_on_app_label.split(".")[-1])

if not bad_dependencies:
continue

references_found = []
for operation in migration.operations:
if hasattr(operation, "field") and hasattr(operation.field, "related_model"):
if get_related_model(operation.field.related_model)._meta.app_label in bad_dependencies:
references_found.append(
(
operation.model_name,
operation.name,
get_related_model(operation.field.related_model),
)
)

for name, field in getattr(operation, "fields", []):
if (hasattr(field, "related_model")):
if get_related_model(field.related_model)._meta.app_label in bad_dependencies:
references_found.append((operation.name, name, get_related_model(field.related_model)))

for model_name, field_name, model in references_found:
print(
f" references {model._meta.app_label}.{model._meta.object_name} via {model_name}.{field_name}"
)
if references_found:
raise CommandError("Bad dependencies found")


def get_related_model(model):
if isinstance(model, str):
return apps.get_model(model)
return model
Loading
Loading