Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds ability to rename elidable functions found in operations #42

Merged
merged 24 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
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
3 changes: 3 additions & 0 deletions django_squash/db/migrations/autodetector.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,13 @@ def add_non_elidables(self, loader, changes):
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
65 changes: 40 additions & 25 deletions django_squash/db/migrations/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ast
import functools
import hashlib
import importlib
import inspect
Expand All @@ -12,6 +13,19 @@
from django.db import migrations
from django.utils.module_loading import import_string

from django_squash import settings as app_settings


@functools.lru_cache(maxsize=1)
def get_custom_rename_function():
"""
Custom function naming when copying elidable functions from one file to another.
"""
custom_rename_function = app_settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION

if custom_rename_function:
return import_string(custom_rename_function)


def file_hash(file_path):
"""
Expand All @@ -38,9 +52,14 @@ class UniqueVariableName:
This class will return a unique name for a variable / function.
"""

def __init__(self):
def __init__(self, context, naming_function=None):
self.names = defaultdict(int)
self.functions = {}
self.context = context
self.naming_function = naming_function or (lambda n, c: n)

def update_context(self, context):
self.context.update(context)

def function(self, func):
if not callable(func):
Expand All @@ -52,39 +71,37 @@ def function(self, func):
if inspect.ismethod(func) or inspect.signature(func).parameters.get("self") is not None:
raise ValueError("func cannot be part of an instance")

name = original_name = func.__qualname__
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 = name = original_name = actual_name
already_accounted = func in self.functions
if already_accounted:
name = actual_name

if func in self.functions:
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):
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)
original_name = name
if force_number:
name = f"{name}_1"
return self.uniq(name, original_name)


def get_imports(module):
Expand All @@ -109,9 +126,10 @@ def get_imports(module):


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


Expand All @@ -123,10 +141,7 @@ def copy_func(f, name):
func.__qualname__ = name
func.__original__ = f
func.__source__ = re.sub(
rf"(def\s+){normalize_function_name(f.__qualname__)}",
rf"\1{normalize_function_name(name)}",
inspect.getsource(f),
1,
rf"(def\s+){normalize_function_name(f.__qualname__)}", rf"\1{name}", inspect.getsource(f), 1
)
return func

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

variables = []
unique_names = utils.UniqueVariableName()
custom_naming_function = utils.get_custom_rename_function()
unique_names = utils.UniqueVariableName(
{"app": self.migration.app_label}, naming_function=custom_naming_function
)
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 @@ -179,12 +191,14 @@ def deconstruct(self):
# 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 = unique_names.function(operation.code)
code_name = utils.normalize_function_name(unique_names.function(operation.code))
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 = unique_names.function(operation.reverse_code)
reversed_code_name = utils.normalize_function_name(
unique_names.function(operation.reverse_code)
)
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
1 change: 1 addition & 0 deletions django_squash/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

DJANGO_SQUASH_IGNORE_APPS = getattr(global_settings, "DJANGO_SQUASH_IGNORE_APPS", None) or []
DJANGO_SQUASH_MIGRATION_NAME = getattr(global_settings, "DJANGO_SQUASH_MIGRATION_NAME", None) or "squashed"
DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION = getattr(global_settings, "DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", None) or None
kingbuzzman marked this conversation as resolved.
Show resolved Hide resolved
17 changes: 14 additions & 3 deletions docs/settings.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
Configuration
~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The following settings are available in order to customize your experience.

``DJANGO_SQUASH_IGNORE_APPS``
--------------------------------
----------------------------------------

Default: ``[]`` (Empty list)

Expand All @@ -13,8 +13,19 @@ Example: (``["app1", "app2"]``)
Hardcoded list of apps to always ignore, no matter what, the same as ``--ignore`` in the ``./manage.py squash_migrations`` command.

``DJANGO_SQUASH_MIGRATION_NAME``
--------------------------------
----------------------------------------

Default: ``"squashed"`` (string)

The generated migration name when ``./manage.py squash_migrations`` command is ran.
kingbuzzman marked this conversation as resolved.
Show resolved Hide resolved

``DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION``
----------------------------------------

Default: ``None`` (None)

Example: "path.to.generator_function"

Dot path to the function that will rename the functions found inside ``RunPython`` operations.

Function needs to accept 2 arguments: ``name`` (``str``) and ``context`` (``dict``) and must return a string (``-> str``)
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from django.test.utils import extend_sys_path
from django.utils.module_loading import module_dir

from django_squash.db.migrations.utils import get_custom_rename_function

from . import utils


Expand Down Expand Up @@ -149,6 +151,17 @@ def _call_squash_migrations(*args, **kwargs):
yield _call_squash_migrations


@pytest.fixture(autouse=True)
def clear_get_custom_rename_function_cache():
"""
To ensure that this function doesn't get cached with the wrong value and breaks tests,
we always clear it before and after each test.
"""
get_custom_rename_function.cache_clear()
yield
get_custom_rename_function.cache_clear()


