Skip to content

Commit

Permalink
v2.1.0 (#8)
Browse files Browse the repository at this point in the history
* v2.1.0

* Small improvements for v2.1.0

* Updated changelog for v2.1.0

---------

Co-authored-by: Ronny Vedrilla <[email protected]>
  • Loading branch information
mad-anne and GitRon committed Jul 10, 2024
1 parent f55ed31 commit c82759c
Show file tree
Hide file tree
Showing 12 changed files with 104 additions and 87 deletions.
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

* *2.1.0* (2024-07-09)
* Discover apps in nested directories
* Use `BASE_DIR` instead of `MIGRATION_ZERO_APPS_DIR`
* Fixed bug for migrations with > 4 leading digits

* *2.0.3* (2024-06-21)
* Linted docs with `blacken-docs` via `ambient-package-update`

Expand Down
2 changes: 1 addition & 1 deletion django_migration_zero/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
Holistic implementation of "migration zero" pattern for Django covering local changes and in-production database adjustments.
"""

__version__ = "2.0.3"
__version__ = "2.1.0"
33 changes: 17 additions & 16 deletions django_migration_zero/helpers/file_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,59 +5,60 @@
from typing import List

from django.apps import apps
from django.apps.config import AppConfig
from django.conf import settings

from django_migration_zero.helpers.logger import get_logger
from django_migration_zero.settings import get_migration_zero_apps_dir

logger = get_logger()


def build_migration_directory_path(*, app_label: str) -> Path:
def build_migration_directory_path(*, app_path: Path) -> Path:
"""
Get directory to the migration directory of a given local Django app
"""
return get_migration_zero_apps_dir() / app_label / "migrations"
return app_path / "migrations"


def get_local_django_apps() -> List[str]:
def get_local_django_apps() -> list[AppConfig]:
"""
Iterate all installed Django apps and detect local ones.
"""
local_apps = []
local_path = str(settings.BASE_DIR).replace("\\", "/")
logger.info("Getting local Django apps...")
for app_config in apps.get_app_configs():
local_path = str(get_migration_zero_apps_dir())
app_path = str(Path(app_config.path))
if local_path in app_path and "site-packages" not in app_path:
app_path = str(app_config.path).replace("\\", "/")
if app_path.startswith(local_path) and "site-packages" not in app_path:
logger.info(f"Local app {app_config.label!r} discovered.")
local_apps.append(app_config.label)
local_apps.append(app_config)
else:
logger.debug(f"App {app_config.label!r} ignored since it's not local.")

return local_apps


def has_migration_directory(*, app_label: str) -> bool:
def has_migration_directory(*, app_path: Path) -> bool:
"""
Determines if the given Django app has a migrations directory and therefore migrations
"""
possible_migration_dir = build_migration_directory_path(app_label=app_label)
possible_migration_dir = build_migration_directory_path(app_path=app_path)
return True if isdir(possible_migration_dir) else False


def get_migration_files(*, app_label: str, exclude_initials: bool = False) -> List[str]:
def get_migration_files(*, app_label: str, app_path: Path, exclude_initials: bool = False) -> List[str]:
"""
Returns a list of all migration files detected in the given Django app.
"""
migration_file_list = []

logger.info(f"Getting migration files from app {app_label!r}...")
migration_dir = build_migration_directory_path(app_label=app_label)
file_pattern = r"^\d{4}_\w+\.py$"
migration_dir = build_migration_directory_path(app_path=app_path)
file_pattern = r"^\d{4,}_\w+\.py$"
for filename in os.listdir(migration_dir):
if re.match(file_pattern, filename):
if exclude_initials:
initial_pattern = r"^\d{4}_initial.py$"
initial_pattern = r"^\d{4,}_initial.py$"
if re.match(initial_pattern, filename):
logger.debug(f"File {filename!r} ignored since it's an initial migration.")
continue
Expand All @@ -70,11 +71,11 @@ def get_migration_files(*, app_label: str, exclude_initials: bool = False) -> Li
return migration_file_list


def delete_file(*, filename: str, app_label: str, dry_run: bool = False) -> None:
def delete_file(*, filename: str, app_path: Path, dry_run: bool = False) -> None:
"""
Physically delete the given file
"""
file_path = build_migration_directory_path(app_label=app_label) / filename
file_path = build_migration_directory_path(app_path=app_path) / filename
if not dry_run:
try:
os.unlink(file_path)
Expand Down
20 changes: 14 additions & 6 deletions django_migration_zero/services/local.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

from django.core.management import call_command

from django_migration_zero.helpers.file_system import (
Expand All @@ -23,17 +25,23 @@ def __init__(self, dry_run: bool = False, exclude_initials: bool = False):

def process(self):
logger = get_logger()
local_app_list = get_local_django_apps()
local_apps = get_local_django_apps()

for app_config in local_apps:
app_path = Path(app_config.path)

for app_label in local_app_list:
if not has_migration_directory(app_label=app_label):
logger.debug(f"Skipping app {app_label!r}. No migration package detected.")
if not has_migration_directory(app_path=app_path):
logger.debug(f"Skipping app {app_config.label!r}. No migration package detected.")
continue

migration_file_list = get_migration_files(app_label=app_label, exclude_initials=self.exclude_initials)
migration_file_list = get_migration_files(
app_label=app_config.label,
app_path=app_path,
exclude_initials=self.exclude_initials,
)

for migration_file in migration_file_list:
delete_file(filename=migration_file, app_label=app_label, dry_run=self.dry_run)
delete_file(filename=migration_file, app_path=app_path, dry_run=self.dry_run)

logger.info("Recreating new initial migration files...")
call_command("makemigrations")
Expand Down
19 changes: 0 additions & 19 deletions django_migration_zero/settings.py

This file was deleted.

10 changes: 2 additions & 8 deletions docs/features/02_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,11 @@

# Settings

This package requires all local Django apps to be inside a single directory. This directory needs to be defined in the
main django settings.

Usually, you can use the `BASE_DIR` variable from Django's default setup and add a path.
This package requires all local Django apps to be inside a `BASE_DIR` directory. This directory should be already
defined in the main django settings.

Note that this variable has to be of type `pathlib.Path`.

```python
MIGRATION_ZERO_APPS_DIR = BASE_DIR / "apps"
```

## Logging

The scripts are quite chatty. If you want to see what's going on under the hood, just configure a quite default-looking
Expand Down
3 changes: 1 addition & 2 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"django_migration_zero",
# Local
"testapp",
"testapp.nested_app",
]

MIDDLEWARE = [
Expand Down Expand Up @@ -81,8 +82,6 @@
}
}

MIGRATION_ZERO_APPS_DIR = BASE_DIR / "django-migration-zero"

# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators

Expand Down
Empty file added testapp/nested_app/__init__.py
Empty file.
Empty file.
54 changes: 43 additions & 11 deletions tests/helpers/test_file_system.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from pathlib import Path
from unittest import mock

from django.test import TestCase, override_settings
from django.apps import apps
from django.test import TestCase

from django_migration_zero.helpers.file_system import (
build_migration_directory_path,
Expand All @@ -13,37 +14,68 @@


class HelperFileSystemTest(TestCase):
@override_settings(MIGRATION_ZERO_APPS_DIR=Path("/user/workspace/migration_zero/"))
def setUp(self):
self.django_migration_zero_config = apps.get_app_config("django_migration_zero")
self.testapp_config = apps.get_app_config("testapp")
self.nested_app_config = apps.get_app_config("nested_app")

def test_build_migration_directory_path_regular(self, *args):
path = Path("/user/workspace/migration_zero/ilyta")
self.assertEqual(
build_migration_directory_path(app_label="ilyta"), Path("/user/workspace/migration_zero/ilyta/migrations")
build_migration_directory_path(app_path=path), Path("/user/workspace/migration_zero/ilyta/migrations")
)

def test_get_local_django_apps_regular(self):
self.assertEqual(get_local_django_apps(), ["django_migration_zero", "testapp"])
self.assertEqual(
get_local_django_apps(),
[
self.django_migration_zero_config,
self.testapp_config,
self.nested_app_config,
],
)

def test_has_migration_directory_positive_case(self):
self.assertTrue(has_migration_directory(app_label="django_migration_zero"))
app_path = Path(self.django_migration_zero_config.path)
self.assertTrue(has_migration_directory(app_path=app_path))

def test_has_migration_directory_negative_case(self):
self.assertFalse(has_migration_directory(app_label="testapp"))
app_path = Path(self.testapp_config.path)
self.assertFalse(has_migration_directory(app_path=app_path))

def test_has_migration_directory_positive_case_no_migrations(self):
app_path = Path(self.nested_app_config.path)
self.assertTrue(has_migration_directory(app_path=app_path))

def test_get_migration_files_regular(self):
self.assertEqual(get_migration_files(app_label="django_migration_zero"), ["0001_initial.py"])
self.assertEqual(
get_migration_files(
app_label=self.django_migration_zero_config.label,
app_path=Path(self.django_migration_zero_config.path),
),
["0001_initial.py"],
)

def test_get_migration_files_exclude_initials(self):
self.assertEqual(get_migration_files(app_label="django_migration_zero", exclude_initials=True), [])
self.assertEqual(
get_migration_files(
app_label=self.django_migration_zero_config.label,
app_path=Path(self.django_migration_zero_config.path),
exclude_initials=True,
),
[],
)

@mock.patch("django_migration_zero.helpers.file_system.os.unlink")
def test_delete_file_regular(self, mocked_unlink):
delete_file(app_label="testapp", filename="my_file.py")
delete_file(app_path=Path(self.testapp_config.path), filename="my_file.py")
mocked_unlink.assert_called_once()

@mock.patch("django_migration_zero.helpers.file_system.os.unlink")
def test_delete_file_dry_run(self, mocked_unlink):
delete_file(app_label="testapp", filename="my_file.py", dry_run=True)
delete_file(app_path=Path(self.testapp_config.path), filename="my_file.py", dry_run=True)
mocked_unlink.assert_not_called()

@mock.patch("django_migration_zero.helpers.file_system.os.unlink", side_effect=OSError)
def test_delete_file_os_error(self, *args):
self.assertIsNone(delete_file(app_label="testapp", filename="my_file.py", dry_run=False))
self.assertIsNone(delete_file(app_path=Path(self.testapp_config.path), filename="my_file.py", dry_run=False))
27 changes: 21 additions & 6 deletions tests/services/test_local.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,47 @@
from pathlib import Path
from unittest import mock

from django.apps import apps
from django.test import TestCase

from django_migration_zero.services.local import ResetMigrationFiles


@mock.patch("django_migration_zero.services.local.delete_file")
class ResetMigrationFilesTest(TestCase):
def setUp(self):
self.django_migration_zero_config = apps.get_app_config("django_migration_zero")
self.testapp_config = apps.get_app_config("testapp")
self.nested_app_config = apps.get_app_config("nested_app")

def test_init_regular(self, *args):
service = ResetMigrationFiles(dry_run=True, exclude_initials=True)

self.assertTrue(service.dry_run)
self.assertTrue(service.exclude_initials)

@mock.patch("django_migration_zero.services.local.get_local_django_apps", return_value=["django_migration_zero"])
@mock.patch("django_migration_zero.services.local.has_migration_directory", return_value=True)
def test_process_case_app_with_migration_found(self, mocked_has_migration_directory, *args):
service = ResetMigrationFiles()
service.process()

with mock.patch(
"django_migration_zero.services.local.get_local_django_apps",
return_value=[self.django_migration_zero_config],
):
service.process()

# Assertion
mocked_has_migration_directory.assert_called()

@mock.patch("django_migration_zero.services.local.get_local_django_apps", return_value=["django_migration_zero"])
@mock.patch("django_migration_zero.services.local.has_migration_directory", return_value=False)
def test_process_case_app_with_no_migration_found(self, mocked_has_migration_directory, *args):
service = ResetMigrationFiles()
service.process()

with mock.patch(
"django_migration_zero.services.local.get_local_django_apps",
return_value=[self.testapp_config, self.nested_app_config],
):
service.process()

# Assertion
mocked_has_migration_directory.assert_called()
Expand All @@ -37,7 +52,7 @@ def test_process_delete_file_called(self, mocked_delete_file):

# Assertion
mocked_delete_file.assert_called_with(
filename="0001_initial.py", app_label="django_migration_zero", dry_run=False
filename="0001_initial.py", app_path=Path(self.django_migration_zero_config.path), dry_run=False
)

def test_process_delete_file_case_dry_run(self, mocked_delete_file):
Expand All @@ -46,7 +61,7 @@ def test_process_delete_file_case_dry_run(self, mocked_delete_file):

# Assertion
mocked_delete_file.assert_called_with(
filename="0001_initial.py", app_label="django_migration_zero", dry_run=True
filename="0001_initial.py", app_path=Path(self.django_migration_zero_config.path), dry_run=True
)

def test_process_delete_file_case_exclude_initials(self, mocked_delete_file):
Expand Down
18 changes: 0 additions & 18 deletions tests/test_settings.py

This file was deleted.

0 comments on commit c82759c

Please sign in to comment.