├── .editorconfig ├── .github └── workflows │ ├── _tests.yml │ ├── daily_tests.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.rst ├── codecov.yml ├── django_squash ├── __init__.py ├── apps.py ├── contrib │ ├── __init__.py │ └── postgres.py ├── db │ ├── __init__.py │ └── migrations │ │ ├── __init__.py │ │ ├── autodetector.py │ │ ├── loader.py │ │ ├── operators.py │ │ ├── questioner.py │ │ ├── serializer.py │ │ ├── utils.py │ │ └── writer.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── squash_migrations.py └── settings.py ├── docs ├── motivation.rst └── settings.rst ├── pyproject.toml └── tests ├── __init__.py ├── app ├── __init__.py ├── models.py └── tests │ ├── __init__.py │ └── migrations │ ├── dangling_leaf │ ├── 0001_initial.py │ ├── 0002_person_age.py │ ├── 0003_add_dob.py │ ├── 0003_squashed.py │ └── __init__.py │ ├── delete_replaced │ ├── 0001_initial.py │ ├── 0002_person_age.py │ ├── 0003_add_dob.py │ ├── 0004_squashed.py │ └── __init__.py │ ├── elidable │ ├── 0001_initial.py │ ├── 0002_person_age.py │ ├── 0003_add_dob.py │ └── __init__.py │ ├── empty │ └── __init__.py │ ├── incorrect_name │ ├── 2_person_age.py │ ├── 3000_auto_20190518_1524.py │ ├── __init__.py │ ├── bad_no_name.py │ └── initial.py │ ├── pg_indexes │ ├── 0001_initial.py │ ├── 0002_use_index.py │ └── __init__.py │ ├── pg_indexes_custom │ ├── 0001_initial.py │ ├── 0002_use_index.py │ └── __init__.py │ ├── run_python_noop │ ├── 0001_initial.py │ ├── 0002_run_python.py │ └── __init__.py │ ├── simple │ ├── 0001_initial.py │ ├── 0002_person_age.py │ ├── 0003_auto_20190518_1524.py │ └── __init__.py │ └── swappable_dependency │ ├── 0001_initial.py │ ├── 0002_add_dob.py │ └── __init__.py ├── app2 ├── __init__.py ├── models.py └── tests │ ├── __init__.py │ └── migrations │ ├── empty │ └── __init__.py │ └── foreign_key │ ├── 0001_initial.py │ └── __init__.py ├── app3 ├── __init__.py ├── models.py └── tests │ ├── __init__.py │ └── migrations │ ├── __init__.py │ └── moved │ ├── 0001_initial.py │ ├── 0002_person_age.py │ ├── 0003_moved.py │ └── __init__.py ├── conftest.py ├── settings.py ├── test_migrations.py ├── test_migrations_autodetector.py ├── test_serializer.py ├── test_standalone.py ├── test_utils.py ├── test_writer.py ├── urls.py └── utils.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Docstrings and comments use max_line_length = 79 14 | [*.py] 15 | max_line_length = 119 16 | 17 | [*.yml] 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.github/workflows/_tests.yml: -------------------------------------------------------------------------------- 1 | name: Test Include 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | ref: 7 | required: true 8 | type: string 9 | 10 | env: 11 | DEFAULT_PYTHON_VERSION: 3.12 12 | 13 | jobs: 14 | prepare-matrix: 15 | name: Prepare Python/Django matrix 16 | runs-on: ubuntu-latest 17 | continue-on-error: true 18 | outputs: 19 | matrix: ${{ steps.set-matrix.outputs.matrix }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | ref: ${{ inputs.ref }} 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ env.DEFAULT_PYTHON_VERSION }} 27 | - name: Install the linters 28 | run: | 29 | pip install uv 30 | uv pip install --system --upgrade setuptools toml 31 | # Note that we don't install .[lint] becuase we don't want to install "this" pakage + lint. We ONLY want to install the "lint" 32 | uv pip install --system $(python -c 'import toml; print(" ".join(toml.load(open("pyproject.toml"))["project"]["optional-dependencies"]["lint"]))') 33 | - name: Generate matrix 34 | run: | 35 | python -c ' 36 | import toml 37 | import json 38 | import os 39 | 40 | DJANGO_VERSIONS = [] 41 | PYTHON_VERSIONS = [] 42 | EXCLUDE_MATRIX = {"3.9": ["5.0.*", "5.1.*", "5.2.*", "main"], "3.10": ["5.2.*", "main"], "3.11": ["5.2.*", "main"]} 43 | 44 | def is_number(s): 45 | try: 46 | float(s) 47 | return True 48 | except ValueError: 49 | return False 50 | 51 | with open("pyproject.toml") as f: 52 | conf = toml.load(f) 53 | for classifier in conf["project"]["classifiers"]: 54 | if "Framework :: Django ::" in classifier: 55 | version = classifier.split("::")[-1].strip() 56 | if "." in version and is_number(version): 57 | DJANGO_VERSIONS.append(version) 58 | elif "Programming Language :: Python ::" in classifier: 59 | version = classifier.split("::")[-1].strip() 60 | if "." in version and is_number(version): 61 | PYTHON_VERSIONS.append(version) 62 | 63 | matrix = { 64 | "python-version": PYTHON_VERSIONS, 65 | "django-version": [f"{v}.*" for v in DJANGO_VERSIONS] + ["main"], 66 | "exclude": [{"python-version": p, "django-version": d} for p, djs in EXCLUDE_MATRIX.items() for d in djs], 67 | } 68 | 69 | with open(os.getenv("GITHUB_ENV"), "a") as env_file: 70 | pretty = " ".join(DJANGO_VERSIONS) 71 | env_file.write(f"django={pretty}\n") 72 | pretty = " ".join(PYTHON_VERSIONS) 73 | env_file.write(f"python={pretty}\n") 74 | env_file.write(f"matrix={json.dumps(matrix)}\n") 75 | ' 76 | - name: Check version EOF 77 | id: set-matrix 78 | run: | 79 | echo "matrix=$matrix" >> $GITHUB_OUTPUT 80 | python -c " 81 | import os 82 | import json 83 | from urllib.request import Request, urlopen 84 | from datetime import date, datetime, timedelta 85 | 86 | DJANGO_VERSIONS = os.getenv('django').split() 87 | PYTHON_VERSIONS = os.getenv('python').split() 88 | 89 | today = date.today() 90 | WARNING_DAYS = timedelta(days=90) 91 | version_by_product = { 92 | 'django': DJANGO_VERSIONS, 93 | 'python': PYTHON_VERSIONS 94 | } 95 | for product, supported_versions in version_by_product.items(): 96 | url = f'https://endoflife.date/api/{product}.json' 97 | with urlopen(Request(url)) as response: 98 | data = json.loads(response.read()) 99 | for detail in data: 100 | version = detail['cycle'] 101 | eol = detail['eol'] 102 | eol_date = datetime.strptime(eol, '%Y-%m-%d').date() 103 | if version not in supported_versions: 104 | if eol_date > today: 105 | print(f'::error ::{product} v{version}: is not in the supported versions list') 106 | continue 107 | if eol_date < today: 108 | print(f'::error ::{product} v{version}: EOL was {eol}') 109 | elif eol_date - today < WARNING_DAYS: 110 | print(f'::warning ::{product} v{version}: EOL is coming up on the {eol}') 111 | " 112 | - name: Ruff check 113 | if: always() 114 | run: | 115 | ruff check --extend-select I . 116 | ruff format --check . 117 | - name: Vulture check 118 | if: always() 119 | run: | 120 | vulture 121 | - name: rst check 122 | if: always() 123 | run: rst-lint . 124 | - name: Continue 125 | if: always() 126 | run: exit 0 127 | 128 | tests: 129 | runs-on: ubuntu-latest 130 | needs: prepare-matrix 131 | strategy: 132 | fail-fast: false 133 | matrix: ${{fromJson(needs.prepare-matrix.outputs.matrix)}} 134 | name: Python ${{ matrix.python-version }} - Django ${{ matrix.django-version }} 135 | steps: 136 | - uses: actions/checkout@v4 137 | with: 138 | ref: ${{ inputs.ref }} 139 | - uses: actions/setup-python@v5 140 | with: 141 | python-version: ${{ matrix.python-version }} 142 | - name: Cache pip 143 | uses: actions/cache@v4 144 | with: 145 | path: ~/.cache/pip 146 | key: ${{ runner.os }}-py-${{ matrix.python-version }}-dj-${{ matrix.django-version }} 147 | - name: Install prerequisites 148 | run: | 149 | echo ARTIFACT_NAME=coverage_${{ runner.os }}-py-${{ matrix.python-version }}-dj-${{ matrix.django-version }} | sed 's|\.\*||g' >> "$GITHUB_ENV" 150 | DJANGO="django==${{ matrix.django-version }}" 151 | if [[ "${{ matrix.django-version }}" == "main" ]]; then 152 | DJANGO="git+https://github.com/django/django.git@main" 153 | fi 154 | pip install uv 155 | uv pip install --system --upgrade setuptools codecov-cli $DJANGO 156 | - name: Install packages 157 | run: uv pip install --system -e '.[test]' 158 | - name: Run tests 159 | run: pytest --cov django_squash --cov-report=term --cov-context test 160 | - name: Upload coverage artifact 161 | uses: actions/upload-artifact@v4 162 | with: 163 | include-hidden-files: true 164 | if-no-files-found: error 165 | name: ${{ env.ARTIFACT_NAME }} 166 | path: .coverage 167 | retention-days: 1 168 | 169 | coverage: 170 | runs-on: ubuntu-latest 171 | needs: tests 172 | if: success() || failure() 173 | steps: 174 | - uses: actions/checkout@v4 175 | - uses: actions/setup-python@v5 176 | with: 177 | python-version: ${{ env.DEFAULT_PYTHON_VERSION }} 178 | - name: Install coverage 179 | run: pip install coverage 180 | - name: Download artifacts 181 | uses: actions/download-artifact@v4 182 | with: 183 | path: downloaded_artifacts 184 | - name: Clean up temporary artifacts 185 | uses: geekyeggo/delete-artifact@v5 186 | with: 187 | name: coverage_* 188 | - name: Combine coverage.py 189 | run: | 190 | coverage combine $(find downloaded_artifacts/ -type f | xargs) 191 | # Used by codecov 192 | coverage xml 193 | - name: Upload single coverage artifact 194 | uses: actions/upload-artifact@v4 195 | with: 196 | include-hidden-files: true 197 | if-no-files-found: error 198 | name: .coverage 199 | path: .coverage 200 | retention-days: 1 201 | 202 | - name: Upload coverage to Codecov 203 | uses: codecov/codecov-action@v4 204 | continue-on-error: true 205 | with: 206 | fail_ci_if_error: true 207 | token: ${{ secrets.CODECOV_TOKEN }} 208 | env_vars: OS,RUST 209 | -------------------------------------------------------------------------------- /.github/workflows/daily_tests.yml: -------------------------------------------------------------------------------- 1 | name: Daily Tests 2 | 3 | concurrency: 4 | group: django-squash-${{ github.head_ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | schedule: 9 | - cron: 0 0 * * * # Run every day at midnight 10 | 11 | jobs: 12 | daily-tests: 13 | name: Django nightlies work 14 | uses: ./.github/workflows/_tests.yml 15 | secrets: inherit 16 | with: 17 | ref: master 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Test & Release 2 | 3 | concurrency: 4 | group: django-squash-release-${{ github.head_ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | django-squash: 14 | uses: ./.github/workflows/_tests.yml 15 | secrets: inherit 16 | with: 17 | ref: ${{ github.ref }} 18 | 19 | pypi-publish: 20 | name: Tag and Release 21 | runs-on: ubuntu-latest 22 | environment: deploy 23 | needs: django-squash 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.x" 30 | - name: Install pypa/build 31 | run: python3 -m pip install build setuptools toml 32 | - name: Build a binary wheel and a source tarball 33 | run: python3 -m build 34 | - name: Extract django-squash version 35 | run: | 36 | SQUASH_VERSION=$(python -c 'import toml; print(toml.load(open("pyproject.toml"))["project"]["version"])') 37 | echo "SQUASH_VERSION=$SQUASH_VERSION" >> "$GITHUB_ENV" 38 | - name: Package version has corresponding git tag 39 | id: tagged 40 | shell: bash 41 | run: | 42 | git fetch --tags 43 | (git show-ref --tags --verify --quiet -- "refs/tags/v$SQUASH_VERSION" && echo "tagged=1" || echo "tagged=0") >> $GITHUB_OUTPUT 44 | - name: Create tags 45 | if: steps.tagged.outputs.tagged == 0 46 | run: | 47 | git tag v$SQUASH_VERSION 48 | git push origin v$SQUASH_VERSION 49 | - name: Publish distribution 📦 to PyPI 50 | if: steps.tagged.outputs.tagged == 0 51 | uses: pypa/gh-action-pypi-publish@release/v1 52 | with: 53 | verbose: true 54 | user: __token__ 55 | password: ${{ secrets.PYPI_API_TOKEN }} 56 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | concurrency: 4 | group: django-squash-${{ github.head_ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | branches: 10 | - "**" 11 | 12 | jobs: 13 | django-squash: 14 | name: Tests 15 | uses: ./.github/workflows/_tests.yml 16 | secrets: inherit 17 | with: 18 | ref: ${{ github.ref }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.egg-info 4 | *.sqlite3 5 | .DS_Store 6 | .eggs 7 | .coverage 8 | coverage.xml 9 | htmlcov/ 10 | build/ 11 | dist/ 12 | venv/ 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to 3 | deal in the Software without restriction, including without limitation the 4 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 5 | sell copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 16 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 17 | IN THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/django-squash.svg?style=flat 2 | :alt: Supported PyPi Version 3 | :target: https://pypi.python.org/pypi/django-squash 4 | 5 | .. image:: https://img.shields.io/pypi/pyversions/django-squash.svg 6 | :alt: Supported Python versions 7 | :target: https://pypi.python.org/pypi/django-squash 8 | 9 | .. image:: https://img.shields.io/pypi/djversions/django-squash.svg 10 | :alt: Supported Django versions 11 | :target: https://pypi.org/project/django-squash/ 12 | 13 | .. image:: https://codecov.io/gh/kingbuzzman/django-squash/branch/master/graph/badge.svg 14 | :alt: Coverage 15 | :target: https://codecov.io/gh/kingbuzzman/django-squash 16 | 17 | .. image:: https://img.shields.io/pypi/dm/django-squash 18 | :alt: PyPI - Downloads 19 | :target: https://pypistats.org/packages/django-squash 20 | 21 | django-squash 22 | ======================== 23 | 24 | "django-squash" is a migration enhancement built on top of Django_'s standard migration classes. It aims to eliminate bloat and slowness in migration processes by replacing certain commands. The vision and architecture of Django migrations remain unchanged. 25 | 26 | Before using "django-squash," it's important to understand the normal Django ``makemigrations`` and ``squashmigrations`` commands. Migration files consist of operations that may or may not affect the database table for a model. "elidable" operations can be eliminated when squashing migrations, while "non-elidable" operations cannot. Best way to think about the word "elidable" is to simply think "forgetable" or "disgardable" -- can this operation be disgarded once it's been ran? 27 | 28 | The package introduces a command named ``squash_migrations`` as an alternative to Django's ``squashmigrations``. This command minimizes the number of operations needed to build the database's schema, resulting in faster testing pipelines and deployments, especially in scenarios with multiple tenants. 29 | 30 | The catch lies in proper usage of elidable vs. non-elidable operations and the requirement that databases must not fall behind to the point where eliminated migration operations are needed. The ``squash_migrations`` command removes all elidable operations and preserves non-elidable ones. 31 | 32 | It's crucial to run the ``squash_migrations`` command once per release after cutting the release. All databases must be on the current release, the prior release, or somewhere in between. Databases before the prior release cannot directly upgrade to the current release; they must first apply the prior release's migrations and then the current release's minimal operations. 33 | 34 | This approach is not a tutorial on migration strategy but emphasizes the need for understanding multi-app systems, avoiding circular dependencies, and designing efficient migration processes. The tool is developed based on experience and frustration, aiming to automate and improve the migration squashing process. 35 | 36 | You can read more about our motivation_ to creating this tool. 37 | 38 | Setup 39 | ~~~~~~~~~~~~~~~~~~~~~~~~ 40 | 41 | 1. ``pip install django-squash`` 42 | 43 | 2. Add ``django_squash`` to your ``INSTALLED_APPS``. 44 | 45 | (optional) There are some settings_ you can customize 46 | 47 | 3. Run ``./manage.py squash_migrations`` once *after* each release 48 | 49 | 4. Profit! 50 | 51 | 52 | Developing 53 | ~~~~~~~~~~~~~~~~~~~~~~~~ 54 | 55 | 1. clone the repo 56 | 57 | 2. ``cd`` into repo 58 | 59 | 3. (optional) run inside ``docker`` environment that way you can change the python version quickly and iterate faster 60 | 61 | .. code-block:: shell 62 | 63 | docker run --rm -it -v .:/app -v django-squash-pip-cache:/root/.cache/pip -e PYTHONDONTWRITEBYTECODE=1 python:3.12 bash -c "cd app; pip install -e .[test,lint]; echo \"alias linters=\\\"echo '> isort'; isort .; echo '> black'; black .; echo '> ruff'; ruff check .;echo '> flake8'; flake8 .; echo '> rst-lint'; rst-lint README.rst docs/*\\\"\" >> ~/.bash_profile; printf '\n\n\nrun **pytest** to run tests, **linters** to run linters\n\n'; exec bash --init-file ~/.bash_profile" 64 | 65 | Alternatively, you can also create a virtual environment and run 66 | 67 | .. code-block:: shell 68 | 69 | python3 -m venv venv 70 | 71 | .. code-block:: shell 72 | 73 | source venv/bin/activate 74 | 75 | .. code-block:: shell 76 | 77 | pip install -e '.[test]' 78 | 79 | 4. Run tests 80 | 81 | .. code-block:: shell 82 | 83 | pytest 84 | 85 | 5. Before making a commit, make sure that the formatter and linter tools do not detect any issues. 86 | 87 | .. code-block:: shell 88 | 89 | ruff check --fix --extend-select I . 90 | ruff format . 91 | rst-lint . 92 | 93 | .. _Django: http://djangoproject.com 94 | .. _`settings`: docs/settings.rst 95 | .. _`motivation`: docs/motivation.rst 96 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 50..85 3 | 4 | status: 5 | project: 6 | enabled: yes 7 | target: auto 8 | threshold: 5% 9 | -------------------------------------------------------------------------------- /django_squash/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from importlib.metadata import version 4 | 5 | __version__ = version("django_squash") 6 | -------------------------------------------------------------------------------- /django_squash/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class DjangoSquashConfig(AppConfig): 7 | """Main app config.""" 8 | 9 | name = "django_squash" 10 | -------------------------------------------------------------------------------- /django_squash/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/django_squash/contrib/__init__.py -------------------------------------------------------------------------------- /django_squash/contrib/postgres.py: -------------------------------------------------------------------------------- 1 | """Postgres specific code.""" 2 | 3 | from __future__ import annotations 4 | 5 | try: 6 | from django.contrib.postgres.operations import CreateExtension as PGCreateExtension 7 | except ImportError: # pragma: no cover 8 | 9 | class PGCreateExtension: # noqa: D101 10 | pass 11 | 12 | 13 | __all__ = ("PGCreateExtension",) 14 | -------------------------------------------------------------------------------- /django_squash/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/django_squash/db/__init__.py -------------------------------------------------------------------------------- /django_squash/db/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/django_squash/db/migrations/__init__.py -------------------------------------------------------------------------------- /django_squash/db/migrations/autodetector.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | import os 4 | import sys 5 | from collections import defaultdict 6 | 7 | from django.apps import apps 8 | from django.conf import settings 9 | from django.db import migrations as dj_migrations 10 | from django.db.migrations.autodetector import MigrationAutodetector as MigrationAutodetectorBase 11 | 12 | from django_squash.contrib import postgres 13 | from django_squash.db.migrations import utils 14 | 15 | RESERVED_MIGRATION_KEYWORDS = ("_deleted", "_dependencies_change", "_replaces_change", "_original_migration") 16 | 17 | 18 | class Migration(dj_migrations.Migration): 19 | 20 | def __init__(self, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self._deleted = False 23 | self._dependencies_change = False 24 | self._replaces_change = False 25 | self._original_migration = None 26 | 27 | def describe(self): 28 | if self._deleted: 29 | yield "Deleted" 30 | if self._dependencies_change: 31 | yield '"dependencies" changed' 32 | if self._replaces_change: 33 | yield '"replaces" keyword removed' 34 | 35 | @property 36 | def is_migration_level(self): 37 | return self._deleted or self._dependencies_change or self._replaces_change 38 | 39 | def __getitem__(self, index): 40 | return (self.app_label, self.name)[index] 41 | 42 | def __iter__(self): 43 | yield from (self.app_label, self.name) 44 | 45 | @classmethod 46 | def from_migration(cls, migration): 47 | if cls in type(migration).mro(): 48 | return migration 49 | 50 | for keyword in RESERVED_MIGRATION_KEYWORDS: 51 | if hasattr(migration, keyword): 52 | raise RuntimeError( 53 | 'Cannot use keyword "%s" in Migration %s.%s' % (keyword, migration.app_label, migration.name) 54 | ) 55 | 56 | new = cls(name=migration.name, app_label=migration.app_label) 57 | new.__dict__.update(migration.__dict__) 58 | new._original_migration = migration 59 | return new 60 | 61 | 62 | class SquashMigrationAutodetector(MigrationAutodetectorBase): 63 | 64 | def add_non_elidables(self, loader, changes): 65 | replacing_migrations_by_app = { 66 | app: [ 67 | loader.disk_migrations[r] 68 | for r in list(dict.fromkeys(itertools.chain.from_iterable([m.replaces for m in migrations]))) 69 | ] 70 | for app, migrations in changes.items() 71 | } 72 | 73 | for app in changes.keys(): 74 | new_operations = [] 75 | new_operations_bubble_top = [] 76 | new_imports = [] 77 | 78 | for migration in replacing_migrations_by_app[app]: 79 | module = sys.modules[migration.__module__] 80 | new_imports.extend(utils.get_imports(module)) 81 | for operation in migration.operations: 82 | if operation.elidable: 83 | continue 84 | 85 | if isinstance(operation, dj_migrations.RunSQL): 86 | operation._original_migration = migration 87 | new_operations.append(operation) 88 | elif isinstance(operation, dj_migrations.RunPython): 89 | operation._original_migration = migration 90 | new_operations.append(operation) 91 | elif isinstance(operation, postgres.PGCreateExtension): 92 | operation._original_migration = migration 93 | new_operations_bubble_top.append(operation) 94 | elif isinstance(operation, dj_migrations.SeparateDatabaseAndState): 95 | # A valid use case for this should be given before any work is done. 96 | pass 97 | 98 | if new_operations_bubble_top: 99 | migration = changes[app][0] 100 | migration.operations = new_operations_bubble_top + migration.operations 101 | migration.extra_imports = new_imports 102 | 103 | migration = changes[app][-1] 104 | migration.operations += new_operations 105 | migration.extra_imports = new_imports 106 | 107 | def replace_current_migrations(self, original, graph, changes): 108 | """ 109 | Adds 'replaces' to the squash migrations with all the current apps we have. 110 | """ 111 | migrations_by_app = defaultdict(list) 112 | for app, migration in original.graph.node_map: 113 | migrations_by_app[app].append((app, migration)) 114 | 115 | for app, migrations in changes.items(): 116 | for migration in migrations: 117 | # TODO: maybe use a proper order??? 118 | migration.replaces = sorted(migrations_by_app[app]) 119 | 120 | def rename_migrations(self, original, graph, changes, migration_name): 121 | """ 122 | Continues the numbering from whats there now. 123 | """ 124 | current_counters_by_app = defaultdict(int) 125 | for app, migration in original.graph.node_map: 126 | migration_number, _, _ = migration.partition("_") 127 | if migration_number.isdigit(): 128 | current_counters_by_app[app] = max([int(migration_number), current_counters_by_app[app]]) 129 | 130 | for app, migrations in changes.items(): 131 | for migration in migrations: 132 | next_number = current_counters_by_app[app] = current_counters_by_app[app] + 1 133 | migration_name = datetime.datetime.now().strftime(migration_name) 134 | migration.name = "%04i_%s" % ( 135 | next_number, 136 | migration_name or "squashed", 137 | ) 138 | 139 | def convert_migration_references_to_objects(self, original, changes, ignore_apps): 140 | """ 141 | Swap django.db.migrations.Migration with a custom one that behaves like a tuple when read, but is still an 142 | object for the purpose of easy renames. 143 | """ 144 | migrations_by_name = {} 145 | 146 | # First pass, swapping new objects 147 | for app_label, migrations in changes.items(): 148 | new_migrations = [] 149 | for migration in migrations: 150 | migration_id = migration.app_label, migration.name 151 | new_migration = Migration.from_migration(migration) 152 | migrations_by_name[migration_id] = new_migration 153 | new_migrations.append(new_migration) 154 | changes[app_label] = new_migrations 155 | 156 | # Second pass, replace the tuples with the newly created objects 157 | for app_label, migrations in changes.items(): 158 | for migration in migrations: 159 | new_dependencies = [] 160 | for dependency in migration.dependencies: 161 | dep_app_label, dep_migration = dependency 162 | if dep_app_label in ignore_apps: 163 | new_dependencies.append(original.graph.leaf_nodes(dep_app_label)[0]) 164 | continue 165 | 166 | if dep_app_label == "__setting__": 167 | app_label = getattr(settings, dep_migration).split(".")[0] 168 | migrations = [ 169 | migration for (app, _), migration in migrations_by_name.items() if app == app_label 170 | ] 171 | if len(migrations) > 0: 172 | dependency = tuple(migrations[-1]) 173 | else: 174 | # Leave as is, the django's migration writer will handle this by default 175 | new_dependencies.append(dependency) 176 | continue 177 | # Technically, the terms '__first__' and '__latest__' could apply to dependencies. However, these 178 | # are not labels that Django assigns automatically. Instead, they would be manually specified by 179 | # the developer after Django has generated the necessary files. Given that our focus is solely 180 | # on handling migrations created by Django, there is no practical need to account for these. 181 | 182 | migration_id = dependency 183 | if migration_id not in migrations_by_name: 184 | new_migration = Migration.from_migration(original.disk_migrations[migration_id]) 185 | migrations_by_name[migration_id] = new_migration 186 | new_dependencies.append(migrations_by_name[migration_id]) 187 | 188 | migration.dependencies = new_dependencies 189 | 190 | def create_deleted_models_migrations(self, loader, changes): 191 | migrations_by_label = defaultdict(list) 192 | for (app, ident), _ in itertools.groupby(loader.disk_migrations.items(), lambda x: x[0]): 193 | migrations_by_label[app].append(ident) 194 | 195 | for app_config in loader.project_state().apps.get_app_configs(): 196 | if app_config.models and app_config.label in migrations_by_label: 197 | migrations_by_label.pop(app_config.label) 198 | 199 | for app_label, migrations in migrations_by_label.items(): 200 | subclass = type("Migration", (Migration,), {"operations": [], "dependencies": []}) 201 | instance = subclass("temp", app_label) 202 | instance.replaces = migrations 203 | changes[app_label] = [instance] 204 | 205 | def squash(self, real_loader, squash_loader, ignore_apps, migration_name=None): 206 | changes_ = self.delete_old_squashed(real_loader, ignore_apps) 207 | 208 | graph = squash_loader.graph 209 | changes = super().changes(graph, trim_to_apps=None, convert_apps=None, migration_name=None) 210 | 211 | for app in ignore_apps: 212 | changes.pop(app, None) 213 | 214 | self.create_deleted_models_migrations(real_loader, changes) 215 | self.convert_migration_references_to_objects(real_loader, changes, ignore_apps) 216 | self.rename_migrations(real_loader, graph, changes, migration_name) 217 | self.replace_current_migrations(real_loader, graph, changes) 218 | self.add_non_elidables(real_loader, changes) 219 | 220 | for app, change in changes_.items(): 221 | changes[app].extend(change) 222 | 223 | return changes 224 | 225 | def delete_old_squashed(self, loader, ignore_apps): 226 | changes = defaultdict(set) 227 | project_path = os.path.abspath(os.curdir) 228 | project_apps = [ 229 | app.label for app in apps.get_app_configs() if utils.source_directory(app.module).startswith(project_path) 230 | ] 231 | 232 | real_migrations = [ 233 | Migration.from_migration(loader.disk_migrations[key]) for key in loader.graph.node_map.keys() 234 | ] 235 | project_migrations = [ 236 | migration 237 | for migration in real_migrations 238 | if migration.app_label in project_apps and migration.app_label not in ignore_apps 239 | ] 240 | replaced_migrations = [migration for migration in project_migrations if migration.replaces] 241 | 242 | migrations_to_remove = set() 243 | for migration in (y for x in replaced_migrations for y in x.replaces if y[0] not in ignore_apps): 244 | real_migration = Migration.from_migration(loader.disk_migrations[migration]) 245 | real_migration._deleted = True 246 | migrations_to_remove.add(migration) 247 | changes[migration[0]].add(real_migration) 248 | 249 | # Remove all the old dependencies that will be removed 250 | for migration in project_migrations: 251 | new_dependencies = [ 252 | migration for migration in migration.dependencies if migration not in migrations_to_remove 253 | ] 254 | if new_dependencies == migration.dependencies: 255 | # There is no need to write anything 256 | continue 257 | migration._dependencies_change = True 258 | changes[migration.app_label].add(migration) 259 | setattr(migration, "dependencies", new_dependencies) 260 | 261 | for migration in replaced_migrations: 262 | migration._replaces_change = True 263 | changes[migration.app_label].add(migration) 264 | setattr(migration, "replaces", []) 265 | 266 | return changes 267 | -------------------------------------------------------------------------------- /django_squash/db/migrations/loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import tempfile 4 | from contextlib import ExitStack 5 | 6 | from django.apps import apps 7 | from django.conf import settings 8 | from django.db.migrations.loader import MigrationLoader 9 | 10 | from django_squash.db.migrations import utils 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class SquashMigrationLoader(MigrationLoader): 16 | def __init__(self, *args, **kwargs): 17 | # keep a copy of the original migration modules to restore it later 18 | original_migration_modules = settings.MIGRATION_MODULES 19 | # make a copy of the migration modules so we can modify it 20 | settings.MIGRATION_MODULES = settings.MIGRATION_MODULES.copy() 21 | site_packages_path = utils.site_packages_path() 22 | 23 | with ExitStack() as stack: 24 | # Find each app that belongs to the user and are not in the site-packages. Create a fake temporary 25 | # directory inside each app that will tell django we don't have any migrations at all. 26 | for app_config in apps.get_app_configs(): 27 | # absolute path to the app 28 | app_path = utils.source_directory(app_config.module) 29 | 30 | if app_path.startswith(site_packages_path): 31 | # ignore any apps in inside site-packages 32 | logger.debug("Ignoring app %s inside site-packages: %s", app_config.label, app_path) 33 | continue 34 | 35 | temp_dir = stack.enter_context(tempfile.TemporaryDirectory(prefix="migrations_", dir=app_path)) 36 | # Need to make this directory a proper python module otherwise django will refuse to recognize it 37 | open(os.path.join(temp_dir, "__init__.py"), "a").close() 38 | settings.MIGRATION_MODULES[app_config.label] = "%s.%s" % ( 39 | app_config.module.__name__, 40 | os.path.basename(temp_dir), 41 | ) 42 | 43 | super().__init__(*args, **kwargs) 44 | 45 | settings.MIGRATION_MODULES = original_migration_modules 46 | -------------------------------------------------------------------------------- /django_squash/db/migrations/operators.py: -------------------------------------------------------------------------------- 1 | class Variable: 2 | """ 3 | Wrapper type to be able to format the variable name correctly inside a migration 4 | """ 5 | 6 | def __init__(self, name, value): 7 | self.name = name 8 | self.value = value 9 | 10 | def __bool__(self): 11 | return bool(self.value) 12 | -------------------------------------------------------------------------------- /django_squash/db/migrations/questioner.py: -------------------------------------------------------------------------------- 1 | from django.db.migrations.questioner import NonInteractiveMigrationQuestioner as NonInteractiveMigrationQuestionerBase 2 | 3 | 4 | class NonInteractiveMigrationQuestioner(NonInteractiveMigrationQuestionerBase): 5 | def ask_initial(self, *args, **kwargs): 6 | # Ensures that the 0001_initial will always be generated 7 | return True 8 | -------------------------------------------------------------------------------- /django_squash/db/migrations/serializer.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import types 3 | 4 | from django.db import migrations as dj_migrations, models as dj_models 5 | from django.db.migrations.serializer import ( 6 | BaseSerializer, 7 | FunctionTypeSerializer as BaseFunctionTypeSerializer, 8 | Serializer, 9 | ) 10 | 11 | from django_squash.db.migrations.operators import Variable 12 | 13 | 14 | class VariableSerializer(BaseSerializer): 15 | def serialize(self): 16 | return (self.value.name, "") 17 | 18 | 19 | class FunctionTypeSerializer(BaseFunctionTypeSerializer): 20 | """ 21 | This serializer is used to serialize functions that are in migrations. 22 | 23 | Knows that "migrations" is available in the global namespace, and sets the 24 | correct import statement for the functions that use it. 25 | """ 26 | 27 | def serialize(self): 28 | response = super().serialize() 29 | 30 | if hasattr(self.value, "__in_migration_file__") and self.value.__in_migration_file__: 31 | return self.value.__qualname__, {} 32 | 33 | full_name = f"{self.value.__module__}.{self.value.__qualname__}" 34 | if full_name.startswith("django.") and ".models." in full_name or ".migrations." in full_name: 35 | atttr = self.value.__qualname__.split(".")[0] 36 | if hasattr(dj_migrations, atttr): 37 | return "migrations.%s" % self.value.__qualname__, {"from django.db import migrations"} 38 | if hasattr(dj_models, atttr): 39 | return "models.%s" % self.value.__qualname__, {"from django.db import models"} 40 | 41 | return response 42 | 43 | 44 | def patch_serializer_registry(func): 45 | """ 46 | Serializer registry patcher. 47 | 48 | This decorator is used to patch the serializer registry to remove serialziers we don't want, and add ones we do. 49 | """ 50 | 51 | @functools.wraps(func) 52 | def wrapper(*args, **kwargs): 53 | original_registry = Serializer._registry 54 | Serializer._registry = {**original_registry} 55 | 56 | for key, value in list(Serializer._registry.items()): 57 | if value == BaseFunctionTypeSerializer: 58 | del Serializer._registry[key] 59 | 60 | Serializer._registry.update( 61 | { 62 | Variable: VariableSerializer, 63 | ( 64 | types.FunctionType, 65 | types.BuiltinFunctionType, 66 | types.MethodType, 67 | functools._lru_cache_wrapper, 68 | ): FunctionTypeSerializer, 69 | } 70 | ) 71 | 72 | try: 73 | return func(*args, **kwargs) 74 | finally: 75 | Serializer._registry = original_registry 76 | 77 | return wrapper 78 | -------------------------------------------------------------------------------- /django_squash/db/migrations/utils.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import functools 3 | import hashlib 4 | import importlib 5 | import inspect 6 | import itertools 7 | import os 8 | import re 9 | import sysconfig 10 | import types 11 | from collections import defaultdict 12 | 13 | from django.db import migrations 14 | from django.utils.module_loading import import_string 15 | 16 | from django_squash import settings as app_settings 17 | 18 | 19 | @functools.lru_cache(maxsize=1) 20 | def get_custom_rename_function(): 21 | """ 22 | Custom function naming when copying elidable functions from one file to another. 23 | """ 24 | custom_rename_function = app_settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION 25 | 26 | if custom_rename_function: 27 | return import_string(custom_rename_function) 28 | 29 | 30 | def file_hash(file_path): 31 | """ 32 | Calculate the hash of a file 33 | """ 34 | BLOCK_SIZE = 65536 35 | 36 | file_hash = hashlib.sha256() 37 | with open(file_path, "rb") as f: 38 | fb = f.read(BLOCK_SIZE) 39 | while len(fb) > 0: 40 | file_hash.update(fb) 41 | fb = f.read(BLOCK_SIZE) 42 | 43 | return file_hash.hexdigest() 44 | 45 | 46 | def source_directory(module): 47 | """ 48 | Return the absolute path of a module 49 | """ 50 | return os.path.dirname(os.path.abspath(inspect.getsourcefile(module))) 51 | 52 | 53 | class UniqueVariableName: 54 | """ 55 | This class will return a unique name for a variable / function. 56 | """ 57 | 58 | def __init__(self, context, naming_function=None): 59 | self.names = defaultdict(int) 60 | self.functions = {} 61 | self.context = context 62 | self.naming_function = naming_function or (lambda n, _: n) 63 | 64 | def update_context(self, context): 65 | self.context.update(context) 66 | 67 | def function(self, func): 68 | if not callable(func): 69 | raise ValueError("func must be a callable") 70 | 71 | if isinstance(func, types.FunctionType) and func.__name__ == "": 72 | raise ValueError("func cannot be a lambda") 73 | 74 | if inspect.ismethod(func) or inspect.signature(func).parameters.get("self") is not None: 75 | raise ValueError("func cannot be part of an instance") 76 | 77 | name = func.__qualname__ 78 | if "." in name: 79 | parent_name, actual_name = name.rsplit(".", 1) 80 | parent = getattr(import_string(func.__module__), parent_name) 81 | if issubclass(parent, migrations.Migration): 82 | name = actual_name 83 | 84 | if func in self.functions: 85 | return self.functions[func] 86 | 87 | name = self.naming_function(name, {**self.context, "type_": "function", "func": func}) 88 | new_name = self.functions[func] = self.uniq(name) 89 | 90 | return new_name 91 | 92 | def uniq(self, name, original_name=None): 93 | original_name = original_name or name 94 | # Endless loop that will try different combinations until it finds a unique name 95 | for i, _ in enumerate(itertools.count(), 2): 96 | if self.names[name] == 0: 97 | self.names[name] += 1 98 | break 99 | 100 | name = "%s_%s" % (original_name, i) 101 | return name 102 | 103 | def __call__(self, name, force_number=False): 104 | original_name = name 105 | if force_number: 106 | name = f"{name}_1" 107 | return self.uniq(name, original_name) 108 | 109 | 110 | def get_imports(module): 111 | """ 112 | Return an generator with all the imports to a particular py file as string 113 | """ 114 | source = inspect.getsource(module) 115 | path = inspect.getsourcefile(module) 116 | 117 | root = ast.parse(source, path) 118 | for node in ast.iter_child_nodes(root): 119 | if isinstance(node, ast.Import): 120 | for n in node.names: 121 | yield f"import {n.name}" 122 | elif isinstance(node, ast.ImportFrom): 123 | module = node.module.split(".") 124 | # Remove old python 2.x imports 125 | if "__future__" not in node.module: 126 | yield f"from {node.module} import {', '.join([x.name for x in node.names])}" 127 | else: 128 | continue 129 | 130 | 131 | def normalize_function_name(name): 132 | _, _, function_name = name.rpartition(".") 133 | if function_name[0].isdigit(): 134 | # Functions CANNOT start with a number 135 | function_name = "f_" + function_name 136 | return function_name 137 | 138 | 139 | def copy_func(f, name): 140 | """ 141 | Return a function with same code, globals, defaults, closure, and name (or provide a new name) 142 | """ 143 | func = types.FunctionType(f.__code__, f.__globals__, name, f.__defaults__, f.__closure__) 144 | func.__qualname__ = name 145 | func.__original__ = f 146 | func.__source__ = re.sub( 147 | pattern=rf"(def\s+){normalize_function_name(f.__qualname__)}", 148 | repl=rf"\1{name}", 149 | string=inspect.getsource(f), 150 | count=1, 151 | ) 152 | return func 153 | 154 | 155 | def find_brackets(line, p_count, b_count): 156 | for char in line: 157 | if char == "(": 158 | p_count += 1 159 | elif char == ")": 160 | p_count -= 1 161 | elif char == "[": 162 | b_count += 1 163 | elif char == "]": 164 | b_count -= 1 165 | return p_count, b_count 166 | 167 | 168 | def is_code_in_site_packages(module_name): 169 | # Find the module in the site-packages directory 170 | site_packages_path_ = site_packages_path() 171 | try: 172 | loader = importlib.util.find_spec(module_name) 173 | except ImportError: 174 | return False 175 | return loader.origin.startswith(site_packages_path_) 176 | 177 | 178 | @functools.lru_cache(maxsize=1) 179 | def site_packages_path(): 180 | # returns the "../site-packages" directory 181 | return sysconfig.get_path("purelib") 182 | 183 | 184 | def replace_migration_attribute(source, attr, value): 185 | tree = ast.parse(source) 186 | # Skip this file if it is not a migration. 187 | migration_node = None 188 | for node in tree.body: 189 | if isinstance(node, ast.ClassDef) and node.name == "Migration": 190 | migration_node = node 191 | break 192 | else: 193 | return 194 | 195 | # Find the `attr` variable. 196 | comment_out_nodes = {} 197 | for node in migration_node.body: 198 | if isinstance(node, ast.Assign) and node.targets[0].id == attr: 199 | comment_out_nodes[node.lineno] = ( 200 | node.targets[0].col_offset, 201 | node.targets[0].id, 202 | ) 203 | 204 | # Skip this migration if it does not contain the `attr` we're looking for 205 | if not comment_out_nodes: 206 | return 207 | 208 | # Remove the lines that form the multi-line "replaces" statement. 209 | p_count = 0 210 | b_count = 0 211 | output = [] 212 | for lineno, line in enumerate(source.splitlines()): 213 | if lineno + 1 in comment_out_nodes.keys(): 214 | output.append(" " * comment_out_nodes[lineno + 1][0] + attr + " = " + str(value)) 215 | p_count = 0 216 | b_count = 0 217 | p_count, b_count = find_brackets(line, p_count, b_count) 218 | elif p_count != 0 or b_count != 0: 219 | p_count, b_count = find_brackets(line, p_count, b_count) 220 | else: 221 | output.append(line) 222 | 223 | # Overwrite the existing migration file to update it. 224 | return "\n".join(output) + "\n" 225 | -------------------------------------------------------------------------------- /django_squash/db/migrations/writer.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import re 4 | import textwrap 5 | import warnings 6 | 7 | from django import get_version 8 | from django.db import migrations as dj_migrations 9 | from django.db.migrations import writer as dj_writer 10 | from django.utils.timezone import now 11 | 12 | from django_squash.contrib import postgres 13 | from django_squash.db.migrations import operators, utils 14 | 15 | SUPPORTED_DJANGO_WRITER = ( 16 | "39645482d4eb04b9dd21478dc4bdfeea02393913dd2161bf272f4896e8b3b343", # 5.0 17 | "2aab183776c34e31969eebd5be4023d3aaa4da584540b91a5acafd716fa85582", # 4.1 / 4.2 18 | "e90b1243a8ce48f06331db8f584b0bce26e2e3f0abdd177cc18ed37425a23515", # 3.2 19 | ) 20 | 21 | 22 | def check_django_migration_hash(): 23 | """ 24 | Check if the django migrations writer file has changed and may not be compatible with django-squash. 25 | """ 26 | current_django_migration_hash = utils.file_hash(dj_writer.__file__) 27 | if current_django_migration_hash not in SUPPORTED_DJANGO_WRITER: 28 | messsage = textwrap.dedent( 29 | f"""\ 30 | Django migrations writer file has changed and may not be compatible with django-squash. 31 | 32 | Django version: {get_version()} 33 | Django migrations writer file: {dj_writer.__file__} 34 | Django migrations writer hash: {current_django_migration_hash} 35 | """ 36 | ) 37 | warnings.warn(messsage, Warning) 38 | 39 | 40 | check_django_migration_hash() 41 | 42 | 43 | class OperationWriter(dj_writer.OperationWriter): 44 | def serialize(self): 45 | if isinstance(self.operation, postgres.PGCreateExtension): 46 | if not utils.is_code_in_site_packages(self.operation.__class__.__module__): 47 | self.feed("%s()," % (self.operation.__class__.__name__)) 48 | return self.render(), set() 49 | 50 | return super().serialize() 51 | 52 | 53 | class ReplacementMigrationWriter(dj_writer.MigrationWriter): 54 | """ 55 | Take a Migration instance and is able to produce the contents 56 | of the migration file from it. 57 | """ 58 | 59 | template_class_header = dj_writer.MIGRATION_HEADER_TEMPLATE 60 | template_class = dj_writer.MIGRATION_TEMPLATE 61 | 62 | def __init__(self, migration, include_header=True): 63 | self.migration = migration 64 | self.include_header = include_header 65 | self.needs_manual_porting = False 66 | 67 | def as_string(self): 68 | """Return a string of the file contents.""" 69 | return self.template_class % self.get_kwargs() 70 | 71 | def get_kwargs(self): # pragma: no cover 72 | """ 73 | Original method from django.db.migrations.writer.MigrationWriter.as_string 74 | """ 75 | items = { 76 | "replaces_str": "", 77 | "initial_str": "", 78 | } 79 | 80 | imports = set() 81 | 82 | # Deconstruct operations 83 | operations = [] 84 | for operation in self.migration.operations: 85 | operation_string, operation_imports = OperationWriter(operation).serialize() 86 | imports.update(operation_imports) 87 | operations.append(operation_string) 88 | items["operations"] = "\n".join(operations) + "\n" if operations else "" 89 | 90 | # Format dependencies and write out swappable dependencies right 91 | dependencies = [] 92 | for dependency in self.migration.dependencies: 93 | if dependency[0] == "__setting__": 94 | dependencies.append(" migrations.swappable_dependency(settings.%s)," % dependency[1]) 95 | imports.add("from django.conf import settings") 96 | else: 97 | dependencies.append(" %s," % self.serialize(dependency)[0]) 98 | items["dependencies"] = "\n".join(sorted(dependencies)) + "\n" if dependencies else "" 99 | 100 | # Format imports nicely, swapping imports of functions from migration files 101 | # for comments 102 | migration_imports = set() 103 | for line in list(imports): 104 | if re.match(r"^import (.*)\.\d+[^\s]*$", line): 105 | migration_imports.add(line.split("import")[1].strip()) 106 | imports.remove(line) 107 | self.needs_manual_porting = True 108 | 109 | # django.db.migrations is always used, but models import may not be. 110 | # If models import exists, merge it with migrations import. 111 | if "from django.db import models" in imports: 112 | imports.discard("from django.db import models") 113 | imports.add("from django.db import migrations, models") 114 | else: 115 | imports.add("from django.db import migrations") 116 | 117 | # Sort imports by the package / module to be imported (the part after 118 | # "from" in "from ... import ..." or after "import" in "import ..."). 119 | # First group the "import" statements, then "from ... import ...". 120 | sorted_imports = sorted(imports, key=lambda i: (i.split()[0] == "from", i.split()[1])) 121 | items["imports"] = "\n".join(sorted_imports) + "\n" if imports else "" 122 | if migration_imports: 123 | items["imports"] += ( 124 | "\n\n# Functions from the following migrations need manual " 125 | "copying.\n# Move them and any dependencies into this file, " 126 | "then update the\n# RunPython operations to refer to the local " 127 | "versions:\n# %s" 128 | ) % "\n# ".join(sorted(migration_imports)) 129 | # If there's a replaces, make a string for it 130 | if self.migration.replaces: 131 | items["replaces_str"] = "\n replaces = %s\n" % self.serialize(self.migration.replaces)[0] 132 | # Hinting that goes into comment 133 | if self.include_header: 134 | items["migration_header"] = self.template_class_header % { 135 | "version": get_version(), 136 | "timestamp": now().strftime("%Y-%m-%d %H:%M"), 137 | } 138 | else: 139 | items["migration_header"] = "" 140 | 141 | if self.migration.initial: 142 | items["initial_str"] = "\n initial = True\n" 143 | 144 | return items 145 | 146 | 147 | class MigrationWriter(ReplacementMigrationWriter): 148 | template_class = """\ 149 | %(migration_header)s%(imports)s%(functions)s%(variables)s 150 | 151 | class Migration(migrations.Migration): 152 | %(replaces_str)s%(initial_str)s 153 | dependencies = [ 154 | %(dependencies)s\ 155 | ] 156 | 157 | operations = [ 158 | %(operations)s\ 159 | ] 160 | """ 161 | 162 | template_variable = """%s = %s""" 163 | 164 | def as_string(self): 165 | if hasattr(self.migration, "is_migration_level") and self.migration.is_migration_level: 166 | return self.replace_in_migration() 167 | 168 | variables = [] 169 | custom_naming_function = utils.get_custom_rename_function() 170 | unique_names = utils.UniqueVariableName( 171 | {"app": self.migration.app_label}, naming_function=custom_naming_function 172 | ) 173 | for operation in self.migration.operations: 174 | unique_names.update_context( 175 | { 176 | "new_migration": self.migration, 177 | "operation": operation, 178 | "migration": ( 179 | operation._original_migration if hasattr(operation, "_original_migration") else self.migration 180 | ), 181 | } 182 | ) 183 | operation._deconstruct = operation.__class__.deconstruct 184 | 185 | def deconstruct(self): 186 | name, args, kwargs = self._deconstruct(self) 187 | kwargs["elidable"] = self.elidable 188 | return name, args, kwargs 189 | 190 | if isinstance(operation, dj_migrations.RunPython): 191 | # Bind the deconstruct() to the instance to get the elidable 192 | operation.deconstruct = deconstruct.__get__(operation, operation.__class__) 193 | if not utils.is_code_in_site_packages(operation.code.__module__): 194 | code_name = utils.normalize_function_name(unique_names.function(operation.code)) 195 | operation.code = utils.copy_func(operation.code, code_name) 196 | operation.code.__in_migration_file__ = True 197 | if operation.reverse_code: 198 | if not utils.is_code_in_site_packages(operation.reverse_code.__module__): 199 | reversed_code_name = utils.normalize_function_name( 200 | unique_names.function(operation.reverse_code) 201 | ) 202 | operation.reverse_code = utils.copy_func(operation.reverse_code, reversed_code_name) 203 | operation.reverse_code.__in_migration_file__ = True 204 | elif isinstance(operation, dj_migrations.RunSQL): 205 | # Bind the deconstruct() to the instance to get the elidable 206 | operation.deconstruct = deconstruct.__get__(operation, operation.__class__) 207 | 208 | variable_name = unique_names("SQL", force_number=True) 209 | variables.append(self.template_variable % (variable_name, repr(operation.sql))) 210 | operation.sql = operators.Variable(variable_name, operation.sql) 211 | if operation.reverse_sql: 212 | reverse_variable_name = "%s_ROLLBACK" % variable_name 213 | variables.append(self.template_variable % (reverse_variable_name, repr(operation.reverse_sql))) 214 | operation.reverse_sql = operators.Variable(reverse_variable_name, operation.reverse_sql) 215 | 216 | return super().as_string() 217 | 218 | def replace_in_migration(self): 219 | if self.migration._deleted: 220 | os.remove(self.path) 221 | return 222 | 223 | changed = False 224 | with open(self.path) as f: 225 | source = f.read() 226 | 227 | if self.migration._dependencies_change: 228 | source = utils.replace_migration_attribute(source, "dependencies", self.migration.dependencies) 229 | changed = True 230 | if self.migration._replaces_change: 231 | source = utils.replace_migration_attribute(source, "replaces", self.migration.replaces) 232 | changed = True 233 | if not changed: 234 | raise NotImplementedError() # pragma: no cover 235 | 236 | return source 237 | 238 | def get_kwargs(self): 239 | kwargs = super().get_kwargs() 240 | functions_references = [] 241 | functions = [] 242 | variables = [] 243 | for operation in self.migration.operations: 244 | if isinstance(operation, dj_migrations.RunPython): 245 | if hasattr(operation.code, "__original__"): 246 | if operation.code.__original__ in functions_references: 247 | continue 248 | functions_references.append(operation.code.__original__) 249 | else: 250 | if operation.code in functions_references: 251 | continue 252 | functions_references.append(operation.code) 253 | 254 | if not utils.is_code_in_site_packages(operation.code.__module__): 255 | functions.append(textwrap.dedent(operation.code.__source__)) 256 | if operation.reverse_code: 257 | if hasattr(operation.reverse_code, "__original__"): 258 | if operation.reverse_code.__original__ in functions_references: 259 | continue 260 | functions_references.append(operation.reverse_code.__original__) 261 | else: 262 | if operation.reverse_code in functions_references: 263 | continue 264 | functions_references.append(operation.reverse_code) 265 | if not utils.is_code_in_site_packages(operation.reverse_code.__module__): 266 | functions.append(textwrap.dedent(operation.reverse_code.__source__)) 267 | elif isinstance(operation, dj_migrations.RunSQL): 268 | variables.append(self.template_variable % (operation.sql.name, repr(operation.sql.value))) 269 | if operation.reverse_sql: 270 | variables.append( 271 | self.template_variable % (operation.reverse_sql.name, repr(operation.reverse_sql.value)) 272 | ) 273 | elif isinstance(operation, postgres.PGCreateExtension): 274 | if not utils.is_code_in_site_packages(operation.__class__.__module__): 275 | functions.append(textwrap.dedent(inspect.getsource(operation.__class__))) 276 | 277 | kwargs["functions"] = ("\n\n" if functions else "") + "\n\n".join(functions) 278 | kwargs["variables"] = ("\n\n" if variables else "") + "\n\n".join(variables) 279 | 280 | imports = (x for x in set(kwargs["imports"].split("\n") + getattr(self.migration, "extra_imports", [])) if x) 281 | sorted_imports = sorted(imports, key=lambda i: (i.split()[0] == "from", i.split())) 282 | kwargs["imports"] = "\n".join(sorted_imports) + "\n" if imports else "" 283 | 284 | return kwargs 285 | -------------------------------------------------------------------------------- /django_squash/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/django_squash/management/__init__.py -------------------------------------------------------------------------------- /django_squash/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/django_squash/management/commands/__init__.py -------------------------------------------------------------------------------- /django_squash/management/commands/squash_migrations.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import os 3 | 4 | from django.apps import apps 5 | from django.core.management.base import BaseCommand, CommandError, no_translations 6 | from django.db.migrations.loader import MigrationLoader 7 | from django.db.migrations.state import ProjectState 8 | 9 | from django_squash import settings as app_settings 10 | from django_squash.db.migrations import serializer 11 | from django_squash.db.migrations.autodetector import SquashMigrationAutodetector 12 | from django_squash.db.migrations.loader import SquashMigrationLoader 13 | from django_squash.db.migrations.questioner import NonInteractiveMigrationQuestioner 14 | from django_squash.db.migrations.writer import MigrationWriter 15 | 16 | 17 | class Command(BaseCommand): 18 | def add_arguments(self, parser): 19 | parser.add_argument("--only", nargs="*", help="Only squash the specified apps") 20 | parser.add_argument( 21 | "--ignore-app", 22 | nargs="*", 23 | default=app_settings.DJANGO_SQUASH_IGNORE_APPS, 24 | help="Ignore app name from quashing, ensure that there is nothing dependent on these apps. " 25 | "(default: %(default)s)", 26 | ) 27 | parser.add_argument( 28 | "--dry-run", 29 | action="store_true", 30 | dest="dry_run", 31 | help="Just show what migrations would be made; don't actually write them.", 32 | ) 33 | parser.add_argument( 34 | "--squashed-name", 35 | default=app_settings.DJANGO_SQUASH_MIGRATION_NAME, 36 | help="Sets the name of the new squashed migration. Also accepted are the standard datetime parse " 37 | 'variables such as "%%Y%%m%%d". (default: "%(default)s" -> "xxxx_%(default)s")', 38 | ) 39 | 40 | @no_translations 41 | def handle(self, **kwargs): 42 | self.verbosity = 1 43 | self.include_header = False 44 | self.dry_run = kwargs["dry_run"] 45 | 46 | ignore_apps = [] 47 | bad_apps = [] 48 | 49 | for app_label in kwargs["ignore_app"]: 50 | try: 51 | apps.get_app_config(app_label) 52 | ignore_apps.append(app_label) 53 | except (LookupError, TypeError): 54 | bad_apps.append(str(app_label)) 55 | 56 | if kwargs["only"]: 57 | only_apps = [] 58 | 59 | for app_label in kwargs["only"]: 60 | try: 61 | apps.get_app_config(app_label) 62 | only_apps.append(app_label) 63 | if app_label in ignore_apps: 64 | raise CommandError( 65 | "The following app cannot be ignored and selected at the same time: %s" % app_label 66 | ) 67 | except (LookupError, TypeError): 68 | bad_apps.append(app_label) 69 | 70 | for app_name in apps.app_configs.keys(): 71 | if app_name not in only_apps: 72 | ignore_apps.append(app_name) 73 | 74 | if bad_apps: 75 | raise CommandError("The following apps are not valid: %s" % (", ".join(bad_apps))) 76 | 77 | questioner = NonInteractiveMigrationQuestioner(specified_apps=None, dry_run=False) 78 | 79 | loader = MigrationLoader(None, ignore_no_migrations=True) 80 | squash_loader = SquashMigrationLoader(None, ignore_no_migrations=True) 81 | 82 | # Set up autodetector 83 | autodetector = SquashMigrationAutodetector( 84 | squash_loader.project_state(), 85 | ProjectState.from_apps(apps), 86 | questioner, 87 | ) 88 | 89 | squashed_changes = autodetector.squash( 90 | real_loader=loader, 91 | squash_loader=squash_loader, 92 | ignore_apps=ignore_apps, 93 | migration_name=kwargs["squashed_name"], 94 | ) 95 | 96 | replacing_migrations = 0 97 | for migration in itertools.chain.from_iterable(squashed_changes.values()): 98 | replacing_migrations += len(migration.replaces) 99 | 100 | if not replacing_migrations: 101 | raise CommandError("There are no migrations to squash.") 102 | 103 | self.write_migration_files(squashed_changes) 104 | 105 | @serializer.patch_serializer_registry 106 | def write_migration_files(self, changes): 107 | """ 108 | Take a changes dict and write them out as migration files. 109 | """ 110 | directory_created = {} 111 | for app_label, app_migrations in changes.items(): 112 | if self.verbosity >= 1: 113 | self.stdout.write(self.style.MIGRATE_HEADING("Migrations for '%s':" % app_label) + "\n") 114 | for migration in app_migrations: 115 | # Describe the migration 116 | writer = MigrationWriter(migration, self.include_header) 117 | if self.verbosity >= 1: 118 | # Display a relative path if it's below the current working 119 | # directory, or an absolute path otherwise. 120 | try: 121 | migration_string = os.path.relpath(writer.path) 122 | except ValueError: 123 | migration_string = writer.path 124 | if migration_string.startswith(".."): 125 | migration_string = writer.path 126 | self.stdout.write(" %s\n" % (self.style.MIGRATE_LABEL(migration_string),)) 127 | if hasattr(migration, "is_migration_level") and migration.is_migration_level: 128 | for operation in migration.describe(): 129 | self.stdout.write(" - %s\n" % operation) 130 | else: 131 | for operation in migration.operations: 132 | self.stdout.write(" - %s\n" % operation.describe()) 133 | if not self.dry_run: 134 | # Write the migrations file to the disk. 135 | migrations_directory = os.path.dirname(writer.path) 136 | if not directory_created.get(app_label): 137 | os.makedirs(migrations_directory, exist_ok=True) 138 | init_path = os.path.join(migrations_directory, "__init__.py") 139 | if not os.path.isfile(init_path): 140 | open(init_path, "w").close() 141 | # We just do this once per app 142 | directory_created[app_label] = True 143 | migration_string = writer.as_string() 144 | if migration_string is None: 145 | # File was deleted 146 | continue 147 | with open(writer.path, "w", encoding="utf-8") as fh: 148 | fh.write(migration_string) 149 | elif self.verbosity == 3: 150 | # Alternatively, makemigrations --dry-run --verbosity 3 151 | # will output the migrations to stdout rather than saving 152 | # the file to the disk. 153 | self.stdout.write( 154 | self.style.MIGRATE_HEADING("Full migrations file '%s':" % writer.filename) + "\n" 155 | ) 156 | self.stdout.write("%s\n" % writer.as_string()) 157 | -------------------------------------------------------------------------------- /django_squash/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings as global_settings 4 | 5 | DJANGO_SQUASH_IGNORE_APPS = getattr(global_settings, "DJANGO_SQUASH_IGNORE_APPS", None) or [] 6 | DJANGO_SQUASH_MIGRATION_NAME = getattr(global_settings, "DJANGO_SQUASH_MIGRATION_NAME", None) or "squashed" 7 | DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION = getattr(global_settings, "DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", None) 8 | -------------------------------------------------------------------------------- /docs/motivation.rst: -------------------------------------------------------------------------------- 1 | Motivation 2 | ~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | "django-squash" is an enhancement built on top of the migration classes that come standard with Django. The vision and architecture of Django migrations are unchanged. We replace one command to eliminate the bloat and slowness that grows in proportion to the changes you introduce to your models over time. 5 | 6 | Before you use this package you need to understand what the normal Django makemigrations and squashmigrations commands do. Every migration file consists of one or more operations. Some of these operations created by makemigrations make changes to Django models that do not affect the DB table for that model (such as changes to validators, help text, etc). Some of these operations created by makemigrations make changes to the DB table for that model (such as column names, data types, foreign keys, etc). Migrations that you create by hand can run any SQL statement or python code you want. 7 | 8 | You specify for each migration operation you create by hand whether it is elidable or not. "Elidable" means the operation can be eliminated when you squash migrations. For example, if you split an existing table into two or more tables, you must populate the new tables from the old. Once that is done, you never need to do it again, even if you are creating a brand new DB. This is because the source table is empty when creating a new DB so it's pointless to populate those two new tables from the empty old one. 9 | 10 | "Non-elidable" means the operation cannot be eliminated when you squash migrations. Perhaps you are creating a very special index on a table that cannot be configured when describing the model. You have to write raw SQL that your flavor of DB understands. If you are creating a new DB, that operation must be run sometime after that table has been created. 11 | 12 | When you run the normal Django squashmigrations command, typically 1 to 3 migration files per app are created for you. They "squash" migrations for that app by consolidating all the operations in the existing migration files. The new squashed migration files replace all those prior files because they now contain all the non-elidable operations contained in those prior files. If you had 50 non-elidable operations across 20 files, you now might have 2 new squashed migration files containing all those 50 operations. You have reduced the number of files, but you have not reduced the number of operations. 13 | 14 | If you have changed the help_text attribute of a model's field three times, you only need to preserve the last one, but the squashmigrations command preserves all of them. If you have created a model, changed it a bit, and then deleted it, you don't need to preserve any of those operations if you're creating a new DB. Why create a model and its DB table just to delete it? Over time you carry this ever-growing burden with you. The step in your testing pipeline that creates the DB runs slower and slower. Every time you deploy your app and DB to a new environment, the step that creates the DB slows down to a crawl as it runs almost every operation created since the beginning of time. 15 | 16 | This package offers an alternate command named squash_migrations. Its name differs from the normal Django squashmigrations by just that underscore in the middle of the name. Instead of preserving all historical non-elidable operations, internally it uses the makemigrations logic in a way that assumes no prior operations exist, and that one or more "initial" migrations must be created to create the DB tables from the current model definitions. This results in the fewest possible operations to build your DB. Testing pipelines and deployments of new databases run much, much faster. This is especially important if you use a schema-per-tenant strategy to support hundreds or thousands of tenants. Every time you create a new schema for a new tenant you must run all migrations to create that schema. Even if you don't use a schema-per-tenant strategy, you should never tolerate long-running testing pipelines as you are forced to choose between wasting valuable developer time and cutting corners by not testing everything all the time. 17 | 18 | So what's the catch? Two things: 1) the proper use of elidable vs. non-elidable operations, and 2) this tool REQUIRES that all databases you are maintaining never fall behind to the point where they need a migration operation you just eliminated. 19 | 20 | Our operation-eliminating squash_migrations command removes all elidable operations. That's what "elidable" means. We keep all non-elidable operations and call them last. But you really need to ask yourself why you are using non-elidable operations at all. What are you doing that cannot be done by simply using django.db.models.signals.post_migrate? 21 | 22 | Our squash_migration command deletes all migrations before the prior time you ran it. Run it once per release AFTER cutting the release. It must be the first thing you do before adding migrations to the new release you're working on. All databases must be on the current release, the prior release, or somewhere in between. Any DB that is BEFORE that prior release cannot go directly to the current release. It must first apply the prior release with the migrations in effect for that release and only then apply the current release, which now contains only the operations needed to go from the prior release to the current release. This is the price you must pay for keeping migration operations to the absolute minimum. 23 | 24 | This is NOT a tutorial on migration strategy and techniques. You need to know how to design multi-app systems that avoid circular dependencies and other problems that often remain hidden until you attempt to squash migrations. 25 | 26 | We developed this approach at the Education Advisory Board after years of frustration and experience. At first we tried to eliminate unneeded operations by tediously searching for redundant or self-eliminating operations against the same field or model. We then tried to hide existing migrations in order to get Django's makemigrations command to create the perfect, minimal operations that an initial migration would create, followed by hand-stitching the replacement and dependency statements a squashing migration needs. Then add to that all those non-elidable operations. 27 | 28 | We found ourselves following the same tedious steps every time we squashed migrations for a new release. When you do that every 2 - 4 weeks, you get highly motivated to automate that process. We hope you take the time to improve your migration strategy and find our tool useful. 29 | 30 | What this does 31 | ~~~~~~~~~~~~~~~~~~~~~~~~ 32 | 33 | Let's say you're working on an app for a couple of years with lots of changes to models and their fields. You use this tool and eliminate all unnecessary migration operations after every release. That app's ``migrations`` directory will evolve something like this. 34 | 35 | .. code-block:: 36 | 37 | app/migrations/__init__.py 38 | app/migrations/0001_initial.py 39 | app/migrations/0002_changes_for_release1.py 40 | ... 41 | app/migrations/0019_changes_for_release1.py 42 | 43 | You cut release 1. The migration directory for that release looks exactly as above. Then you run our ``python manage.py squash_migrations`` command. It will look something like below. You might have fewer or more migration files, depending on foreign keys and other things that determine how many migration files are needed. 44 | 45 | .. code-block:: 46 | 47 | app/migrations/__init__.py 48 | app/migrations/0001_initial.py 49 | app/migrations/0002_changes_for_release1.py 50 | ... 51 | app/migrations/0019_changes_for_release1.py 52 | app/migrations/0020_squashed.py 53 | app/migrations/0021_squashed.py 54 | 55 | Inside the ``0020_squashed.py`` and ``0021_squashed.py`` files you will find the minimum operations needed to create your current models from scratch. The ``0021_squashed.py`` file will contain all your non-elidable ``RunPython`` and ``RunSQL`` operations that you wrote by hand. The variable and function names will be different to avoid duplicate names, but they will run in the exact order you put them. 56 | 57 | Note that no migration files were deleted above. This is the only time this will happen. 58 | 59 | Now you work on release 2, adding migrations as you go. The app's ``migrations`` directory will look something like below. 60 | 61 | .. code-block:: 62 | 63 | app/migrations/__init__.py 64 | app/migrations/0001_initial.py 65 | app/migrations/0002_changes_for_release1.py 66 | ... 67 | app/migrations/0019_changes_for_release1.py 68 | app/migrations/0020_squashed.py 69 | app/migrations/0021_squashed.py 70 | app/migrations/0022_changes_for_release2.py 71 | ... 72 | app/migrations/0037_changes_for_release2.py 73 | 74 | You cut release 2. The migration directory for that release looks exactly as above. All databases at the level of release 1 will have applied all migrations up to ``0019_changes_for_release1.py``. When this release 2 is applied to them, migrations ``0020_squashed.py`` and ``0021_squashed.py`` will be faked and migrations ``0022_changes_for_release2.py`` to ``0037_changes_for_release2.py`` will be applied. 75 | 76 | Then you run our ``python manage.py squash_migrations`` command. It will look something like below. 77 | 78 | .. code-block:: 79 | 80 | app/migrations/__init__.py 81 | app/migrations/0020_squashed.py 82 | app/migrations/0021_squashed.py 83 | app/migrations/0022_changes_for_release2.py 84 | ... 85 | app/migrations/0037_changes_for_release2.py 86 | app/migrations/0038_squashed.py 87 | app/migrations/0039_squashed.py 88 | 89 | Inside the ``0038_squashed.py`` and ``0039_squashed.py`` files you will find the minimum operations needed to create your current models from scratch. Note that the migration files before the ``0020_squashed.py`` file were deleted above. When you run your tests or when you deploy this branch to a new environment and build your DB from scratch, only the ``0038_squashed.py`` and ``0039_squashed.py`` files will be used. This should run much faster than running all the operations contained in ``0020_squashed.py`` through ``0037_changes_for_release2.py``. Now you're ready to work on release 3. 90 | 91 | But wait!! This is not realistic. You probably had to patch release 1, which required three migration files. What impact will that have on these releases? 92 | 93 | Release 1 should now look like this: 94 | 95 | .. code-block:: 96 | 97 | app/migrations/__init__.py 98 | app/migrations/0001_initial.py 99 | app/migrations/0002_changes_for_release1.py 100 | ... 101 | app/migrations/0019_changes_for_release1.py 102 | app/migrations/0020_changes_for_release1.py 103 | app/migrations/0021_changes_for_release1.py 104 | app/migrations/0022_changes_for_release1.py 105 | 106 | You must insert those same migrations logically AFTER what release 1 looked like IMMEDIATELY after squashing and BEFORE any migrations were introduced for release 2. 107 | 108 | Done correctly release 2 should now look like the following except it will be ordered perfectly alphabetically: 109 | 110 | .. code-block:: 111 | 112 | app/migrations/__init__.py 113 | app/migrations/0001_initial.py 114 | app/migrations/0002_changes_for_release1.py 115 | ... 116 | app/migrations/0019_changes_for_release1.py 117 | app/migrations/0020_squashed.py 118 | app/migrations/0021_squashed.py 119 | 120 | app/migrations/0020_changes_for_release1.py 121 | app/migrations/0021_changes_for_release1.py 122 | app/migrations/0022_changes_for_release1.py 123 | 124 | app/migrations/0022_changes_for_release2.py 125 | ... 126 | app/migrations/0037_changes_for_release2.py 127 | 128 | You have to manually change ``0020_changes_for_release1.py`` to depend on ``0021_squashed.py`` instead of ``0019_changes_for_release1.py``. This is how you insert it logically between release 1 and release 2. 129 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | The following settings are available in order to customize your experience. 5 | 6 | ``DJANGO_SQUASH_IGNORE_APPS`` 7 | ---------------------------------------- 8 | 9 | Default: ``[]`` (Empty list) 10 | 11 | Example: (``["app1", "app2"]``) 12 | 13 | Hardcoded list of apps to always ignore, no matter what, the same as ``--ignore`` in the ``./manage.py squash_migrations`` command. 14 | 15 | ``DJANGO_SQUASH_MIGRATION_NAME`` 16 | ---------------------------------------- 17 | 18 | Default: ``"squashed"`` (string) 19 | 20 | Example: (``"squashed_%Y%m%d"``) 21 | 22 | The generated migration name when ``./manage.py squash_migrations`` command is run. It's possible to use the `python date formats `_. 23 | 24 | 25 | ``DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION`` 26 | ---------------------------------------- 27 | 28 | Default: ``None`` (None) 29 | 30 | Example: "path.to.generator_function" 31 | 32 | Dot path to the function that will rename the functions found inside ``RunPython`` operations. 33 | 34 | Function needs to accept 2 arguments: ``name`` (``str``) and ``context`` (``dict``) and must return a string (``-> str``) 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django_squash" 7 | version = "0.0.14" 8 | description = "A migration squasher that doesn't care how Humpty Dumpty was put together." 9 | readme = "README.rst" 10 | keywords = ["django", "migration", "squashing", "squash"] 11 | authors = [ 12 | {name = "Javier Buzzi", email = "buzzi.javier@gmail.com"}, 13 | ] 14 | license = {text = "MIT"} 15 | classifiers = [ 16 | # See https://pypi.org/pypi?%3Aaction=list_classifiers 17 | "Development Status :: 5 - Production/Stable", 18 | "Environment :: Console", 19 | "Intended Audience :: Developers", 20 | "Framework :: Django", 21 | "Framework :: Django :: 4.2", 22 | "Framework :: Django :: 5.0", 23 | "Framework :: Django :: 5.1", 24 | "Framework :: Django :: 5.2", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Programming Language :: Python :: Implementation :: CPython", 33 | "Programming Language :: Python :: Implementation :: PyPy", 34 | "Topic :: Software Development :: Libraries :: Python Modules", 35 | "Topic :: Utilities", 36 | "License :: OSI Approved :: MIT License", 37 | ] 38 | dependencies = [ 39 | "django>=4.2", 40 | ] 41 | requires-python = ">=3.9" 42 | 43 | [project.optional-dependencies] 44 | lint = [ 45 | "pygments", 46 | "restructuredtext-lint", 47 | "ruff", 48 | "vulture" 49 | ] 50 | test = [ 51 | "black", 52 | "build", 53 | "ipdb", 54 | "libcst", 55 | "psycopg2-binary", 56 | "pytest-cov", 57 | "pytest-django" 58 | ] 59 | 60 | [project.urls] 61 | homepage = "https://github.com/kingbuzzman/django-squash" 62 | 63 | [tool.setuptools.packages.find] 64 | exclude = ["tests*", "docs*"] 65 | 66 | [tool.setuptools] 67 | zip-safe = true 68 | platforms = ["any"] 69 | 70 | [tool.ruff] 71 | exclude = ["*migrations*", ".*", "/usr/local/lib", "dist", "venv"] 72 | line-length = 119 73 | target-version = "py39" 74 | 75 | [tool.ruff.lint] 76 | select = ["ALL"] 77 | ignore = [ 78 | "ANN", 79 | "D100", 80 | "D104", 81 | "D400", 82 | ] 83 | 84 | [tool.ruff.lint.per-file-ignores] 85 | "**/conftest.py" = [ 86 | "B011", 87 | "D102", 88 | "D103", 89 | "D101", 90 | "D107", 91 | "D400", 92 | "D401", 93 | "D405", 94 | "D412", 95 | "D415", 96 | "DJ008", 97 | "E712", 98 | "EM101", 99 | "EM102", 100 | "FBT", 101 | "FURB177", 102 | "N806", 103 | "PLR0124", 104 | "PLR0913", 105 | "PLR2004", 106 | "PT007", 107 | "PT011", 108 | "PT015", 109 | "S", 110 | "SIM117", 111 | "SLF001", 112 | "PTH", 113 | "TRY002", 114 | "TRY003", 115 | "TRY301", 116 | "UP014", 117 | "LOG015", 118 | "PLC1901", 119 | "FURB157", 120 | "PLR0917", 121 | ] 122 | "**/tests/*.py" = [ 123 | "B011", 124 | "D102", 125 | "D103", 126 | "D401", 127 | "D101", 128 | "D107", 129 | "D400", 130 | "D405", 131 | "D412", 132 | "D415", 133 | "DJ008", 134 | "E712", 135 | "EM101", 136 | "EM102", 137 | "FBT", 138 | "FURB177", 139 | "N806", 140 | "PLR0124", 141 | "PLR0913", 142 | "PLR2004", 143 | "PT007", 144 | "PT011", 145 | "PT015", 146 | "S", 147 | "SIM117", 148 | "SLF001", 149 | "PTH", 150 | "TRY002", 151 | "TRY003", 152 | "TRY301", 153 | "UP014", 154 | "LOG015", 155 | "PLC1901", 156 | "FURB157", 157 | "PLR0917", 158 | ] 159 | 160 | [tool.ruff.lint.isort] 161 | order-by-type = true 162 | force-sort-within-sections = true 163 | required-imports = [ 164 | "from __future__ import annotations", 165 | ] 166 | section-order = [ 167 | "future", 168 | "standard-library", 169 | "third-party", 170 | "first-party", 171 | "local-folder", 172 | ] 173 | 174 | [tool.ruff.lint.mccabe] 175 | max-complexity = 10 176 | 177 | [tool.coverage.run] 178 | branch = true 179 | relative_files = true 180 | parallel = true 181 | source = ["django_squash"] 182 | omit = ["*/migrations_*/*", "./tests/*"] 183 | 184 | [tool.coverage.report] 185 | show_missing = true 186 | fail_under = 90 187 | 188 | [tool.coverage.html] 189 | show_contexts = true 190 | skip_covered = false 191 | 192 | [tool.pypi] 193 | repository = "https://upload.pypi.org/legacy/" 194 | username = "kingbuzzman" 195 | 196 | [tool.vulture] 197 | make_whitelist = false 198 | min_confidence = 80 199 | paths = ["django_squash"] 200 | sort_by_size = true 201 | verbose = false 202 | 203 | [tool.pytest.ini_options] 204 | DJANGO_SETTINGS_MODULE = "settings" 205 | pythonpath = "tests" 206 | addopts = "--pdbcls=IPython.terminal.debugger:TerminalPdb" 207 | python_files = ["test_*.py", "*_tests.py"] 208 | 209 | # Custom markers for pytest 210 | markers = [ 211 | "temporary_migration_module", 212 | "temporary_migration_module2", 213 | "temporary_migration_module3", 214 | "slow: marks tests as slow", 215 | "no_cover: marks tests to be excluded from coverage" 216 | ] 217 | 218 | filterwarnings = [ 219 | "error", 220 | 221 | # Internal warning to tell the user that the writer.py file has changed, and may not be compatible. 222 | "ignore:Django migrations writer file has changed and may not be compatible with django-squash", 223 | 224 | # Warning: django.utils.deprecation.RemovedInDjango50Warning: The USE_L10N setting is deprecated. Starting with Django 5.0, localized formatting of data will always be enabled. For example Django will display numbers and dates using the format of the current locale. 225 | # Don't specify the exact warning (django.utils.deprecation.RemovedInDjango50Warning) as not all version of Django know it and pytest will fail 226 | "ignore:The USE_L10N setting is deprecated:", 227 | 228 | # Warning: cgi is only being used by Django 3.2 229 | "ignore:'cgi' is deprecated and slated for removal in Python 3.13", 230 | 231 | # Django 3.2 throws a warning about the USE_I18N setting being deprecated 232 | "ignore:datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects .*" 233 | ] 234 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/__init__.py -------------------------------------------------------------------------------- /tests/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/__init__.py -------------------------------------------------------------------------------- /tests/app/models.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: ERA001 2 | # from django.db import models 3 | 4 | 5 | # class Person(models.Model): 6 | # name = models.CharField(max_length=10) 7 | # dob = models.DateField() 8 | -------------------------------------------------------------------------------- /tests/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/dangling_leaf/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 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="Person", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=10)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/dangling_leaf/0002_person_age.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("app", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="person", 15 | name="age", 16 | field=models.IntegerField(default=0), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/dangling_leaf/0003_add_dob.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | import datetime 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("app", "0002_person_age"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="person", 17 | name="dob", 18 | field=models.DateField(default=datetime.datetime(1900, 1, 1, 0, 0)), 19 | preserve_default=False, 20 | ), 21 | migrations.RemoveField( 22 | model_name="person", 23 | name="age", 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/dangling_leaf/0003_squashed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | replaces = [("app", "0001_initial"), ("app", "0002_person_age")] 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Person", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=10)), 26 | ("age", models.IntegerField(default=0)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/dangling_leaf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/dangling_leaf/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/delete_replaced/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 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="Person", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=10)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/delete_replaced/0002_person_age.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("app", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="person", 15 | name="age", 16 | field=models.IntegerField(default=0), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/delete_replaced/0003_add_dob.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | import datetime 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | def update_dob(apps, schema_editor): 9 | Person = apps.get_model("app", "Person") 10 | 11 | for person in Person.objects.all(): 12 | person.dob = datetime.date.today() - datetime.timedelta(days=12 * 365) 13 | person.save() 14 | 15 | 16 | def create_admin_MUST_ALWAYS_EXIST(apps, schema_editor): 17 | Person = apps.get_model("app", "Person") 18 | 19 | Person.objects.create(name="admin", age=30) 20 | 21 | 22 | class Migration(migrations.Migration): 23 | 24 | dependencies = [ 25 | ("app", "0002_person_age"), 26 | ] 27 | 28 | operations = [ 29 | migrations.RunPython(create_admin_MUST_ALWAYS_EXIST, elidable=False), 30 | migrations.AddField( 31 | model_name="person", 32 | name="dob", 33 | field=models.DateField(default=datetime.datetime(1900, 1, 1, 0, 0)), 34 | preserve_default=False, 35 | ), 36 | migrations.RunPython(update_dob, elidable=True), 37 | migrations.RemoveField( 38 | model_name="person", 39 | name="age", 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/delete_replaced/0004_squashed.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | def create_admin_MUST_ALWAYS_EXIST(apps, schema_editor): 5 | Person = apps.get_model("app", "Person") 6 | 7 | Person.objects.create(name="admin", age=30) 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | replaces = [ 13 | ("app", "0001_initial"), 14 | ("app", "0002_person_age"), 15 | ("app", "0003_add_dob"), 16 | ] 17 | 18 | initial = True 19 | 20 | dependencies = [] 21 | 22 | operations = [ 23 | migrations.CreateModel( 24 | name="Person", 25 | fields=[ 26 | ( 27 | "id", 28 | models.AutoField( 29 | auto_created=True, 30 | primary_key=True, 31 | serialize=False, 32 | verbose_name="ID", 33 | ), 34 | ), 35 | ("name", models.CharField(max_length=10)), 36 | ("dob", models.DateField()), 37 | ], 38 | ), 39 | migrations.RunPython( 40 | code=create_admin_MUST_ALWAYS_EXIST, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/delete_replaced/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/delete_replaced/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/elidable/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 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="Person", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=10)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/elidable/0002_person_age.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def same_name(apps, schema_editor): 7 | """ 8 | Content not important, testing same function name in multiple migrations 9 | """ 10 | pass 11 | 12 | 13 | def same_name_2(apps, schema_editor): 14 | """ 15 | Content not important, testing same function name in multiple migrations, nasty 16 | """ 17 | pass 18 | 19 | 20 | class Migration(migrations.Migration): 21 | 22 | dependencies = [ 23 | ("app", "0001_initial"), 24 | ] 25 | 26 | operations = [ 27 | migrations.RunPython(same_name, elidable=False), 28 | migrations.RunPython(same_name_2, reverse_code=migrations.RunPython.noop, elidable=False), 29 | migrations.AddField( 30 | model_name="person", 31 | name="age", 32 | field=models.IntegerField(default=0), 33 | preserve_default=False, 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/elidable/0003_add_dob.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | import datetime 4 | import itertools 5 | from random import randrange 6 | 7 | from django.db import migrations, models 8 | 9 | 10 | def update_dob(apps, schema_editor): 11 | # This needs to exist only once, this should NOT be copied over. 12 | Person = apps.get_model("app", "Person") 13 | 14 | for person in Person.objects.all(): 15 | person.dob = datetime.date.today() - datetime.timedelta(days=12 * 365) 16 | person.save() 17 | 18 | 19 | def same_name(apps, schema_editor): 20 | """ 21 | Content not important, testing same function name in multiple migrations, second function 22 | """ 23 | pass 24 | 25 | 26 | def create_admin_MUST_ALWAYS_EXIST(apps, schema_editor): 27 | """ 28 | This is a test doc string 29 | """ 30 | itertools.chain() # noop used to make sure the import was included 31 | randrange # noop used to make sure the import was included 32 | 33 | Person = apps.get_model("app", "Person") 34 | 35 | Person.objects.get_or_create(name="admin", age=30) 36 | 37 | 38 | def rollback_admin_MUST_ALWAYS_EXIST(apps, schema_editor): 39 | """Single comments""" 40 | print("Ignoring, there is no need to do this.") 41 | 42 | 43 | # fmt: off 44 | important_sql = """\ 45 | select 1 from \"sqlite_master\"""" 46 | 47 | important_rollback_sql = [ 48 | "select 2", 49 | 'select 21', 50 | """select 23""", 51 | """select 24 from \"sqlite_master\"""" 52 | ] 53 | 54 | not_important_sql = "select 3" 55 | 56 | also_important_sql = """ 57 | select 4 58 | """ 59 | # fmt: on 60 | 61 | 62 | class Migration(migrations.Migration): 63 | 64 | dependencies = [ 65 | ("app", "0002_person_age"), 66 | ] 67 | 68 | operations = [ 69 | migrations.RunPython( 70 | create_admin_MUST_ALWAYS_EXIST, 71 | reverse_code=rollback_admin_MUST_ALWAYS_EXIST, 72 | elidable=False, 73 | ), 74 | migrations.RunSQL(not_important_sql, elidable=True), 75 | migrations.AddField( 76 | model_name="person", 77 | name="dob", 78 | field=models.DateField(default=datetime.datetime(1900, 1, 1, 0, 0)), 79 | preserve_default=False, 80 | ), 81 | migrations.RunPython(update_dob, elidable=True), 82 | migrations.RunPython(same_name, elidable=False), 83 | migrations.RunSQL(important_sql, reverse_sql=important_rollback_sql, elidable=False), 84 | migrations.RemoveField( 85 | model_name="person", 86 | name="age", 87 | ), 88 | migrations.RunSQL(also_important_sql, elidable=False), 89 | ] 90 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/elidable/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/elidable/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/empty/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/empty/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/incorrect_name/2_person_age.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("app", "initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="person", 15 | name="age", 16 | field=models.IntegerField(default=0), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/incorrect_name/3000_auto_20190518_1524.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | import datetime 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("app", "2_person_age"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name="person", 17 | name="age", 18 | ), 19 | migrations.AddField( 20 | model_name="person", 21 | name="dob", 22 | field=models.DateField(default=datetime.datetime(1900, 1, 1, 0, 0)), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/incorrect_name/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/incorrect_name/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/incorrect_name/bad_no_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("app", "3000_auto_20190518_1524"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="person", 15 | name="dob", 16 | field=models.DateField(), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/incorrect_name/initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 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="Person", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=10)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/pg_indexes/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.operations import BtreeGinExtension 2 | from django.db import migrations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [] 7 | 8 | operations = [ 9 | BtreeGinExtension(), 10 | ] 11 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/pg_indexes/0002_use_index.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.22 on 2023-10-13 10:38 2 | 3 | import django.contrib.postgres.indexes 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [ 11 | ("app", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Message", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("score", models.IntegerField(default=0)), 28 | ("unicode_name", models.CharField(db_index=True, max_length=255)), 29 | ], 30 | ), 31 | migrations.AddIndex( 32 | model_name="message", 33 | index=models.Index(fields=["-score"], name="message_e_score_385f90_idx"), 34 | ), 35 | migrations.AddIndex( 36 | model_name="message", 37 | index=django.contrib.postgres.indexes.GinIndex( 38 | fields=["unicode_name"], name="message_e_unicode_6789fc_gin" 39 | ), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/pg_indexes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/pg_indexes/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/pg_indexes_custom/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.operations import BtreeGinExtension 2 | from django.db import migrations 3 | 4 | 5 | class IgnoreRollbackBtreeGinExtension(BtreeGinExtension): 6 | """ 7 | Custom extension that doesn't rollback no matter what 8 | """ 9 | 10 | def database_backwards(self, *args, **kwargs): 11 | pass 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [] 16 | 17 | operations = [ 18 | IgnoreRollbackBtreeGinExtension(), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/pg_indexes_custom/0002_use_index.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.22 on 2023-10-13 10:38 2 | 3 | import django.contrib.postgres.indexes 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [ 11 | ("app", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Message", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("score", models.IntegerField(default=0)), 28 | ("unicode_name", models.CharField(db_index=True, max_length=255)), 29 | ], 30 | ), 31 | migrations.AddIndex( 32 | model_name="message", 33 | index=models.Index(fields=["-score"], name="message_e_score_385f90_idx"), 34 | ), 35 | migrations.AddIndex( 36 | model_name="message", 37 | index=django.contrib.postgres.indexes.GinIndex( 38 | fields=["unicode_name"], name="message_e_unicode_6789fc_gin" 39 | ), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/pg_indexes_custom/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/pg_indexes_custom/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/run_python_noop/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 2 | 3 | from django.db import migrations 4 | from django.db.migrations import RunPython 5 | 6 | OtherRunPython = RunPython 7 | noop = OtherRunPython.noop 8 | 9 | 10 | def same_name(apps, schema_editor): 11 | # original function 12 | return 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | initial = True 18 | 19 | dependencies = [] 20 | 21 | def same_name_2(apps, schema_editor): 22 | # original function 2 23 | return 24 | 25 | operations = [ 26 | migrations.RunPython(same_name, migrations.RunPython.noop), 27 | migrations.RunPython(noop, OtherRunPython.noop), 28 | migrations.RunPython(same_name_2, same_name), 29 | migrations.RunPython(same_name, same_name_2), 30 | ] 31 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/run_python_noop/0002_run_python.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:23 2 | 3 | from django.db import migrations 4 | from django.db.migrations.operations.special import RunPython 5 | 6 | 7 | def same_name(apps, schema_editor): 8 | # other function 9 | return 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | ("app", "0001_initial"), 16 | ] 17 | 18 | operations = [ 19 | migrations.RunPython(RunPython.noop), 20 | migrations.RunPython(same_name), 21 | ] 22 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/run_python_noop/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/run_python_noop/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/simple/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 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="Person", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=10)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/simple/0002_person_age.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("app", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="person", 15 | name="age", 16 | field=models.IntegerField(default=0), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/simple/0003_auto_20190518_1524.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | import datetime 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("app", "0002_person_age"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name="person", 17 | name="age", 18 | ), 19 | migrations.AddField( 20 | model_name="person", 21 | name="dob", 22 | field=models.DateField(default=datetime.datetime(1900, 1, 1, 0, 0)), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/simple/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/simple/__init__.py -------------------------------------------------------------------------------- /tests/app/tests/migrations/swappable_dependency/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="UserProfile", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ( 27 | "user", 28 | models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE), 29 | ), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/swappable_dependency/0002_add_dob.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | import datetime 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("app", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="UserProfile", 17 | name="dob", 18 | field=models.DateField(default=datetime.datetime(1900, 1, 1, 0, 0)), 19 | preserve_default=False, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /tests/app/tests/migrations/swappable_dependency/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app/tests/migrations/swappable_dependency/__init__.py -------------------------------------------------------------------------------- /tests/app2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app2/__init__.py -------------------------------------------------------------------------------- /tests/app2/models.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: ERA001 2 | # from django.db import models 3 | 4 | 5 | # class Address(models.Model): 6 | # person = models.ForeignKey('app.Person', on_delete=models.deletion.CASCADE) 7 | # address1 = models.CharField(max_length=100) 8 | # address2 = models.CharField(max_length=100) 9 | # city = models.CharField(max_length=50) 10 | # postal_code = models.CharField(max_length=50) 11 | # province = models.CharField(max_length=50) 12 | # country = models.CharField(max_length=50) 13 | -------------------------------------------------------------------------------- /tests/app2/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app2/tests/__init__.py -------------------------------------------------------------------------------- /tests/app2/tests/migrations/empty/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app2/tests/migrations/empty/__init__.py -------------------------------------------------------------------------------- /tests/app2/tests/migrations/foreign_key/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ("app", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Address", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ( 28 | "person", 29 | models.ForeignKey(on_delete=models.deletion.CASCADE, to="app.Person"), 30 | ), 31 | ("address1", models.CharField(max_length=100)), 32 | ("address2", models.CharField(max_length=100)), 33 | ("city", models.CharField(max_length=50)), 34 | ("postal_code", models.CharField(max_length=50)), 35 | ("province", models.CharField(max_length=50)), 36 | ("country", models.CharField(max_length=50)), 37 | ], 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /tests/app2/tests/migrations/foreign_key/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app2/tests/migrations/foreign_key/__init__.py -------------------------------------------------------------------------------- /tests/app3/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app3/__init__.py -------------------------------------------------------------------------------- /tests/app3/models.py: -------------------------------------------------------------------------------- 1 | # Had models, have been moved 2 | -------------------------------------------------------------------------------- /tests/app3/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app3/tests/__init__.py -------------------------------------------------------------------------------- /tests/app3/tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app3/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/app3/tests/migrations/moved/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:22 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="Person", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=10)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/app3/tests/migrations/moved/0002_person_age.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("app3", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="person", 15 | name="age", 16 | field=models.IntegerField(default=0), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/app3/tests/migrations/moved/0003_moved.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2019-05-18 15:24 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("app3", "0002_person_age"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RunSQL( 14 | """ 15 | CREATE TABLE new_table AS 16 | SELECT * FROM app3_person 17 | """, 18 | elidable=True, 19 | ), 20 | migrations.DeleteModel( 21 | name="Person", 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/app3/tests/migrations/moved/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingbuzzman/django-squash/972e3c5105ab5a7571554c0850a7425fdf5e9862/tests/app3/tests/migrations/moved/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: TD002, TD003, FIX002, TRY003, EM101, EM102, D401, PTH102, PTH118, PTH119 2 | from __future__ import annotations 3 | 4 | from collections import defaultdict 5 | from contextlib import ExitStack 6 | from importlib import import_module 7 | import os 8 | from pathlib import Path 9 | import shutil 10 | import tempfile 11 | 12 | from django.core.management import call_command 13 | from django.db import connections 14 | from django.db.models.options import Options 15 | from django.test.utils import extend_sys_path 16 | from django.utils.module_loading import module_dir 17 | import pytest 18 | 19 | from django_squash.db.migrations.utils import get_custom_rename_function 20 | from tests import utils 21 | 22 | 23 | class MigrationPath(Path): 24 | """A subclass of Path that provides a method to list migration files.""" 25 | 26 | if utils.is_pyvsupported("3.11"): 27 | try: 28 | from pathlib import _PosixFlavour, _WindowsFlavour 29 | 30 | # TODO: delete after python 3.11 is no longer supported 31 | _flavour = _PosixFlavour() if os.name == "posix" else _WindowsFlavour() 32 | except ImportError: 33 | pass 34 | else: 35 | raise Exception("Remove this whole block please!") 36 | 37 | def migration_load(self, file_name): 38 | """Returns the migration module object.""" 39 | return utils.load_migration_module(self / file_name) 40 | 41 | def migration_read(self, file_name, traverse): 42 | """Returns the string contents of the migration file.""" 43 | return utils.pretty_extract_piece(self.migration_load(file_name), traverse=traverse) 44 | 45 | def migration_files(self): 46 | """Returns a list of migration files in this directory.""" 47 | return sorted(p.name for p in self.glob("*.py")) 48 | 49 | 50 | @pytest.fixture 51 | def migration_app_dir(request, isolated_apps, settings): 52 | del isolated_apps 53 | yield from _migration_app_dir("temporary_migration_module", request, settings) 54 | 55 | 56 | @pytest.fixture 57 | def migration_app2_dir(request, isolated_apps, settings): 58 | del isolated_apps 59 | yield from _migration_app_dir("temporary_migration_module2", request, settings) 60 | 61 | 62 | @pytest.fixture 63 | def migration_app3_dir(request, isolated_apps, settings): 64 | del isolated_apps 65 | yield from _migration_app_dir("temporary_migration_module3", request, settings) 66 | 67 | 68 | def _migration_app_dir(marker_name, request, settings): 69 | """Allows testing management commands in a temporary migrations module. 70 | 71 | Wrap all invocations to makemigrations and squashmigrations with this 72 | context manager in order to avoid creating migration files in your 73 | source tree inadvertently. 74 | Takes the application label that will be passed to makemigrations or 75 | squashmigrations and the Python path to a migrations module. 76 | The migrations module is used as a template for creating the temporary 77 | migrations module. If it isn't provided, the application's migrations 78 | module is used, if it exists. 79 | Returns the filesystem path to the temporary migrations module. 80 | """ 81 | marks = list(request.node.iter_markers(marker_name)) 82 | if len(marks) != 1: 83 | raise ValueError(f"Expected exactly one {marker_name!r} marker") 84 | mark = marks[0] 85 | 86 | app_label = mark.kwargs["app_label"] 87 | module = mark.kwargs.get("module") 88 | 89 | source_module_path = module_dir(import_module(module)) 90 | target_module = import_module(settings.MIGRATION_MODULES[app_label]) 91 | target_module_path = module_dir(target_module) 92 | shutil.rmtree(target_module_path) 93 | shutil.copytree(source_module_path, target_module_path) 94 | yield MigrationPath(target_module_path) 95 | 96 | 97 | def pytest_collection_modifyitems(config, items): 98 | """Prevents issues from being ignored. 99 | 100 | Meta test to ensure we define `@pytest.mark.temporary_migration_module` and use `migration_app_dir` in the 101 | function arguments/signature. 102 | """ 103 | del config 104 | required_function_argument_by_markers = { 105 | "temporary_migration_module": "migration_app_dir", 106 | "temporary_migration_module2": "migration_app2_dir", 107 | "temporary_migration_module3": "migration_app3_dir", 108 | } 109 | for test_function in items: 110 | markers = {m.name: m for m in test_function.iter_markers()} 111 | markers_found = required_function_argument_by_markers.keys() & markers.keys() 112 | if not markers_found: 113 | continue 114 | 115 | for marker in markers_found: 116 | argument_name = required_function_argument_by_markers[marker] 117 | if argument_name not in test_function.fixturenames: 118 | message = ( 119 | f"Test {test_function.name} uses @pytest.mark.{marker} but does not " 120 | f"have '{argument_name}' argument." 121 | ) 122 | pytest.fail(message, pytrace=False) 123 | 124 | 125 | @pytest.fixture(autouse=True) 126 | def isolated_apps(settings, monkeypatch): 127 | """Isolate the apps between tests. 128 | 129 | Django registers models in the apps cache, this is a helper to remove them, otherwise 130 | django throws warnings that this model already exists. 131 | """ 132 | with ExitStack() as stack: 133 | original_apps = Options.default_apps 134 | original_all_models = original_apps.all_models 135 | original_app_configs = original_apps.app_configs 136 | new_all_models = defaultdict(dict) 137 | new_app_configs = {} 138 | 139 | monkeypatch.setattr("django.apps.apps.all_models", new_all_models) 140 | monkeypatch.setattr("django.apps.apps.app_configs", new_app_configs) 141 | monkeypatch.setattr("django.apps.apps.stored_app_configs", []) 142 | monkeypatch.setattr("django.apps.apps.apps_ready", False) 143 | monkeypatch.setattr("django.apps.apps.models_ready", False) 144 | monkeypatch.setattr("django.apps.apps.ready", False) 145 | monkeypatch.setattr("django.apps.apps.loading", False) 146 | monkeypatch.setattr("django.apps.apps._pending_operations", defaultdict(list)) 147 | installed_app = settings.INSTALLED_APPS.copy() 148 | _installed_app = installed_app.copy() 149 | _installed_app.remove("django.contrib.auth") 150 | _installed_app.remove("django.contrib.contenttypes") 151 | original_apps.populate(_installed_app) 152 | 153 | for app_label in ("auth", "contenttypes"): 154 | new_all_models[app_label] = original_all_models[app_label] 155 | new_app_configs[app_label] = original_app_configs[app_label] 156 | 157 | temp_dir = tempfile.TemporaryDirectory() 158 | stack.enter_context(temp_dir) 159 | stack.enter_context(extend_sys_path(temp_dir.name)) 160 | with (Path(temp_dir.name) / "__init__.py").open("w"): 161 | pass 162 | 163 | for app_label in installed_app: 164 | target_dir = tempfile.mkdtemp(prefix=f"{app_label}_", dir=temp_dir.name) 165 | with (Path(target_dir) / "__init__.py").open("w"): 166 | pass 167 | migration_path = os.path.join(target_dir, "migrations") 168 | os.mkdir(migration_path) 169 | with (Path(migration_path) / "__init__.py").open("w"): 170 | pass 171 | module_name = f"{os.path.basename(target_dir)}.migrations" 172 | 173 | settings.MIGRATION_MODULES[app_label] = module_name 174 | stack.enter_context(extend_sys_path(target_dir)) 175 | 176 | yield original_apps 177 | 178 | 179 | @pytest.fixture 180 | def call_squash_migrations(): 181 | """Returns a function that calls squashmigrations.""" 182 | 183 | def _call_squash_migrations(*args, **kwargs): 184 | kwargs["verbosity"] = kwargs.get("verbosity", 1) 185 | kwargs["no_color"] = kwargs.get("no_color", True) 186 | 187 | call_command("squash_migrations", *args, **kwargs) 188 | 189 | return _call_squash_migrations 190 | 191 | 192 | @pytest.fixture(autouse=True) 193 | def clear_get_custom_rename_function_cache(): 194 | """Remove the rename function cache between runs. 195 | 196 | To ensure that this function doesn't get cached with the wrong value and breaks tests, 197 | we always clear it before and after each test. 198 | """ 199 | get_custom_rename_function.cache_clear() 200 | yield 201 | get_custom_rename_function.cache_clear() 202 | 203 | 204 | @pytest.fixture 205 | def clean_db(django_db_blocker): 206 | """Clean the database after each test. As in a new database. 207 | 208 | Usage: 209 | 210 | @pytest.mark.usefixtures("clean_db") 211 | @pytest.mark.django_db(transaction=True) 212 | def test_something(): 213 | ... 214 | """ 215 | with django_db_blocker.unblock(): 216 | # Because we're using an in memory sqlite database, we can just reset the connection 217 | # When the connection is reestablished, the database will be empty 218 | connections["default"].connection.close() 219 | del connections["default"] 220 | 221 | with connections["default"].cursor() as c: 222 | # make sure the database is of any tables 223 | assert connections["default"].introspection.get_table_list(c) == [] 224 | 225 | yield 226 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: PTH100, PTH118, PTH120 2 | """Django settings for prj_test project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.0. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.0/ref/settings/ 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | import os 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = "inm4i9$^%xfh3yv7$+xt9z)nu58cf^9(=ths4i2ewuci6an6+o" 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django_squash", 39 | "app", 40 | "app2", 41 | "app3", 42 | ] 43 | 44 | MIDDLEWARE = [] 45 | 46 | TEMPLATES = [] 47 | 48 | 49 | # Database 50 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 51 | 52 | DATABASES = { 53 | "default": { 54 | "ENGINE": "django.db.backends.sqlite3", 55 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 56 | }, 57 | } 58 | 59 | 60 | # Password validation 61 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 62 | 63 | AUTH_PASSWORD_VALIDATORS = [] 64 | 65 | 66 | # Internationalization 67 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 68 | 69 | LANGUAGE_CODE = "en-us" 70 | 71 | TIME_ZONE = "UTC" 72 | 73 | USE_I18N = True 74 | 75 | USE_L10N = True 76 | 77 | USE_TZ = True 78 | 79 | 80 | # Static files (CSS, JavaScript, Images) 81 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 82 | 83 | STATIC_URL = "/static/" 84 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | import unittest.mock 3 | 4 | import pytest 5 | from django.contrib.postgres.indexes import GinIndex 6 | from django.core.management import CommandError 7 | from django.db import models 8 | from django.db.migrations.recorder import MigrationRecorder 9 | 10 | DjangoMigrationModel = MigrationRecorder.Migration 11 | 12 | 13 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.elidable", app_label="app") 14 | def test_squashing_elidable_migration_simple(migration_app_dir, call_squash_migrations): 15 | class Person(models.Model): 16 | name = models.CharField(max_length=10) 17 | dob = models.DateField() 18 | 19 | class Meta: 20 | app_label = "app" 21 | 22 | call_squash_migrations() 23 | 24 | expected = textwrap.dedent( 25 | """\ 26 | import datetime 27 | import itertools 28 | from django.db import migrations 29 | from django.db import migrations, models 30 | from random import randrange 31 | 32 | 33 | def same_name(apps, schema_editor): 34 | \"\"\" 35 | Content not important, testing same function name in multiple migrations 36 | \"\"\" 37 | pass 38 | 39 | 40 | def same_name_2(apps, schema_editor): 41 | \"\"\" 42 | Content not important, testing same function name in multiple migrations, nasty 43 | \"\"\" 44 | pass 45 | 46 | 47 | def create_admin_MUST_ALWAYS_EXIST(apps, schema_editor): 48 | \"\"\" 49 | This is a test doc string 50 | \"\"\" 51 | itertools.chain() # noop used to make sure the import was included 52 | randrange # noop used to make sure the import was included 53 | 54 | Person = apps.get_model("app", "Person") 55 | 56 | Person.objects.get_or_create(name="admin", age=30) 57 | 58 | 59 | def rollback_admin_MUST_ALWAYS_EXIST(apps, schema_editor): 60 | \"\"\"Single comments\"\"\" 61 | print("Ignoring, there is no need to do this.") 62 | 63 | 64 | def same_name_3(apps, schema_editor): 65 | \"\"\" 66 | Content not important, testing same function name in multiple migrations, second function 67 | \"\"\" 68 | pass 69 | 70 | 71 | SQL_1 = 'select 1 from "sqlite_master"' 72 | 73 | SQL_1_ROLLBACK = ["select 2", "select 21", "select 23", 'select 24 from "sqlite_master"'] 74 | 75 | SQL_2 = "\\nselect 4\\n" 76 | 77 | 78 | class Migration(migrations.Migration): 79 | 80 | replaces = [("app", "0001_initial"), ("app", "0002_person_age"), ("app", "0003_add_dob")] 81 | 82 | initial = True 83 | 84 | dependencies = [] 85 | 86 | operations = [ 87 | migrations.CreateModel( 88 | name="Person", 89 | fields=[ 90 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 91 | ("name", models.CharField(max_length=10)), 92 | ("dob", models.DateField()), 93 | ], 94 | ), 95 | migrations.RunPython( 96 | code=same_name, 97 | elidable=False, 98 | ), 99 | migrations.RunPython( 100 | code=same_name_2, 101 | reverse_code=migrations.RunPython.noop, 102 | elidable=False, 103 | ), 104 | migrations.RunPython( 105 | code=create_admin_MUST_ALWAYS_EXIST, 106 | reverse_code=rollback_admin_MUST_ALWAYS_EXIST, 107 | elidable=False, 108 | ), 109 | migrations.RunPython( 110 | code=same_name_3, 111 | elidable=False, 112 | ), 113 | migrations.RunSQL( 114 | sql=SQL_1, 115 | reverse_sql=SQL_1_ROLLBACK, 116 | elidable=False, 117 | ), 118 | migrations.RunSQL( 119 | sql=SQL_2, 120 | elidable=False, 121 | ), 122 | ] 123 | """ # noqa 124 | ) 125 | assert migration_app_dir.migration_read("0004_squashed.py", "") == expected 126 | 127 | 128 | def custom_func_naming(original_name, context): 129 | """ 130 | Used in test_squashing_elidable_migration_unique_name_formatting to format the function names 131 | """ 132 | return f"{context['migration'].name}_{original_name}" 133 | 134 | 135 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.elidable", app_label="app") 136 | def test_squashing_elidable_migration_unique_name_formatting(migration_app_dir, call_squash_migrations, monkeypatch): 137 | """ 138 | Test that DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION integration works properly 139 | """ 140 | monkeypatch.setattr( 141 | "django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", f"{__name__}.custom_func_naming" 142 | ) 143 | 144 | class Person(models.Model): 145 | name = models.CharField(max_length=10) 146 | dob = models.DateField() 147 | 148 | class Meta: 149 | app_label = "app" 150 | 151 | call_squash_migrations() 152 | 153 | source = migration_app_dir.migration_read("0004_squashed.py", "") 154 | assert source.count("f_0002_person_age_same_name(") == 1 155 | assert source.count("code=f_0002_person_age_same_name,") == 1 156 | assert source.count("f_0002_person_age_same_name_2(") == 1 157 | assert source.count("code=f_0002_person_age_same_name_2,") == 1 158 | assert source.count("f_0003_add_dob_same_name(") == 1 159 | assert source.count("code=f_0003_add_dob_same_name,") == 1 160 | assert source.count("f_0003_add_dob_create_admin_MUST_ALWAYS_EXIST(") == 1 161 | assert source.count("code=f_0003_add_dob_create_admin_MUST_ALWAYS_EXIST,") == 1 162 | assert source.count("f_0003_add_dob_rollback_admin_MUST_ALWAYS_EXIST(") == 1 163 | assert source.count("code=f_0003_add_dob_rollback_admin_MUST_ALWAYS_EXIST,") == 1 164 | 165 | 166 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.simple", app_label="app") 167 | @pytest.mark.temporary_migration_module2(module="app2.tests.migrations.foreign_key", app_label="app2") 168 | def test_squashing_migration_simple(migration_app_dir, migration_app2_dir, call_squash_migrations): 169 | class Person(models.Model): 170 | name = models.CharField(max_length=10) 171 | dob = models.DateField() 172 | # place_of_birth = models.CharField(max_length=100, blank=True) 173 | 174 | class Meta: 175 | app_label = "app" 176 | 177 | class Address(models.Model): 178 | person = models.ForeignKey("app.Person", on_delete=models.deletion.CASCADE) 179 | address1 = models.CharField(max_length=100) 180 | address2 = models.CharField(max_length=100) 181 | city = models.CharField(max_length=50) 182 | postal_code = models.CharField(max_length=50) 183 | province = models.CharField(max_length=50) 184 | country = models.CharField(max_length=50) 185 | 186 | class Meta: 187 | app_label = "app2" 188 | 189 | call_squash_migrations() 190 | 191 | files_in_app = migration_app_dir.migration_files() 192 | files_in_app2 = migration_app2_dir.migration_files() 193 | assert "0004_squashed.py" in files_in_app 194 | assert "0002_squashed.py" in files_in_app2 195 | 196 | app_squash = migration_app_dir.migration_load("0004_squashed.py") 197 | app2_squash = migration_app2_dir.migration_load("0002_squashed.py") 198 | 199 | assert app_squash.Migration.replaces == [ 200 | ("app", "0001_initial"), 201 | ("app", "0002_person_age"), 202 | ("app", "0003_auto_20190518_1524"), 203 | ] 204 | 205 | assert app2_squash.Migration.replaces == [("app2", "0001_initial")] 206 | 207 | 208 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.simple", app_label="app") 209 | @pytest.mark.temporary_migration_module2(module="app2.tests.migrations.foreign_key", app_label="app2", join=True) 210 | def test_squashing_migration_simple_ignore(migration_app_dir, migration_app2_dir, call_squash_migrations): 211 | """ 212 | Test that "app" gets ignored correctly, nothing changes inside it's migration directory. "app2" gets squashed, 213 | and points to the latest "app" migration as a dependency. 214 | """ 215 | 216 | class Person(models.Model): 217 | name = models.CharField(max_length=10) 218 | dob = models.DateField() 219 | # place_of_birth = models.CharField(max_length=100, blank=True) 220 | 221 | class Meta: 222 | app_label = "app" 223 | 224 | class Address(models.Model): 225 | person = models.ForeignKey("app.Person", on_delete=models.deletion.CASCADE) 226 | address1 = models.CharField(max_length=100) 227 | address2 = models.CharField(max_length=100) 228 | city = models.CharField(max_length=50) 229 | postal_code = models.CharField(max_length=50) 230 | province = models.CharField(max_length=50) 231 | country = models.CharField(max_length=50) 232 | 233 | class Meta: 234 | app_label = "app2" 235 | 236 | call_squash_migrations( 237 | "--ignore-app", 238 | "app", 239 | ) 240 | 241 | files_in_app = migration_app_dir.migration_files() 242 | assert files_in_app == ["0001_initial.py", "0002_person_age.py", "0003_auto_20190518_1524.py", "__init__.py"] 243 | 244 | files_in_app2 = migration_app2_dir.migration_files() 245 | assert files_in_app2 == ["0001_initial.py", "0002_squashed.py", "__init__.py"] 246 | 247 | app2_squash = migration_app2_dir.migration_load("0002_squashed.py") 248 | assert app2_squash.Migration.replaces == [("app2", "0001_initial")] 249 | assert app2_squash.Migration.dependencies == [("app", "0003_auto_20190518_1524")] 250 | 251 | 252 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.empty", app_label="app") 253 | def test_squashing_migration_empty(migration_app_dir, call_squash_migrations): 254 | del migration_app_dir 255 | 256 | class Person(models.Model): 257 | name = models.CharField(max_length=10) 258 | dob = models.DateField() 259 | 260 | class Meta: 261 | app_label = "app" 262 | 263 | with pytest.raises(CommandError) as error: 264 | call_squash_migrations() 265 | assert str(error.value) == "There are no migrations to squash." 266 | 267 | 268 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.empty", app_label="app") 269 | def test_invalid_apps(migration_app_dir, call_squash_migrations): 270 | del migration_app_dir 271 | with pytest.raises(CommandError) as error: 272 | call_squash_migrations( 273 | "--ignore-app", 274 | "aaa", 275 | "bbb", 276 | ) 277 | assert str(error.value) == "The following apps are not valid: aaa, bbb" 278 | 279 | 280 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.empty", app_label="app") 281 | def test_invalid_apps_ignore(migration_app_dir, monkeypatch, call_squash_migrations): 282 | del migration_app_dir 283 | monkeypatch.setattr("django_squash.settings.DJANGO_SQUASH_IGNORE_APPS", ["aaa", "bbb"]) 284 | with pytest.raises(CommandError) as error: 285 | call_squash_migrations() 286 | assert str(error.value) == "The following apps are not valid: aaa, bbb" 287 | 288 | 289 | @pytest.mark.filterwarnings("ignore") 290 | def test_only_apps_with_ignored_app(call_squash_migrations): 291 | """ 292 | Edge case: if the app was previously ignored, remove it from the ignore list 293 | """ 294 | with pytest.raises(CommandError) as error: 295 | call_squash_migrations( 296 | "--ignore-app", 297 | "app2", 298 | "app", 299 | "--only", 300 | "app2", 301 | ) 302 | assert str(error.value) == "The following app cannot be ignored and selected at the same time: app2" 303 | 304 | 305 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.empty", app_label="app") 306 | def test_ignore_apps_argument(migration_app_dir, call_squash_migrations, monkeypatch): 307 | del migration_app_dir 308 | mock_squash = unittest.mock.MagicMock() 309 | monkeypatch.setattr( 310 | "django_squash.db.migrations.autodetector.SquashMigrationAutodetector.squash", 311 | mock_squash, 312 | ) 313 | with pytest.raises(CommandError) as error: 314 | call_squash_migrations( 315 | "--ignore-app", 316 | "app2", 317 | "app", 318 | ) 319 | assert str(error.value) == "There are no migrations to squash." 320 | assert mock_squash.called 321 | assert set(mock_squash.call_args[1]["ignore_apps"]) == {"app2", "app"} 322 | 323 | 324 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.empty", app_label="app") 325 | def test_only_argument(migration_app_dir, call_squash_migrations, settings, monkeypatch): 326 | del migration_app_dir 327 | mock_squash = unittest.mock.MagicMock() 328 | monkeypatch.setattr( 329 | "django_squash.db.migrations.autodetector.SquashMigrationAutodetector.squash", 330 | mock_squash, 331 | ) 332 | with pytest.raises(CommandError) as error: 333 | call_squash_migrations( 334 | "--only", 335 | "app2", 336 | "app", 337 | ) 338 | assert str(error.value) == "There are no migrations to squash." 339 | assert mock_squash.called 340 | installed_apps = {full_app.rsplit(".")[-1] for full_app in settings.INSTALLED_APPS} 341 | assert set(mock_squash.call_args[1]["ignore_apps"]) == installed_apps - {"app2", "app"} 342 | 343 | 344 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.empty", app_label="app") 345 | def test_only_argument_with_invalid_apps(migration_app_dir, call_squash_migrations, monkeypatch): 346 | del migration_app_dir 347 | mock_squash = unittest.mock.MagicMock() 348 | monkeypatch.setattr( 349 | "django_squash.db.migrations.autodetector.SquashMigrationAutodetector.squash", 350 | mock_squash, 351 | ) 352 | with pytest.raises(CommandError) as error: 353 | call_squash_migrations( 354 | "--only", 355 | "app2", 356 | "invalid", 357 | ) 358 | assert str(error.value) == "The following apps are not valid: invalid" 359 | assert not mock_squash.called 360 | 361 | 362 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.elidable", app_label="app") 363 | def test_simple_delete_squashing_migrations_noop(migration_app_dir, call_squash_migrations): 364 | class Person(models.Model): 365 | name = models.CharField(max_length=10) 366 | dob = models.DateField() 367 | 368 | class Meta: 369 | app_label = "app" 370 | 371 | call_squash_migrations() 372 | 373 | files_in_app = migration_app_dir.migration_files() 374 | expected = [ 375 | "0001_initial.py", 376 | "0002_person_age.py", 377 | "0003_add_dob.py", 378 | "0004_squashed.py", 379 | "__init__.py", 380 | ] 381 | assert files_in_app == expected 382 | 383 | 384 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.delete_replaced", app_label="app") 385 | def test_simple_delete_squashing_migrations(migration_app_dir, call_squash_migrations): 386 | class Person(models.Model): 387 | name = models.CharField(max_length=10) 388 | dob = models.DateField() 389 | 390 | class Meta: 391 | app_label = "app" 392 | 393 | original_app_squash = migration_app_dir.migration_load("0004_squashed.py") 394 | assert original_app_squash.Migration.replaces == [ 395 | ("app", "0001_initial"), 396 | ("app", "0002_person_age"), 397 | ("app", "0003_add_dob"), 398 | ] 399 | 400 | call_squash_migrations() 401 | 402 | files_in_app = migration_app_dir.migration_files() 403 | old_app_squash = migration_app_dir.migration_load("0004_squashed.py") 404 | new_app_squash = migration_app_dir.migration_load("0005_squashed.py") 405 | 406 | # We altered an existing file, and removed all the "replaces" items 407 | assert old_app_squash.Migration.replaces == [] 408 | # The new squashed migration replaced the old one now 409 | assert new_app_squash.Migration.replaces == [("app", "0004_squashed")] 410 | assert files_in_app == ["0004_squashed.py", "0005_squashed.py", "__init__.py"] 411 | 412 | 413 | @pytest.mark.temporary_migration_module(module="app3.tests.migrations.moved", app_label="app3") 414 | def test_empty_models_migrations(migration_app_dir, call_squash_migrations): 415 | """ 416 | If apps are moved but migrations remain, a fake migration must be made that does nothing and replaces the 417 | existing migrations, that way django doesn't throw errors when trying to do the same work again. 418 | """ 419 | call_squash_migrations() 420 | files_in_app = migration_app_dir.migration_files() 421 | assert "0004_squashed.py" in files_in_app 422 | app_squash = migration_app_dir.migration_load("0004_squashed.py") 423 | 424 | expected_files = [ 425 | "0001_initial.py", 426 | "0002_person_age.py", 427 | "0003_moved.py", 428 | "0004_squashed.py", 429 | "__init__.py", 430 | ] 431 | assert files_in_app == expected_files 432 | assert app_squash.Migration.replaces == [ 433 | ("app3", "0001_initial"), 434 | ("app3", "0002_person_age"), 435 | ("app3", "0003_moved"), 436 | ] 437 | 438 | 439 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.incorrect_name", app_label="app") 440 | def test_squashing_migration_incorrect_name(migration_app_dir, call_squash_migrations): 441 | """ 442 | If the app has incorrect migration numbers like: `app/migrations/initial.py` instead of `0001_initial.py` 443 | it should not fail. Same goes for bad formats all around. 444 | """ 445 | 446 | class Person(models.Model): 447 | name = models.CharField(max_length=10) 448 | dob = models.DateField() 449 | 450 | class Meta: 451 | app_label = "app" 452 | 453 | call_squash_migrations() 454 | 455 | files_in_app = migration_app_dir.migration_files() 456 | assert "3001_squashed.py" in files_in_app 457 | 458 | app_squash = migration_app_dir.migration_load("3001_squashed.py") 459 | 460 | assert app_squash.Migration.replaces == [ 461 | ("app", "2_person_age"), 462 | ("app", "3000_auto_20190518_1524"), 463 | ("app", "bad_no_name"), 464 | ("app", "initial"), 465 | ] 466 | 467 | 468 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.run_python_noop", app_label="app") 469 | def test_run_python_same_name_migrations(migration_app_dir, call_squash_migrations): 470 | 471 | call_squash_migrations() 472 | 473 | files_in_app = migration_app_dir.migration_files() 474 | expected_files = [ 475 | "0001_initial.py", 476 | "0002_run_python.py", 477 | "0003_squashed.py", 478 | "__init__.py", 479 | ] 480 | assert files_in_app == expected_files 481 | 482 | expected = textwrap.dedent( 483 | """\ 484 | from django.db import migrations 485 | from django.db.migrations import RunPython 486 | from django.db.migrations.operations.special import RunPython 487 | 488 | 489 | def same_name(apps, schema_editor): 490 | # original function 491 | return 492 | 493 | 494 | def same_name_2(apps, schema_editor): 495 | # original function 2 496 | return 497 | 498 | 499 | def same_name_3(apps, schema_editor): 500 | # other function 501 | return 502 | 503 | 504 | class Migration(migrations.Migration): 505 | 506 | replaces = [("app", "0001_initial"), ("app", "0002_run_python")] 507 | 508 | dependencies = [] 509 | 510 | operations = [ 511 | migrations.RunPython( 512 | code=same_name, 513 | reverse_code=migrations.RunPython.noop, 514 | elidable=False, 515 | ), 516 | migrations.RunPython( 517 | code=migrations.RunPython.noop, 518 | reverse_code=migrations.RunPython.noop, 519 | elidable=False, 520 | ), 521 | migrations.RunPython( 522 | code=same_name_2, 523 | reverse_code=same_name, 524 | elidable=False, 525 | ), 526 | migrations.RunPython( 527 | code=same_name, 528 | reverse_code=same_name_2, 529 | elidable=False, 530 | ), 531 | migrations.RunPython( 532 | code=migrations.RunPython.noop, 533 | elidable=False, 534 | ), 535 | migrations.RunPython( 536 | code=same_name_3, 537 | elidable=False, 538 | ), 539 | ] 540 | """ # noqa 541 | ) 542 | assert migration_app_dir.migration_read("0003_squashed.py", "") == expected 543 | 544 | 545 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.swappable_dependency", app_label="app") 546 | def test_swappable_dependency_migrations(migration_app_dir, settings, call_squash_migrations): 547 | class UserProfile(models.Model): 548 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 549 | dob = models.DateField() 550 | 551 | class Meta: 552 | app_label = "app" 553 | 554 | call_squash_migrations() 555 | files_in_app = migration_app_dir.migration_files() 556 | 557 | assert files_in_app == [ 558 | "0001_initial.py", 559 | "0002_add_dob.py", 560 | "0003_squashed.py", 561 | "__init__.py", 562 | ] 563 | 564 | expected = textwrap.dedent( 565 | """\ 566 | import datetime 567 | from django.conf import settings 568 | from django.db import migrations, models 569 | 570 | 571 | class Migration(migrations.Migration): 572 | 573 | replaces = [("app", "0001_initial"), ("app", "0002_add_dob")] 574 | 575 | initial = True 576 | 577 | dependencies = [ 578 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 579 | ] 580 | 581 | operations = [ 582 | migrations.CreateModel( 583 | name="UserProfile", 584 | fields=[ 585 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 586 | ("dob", models.DateField()), 587 | ("user", models.ForeignKey(on_delete=models.CASCADE, to=settings.AUTH_USER_MODEL)), 588 | ], 589 | ), 590 | ] 591 | """ # noqa 592 | ) 593 | assert migration_app_dir.migration_read("0003_squashed.py", "") == expected 594 | 595 | 596 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.pg_indexes", app_label="app") 597 | def test_squashing_migration_pg_indexes(migration_app_dir, call_squash_migrations): 598 | 599 | class Message(models.Model): 600 | score = models.IntegerField(default=0) 601 | unicode_name = models.CharField(max_length=255, db_index=True) 602 | 603 | class Meta: 604 | indexes = [models.Index(fields=["-score"]), GinIndex(fields=["unicode_name"])] 605 | app_label = "app" 606 | 607 | call_squash_migrations() 608 | assert migration_app_dir.migration_files() == [ 609 | "0001_initial.py", 610 | "0002_use_index.py", 611 | "0003_squashed.py", 612 | "__init__.py", 613 | ] 614 | expected = textwrap.dedent( 615 | """\ 616 | import django.contrib.postgres.indexes 617 | import django.contrib.postgres.operations 618 | from django.contrib.postgres.operations import BtreeGinExtension 619 | from django.db import migrations 620 | from django.db import migrations, models 621 | 622 | 623 | class Migration(migrations.Migration): 624 | 625 | replaces = [("app", "0001_initial"), ("app", "0002_use_index")] 626 | 627 | initial = True 628 | 629 | dependencies = [] 630 | 631 | operations = [ 632 | django.contrib.postgres.operations.BtreeGinExtension(), 633 | migrations.CreateModel( 634 | name="Message", 635 | fields=[ 636 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 637 | ("score", models.IntegerField(default=0)), 638 | ("unicode_name", models.CharField(db_index=True, max_length=255)), 639 | ], 640 | """ # noqa 641 | ) 642 | # NOTE: different django versions handle index differently, since the Index part is actually not 643 | # being tested, it doesn't matter that is not checked 644 | assert migration_app_dir.migration_read("0003_squashed.py", "").startswith(expected) 645 | 646 | 647 | @pytest.mark.temporary_migration_module(module="app.tests.migrations.pg_indexes_custom", app_label="app") 648 | def test_squashing_migration_pg_indexes_custom(migration_app_dir, call_squash_migrations): 649 | 650 | class Message(models.Model): 651 | score = models.IntegerField(default=0) 652 | unicode_name = models.CharField(max_length=255, db_index=True) 653 | 654 | class Meta: 655 | indexes = [models.Index(fields=["-score"]), GinIndex(fields=["unicode_name"])] 656 | app_label = "app" 657 | 658 | call_squash_migrations() 659 | assert migration_app_dir.migration_files() == [ 660 | "0001_initial.py", 661 | "0002_use_index.py", 662 | "0003_squashed.py", 663 | "__init__.py", 664 | ] 665 | expected = textwrap.dedent( 666 | """\ 667 | import django.contrib.postgres.indexes 668 | from django.contrib.postgres.operations import BtreeGinExtension 669 | from django.db import migrations 670 | from django.db import migrations, models 671 | 672 | 673 | class IgnoreRollbackBtreeGinExtension(BtreeGinExtension): 674 | \"\"\" 675 | Custom extension that doesn't rollback no matter what 676 | \"\"\" 677 | 678 | def database_backwards(self, *args, **kwargs): 679 | pass 680 | 681 | 682 | class Migration(migrations.Migration): 683 | 684 | replaces = [("app", "0001_initial"), ("app", "0002_use_index")] 685 | 686 | initial = True 687 | 688 | dependencies = [] 689 | 690 | operations = [ 691 | IgnoreRollbackBtreeGinExtension(), 692 | migrations.CreateModel( 693 | name="Message", 694 | fields=[ 695 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 696 | ("score", models.IntegerField(default=0)), 697 | ("unicode_name", models.CharField(db_index=True, max_length=255)), 698 | ], 699 | """ # noqa 700 | ) 701 | # NOTE: different django versions handle index differently, since the Index part is actually not 702 | # being tested, it doesn't matter that is not checked 703 | assert migration_app_dir.migration_read("0003_squashed.py", "").startswith(expected) 704 | -------------------------------------------------------------------------------- /tests/test_migrations_autodetector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db.migrations import Migration as OriginalMigration 3 | 4 | from django_squash.db.migrations import autodetector 5 | 6 | 7 | def test_migration(): 8 | original = OriginalMigration("0001_inital", "app") 9 | new = autodetector.Migration.from_migration(original) 10 | assert new.name == "0001_inital" 11 | assert new.app_label == "app" 12 | assert new._original_migration == original 13 | 14 | assert new[0] == "app" 15 | assert new[1] == "0001_inital" 16 | assert list(new) == ["app", "0001_inital"] 17 | 18 | assert not list(new.describe()) 19 | assert not new.is_migration_level 20 | new._deleted = True 21 | new._dependencies_change = True 22 | new._replaces_change = True 23 | assert new.is_migration_level 24 | assert list(new.describe()) == ["Deleted", '"dependencies" changed', '"replaces" keyword removed'] 25 | 26 | 27 | def test_migration_using_keywords(): 28 | """ 29 | Test that the migration can be created using our internal keywords 30 | """ 31 | 32 | class FakeMigration: 33 | app_label = "app" 34 | name = "0001_inital" 35 | 36 | autodetector.Migration.from_migration(FakeMigration()) 37 | 38 | for keyword in autodetector.RESERVED_MIGRATION_KEYWORDS: 39 | migration = OriginalMigration("0001_inital", "app") 40 | fake_migration = FakeMigration() 41 | new_migration = autodetector.Migration("0001_inital", "app") 42 | 43 | setattr(migration, keyword, True) 44 | setattr(fake_migration, keyword, True) 45 | setattr(new_migration, keyword, True) 46 | 47 | with pytest.raises(RuntimeError): 48 | autodetector.Migration.from_migration(migration) 49 | 50 | with pytest.raises(RuntimeError): 51 | autodetector.Migration.from_migration(fake_migration) 52 | 53 | autodetector.Migration.from_migration(new_migration) 54 | -------------------------------------------------------------------------------- /tests/test_serializer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db.migrations.migration import Migration, swappable_dependency 4 | from django.db.migrations.operations.special import RunPython 5 | from django.db.models.base import Model 6 | from django.db.models.fields.proxy import OrderWrt 7 | 8 | from django_squash.db.migrations import serializer 9 | 10 | 11 | def noop(): 12 | pass 13 | 14 | 15 | def test_function_type_serializer(): 16 | S = serializer.FunctionTypeSerializer 17 | assert S(OrderWrt).serialize() == ( 18 | "models.OrderWrt", 19 | {"from django.db import models"}, 20 | ) 21 | assert S(Model).serialize() == ("models.Model", {"from django.db import models"}) 22 | 23 | assert S(Migration).serialize() == ( 24 | "migrations.Migration", 25 | {"from django.db import migrations"}, 26 | ) 27 | assert S(swappable_dependency).serialize() == ( 28 | "migrations.swappable_dependency", 29 | {"from django.db import migrations"}, 30 | ) 31 | assert S(RunPython).serialize() == ( 32 | "migrations.RunPython", 33 | {"from django.db import migrations"}, 34 | ) 35 | assert S(RunPython.noop).serialize() == ( 36 | "migrations.RunPython.noop", 37 | {"from django.db import migrations"}, 38 | ) 39 | 40 | assert S(noop).serialize() == ( 41 | "tests.test_serializer.noop", 42 | {"import tests.test_serializer"}, 43 | ) 44 | noop.__in_migration_file__ = False 45 | assert S(noop).serialize() == ( 46 | "tests.test_serializer.noop", 47 | {"import tests.test_serializer"}, 48 | ) 49 | noop.__in_migration_file__ = True 50 | assert S(noop).serialize() == ("noop", {}) 51 | -------------------------------------------------------------------------------- /tests/test_standalone.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import os 5 | import sqlite3 6 | import tarfile 7 | import tempfile 8 | import urllib.request 9 | 10 | import pytest 11 | 12 | from django_squash import __version__ as version 13 | from tests import utils 14 | 15 | if utils.is_pyvsupported("3.11"): 16 | try: 17 | from contextlib import chdir 18 | except ImportError: 19 | # Delete after python 3.10 is no longer supported (31 Oct 2026) 20 | @contextlib.contextmanager 21 | def chdir(path): 22 | prev_cwd = os.getcwd() 23 | os.chdir(path) 24 | try: 25 | yield 26 | finally: 27 | os.chdir(prev_cwd) 28 | 29 | else: 30 | raise Exception("Remove this whole block please! and use contextlib.chdir instead.") 31 | 32 | 33 | SETTINGS_PY_DIFF = """\ 34 | --- original.py 2024-03-19 13:32:55 35 | +++ diff.py 2024-03-19 13:33:05 36 | @@ -31,13 +31,14 @@ 37 | # Application definition 38 | 39 | INSTALLED_APPS = [ 40 | - 'polls.apps.PollsConfig', 41 | 'django.contrib.admin', 42 | 'django.contrib.auth', 43 | 'django.contrib.contenttypes', 44 | 'django.contrib.sessions', 45 | 'django.contrib.messages', 46 | 'django.contrib.staticfiles', 47 | + 'django_squash', 48 | + 'polls.apps.PollsConfig', 49 | ] 50 | 51 | MIDDLEWARE = [ 52 | """ 53 | 54 | 55 | @contextlib.contextmanager 56 | def download_and_extract_tar(url): 57 | """Downloads and extracts the url into a temporary directory.""" 58 | # An attempt was made to introduce etag support to this function and was removed as the 59 | # benefits where sub 1 second and the amount of code added was not justifiable. 60 | with tempfile.TemporaryDirectory() as tmp_dir: 61 | filename = os.path.basename(url) 62 | filepath = os.path.join(tmp_dir, filename) 63 | 64 | # Download the file using urllib 65 | with urllib.request.urlopen(url) as response, open(filepath, "wb") as f: 66 | f.write(response.read()) 67 | 68 | # Extract the tar.gz file 69 | with tarfile.open(filepath, "r:gz") as tar: 70 | replace_path = tar.getmembers()[0].name + "/mysite/" 71 | for member in tar.getmembers(): 72 | # Remove the root directory 73 | if "mysite" not in member.name: 74 | continue 75 | member.name = member.name.replace(replace_path, "") # Remove leading path 76 | tar.extract(member, tmp_dir, filter="data") 77 | 78 | # Apply the INSTALLED_APPS patch 79 | with open(f"{tmp_dir}/diff.patch", "w") as f: 80 | f.write(SETTINGS_PY_DIFF) 81 | os.system(f"patch {tmp_dir}/mysite/settings.py {tmp_dir}/diff.patch") 82 | 83 | with chdir(tmp_dir): 84 | yield tmp_dir 85 | 86 | 87 | @pytest.mark.slow 88 | @pytest.mark.no_cover 89 | def test_standalone_app(): 90 | """Test that a standalone (django sample poll) app can be installed using venv. 91 | 92 | This test is slow because it downloads a tar.gz file from the internet, extracts it and 93 | pip installs django + dependencies. After runs migrations, squashes them, and runs them again! 94 | """ 95 | url = "https://github.com/consideratecode/django-tutorial-step-by-step/archive/refs/tags/2.0/7.4.tar.gz" 96 | # This is a full django_squash package, with a copy of all the code, crucial for testing 97 | # This will alert us if a new module is added but not included in the final package 98 | project_path = os.getcwd() 99 | with download_and_extract_tar(url): 100 | # Build the package from scratch 101 | assert os.system(f"python3 -m build {project_path} --outdir=./dist") == 0 102 | assert sorted(os.listdir("dist")) == [ 103 | f"django_squash-{version}-py3-none-any.whl", 104 | f"django_squash-{version}.tar.gz", 105 | ] 106 | 107 | # Setup 108 | assert os.system("python -m venv venv") == 0 109 | assert os.system(f"venv/bin/pip install django dist/django_squash-{version}-py3-none-any.whl") == 0 110 | 111 | # Everything works as expected 112 | assert os.system("DJANGO_SETTINGS_MODULE=mysite.settings venv/bin/python manage.py migrate") == 0 113 | os.rename("db.sqlite3", "db_original.sqlite3") 114 | 115 | # Squash and run the migrations 116 | assert os.system("DJANGO_SETTINGS_MODULE=mysite.settings venv/bin/python manage.py squash_migrations") == 0 117 | # __pycache__ can be present in the migrations folder. We don't care about it. 118 | actual_files = sorted(set(os.listdir("polls/migrations")) - {"__pycache__"}) 119 | assert actual_files == ["0001_initial.py", "0002_squashed.py", "__init__.py"] 120 | assert os.system("DJANGO_SETTINGS_MODULE=mysite.settings venv/bin/python manage.py migrate") == 0 121 | 122 | original_con = sqlite3.connect("db_original.sqlite3") 123 | squashed_con = sqlite3.connect("db.sqlite3") 124 | 125 | # Check that the squashed migrations schema is the same as the original ones 126 | sql = "select name, tbl_name, sql from sqlite_schema" 127 | assert original_con.execute(sql).fetchall() == squashed_con.execute(sql).fetchall() 128 | 129 | # Check that the migrations were applied 130 | sql = "select name from django_migrations where app = 'polls' order by name" 131 | assert original_con.execute(sql).fetchall() == [("0001_initial",)] 132 | assert squashed_con.execute(sql).fetchall() == [("0001_initial",), ("0002_squashed",)] 133 | 134 | original_con.close() 135 | squashed_con.close() 136 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: N805 2 | from __future__ import annotations 3 | 4 | import tempfile 5 | 6 | import django 7 | from django.db import migrations 8 | import pytest 9 | 10 | import django_squash 11 | from django_squash.db.migrations import utils 12 | 13 | func = lambda: 1 # noqa: E731 14 | 15 | 16 | def func2(): 17 | return 2 18 | 19 | 20 | def func2_impostor(): 21 | return 21 22 | 23 | 24 | func2_impostor.__qualname__ = "func2" 25 | 26 | 27 | def func2_impostor2(): 28 | return 22 29 | 30 | 31 | func2_impostor2.__qualname__ = "func2" 32 | 33 | 34 | def func2_3(): 35 | return 2 36 | 37 | 38 | class A: 39 | @staticmethod 40 | def func(): 41 | return 3 42 | 43 | 44 | class B: 45 | @classmethod 46 | def func(cls): 47 | return 4 48 | 49 | 50 | class C: 51 | def func(self): 52 | return 5 53 | 54 | 55 | class D(migrations.Migration): 56 | def func(self): 57 | return 6 58 | 59 | def func2(apps, schema_editor): 60 | del schema_editor 61 | return 61 62 | 63 | 64 | def test_is_code_in_site_packages(): 65 | assert utils.is_code_in_site_packages(django.get_version.__module__) 66 | path = django_squash.db.migrations.utils.is_code_in_site_packages.__module__ 67 | assert not utils.is_code_in_site_packages(path) 68 | assert not utils.is_code_in_site_packages("bad.path") 69 | 70 | 71 | def test_unique_names(): 72 | names = utils.UniqueVariableName({}) 73 | assert names("var") == "var" 74 | assert names("var") == "var_2" 75 | assert names("var_2") == "var_2_2" 76 | 77 | 78 | def test_unique_function_names_errors(): 79 | names = utils.UniqueVariableName({}) 80 | 81 | with pytest.raises(ValueError): 82 | names.function("not-a-function") 83 | 84 | with pytest.raises(ValueError): 85 | names.function(func) 86 | 87 | with pytest.raises(ValueError): 88 | names.function(B.func) 89 | 90 | with pytest.raises(ValueError): 91 | names.function(B().func) 92 | 93 | with pytest.raises(ValueError): 94 | names.function(C.func) 95 | 96 | with pytest.raises(ValueError): 97 | names.function(C().func) 98 | 99 | with pytest.raises(ValueError): 100 | names.function(D.func) 101 | 102 | 103 | def test_unique_function_names_context(): 104 | def custom_name(name, context): 105 | return "{module}_{i}_{name}".format(**context, name=name) 106 | 107 | names = utils.UniqueVariableName({"module": __name__.replace(".", "_")}, naming_function=custom_name) 108 | collector = [] 109 | for i, func in enumerate((func2, func2_3, func2_impostor, func2_impostor2)): 110 | names.update_context({"func": func, "i": i}) 111 | collector.append(names.function(func)) 112 | 113 | assert collector == [ 114 | "tests_test_utils_0_func2", 115 | "tests_test_utils_1_func2_3", 116 | "tests_test_utils_2_func2", 117 | "tests_test_utils_3_func2", 118 | ] 119 | 120 | 121 | def test_unique_function_names(): 122 | uniq1 = utils.UniqueVariableName({}) 123 | uniq2 = utils.UniqueVariableName({}) 124 | 125 | reassigned_func2 = func2 126 | reassigned_func2_impostor = func2_impostor 127 | 128 | assert uniq1("func2") == "func2" 129 | assert uniq1.function(func2) == "func2_2" 130 | assert uniq1.function(func2) == "func2_2" 131 | assert uniq1.function(reassigned_func2) == "func2_2" 132 | assert uniq1.function(func2_impostor) == "func2_3" 133 | assert uniq1.function(func2_impostor) == "func2_3" 134 | assert uniq1.function(reassigned_func2_impostor) == "func2_3" 135 | assert uniq1.function(func2_3) == "func2_3_2" 136 | assert uniq1.function(func2_impostor2) == "func2_4" 137 | assert uniq1.function(A.func) == "A.func" 138 | assert uniq1.function(A().func) == "A.func" 139 | assert uniq1("A.func") == "A.func_2" 140 | assert uniq1.function(A.func) == "A.func" 141 | assert uniq1.function(A().func) == "A.func" 142 | assert uniq1.function(D.func2) == "func2_5" 143 | 144 | assert uniq2.function(func2_impostor) == "func2" 145 | assert uniq2.function(func2_impostor) == "func2" 146 | assert uniq2.function(func2) == "func2_2" 147 | assert uniq2.function(func2) == "func2_2" 148 | assert uniq2.function(func2_3) == "func2_3" 149 | assert uniq2.function(func2_impostor2) == "func2_4" 150 | assert uniq2.function(func2_impostor) == "func2" 151 | assert uniq2.function(func2_impostor) == "func2" 152 | assert uniq2.function(func2) == "func2_2" 153 | assert uniq2.function(func2) == "func2_2" 154 | assert uniq2.function(D.func2) == "func2_5" 155 | 156 | 157 | def test_file_hash(): 158 | with tempfile.NamedTemporaryFile() as f: 159 | f.write(b"test") 160 | f.flush() 161 | assert utils.file_hash(f.name) == "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" 162 | 163 | 164 | def test_normalize_function_name(): 165 | reassigned_func2 = func2 166 | reassigned_func2_impostor = func2_impostor 167 | 168 | assert utils.normalize_function_name(func.__qualname__) == "" 169 | assert utils.normalize_function_name(func2.__qualname__) == "func2" 170 | assert utils.normalize_function_name(reassigned_func2.__qualname__) == "func2" 171 | assert utils.normalize_function_name(func2_impostor.__qualname__) == "func2" 172 | assert utils.normalize_function_name(reassigned_func2_impostor.__qualname__) == "func2" 173 | assert utils.normalize_function_name(A().func.__qualname__) == "func" 174 | assert utils.normalize_function_name(D.func.__qualname__) == "func" 175 | 176 | 177 | def test_get_custom_rename_function(monkeypatch): 178 | """Cover all cases where DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION can go wrong""" 179 | assert not utils.get_custom_rename_function() 180 | utils.get_custom_rename_function.cache_clear() 181 | 182 | monkeypatch.setattr("django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", "") 183 | assert not utils.get_custom_rename_function() 184 | utils.get_custom_rename_function.cache_clear() 185 | 186 | monkeypatch.setattr("django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", "tests.test_utils.func2") 187 | assert utils.get_custom_rename_function() == func2 188 | utils.get_custom_rename_function.cache_clear() 189 | 190 | monkeypatch.setattr("django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", "tests.test_utils.bad") 191 | with pytest.raises(ImportError): 192 | utils.get_custom_rename_function() 193 | utils.get_custom_rename_function.cache_clear() 194 | 195 | monkeypatch.setattr("django_squash.settings.DJANGO_SQUASH_CUSTOM_RENAME_FUNCTION", "does.not.exist") 196 | with pytest.raises(ModuleNotFoundError): 197 | utils.get_custom_rename_function() 198 | -------------------------------------------------------------------------------- /tests/test_writer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from django_squash.db.migrations import writer 6 | 7 | 8 | @pytest.mark.filterwarnings("error") 9 | @pytest.mark.parametrize( 10 | "response_hash, throws_warning", # noqa: PT006 11 | ( 12 | ("bad_hash", True), 13 | (writer.SUPPORTED_DJANGO_WRITER[0], False), 14 | ), 15 | ) 16 | def test_check_django_migration_hash(response_hash, throws_warning, monkeypatch): 17 | monkeypatch.setattr("django_squash.db.migrations.writer.utils.file_hash", lambda _: response_hash) 18 | if throws_warning: 19 | with pytest.warns(Warning): 20 | writer.check_django_migration_hash() 21 | else: 22 | writer.check_django_migration_hash() 23 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | """This URLconf exists because Django expects ROOT_URLCONF to exist. 2 | 3 | URLs should be added within the test folders, and use TestCase.urls to set them. 4 | This helps the tests remain isolated. 5 | """ # noqa: D404 6 | 7 | from __future__ import annotations 8 | 9 | urlpatterns = [] 10 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | import importlib.util 5 | import inspect 6 | from pathlib import Path 7 | import sys 8 | 9 | import black 10 | import libcst 11 | 12 | try: 13 | import tomllib 14 | 15 | with open("pyproject.toml") as f: # pragma: no cover 16 | if "Programming Language :: Python :: 3.10" not in f.read(): 17 | raise Exception("Delete this try/except block and leave the just the 'import tomllib'.") 18 | except ImportError: 19 | # Python 3.10 does not support tomllib 20 | import tomli as tomllib 21 | 22 | import warnings 23 | 24 | from django import VERSION as _DJANGO_FULL_VERSION 25 | from packaging.version import Version 26 | 27 | 28 | def is_pyvsupported(version): 29 | """Check if the Python version is supported by the package.""" 30 | return Version(version) in SUPPORTED_PYTHON_VERSIONS 31 | 32 | 33 | def is_djvsupported(version): 34 | """Check if the Django version is supported by the package.""" 35 | return Version(version) in SUPPORTED_DJANGO_VERSIONS 36 | 37 | 38 | def is_number(s): 39 | """Returns True if string is a number.""" 40 | try: 41 | float(s) 42 | except ValueError: 43 | return False 44 | else: 45 | return True 46 | 47 | 48 | def shorten_version(version): 49 | parts = version.split(".") 50 | return ".".join(parts[:2]) 51 | 52 | 53 | def is_supported_version(supported_versions, version_to_check): 54 | if version_to_check.is_prerelease: 55 | return True 56 | 57 | for base_version in supported_versions: 58 | if version_to_check.major == base_version.major and version_to_check.minor == base_version.minor: 59 | return True 60 | 61 | return False 62 | 63 | 64 | SUPPORTED_PYTHON_VERSIONS = [] 65 | SUPPORTED_DJANGO_VERSIONS = [] 66 | 67 | with open(Path().resolve() / "pyproject.toml", "rb") as f: 68 | conf = tomllib.load(f) 69 | for classifier in conf["project"]["classifiers"]: 70 | if "Framework :: Django ::" in classifier: 71 | version = classifier.split("::")[-1].strip() 72 | if is_number(version): 73 | SUPPORTED_DJANGO_VERSIONS.append(Version(version)) 74 | globals()["DJ" + version.replace(".", "")] = False 75 | elif "Programming Language :: Python ::" in classifier: 76 | version = classifier.split("::")[-1].strip() 77 | if is_number(version) and "." in version: 78 | SUPPORTED_PYTHON_VERSIONS.append(Version(version)) 79 | globals()["PY" + version.replace(".", "")] = False 80 | 81 | current_python_version = Version(f"{sys.version_info.major}.{sys.version_info.minor}") 82 | pre_release_map = {"alpha": "a", "beta": "b", "rc": "rc"} 83 | # Extract the components of the tuple 84 | major, minor, micro, pre_release, pre_release_num = _DJANGO_FULL_VERSION 85 | # Get the corresponding identifier 86 | pre_release_identifier = pre_release_map.get(pre_release, "") 87 | # Construct the version string 88 | _DJANGO_VERSION = f"{major}.{minor}.{micro}{pre_release_identifier}.{pre_release_num}" 89 | current_django_version = Version(_DJANGO_VERSION) 90 | 91 | globals()["DJ" + shorten_version(str(current_django_version).replace(".", ""))] = True 92 | globals()["PY" + shorten_version(str(current_python_version).replace(".", ""))] = True 93 | 94 | if not is_supported_version(SUPPORTED_DJANGO_VERSIONS, current_django_version): 95 | versions = ", ".join([str(v) for v in SUPPORTED_DJANGO_VERSIONS]) 96 | warnings.warn( 97 | f"Current Django version {current_django_version} is not in the supported versions: {versions}", 98 | stacklevel=0, 99 | ) 100 | 101 | if not is_supported_version(SUPPORTED_PYTHON_VERSIONS, current_python_version): 102 | versions = ", ".join([str(v) for v in SUPPORTED_PYTHON_VERSIONS]) 103 | warnings.warn( 104 | f"Current Python version {current_python_version} is not in the supported versions: {versions}", 105 | stacklevel=0, 106 | ) 107 | 108 | 109 | def load_migration_module(path): 110 | spec = importlib.util.spec_from_file_location("__module__", path) 111 | module = importlib.util.module_from_spec(spec) 112 | try: 113 | spec.loader.exec_module(module) 114 | except Exception as e: 115 | with open(path) as f: 116 | lines = f.readlines() 117 | formatted_lines = "".join(f"{i}: {line}" for i, line in enumerate(lines, start=1)) 118 | raise type(e)(f"{e}.\nError loading module file containing:\n\n{formatted_lines}") from e 119 | return module 120 | 121 | 122 | def pretty_extract_piece(module, traverse): 123 | """Format the code extracted from the module, so it can be compared to the expected output""" 124 | return format_code(extract_piece(module, traverse)) 125 | 126 | 127 | def extract_piece(module, traverse): 128 | """Extract a piece of code from a module""" 129 | source_code = inspect.getsource(module) 130 | tree = libcst.parse_module(source_code).body 131 | 132 | for looking_for in traverse.split("."): 133 | if looking_for: 134 | tree = traverse_node(tree, looking_for) 135 | 136 | if not isinstance(tree, tuple): 137 | tree = (tree,) 138 | return libcst.Module(body=tree).code 139 | 140 | 141 | def format_code(code_string): 142 | """Format the code so it's reproducible""" 143 | mode = black.FileMode(line_length=10_000) 144 | return black.format_str(code_string, mode=mode) 145 | 146 | 147 | def traverse_node(nodes, looking_for): 148 | """Traverse the tree looking for a node""" 149 | if not isinstance(nodes, (list, tuple)): 150 | nodes = [nodes] 151 | 152 | for node in nodes: 153 | if isinstance(node, (libcst.ClassDef, libcst.FunctionDef)) and node.name.value == looking_for: 154 | return node 155 | if isinstance(node, libcst.Assign) and looking_for in [n.target.value for n in node.targets]: 156 | return node 157 | 158 | for child in node.children: 159 | result = traverse_node(child, looking_for) 160 | if result: 161 | return result 162 | 163 | return None 164 | --------------------------------------------------------------------------------