@pytest.fixture
def clean_db(django_db_blocker):
"""
Expand Down
39 changes: 39 additions & 0 deletions tests/test_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,45 @@ class Migration(migrations.Migration):
assert pretty_extract_piece(app_squash, "") == expected


def custom_func_naming(original_name, context):
"""
Used in test_squashing_elidable_migration_unique_name_formatting to format the function names
"""
return f"{context['migration'].name}_{original_name}"


@pytest.mark.temporary_migration_module(module="app.tests.migrations.elidable", app_label="app")
def test_squashing_elidable_migration_unique_name_formatting(migration_app_dir, call_squash_migrations, monkeypatch):
"""
Test that DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION integration works properly
"""
monkeypatch.setattr(
"django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", f"{__name__}.custom_func_naming"
)

class Person(models.Model):
name = models.CharField(max_length=10)
dob = models.DateField()

class Meta:
app_label = "app"

call_squash_migrations()

app_squash = load_migration_module(migration_app_dir / "0004_squashed.py")
source = pretty_extract_piece(app_squash, "")
assert source.count("f_0002_person_age_same_name(") == 1
assert source.count("code=f_0002_person_age_same_name,") == 1
assert source.count("f_0002_person_age_same_name_2(") == 1
assert source.count("code=f_0002_person_age_same_name_2,") == 1
assert source.count("f_0003_add_dob_same_name(") == 1
assert source.count("code=f_0003_add_dob_same_name,") == 1
assert source.count("f_0003_add_dob_create_admin_MUST_ALWAYS_EXIST(") == 1
assert source.count("code=f_0003_add_dob_create_admin_MUST_ALWAYS_EXIST,") == 1
assert source.count("f_0003_add_dob_rollback_admin_MUST_ALWAYS_EXIST(") == 1
assert source.count("code=f_0003_add_dob_rollback_admin_MUST_ALWAYS_EXIST,") == 1


@pytest.mark.temporary_migration_module(module="app.tests.migrations.simple", app_label="app")
@pytest.mark.temporary_migration_module2(module="app2.tests.migrations.foreign_key", app_label="app2")
def test_squashing_migration_simple(migration_app_dir, migration_app2_dir, call_squash_migrations):
Expand Down
47 changes: 43 additions & 4 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,14 @@ def test_is_code_in_site_packages():


def test_unique_names():
names = utils.UniqueVariableName()
names = utils.UniqueVariableName({})
assert names("var") == "var"
assert names("var") == "var_2"
assert names("var_2") == "var_2_2"


def test_unique_function_names_errors():
names = utils.UniqueVariableName()
names = utils.UniqueVariableName({})

with pytest.raises(ValueError):
names.function("not-a-function")
Expand All @@ -96,9 +96,27 @@ def test_unique_function_names_errors():
names.function(D.func)


def test_unique_function_names_context():
def custom_name(name, context):
return "{module}_{i}_{name}".format(**context, name=name)

names = utils.UniqueVariableName({"module": __name__.replace(".", "_")}, naming_function=custom_name)
collector = []
for i, func in enumerate((func2, func2_3, func2_impostor, func2_impostor2)):
names.update_context({"func": func, "i": i})
collector.append(names.function(func))

assert collector == [
"tests_test_utils_0_func2",
"tests_test_utils_1_func2_3",
"tests_test_utils_2_func2",
"tests_test_utils_3_func2",
]


def test_unique_function_names():
uniq1 = utils.UniqueVariableName()
uniq2 = utils.UniqueVariableName()
uniq1 = utils.UniqueVariableName({})
uniq2 = utils.UniqueVariableName({})

reassigned_func2 = func2
reassigned_func2_impostor = func2_impostor
Expand Down Expand Up @@ -150,3 +168,24 @@ def test_normalize_function_name():
assert utils.normalize_function_name(reassigned_func2_impostor.__qualname__) == "func2"
assert utils.normalize_function_name(A().func.__qualname__) == "func"
assert utils.normalize_function_name(D.func.__qualname__) == "func"


def test_get_custom_rename_function(monkeypatch):
"""
Cover all cases where DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION can go wrong
"""
assert not utils.get_custom_rename_function()
utils.get_custom_rename_function.cache_clear()

monkeypatch.setattr("django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", "tests.test_utils.func2")
assert utils.get_custom_rename_function() == func2
utils.get_custom_rename_function.cache_clear()

monkeypatch.setattr("django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", "tests.test_utils.bad")
with pytest.raises(ImportError):
utils.get_custom_rename_function()
utils.get_custom_rename_function.cache_clear()

monkeypatch.setattr("django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", "does.not.exist")
with pytest.raises(ModuleNotFoundError):
utils.get_custom_rename_function()
Loading