pax_global_header00006660000000000000000000000064150004230140014477gustar00rootroot0000000000000052 comment=111b449b7029710149f0faef71aa85cbfc77e50e django-test-migrations-1.5.0/000077500000000000000000000000001500042301400160735ustar00rootroot00000000000000django-test-migrations-1.5.0/.dev/000077500000000000000000000000001500042301400167275ustar00rootroot00000000000000django-test-migrations-1.5.0/.dev/scripts/000077500000000000000000000000001500042301400204165ustar00rootroot00000000000000django-test-migrations-1.5.0/.dev/scripts/ci-mysql-setup-integration-tests.sh000066400000000000000000000005721500042301400273330ustar00rootroot00000000000000#!/usr/bin/env bash set -o errexit set -o pipefail set -o nounset set -o xtrace _SETUP_SCRIPT="$(cat << EOM CREATE DATABASE IF NOT EXISTS db; GRANT ALL PRIVILEGES ON db.* TO django; FLUSH PRIVILEGES; EOM )" mysql \ --host="${DJANGO_DATABASE_HOST}" \ --port="${DJANGO_DATABASE_PORT}" \ --user="root" \ --password="superpasswd123" \ --execute="${_SETUP_SCRIPT}" django-test-migrations-1.5.0/.editorconfig000066400000000000000000000005111500042301400205450ustar00rootroot00000000000000# Check http://editorconfig.org for more information # This is the main config file for this project: root = true [*] charset = utf-8 trim_trailing_whitespace = true end_of_line = lf indent_style = space insert_final_newline = true indent_size = 2 [*.py] indent_size = 4 [*.pyi] indent_size = 4 [Makefile] indent_style = tab django-test-migrations-1.5.0/.github/000077500000000000000000000000001500042301400174335ustar00rootroot00000000000000django-test-migrations-1.5.0/.github/dependabot.yml000066400000000000000000000004311500042301400222610ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily time: "02:00" open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: "/" schedule: interval: daily time: "02:00" open-pull-requests-limit: 10 django-test-migrations-1.5.0/.github/workflows/000077500000000000000000000000001500042301400214705ustar00rootroot00000000000000django-test-migrations-1.5.0/.github/workflows/test.yml000066400000000000000000000113771500042301400232030ustar00rootroot00000000000000--- name: test 'on': push: branches: - master pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true permissions: contents: read jobs: build: runs-on: ubuntu-latest env: DJANGO_DATABASE_ENGINE: "${{ matrix.env.DJANGO_DATABASE_ENGINE || 'django.db.backends.sqlite3' }}" DJANGO_DATABASE_USER: django DJANGO_DATABASE_PASSWORD: passwd123 DJANGO_DATABASE_NAME: db DJANGO_DATABASE_HOST: 127.0.0.1 DJANGO_DATABASE_PORT: "${{ matrix.env.DJANGO_DATABASE_PORT }}" strategy: fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] django-version: - 'Django~=4.2.0' - 'Django~=5.1.0' - 'Django~=5.2.0' docker-compose-services: [''] additional-dependencies: [''] env: [{}] integration-test-setup-script: [''] include: - python-version: '3.12' django-version: 'Django~=5.1.0' docker-compose-services: postgresql-db additional-dependencies: psycopg2 env: DJANGO_DATABASE_ENGINE: 'django.db.backends.postgresql' DJANGO_DATABASE_PORT: 5432 - python-version: '3.13' django-version: 'Django~=5.2.0' docker-compose-services: postgresql-db-17 additional-dependencies: psycopg env: DJANGO_DATABASE_ENGINE: 'django.db.backends.postgresql' DJANGO_DATABASE_PORT: 5433 # TODO: reenable # - python-version: '3.12' # django-version: 'Django~=5.0.0' # docker-compose-services: mysql-db # additional-dependencies: mysqlclient # env: # DJANGO_DATABASE_ENGINE: 'django.db.backends.mysql' # DJANGO_DATABASE_PORT: 3306 # integration-test-setup-script: >- # ./.dev/scripts/ci-mysql-setup-integration-tests.sh - python-version: '3.12' django-version: 'Django~=5.2.0' docker-compose-services: maria-db additional-dependencies: mysqlclient env: DJANGO_DATABASE_ENGINE: 'django.db.backends.mysql' DJANGO_DATABASE_PORT: 3307 integration-test-setup-script: >- ./.dev/scripts/ci-mysql-setup-integration-tests.sh steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install poetry run: | curl -sSL "https://install.python-poetry.org" | python # Adding `poetry` to `$PATH`: echo "$HOME/.poetry/bin" >> $GITHUB_PATH - name: Install dependencies run: | poetry config virtualenvs.in-project true poetry run pip install -U pip poetry install poetry run pip install \ --upgrade \ "${{ matrix.django-version }}" \ ${{ matrix.additional-dependencies }} - name: Pull and build docker compose services if: ${{ matrix.docker-compose-services }} run: | docker compose pull ${{ matrix.docker-compose-services }} docker compose up --detach ${{ matrix.docker-compose-services }} - name: Wait for docker-compose services if: ${{ matrix.docker-compose-services }} run: | sudo apt-get update && sudo apt-get install -y wait-for-it wait-for-it \ --host='localhost' \ --port="${{ matrix.env.DJANGO_DATABASE_PORT }}" \ --timeout=30 \ --strict - name: "Run checks for python ${{ matrix.python-version }} and django ${{ matrix.django-version }}" run: make test - name: >- Run integration tests for python ${{ matrix.python-version }} and django ${{ matrix.django-version }} using ${{ matrix.docker-compose-services }} if: ${{ matrix.docker-compose-services }} run: | if [ -f '${{ matrix.integration-test-setup-script }}' ]; then bash '${{ matrix.integration-test-setup-script }}' fi CHECK_OUTPUT="$(poetry run python django_test_app/manage.py check 2>&1 || true)" echo "${CHECK_OUTPUT}" echo "${CHECK_OUTPUT}" \ | grep --quiet --extended-regexp '^System check identified 4 issues' - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: files: ./coverage.xml - name: Stop docker-compose services if: ${{ always() && matrix.docker-compose-services }} run: docker compose down || true django-test-migrations-1.5.0/.gitignore000066400000000000000000000055351500042301400200730ustar00rootroot00000000000000#### joe made this: http://goel.io/joe #### python #### # Byte-compiled / optimized / DLL files .pytest_cache __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg pip-wheel-metadata/ # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ #### macos #### # General *.DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk #### windows #### # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Dump file *.stackdump # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msm *.msp # Windows shortcuts *.lnk #### linux #### *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* #### jetbrains #### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff: .idea/ ## File-based project format: *.iws ## Plugin-specific files: # IntelliJ /out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties ### Custom ### ex.py *.sqlite3 django-test-migrations-1.5.0/CHANGELOG.md000066400000000000000000000047331500042301400177130ustar00rootroot00000000000000# Version history We follow Semantic Versions since the `0.1.0` release. ## Version 1.5.0 ### Features - Adds Python 3.13 support - Drops Python 3.9 support - Adds Django 5.2 support ## Version 1.4.0 ### Features - Adds Python 3.12 support - Drops Python 3.8 support - Updates `typing_extensions` to `>=4,<5` - Adds more typing to the project ### Fixes - Fixes getting the `statement_timeout` setting name on MariaDB servers - Fixes delayed apps cache ## Version 1.3.0 ### Features - Adds Python 3.11 support - Drops Python 3.7 support - Adds Django 4.1 support - Adds Django 4.2 support - Drops Django 2.2 support ## Version 1.2.0 ### Features - Adds Python 3.10 - Adds Django 4.0 support - Updates `typing_extensions` to `>=3.6,<5` ## Version 1.1.0 ### Features - Adds Django 3.1 support (#123, #154) - Adds markers/tags to migration tests (#138) - Adds database configuration checks (#91) ### Bugfixes - Fixes tables dropping on MySQL by disabling foreign keys checks (#149) - Fixes migrate signals muting when running migrations tests (#133) ### Misc - Runs tests against PostgreSQL and MySQL database engines (#129) ## Version 1.0.0 ### Breaking Changes - Rename following `Migrator` methods (#83): + `before` to `apply_initial_migration` + `after` to `apply_tested_migration` - Improves databases setup and teardown for migrations tests (#76) Currently `Migrator.reset` uses `migrate` management command and all logic related to migrations tests setup is moved to `Migrator.apply_tested_migration`. ### Bugfixes - Fixes `pre_migrate` and `post_migrate` signals muting (#87) - Adds missing `typing_extension` dependency (#86) ### Misc - Refactor tests (#79) - Return `django` installed from `master` branch to testing matrix (#77) ## Version 0.3.0 ### Features - Drops `django@2.1` support - Adds `'*'` alias for ignoring all migrations in an app with `DTM_IGNORED_MIGRATIONS` ### Bugfixes - Fixes how `pre_migrate` and `post_migrate` signals are muted ### Misc - Updates `wemake-python-styleguide` - Moves from `travis` to Github Actions ## Version 0.2.0 ### Features - Adds `autoname` check to forbid `*_auto_*` named migrations - Adds `django@3.0` support - Adds `python3.8` support ### Bugfixes - Fixes that migtaions were failing with `pre_migrate` and `post_migrate` signals - Fixes that tests were failing when `pytest --nomigration` was executed, now they are skipped ### Misc - Updates to `poetry@1.0` ## Version 0.1.0 - Initial release django-test-migrations-1.5.0/LICENSE000066400000000000000000000020601500042301400170760ustar00rootroot00000000000000MIT License Copyright (c) 2019 wemake.services Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. django-test-migrations-1.5.0/Makefile000066400000000000000000000010301500042301400175250ustar00rootroot00000000000000SHELL:=/usr/bin/env bash .PHONY: format format: poetry run ruff format poetry run ruff check .PHONY: lint lint: poetry run ruff check --exit-non-zero-on-fix --diff poetry run ruff format --check --diff poetry run mypy django_test_migrations poetry run flake8 . .PHONY: unit unit: # We need one more test run to make sure that `--nomigrations` work: poetry run pytest -p no:cov -o addopts="" --nomigrations poetry run pytest .PHONY: package package: poetry check poetry run pip check .PHONY: test test: lint unit package django-test-migrations-1.5.0/README.md000066400000000000000000000277321500042301400173650ustar00rootroot00000000000000# django-test-migrations [![wemake.services](https://img.shields.io/badge/%20-wemake.services-green.svg?label=%20&logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC%2FxhBQAAAAFzUkdCAK7OHOkAAAAbUExURQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP%2F%2F%2F5TvxDIAAAAIdFJOUwAjRA8xXANAL%2Bv0SAAAADNJREFUGNNjYCAIOJjRBdBFWMkVQeGzcHAwksJnAPPZGOGAASzPzAEHEGVsLExQwE7YswCb7AFZSF3bbAAAAABJRU5ErkJggg%3D%3D)](https://wemake-services.github.io) [![Build status](https://github.com/wemake-services/django-test-migrations/workflows/test/badge.svg?branch=master&event=push)](https://github.com/wemake-services/django-test-migrations/actions?query=workflow%3Atest) [![codecov](https://codecov.io/gh/wemake-services/django-test-migrations/branch/master/graph/badge.svg)](https://codecov.io/gh/wemake-services/django-test-migrations) [![Python Version](https://img.shields.io/pypi/pyversions/django-test-migrations.svg)](https://pypi.org/project/django-test-migrations/) ![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-test-migrations) [![wemake-python-styleguide](https://img.shields.io/badge/style-wemake-000000.svg)](https://github.com/wemake-services/wemake-python-styleguide) ## Features - Allows to test `django` schema and data migrations - Allows to test both forward and rollback migrations - Allows to test the migrations order - Allows to test migration names - Allows to test database configuration - Fully typed with annotations and checked with `mypy`, [PEP561 compatible](https://www.python.org/dev/peps/pep-0561/) - Easy to start: has lots of docs, tests, and tutorials Read the [announcing post](https://sobolevn.me/2019/10/testing-django-migrations). See real-world [usage example](https://github.com/wemake-services/wemake-django-template). ## Installation ```bash pip install django-test-migrations ``` We support several `django` versions: - `3.2` - `4.1` - `4.2` - `5.0` Other versions most likely will work too, but they are not officially supported. ## Testing Django migrations Testing migrations is not a frequent thing in `django` land. But, sometimes it is totally required. When? When we do complex schema or data changes and what to be sure that existing data won't be corrupted. We might also want to be sure that all migrations can be safely rolled back. And as a final touch, we want to be sure that migrations are in the correct order and have correct dependencies. ### Testing forward migrations To test all migrations we have a [`Migrator`](https://github.com/wemake-services/django-test-migrations/blob/master/django_test_migrations/migrator.py) class. It has three methods to work with: - `.apply_initial_migration()` which takes app and migration names to generate a state before the actual migration happens. It creates the `before state` by applying all migrations up to and including the ones passed as an argument. - `.apply_tested_migration()` which takes app and migration names to perform the actual migration - `.reset()` to clean everything up after we are done with testing So, here's an example: ```python from django_test_migrations.migrator import Migrator migrator = Migrator(database='default') # Initial migration, currently our model has only a single string field: # Note: # We are testing migration `0002_someitem_is_clean`, so we are specifying # the name of the previous migration (`0001_initial`) in the # .apply_initial_migration() method in order to prepare a state of the database # before applying the migration we are going to test. # old_state = migrator.apply_initial_migration(('main_app', '0001_initial')) SomeItem = old_state.apps.get_model('main_app', 'SomeItem') # Let's create a model with just a single field specified: SomeItem.objects.create(string_field='a') assert len(SomeItem._meta.get_fields()) == 2 # id + string_field # Now this migration will add `is_clean` field to the model: new_state = migrator.apply_tested_migration( ('main_app', '0002_someitem_is_clean'), ) SomeItem = new_state.apps.get_model('main_app', 'SomeItem') # We can now test how our migration worked, new field is there: assert SomeItem.objects.filter(is_clean=True).count() == 0 assert len(SomeItem._meta.get_fields()) == 3 # id + string_field + is_clean # Cleanup: migrator.reset() ``` That was an example of a forward migration. ### Backward migration The thing is that you can also test backward migrations. Nothing really changes except migration names that you pass and your logic: ```python migrator = Migrator() # Currently our model has two field, but we need a rollback: old_state = migrator.apply_initial_migration( ('main_app', '0002_someitem_is_clean'), ) SomeItem = old_state.apps.get_model('main_app', 'SomeItem') # Create some data to illustrate your cases: # ... # Now this migration will drop `is_clean` field: new_state = migrator.apply_tested_migration(('main_app', '0001_initial')) # Assert the results: # ... # Cleanup: migrator.reset() ``` ### Testing migrations ordering Sometimes we also want to be sure that our migrations are in the correct order and that all our `dependencies = [...]` are correct. To achieve that we have [`plan.py`](https://github.com/wemake-services/django-test-migrations/blob/master/django_test_migrations/plan.py) module. That's how it can be used: ```python from django_test_migrations.plan import all_migrations, nodes_to_tuples main_migrations = all_migrations('default', ['main_app', 'other_app']) assert nodes_to_tuples(main_migrations) == [ ('main_app', '0001_initial'), ('main_app', '0002_someitem_is_clean'), ('other_app', '0001_initial'), ('main_app', '0003_update_is_clean'), ('main_app', '0004_auto_20191119_2125'), ('other_app', '0002_auto_20191120_2230'), ] ``` This way you can be sure that migrations and apps that depend on each other will be executed in the correct order. ### `factory_boy` integration If you use factories to create models, you can replace their respective `.build()` or `.create()` calls with methods of `factory` and pass the model name and factory class as arguments: ```python import factory old_state = migrator.apply_initial_migration( ('main_app', '0002_someitem_is_clean'), ) SomeItem = old_state.apps.get_model('main_app', 'SomeItem') # instead of # item = SomeItemFactory.create() # use this: factory.create(SomeItem, FACTORY_CLASS=SomeItemFactory) # ... ``` ## Test framework integrations 🐍 We support several test frameworks as first-class citizens. That's a testing tool after all! Note that the Django `post_migrate` signal's receiver list is cleared at the start of tests and restored afterwards. If you need to test your own `post_migrate` signals then attach/remove them during a test. ### pytest We ship `django-test-migrations` with a `pytest` plugin that provides two convenient fixtures: - `migrator_factory` that gives you an opportunity to create `Migrator` classes for any database - `migrator` instance for the `'default'` database That's how it can be used: ```python import pytest @pytest.mark.django_db def test_pytest_plugin_initial(migrator): """Ensures that the initial migration works.""" old_state = migrator.apply_initial_migration(('main_app', None)) with pytest.raises(LookupError): # Model does not yet exist: old_state.apps.get_model('main_app', 'SomeItem') new_state = migrator.apply_tested_migration(('main_app', '0001_initial')) # After the initial migration is done, we can use the model state: SomeItem = new_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.filter(string_field='').count() == 0 ``` ### unittest We also ship an integration with the built-in `unittest` framework. Here's how it can be used: ```python from django_test_migrations.contrib.unittest_case import MigratorTestCase class TestDirectMigration(MigratorTestCase): """This class is used to test direct migrations.""" migrate_from = ('main_app', '0002_someitem_is_clean') migrate_to = ('main_app', '0003_update_is_clean') def prepare(self): """Prepare some data before the migration.""" SomeItem = self.old_state.apps.get_model('main_app', 'SomeItem') SomeItem.objects.create(string_field='a') SomeItem.objects.create(string_field='a b') def test_migration_main0003(self): """Run the test itself.""" SomeItem = self.new_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.count() == 2 assert SomeItem.objects.filter(is_clean=True).count() == 1 ``` ### Choosing only migrations tests In CI systems it is important to get instant feedback. Running tests that apply database migration can slow down tests execution, so it is often a good idea to run standard, fast, regular unit tests without migrations in parallel with slower migrations tests. #### pytest `django_test_migrations` adds `migration_test` marker to each test using `migrator_factory` or `migrator` fixture. To run only migrations test, use `-m` option: ```bash pytest -m migration_test # Runs only migration tests pytest -m "not migration_test" # Runs all except migration tests ``` #### unittest `django_test_migrations` adds `migration_test` [tag](https://docs.djangoproject.com/en/3.0/topics/testing/tools/#tagging-tests) to every `MigratorTestCase` subclass. To run only migrations tests, use `--tag` option: ```bash python mange.py test --tag=migration_test # Runs only migration tests python mange.py test --exclude-tag=migration_test # Runs all except migration tests ``` ## Django Checks `django_test_migrations` comes with 2 groups of Django's checks for: + detecting migrations scripts automatically generated names + validating some subset of database settings ### Testing migration names `django` generates migration names for you when you run `makemigrations`. These names are bad ([read more](https://adamj.eu/tech/2020/02/24/how-to-disallow-auto-named-django-migrations/) about why it is bad)! Just look at this: `0004_auto_20191119_2125.py` What does this migration do? What changes does it have? One can also pass `--name` attribute when creating migrations, but it is easy to forget. We offer an automated solution: `django` check that produces an error for each badly named migration. Add our check into your `INSTALLED_APPS`: ```python INSTALLED_APPS = [ # ... # Our custom check: 'django_test_migrations.contrib.django_checks.AutoNames', ] ``` Then in your CI run: ```bash python manage.py check --deploy ``` This way you will be safe from wrong names in your migrations. Do you have a migrations that cannot be renamed? Add them to the ignore list: ```python # settings.py DTM_IGNORED_MIGRATIONS = { ('main_app', '0004_auto_20191119_2125'), ('dependency_app', '0001_auto_20201110_2100'), } ``` Then we won't complain about them. Or you can completely ignore entire app: ```python # settings.py DTM_IGNORED_MIGRATIONS = { ('dependency_app', '*'), ('another_dependency_app', '*'), } ``` ### Database configuration Add our check to `INSTALLED_APPS`: ```python INSTALLED_APPS = [ # ... # Our custom check: 'django_test_migrations.contrib.django_checks.DatabaseConfiguration', ] ``` Then just run `check` management command in your CI like listed in section above. ## Related projects You might also like: - [django-migration-linter](https://github.com/3YOURMIND/django-migration-linter) - Detect backward incompatible migrations for your django project. - [wemake-django-template](https://github.com/wemake-services/wemake-django-template/) - Bleeding edge django template focused on code quality and security with both `django-test-migrations` and `django-migration-linter` on board. ## Credits This project is based on work of other awesome people: - [@asfaltboy](https://gist.github.com/asfaltboy/b3e6f9b5d95af8ba2cc46f2ba6eae5e2) - [@blueyed](https://gist.github.com/blueyed/4fb0a807104551f103e6) - [@fernandogrd](https://gist.github.com/blueyed/4fb0a807104551f103e6#gistcomment-1546191) - [@adamchainz](https://adamj.eu/tech/2020/02/24/how-to-disallow-auto-named-django-migrations/) ## License MIT. django-test-migrations-1.5.0/django_test_app/000077500000000000000000000000001500042301400212345ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/django_test_app/000077500000000000000000000000001500042301400243755ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/django_test_app/__init__.py000066400000000000000000000000001500042301400264740ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/django_test_app/settings.py000066400000000000000000000063301500042301400266110ustar00rootroot00000000000000"""Django settings for django_test_app project.""" import os from pathlib import Path # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = Path(__file__).parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = '_dpvr*#hjgv)6v=potf%*+$na7_ck(*+^g08lw0^44zoo88)wb' # noqa: S105 # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', # Our custom checks: 'django_test_migrations.contrib.django_checks.AutoNames', 'django_test_migrations.contrib.django_checks.DatabaseConfiguration', # Custom: 'main_app', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] ROOT_URLCONF = 'django_test_app.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] WSGI_APPLICATION = 'django_test_app.wsgi.application' # Database # https://docs.djangoproject.com/en/2.2/ref/settings/#databases _DATABASE_NAME = os.environ.get( 'DJANGO_DATABASE_NAME', default=BASE_DIR.joinpath('db.sqlite3'), ) DATABASES = { 'default': { 'ENGINE': os.environ.get( 'DJANGO_DATABASE_ENGINE', default='django.db.backends.sqlite3', ), 'USER': os.environ.get('DJANGO_DATABASE_USER', default=''), 'PASSWORD': os.environ.get('DJANGO_DATABASE_PASSWORD', default=''), 'NAME': _DATABASE_NAME, 'PORT': os.environ.get('DJANGO_DATABASE_PORT', default=''), 'HOST': os.environ.get('DJANGO_DATABASE_HOST', default=''), 'TEST': { 'NAME': ( _DATABASE_NAME if _DATABASE_NAME.startswith('test_') else f'test_{_DATABASE_NAME}' ), }, }, } # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [] # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ STATIC_URL = '/static/' django-test-migrations-1.5.0/django_test_app/django_test_app/urls.py000066400000000000000000000012151500042301400257330ustar00rootroot00000000000000""" django_test_app URL Configuration. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/2.2/topics/http/urls/ Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path('', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ urlpatterns = [] django-test-migrations-1.5.0/django_test_app/django_test_app/wsgi.py000066400000000000000000000006271500042301400257250ustar00rootroot00000000000000""" WSGI config for django_test_app project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_test_app.settings') application = get_wsgi_application() django-test-migrations-1.5.0/django_test_app/main_app/000077500000000000000000000000001500042301400230205ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/main_app/__init__.py000066400000000000000000000000001500042301400251170ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/main_app/apps.py000066400000000000000000000002051500042301400243320ustar00rootroot00000000000000from django.apps import AppConfig class MainAppConfig(AppConfig): """Configuration for ``main_app``.""" name = 'main_app' django-test-migrations-1.5.0/django_test_app/main_app/logic/000077500000000000000000000000001500042301400241155ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/main_app/logic/__init__.py000066400000000000000000000000001500042301400262140ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/main_app/logic/pure/000077500000000000000000000000001500042301400250705ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/main_app/logic/pure/__init__.py000066400000000000000000000000001500042301400271670ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/main_app/logic/pure/migrations.py000066400000000000000000000004001500042301400276100ustar00rootroot00000000000000import typing if typing.TYPE_CHECKING: from main_app.models import SomeItem def is_clean_item(instance: 'SomeItem') -> bool: """Pure function that decides whether or not whitespace is in the model.""" return ' ' not in instance.string_field django-test-migrations-1.5.0/django_test_app/main_app/migrations/000077500000000000000000000000001500042301400251745ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/main_app/migrations/0001_initial.py000066400000000000000000000013631500042301400276420ustar00rootroot00000000000000# Generated by Django 2.2.7 on 2019-11-19 20:00 from django.db import migrations, models class Migration(migrations.Migration): """Initial migration.""" initial = True dependencies = [] operations = [ migrations.CreateModel( name='SomeItem', fields=[ ( 'id', models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name='ID', ), ), ( 'string_field', models.CharField(max_length=50), ), ], ), ] django-test-migrations-1.5.0/django_test_app/main_app/migrations/0002_someitem_is_clean.py000066400000000000000000000006731500042301400316740ustar00rootroot00000000000000# Generated by Django 2.2.7 on 2019-11-19 21:24 from django.db import migrations, models class Migration(migrations.Migration): """Migration to add ``is_clean`` field to ``SomeItem``.""" dependencies = [ ('main_app', '0001_initial'), ] operations = [ migrations.AddField( model_name='someitem', name='is_clean', field=models.BooleanField(default=True), ), ] django-test-migrations-1.5.0/django_test_app/main_app/migrations/0003_update_is_clean.py000066400000000000000000000022641500042301400313330ustar00rootroot00000000000000# Generated by Django 2.2.7 on 2019-11-19 21:25 from django.db import migrations from main_app.logic.pure.migrations import is_clean_item def _set_clean_flag(apps, schema_editor): """ Performs the data migration. We can't import the ``SomeItem`` model directly as it may be a newer version than this migration expects. We use the historical version. We are using ``.all()`` because we don't have a lot of ``SomeItem`` instances. In real-life you should not do that. """ SomeItem = apps.get_model('main_app', 'SomeItem') for instance in SomeItem.objects.all(): instance.is_clean = is_clean_item(instance) instance.save(update_fields=['is_clean']) def _remove_clean_flags(apps, schema_editor): """ This is just a noop example of a rollback function. It is not used in our simple case, but it should be implemented for more complex scenarios. """ class Migration(migrations.Migration): """Performs the logical data migration for ``SomeItem``.""" dependencies = [ ('main_app', '0002_someitem_is_clean'), ] operations = [ migrations.RunPython(_set_clean_flag, _remove_clean_flags), ] django-test-migrations-1.5.0/django_test_app/main_app/migrations/0004_auto_20191119_2125.py000066400000000000000000000010651500042301400306230ustar00rootroot00000000000000# Generated by Django 2.2.7 on 2019-11-19 21:25 """ This migration is named incorrectly. We use it as a test for wrong autonames. Please, do not rename it! """ from django.db import migrations, models class Migration(migrations.Migration): """Removes the default value from ``is_clean`` from ``SomeItem``.""" dependencies = [ ('main_app', '0003_update_is_clean'), ] operations = [ migrations.AlterField( model_name='someitem', name='is_clean', field=models.BooleanField(), ), ] django-test-migrations-1.5.0/django_test_app/main_app/migrations/0005_auto_20200329_1118.py000066400000000000000000000005531500042301400306200ustar00rootroot00000000000000# Generated by Django 2.2.10 on 2020-03-29 11:18 """ This migration is named incorrectly. We use it as a test for wrong autonames. Please, do not rename it! """ from django.db import migrations class Migration(migrations.Migration): """Dummy migration.""" dependencies = [ ('main_app', '0004_auto_20191119_2125'), ] operations = [] django-test-migrations-1.5.0/django_test_app/main_app/migrations/__init__.py000066400000000000000000000000001500042301400272730ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_app/main_app/models.py000066400000000000000000000004031500042301400246520ustar00rootroot00000000000000from django.db import models _SomeItemStringFieldLength = 50 class SomeItem(models.Model): """We use this model for testing migrations.""" string_field = models.CharField(max_length=_SomeItemStringFieldLength) is_clean = models.BooleanField() django-test-migrations-1.5.0/django_test_app/main_app/views.py000066400000000000000000000000621500042301400245250ustar00rootroot00000000000000from django.shortcuts import render # noqa: F401 django-test-migrations-1.5.0/django_test_app/manage.py000077500000000000000000000004741500042301400230460ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys from django.core.management import execute_from_command_line if __name__ == '__main__': os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_test_app.settings') execute_from_command_line(sys.argv) django-test-migrations-1.5.0/django_test_migrations/000077500000000000000000000000001500042301400226305ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/__init__.py000066400000000000000000000000001500042301400247270ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/checks/000077500000000000000000000000001500042301400240705ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/checks/__init__.py000066400000000000000000000000001500042301400261670ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/checks/autonames.py000066400000000000000000000053361500042301400264450ustar00rootroot00000000000000from collections.abc import Sequence from fnmatch import fnmatch from typing import Final from django.conf import settings from django.core.checks import CheckMessage from django.core.checks import Warning as DjangoWarning _IgnoreAppSpec = frozenset[str] _IgnoreMigrationSpec = frozenset[tuple[str, str]] #: We use this type hint to represent ignore rules for migrations. _IgnoreSpec = tuple[_IgnoreAppSpec, _IgnoreMigrationSpec] #: We use this value as a unique identifier of this check. CHECK_NAME: Final = 'django_test_migrations.checks.autonames' #: Settings name for this check to ignore some migrations. _SETTINGS_NAME: Final = 'DTM_IGNORED_MIGRATIONS' # Special key to ignore all migrations inside an app _IGNORE_APP_MIGRATIONS_SPECIAL_KEY: Final = '*' def _is_ignored( app_label: str, migration_name: str, ignored: _IgnoreSpec, ) -> bool: ignored_apps, ignored_migrations = ignored return ( app_label in ignored_apps or (app_label, migration_name) in ignored_migrations ) def _build_ignores() -> _IgnoreSpec: ignored_migrations: _IgnoreMigrationSpec = getattr( settings, _SETTINGS_NAME, frozenset(), ) ignored_apps: _IgnoreAppSpec = frozenset( app_label for app_label, migration_name in ignored_migrations if migration_name == _IGNORE_APP_MIGRATIONS_SPECIAL_KEY ) return ignored_apps, ignored_migrations def check_migration_names( *args: object, **kwargs: object, ) -> Sequence[CheckMessage]: """ Finds automatic names in available migrations. We use nested import here, because some versions of django fails otherwise. They do raise: ``django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet.`` """ from django.db.migrations.loader import MigrationLoader # noqa: PLC0415 loader = MigrationLoader(None, ignore_no_migrations=True) loader.load_disk() messages = [] ignores = _build_ignores() for app_label, migration_name in loader.disk_migrations: if _is_ignored(app_label, migration_name, ignores): continue if fnmatch(migration_name, '????_auto_*'): messages.append( DjangoWarning( ( f'Migration {app_label}.{migration_name} ' 'has an automatic name.' ), hint=( 'Rename the migration to describe its contents, ' + "or if it's from a third party app, add to " + _SETTINGS_NAME ), id=f'{CHECK_NAME}.W001', ), ) return messages CHECKS: Final = (check_migration_names,) django-test-migrations-1.5.0/django_test_migrations/checks/database_configuration.py000066400000000000000000000005111500042301400311320ustar00rootroot00000000000000from typing import Final from django_test_migrations.db.checks.statement_timeout import ( check_statement_timeout_setting, ) #: We use this value as a unique identifier of databases related check. CHECK_NAME: Final = 'django_test_migrations.checks.database_configuration' CHECKS: Final = (check_statement_timeout_setting,) django-test-migrations-1.5.0/django_test_migrations/constants.py000066400000000000000000000002211500042301400252110ustar00rootroot00000000000000from typing import Final #: marker/tag indicating that marked test is a Django's migration test MIGRATION_TEST_MARKER: Final = 'migration_test' django-test-migrations-1.5.0/django_test_migrations/contrib/000077500000000000000000000000001500042301400242705ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/contrib/__init__.py000066400000000000000000000000001500042301400263670ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/contrib/django_checks.py000066400000000000000000000042161500042301400274270ustar00rootroot00000000000000from django.apps import AppConfig from django.core import checks from typing_extensions import final from django_test_migrations.checks import autonames, database_configuration @final class AutoNames(AppConfig): """ Class to install this check into ``INSTALLED_APPS`` in ``django``. If you have migrations that cannot be renamed, use ``DTM_IGNORED_MIGRATIONS`` setting in ``django.conf`` to ignore ones you have to deal with: .. code:: python # settings.py DTM_IGNORED_MIGRATIONS = { ('main_app', '0004_auto_20191119_2125'), ('dependency_app', '0001_auto_20201110_2100'), } To run checks use: .. code:: bash python manage.py check --deploy --fail-level WARNING It will return exit code ``1`` if any violations are found. This can be easily added into your CI. See: https://docs.djangoproject.com/en/3.0/ref/applications/ https://twitter.com/AdamChainz/status/1231895529686208512 """ #: Part of Django API. name = autonames.CHECK_NAME def ready(self) -> None: """That's how we register our check when apps are ready.""" for check in autonames.CHECKS: checks.register(check, checks.Tags.compatibility) @final class DatabaseConfiguration(AppConfig): """Class to install this check into ``INSTALLED_APPS`` in ``django``. Database configuration checks are made with aim to help/guide developers set the most appropriate values for some database settings according to best practices. Currently supported database settings: * statement timeout (timeout queries that execution take too long): * `postgresql` via `statement_timeout` - https://bit.ly/2ZFjaRM * `mysql` via `max_execution_time` - https://bit.ly/399TBvk See: https://github.com/wemake-services/wemake-django-template/issues/1064 """ #: Part of Django API. name = database_configuration.CHECK_NAME def ready(self) -> None: """Register database configuration checks.""" for check in database_configuration.CHECKS: checks.register(check, checks.Tags.database) django-test-migrations-1.5.0/django_test_migrations/contrib/pytest_plugin.py000066400000000000000000000065321500042301400275560ustar00rootroot00000000000000from typing import TYPE_CHECKING, Protocol import pytest from django.db import DEFAULT_DB_ALIAS from django_test_migrations.constants import MIGRATION_TEST_MARKER if TYPE_CHECKING: from django_test_migrations.migrator import Migrator def pytest_load_initial_conftests(early_config: pytest.Config) -> None: """Register pytest's markers.""" early_config.addinivalue_line( 'markers', f"{MIGRATION_TEST_MARKER}: mark the test as a Django's migration test.", ) def pytest_collection_modifyitems( session: pytest.Session, items: list[pytest.Item], # noqa: WPS110 ) -> None: """ Mark all tests using ``migrator_factory`` fixture with proper marks. Add ``MIGRATION_TEST_MARKER`` marker to all items using ``migrator_factory`` fixture. """ for pytest_item in items: if 'migrator_factory' in getattr(pytest_item, 'fixturenames', []): pytest_item.add_marker(MIGRATION_TEST_MARKER) class MigratorFactory(Protocol): """Protocol for `migrator_factory` fixture.""" def __call__(self, database_name: str | None = None) -> 'Migrator': """It only has a `__call__` magic method.""" @pytest.fixture def migrator_factory( request: pytest.FixtureRequest, transactional_db: None, django_db_use_migrations: bool, # noqa: FBT001 ) -> MigratorFactory: """ Pytest fixture to create migrators inside the pytest tests. How? Here's an example. .. code:: python @pytest.mark.django_db def test_migration(migrator_factory): migrator = migrator_factory('custom_db_alias') old_state = migrator.apply_initial_migration(('main_app', None)) new_state = migrator.apply_tested_migration( ('main_app', '0001_initial'), ) assert isinstance(old_state, ProjectState) assert isinstance(new_state, ProjectState) Why do we import :class:`Migrator` inside the fixture function? Otherwise, coverage won't work correctly during our internal tests. Why? Because modules in Python are singletons. Once imported, they will be stored in memory and reused. That's why we cannot import ``Migrator`` on a module level. Because it won't be caught be coverage later on. """ from django_test_migrations.migrator import Migrator # noqa: PLC0415 if not django_db_use_migrations: pytest.skip('--nomigrations was specified') def factory(database_name: str | None = None) -> Migrator: migrator = Migrator(database_name) request.addfinalizer(migrator.reset) return migrator return factory @pytest.fixture def migrator(migrator_factory: MigratorFactory) -> 'Migrator': """ Useful alias for ``'default'`` database in ``django``. That's a predefined instance of a ``migrator_factory``. How to use it? Here's an example. .. code:: python @pytest.mark.django_db def test_migration(migrator): old_state = migrator.apply_initial_migration(('main_app', None)) new_state = migrator.apply_tested_migration( ('main_app', '0001_initial'), ) assert isinstance(old_state, ProjectState) assert isinstance(new_state, ProjectState) Just one step easier than ``migrator_factory`` fixture. """ return migrator_factory(DEFAULT_DB_ALIAS) django-test-migrations-1.5.0/django_test_migrations/contrib/unittest_case.py000066400000000000000000000053101500042301400275130ustar00rootroot00000000000000from typing import Any, ClassVar import django from django.db.migrations.state import ProjectState from django.db.models.signals import post_migrate, pre_migrate from django.test import TransactionTestCase, tag from django_test_migrations.constants import MIGRATION_TEST_MARKER from django_test_migrations.migrator import Migrator from django_test_migrations.types import MigrationSpec @tag(MIGRATION_TEST_MARKER) class MigratorTestCase(TransactionTestCase): """Used when using raw ``unitest`` library for test.""" database_name: ClassVar[str | None] = None old_state: ProjectState new_state: ProjectState #: Part of the end-user API. Used to tell what migrations we are using. migrate_from: ClassVar[MigrationSpec] migrate_to: ClassVar[MigrationSpec] # hold original receivers to restore them after each test _pre_migrate_receivers: list[Any] _post_migrate_receivers: list[Any] def setUp(self) -> None: """ Regular ``unittest`` styled setup case. What it does? - It starts with defining the initial migration state - Then it allows to run custom method to prepare some data before the migration will happen - Then it applies the migration and saves all states """ super().setUp() self._migrator = Migrator(self.database_name) self.old_state = self._migrator.apply_initial_migration( self.migrate_from, ) self.prepare() self.new_state = self._migrator.apply_tested_migration(self.migrate_to) def prepare(self) -> None: """ Part of the end-user API. Used to prepare some data before the migration process. """ def tearDown(self) -> None: """Used to clean mess up after each test.""" pre_migrate.receivers = self._pre_migrate_receivers post_migrate.receivers = self._post_migrate_receivers self._migrator.reset() super().tearDown() @classmethod def _store_receivers(cls) -> None: cls._pre_migrate_receivers, pre_migrate.receivers = ( # noqa: WPS414 pre_migrate.receivers, [], ) cls._post_migrate_receivers, post_migrate.receivers = ( # noqa: WPS414 post_migrate.receivers, [], ) if django.VERSION[:2] < (5, 2): # noqa: WPS604 # pragma: no cover def _pre_setup(self) -> None: self._store_receivers() super()._pre_setup() # type: ignore[misc] else: # pragma: no cover @classmethod def _pre_setup(cls) -> None: # type: ignore[misc] # noqa: WPS614 cls._store_receivers() super()._pre_setup() # type: ignore[misc] django-test-migrations-1.5.0/django_test_migrations/db/000077500000000000000000000000001500042301400232155ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/__init__.py000066400000000000000000000000001500042301400253140ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/backends/000077500000000000000000000000001500042301400247675ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/backends/__init__.py000066400000000000000000000005211500042301400270760ustar00rootroot00000000000000# register all ``BaseDatabaseConfiguration`` subclasses from django_test_migrations.db.backends.mysql.configuration import ( MySQLDatabaseConfiguration as MySQLDatabaseConfiguration, ) from django_test_migrations.db.backends.postgresql.configuration import ( PostgreSQLDatabaseConfiguration as PostgreSQLDatabaseConfiguration, ) django-test-migrations-1.5.0/django_test_migrations/db/backends/base/000077500000000000000000000000001500042301400257015ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/backends/base/__init__.py000066400000000000000000000000001500042301400300000ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/backends/base/configuration.py000066400000000000000000000024651500042301400311310ustar00rootroot00000000000000import abc import inspect from typing import ClassVar from django_test_migrations.db.backends.exceptions import ( DatabaseConfigurationSettingNotFound, ) from django_test_migrations.db.backends.registry import ( database_configuration_registry, ) from django_test_migrations.types import AnyConnection, DatabaseSettingValue class BaseDatabaseConfiguration(abc.ABC): """Interact with database's settings.""" vendor: ClassVar[str] @classmethod def __init_subclass__(cls, **kwargs: object) -> None: """Register ``BaseDatabaseConfiguration`` subclass of db ``vendor``.""" if not inspect.isabstract(cls): database_configuration_registry.setdefault(cls.vendor, cls) def __init__(self, connection: AnyConnection) -> None: """Bind database ``connection`` used to retrieve settings values.""" self.connection = connection @property @abc.abstractmethod def statement_timeout(self) -> str: """Get `STATEMENT TIMEOUT` setting name.""" @abc.abstractmethod def get_setting_value(self, name: str) -> DatabaseSettingValue: """Retrieve value of ``vendor`` database's ``name`` setting. Raises: DatabaseConfigurationSettingNotFound """ raise DatabaseConfigurationSettingNotFound(self.vendor, name) django-test-migrations-1.5.0/django_test_migrations/db/backends/exceptions.py000066400000000000000000000021011500042301400275140ustar00rootroot00000000000000from typing import ClassVar class BaseDatabaseConfigurationException(Exception): # noqa: N818 """Base exception for errors related to database configuration.""" class DatabaseConfigurationNotFound(BaseDatabaseConfigurationException): """``BaseDatabaseConfiguration`` subclass when given db vendor not found.""" message_template: ClassVar[str] = ( '``BaseDatabaseConfiguration`` subclass for "{0}" vendor not found' ) def __init__(self, vendor: str) -> None: """Format and set message from args and ``message_template``.""" super().__init__(self.message_template.format(vendor)) class DatabaseConfigurationSettingNotFound(BaseDatabaseConfigurationException): """Database configurations setting not found.""" message_template: ClassVar[str] = ( 'Database vendor "{0}" does not support setting "{1}"' ) def __init__(self, vendor: str, setting_name: str) -> None: """Format and set message from args and ``message_template``.""" super().__init__(self.message_template.format(vendor, setting_name)) django-test-migrations-1.5.0/django_test_migrations/db/backends/mysql/000077500000000000000000000000001500042301400261345ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/backends/mysql/__init__.py000066400000000000000000000000001500042301400302330ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/backends/mysql/configuration.py000066400000000000000000000024371500042301400313630ustar00rootroot00000000000000from functools import cached_property from typing import cast from typing_extensions import final from django_test_migrations.db.backends.base.configuration import ( BaseDatabaseConfiguration, ) from django_test_migrations.types import DatabaseSettingValue @final class MySQLDatabaseConfiguration(BaseDatabaseConfiguration): """Interact with MySQL database configuration.""" vendor = 'mysql' def get_setting_value(self, name: str) -> DatabaseSettingValue: """Retrieve value of MySQL database's setting with ``name``.""" with self.connection.cursor() as cursor: quoted = self.connection.ops.quote_name(name) cursor.execute(f'SELECT @@{quoted};') setting_value = cursor.fetchone() if not setting_value: return super().get_setting_value(name) return cast(DatabaseSettingValue, setting_value[0]) @cached_property def version(self) -> str: """Get MySQL DB server version.""" return str(self.get_setting_value('VERSION')) @property def statement_timeout(self) -> str: """Get `STATEMENT TIMEOUT` setting name based on DB server version.""" if 'mariadb' in self.version.lower(): return 'MAX_STATEMENT_TIME' return 'MAX_EXECUTION_TIME' django-test-migrations-1.5.0/django_test_migrations/db/backends/postgresql/000077500000000000000000000000001500042301400271725ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/backends/postgresql/__init__.py000066400000000000000000000000001500042301400312710ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/backends/postgresql/configuration.py000066400000000000000000000020161500042301400324120ustar00rootroot00000000000000from typing import cast from typing_extensions import final from django_test_migrations.db.backends.base.configuration import ( BaseDatabaseConfiguration, ) from django_test_migrations.types import DatabaseSettingValue @final class PostgreSQLDatabaseConfiguration(BaseDatabaseConfiguration): """Interact with PostgreSQL database configuration.""" vendor = 'postgresql' statement_timeout = 'statement_timeout' def get_setting_value(self, name: str) -> DatabaseSettingValue: """Retrieve value of PostgreSQL database's setting with ``name``.""" with self.connection.cursor() as cursor: cursor.execute( ( 'SELECT setting FROM pg_settings ' # noqa: S608 + 'WHERE name = %s;' ), (name,), ) setting_value = cursor.fetchone() if not setting_value: return super().get_setting_value(name) return cast(DatabaseSettingValue, setting_value[0]) django-test-migrations-1.5.0/django_test_migrations/db/backends/registry.py000066400000000000000000000022131500042301400272070ustar00rootroot00000000000000from collections.abc import MutableMapping from typing import TYPE_CHECKING from django_test_migrations.db.backends.exceptions import ( DatabaseConfigurationNotFound, ) from django_test_migrations.types import AnyConnection if TYPE_CHECKING: from django_test_migrations.db.backends.base.configuration import ( BaseDatabaseConfiguration, ) _DatabaseConfigurationMapping = MutableMapping[ str, type['BaseDatabaseConfiguration'], ] database_configuration_registry: _DatabaseConfigurationMapping = {} def get_database_configuration( connection: AnyConnection, ) -> 'BaseDatabaseConfiguration': """Return proper ``BaseDatabaseConfiguration`` subclass instance. Raises: DatabaseConfigurationNotFound when vendor extracted from ``connection`` doesn't support interaction with database configuration/settings """ vendor = getattr(connection, 'vendor', '') try: database_configuration_class = database_configuration_registry[vendor] except KeyError as exc: raise DatabaseConfigurationNotFound(vendor) from exc return database_configuration_class(connection) django-test-migrations-1.5.0/django_test_migrations/db/checks/000077500000000000000000000000001500042301400244555ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/checks/__init__.py000066400000000000000000000000001500042301400265540ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/db/checks/statement_timeout.py000066400000000000000000000070211500042301400306010ustar00rootroot00000000000000import datetime from typing import Final from django.core.checks import CheckMessage from django.core.checks import Warning as DjangoWarning from django.db import connections from django_test_migrations.db.backends import exceptions, registry from django_test_migrations.db.backends.base.configuration import ( BaseDatabaseConfiguration, ) from django_test_migrations.logic.datetime import timedelta_to_milliseconds from django_test_migrations.types import AnyConnection #: We use this value as a unique identifier of databases related check. CHECK_NAME: Final = 'django_test_migrations.checks.database_configuration' STATEMENT_TIMEOUT_MINUTES_UPPER_LIMIT: Final = 30 def check_statement_timeout_setting( *args: object, **kwargs: object, ) -> list[CheckMessage]: """Check if statements' timeout settings is properly configured.""" messages: list[CheckMessage] = [] for connection in connections.all(): _check_statement_timeout_setting(connection, messages) return messages def _check_statement_timeout_setting( connection: AnyConnection, messages: list[CheckMessage], ) -> None: try: database_configuration = registry.get_database_configuration( connection, ) except exceptions.DatabaseConfigurationNotFound: return try: setting_value = int( database_configuration.get_setting_value( database_configuration.statement_timeout, ) ) except exceptions.DatabaseConfigurationSettingNotFound: return _ensure_statement_timeout_is_set( database_configuration, setting_value, messages, ) _ensure_statement_timeout_not_too_high( database_configuration, setting_value, messages, ) def _ensure_statement_timeout_is_set( database_configuration: BaseDatabaseConfiguration, setting_value: int, messages: list[CheckMessage], ) -> None: if not setting_value: connection = database_configuration.connection messages.append( DjangoWarning( ( f'{connection.alias}: statement timeout' ' "{database_configuration.statement_timeout}" ' 'setting is not set.' ), hint=( f'Set "{database_configuration.statement_timeout}" database' ' setting to some reasonable value.' ), id=f'{CHECK_NAME}.W001', ), ) def _ensure_statement_timeout_not_too_high( database_configuration: BaseDatabaseConfiguration, setting_value: int, messages: list[CheckMessage], ) -> None: upper_limit = timedelta_to_milliseconds( datetime.timedelta(minutes=STATEMENT_TIMEOUT_MINUTES_UPPER_LIMIT), ) if setting_value > upper_limit: messages.append( DjangoWarning( ( '{0}: statement timeout "{1}" setting value - "{2} ms" ' + 'might be too high.' ).format( database_configuration.connection.alias, database_configuration.statement_timeout, setting_value, ), hint=( 'Set "{0}" database setting to some ' + 'reasonable value, but remember it should not be ' + 'too high.' ).format(database_configuration.statement_timeout), id=f'{CHECK_NAME}.W002', ), ) django-test-migrations-1.5.0/django_test_migrations/exceptions.py000066400000000000000000000007321500042301400253650ustar00rootroot00000000000000from django_test_migrations.types import MigrationTarget class MigrationNotInPlan(Exception): # noqa: N818 """``MigrationTarget`` not found in migrations plan.""" def __init__(self, migration_target: MigrationTarget) -> None: # noqa: D107 self.migration_target = migration_target def __str__(self) -> str: """String representation of exception's instance.""" return f'Migration {self.migration_target} not found in migrations plan' django-test-migrations-1.5.0/django_test_migrations/logic/000077500000000000000000000000001500042301400237255ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/logic/__init__.py000066400000000000000000000000001500042301400260240ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/logic/datetime.py000066400000000000000000000003001500042301400260640ustar00rootroot00000000000000import datetime def timedelta_to_milliseconds(timedelta: datetime.timedelta) -> int: """Convert ``timedelta`` object to milliseconds.""" return int(timedelta.total_seconds() * 1000) django-test-migrations-1.5.0/django_test_migrations/logic/migrations.py000066400000000000000000000005111500042301400264500ustar00rootroot00000000000000from django_test_migrations.types import MigrationSpec, MigrationTarget def normalize(migration_target: MigrationSpec) -> list[MigrationTarget]: """Normalize ``migration_target`` to expected format.""" if not isinstance(migration_target, list): migration_target = [migration_target] return migration_target django-test-migrations-1.5.0/django_test_migrations/migrator.py000066400000000000000000000061331500042301400250310ustar00rootroot00000000000000from django.core.management import call_command from django.core.management.color import no_style from django.db import DEFAULT_DB_ALIAS, connections from django.db.migrations.executor import MigrationExecutor from django.db.migrations.state import ProjectState from django_test_migrations import sql from django_test_migrations.logic.migrations import normalize from django_test_migrations.plan import truncate_plan from django_test_migrations.signals import mute_migrate_signals from django_test_migrations.types import ( MigrationPlan, MigrationSpec, MigrationTarget, ) class Migrator: """ Class to manage your migrations and app state. It is designed to be used inside the tests to ensure that migrations are working as intended: both data and schema migrations. This class can be but probably should not be used directly. Because we have utility test framework integrations for ``unitest`` and ``pytest``. Use them for better experience. """ def __init__( self, database: str | None = None, ) -> None: """That's where we initialize all required internals.""" if database is None: database = DEFAULT_DB_ALIAS self._database: str = database self._executor = MigrationExecutor(connections[self._database]) def apply_initial_migration(self, targets: MigrationSpec) -> ProjectState: """Reverse back to the original migration.""" migration_targets = normalize(targets) style = no_style() # start from clean database state sql.drop_models_tables(self._database, style) sql.flush_django_migrations_table(self._database, style) # prepare as broad plan as possible based on full plan self._executor.loader.build_graph() # reload full_plan = self._executor.migration_plan( self._executor.loader.graph.leaf_nodes(), clean_start=True, ) plan = truncate_plan(migration_targets, full_plan) # apply all migrations from generated plan on clean database # (only forward, so any unexpected migration won't be applied) # to restore database state before tested migration return self._migrate(migration_targets, plan=plan) def apply_tested_migration(self, targets: MigrationSpec) -> ProjectState: """Apply the next migration.""" self._executor.loader.build_graph() # reload return self._migrate(normalize(targets)) def reset(self) -> None: """ Reset the state to the most recent one. Notably, signals are not muted here to avoid https://github.com/wemake-services/django-test-migrations/issues/128 """ call_command('migrate', verbosity=0, database=self._database) def _migrate( self, migration_targets: list[MigrationTarget], plan: MigrationPlan | None = None, ) -> ProjectState: with mute_migrate_signals(): project_state = self._executor.migrate(migration_targets, plan=plan) project_state.clear_delayed_apps_cache() return project_state django-test-migrations-1.5.0/django_test_migrations/plan.py000066400000000000000000000106101500042301400241320ustar00rootroot00000000000000from django.apps import apps from django.db import DEFAULT_DB_ALIAS, connections from django.db.migrations import Migration from django.db.migrations.graph import Node from django.db.migrations.loader import MigrationLoader from django_test_migrations.exceptions import MigrationNotInPlan from django_test_migrations.types import MigrationPlan, MigrationTarget def all_migrations( database: str = DEFAULT_DB_ALIAS, app_names: list[str] | None = None, ) -> list[Node]: """ Returns the sorted list of migrations nodes. The order is the same as when migrations are applied. When you might need this function? When you are testing the migration order. For example, imagine that you have a direct dependency: ``main_app.0002_migration`` and ``other_app.0001_initial`` where ``other_app.0001_initial`` relies on the model or field introduced in ``main_app.0002_migration``. You can use ``dependencies`` field to ensure that everything works correctly. But, sometimes migrations are squashed, sometimes they are renamed, refactored, and moved. It would be better to have a test that will ensure that ``other_app.0001_initial`` comes after ``main_app.0002_migration``. And everything works as expected. """ loader = MigrationLoader(connections[database]) if app_names: _validate_app_names(app_names) targets = [ key for key in loader.graph.leaf_nodes() if key[0] in app_names ] else: targets = loader.graph.leaf_nodes() return _generate_plan(targets, loader) def nodes_to_tuples(nodes: list[Node]) -> list[tuple[str, str]]: """Utility function to transform nodes to tuples.""" return [(node[0], node[1]) for node in nodes] def _validate_app_names(app_names: list[str]) -> None: """ Validates the provided app names. Raises ```LookupError`` when incorrect app names are provided. """ for app_name in app_names: apps.get_app_config(app_name) def _generate_plan( targets: list[Node], loader: MigrationLoader, ) -> list[Node]: plan = [] seen: set[Node] = set() # Generate the plan for target in targets: for migration in loader.graph.forwards_plan(target): if migration not in seen: node = loader.graph.node_map[migration] plan.append(node) seen.add(migration) return plan def truncate_plan( targets: list[MigrationTarget], plan: MigrationPlan, ) -> MigrationPlan: """Truncate migrations ``plan`` up to ``targets``. This method is used mainly to truncate full/clean migrations plan to get as broad plan as possible. By "broad" plan we mean plan with all targets migrations included as well as all older migrations not related with targets. "Broad" plan is needed mostly to make ``Migrator`` API developers' friendly, just to not force developers to include migrations targets for all other models they want to use in test (e.g. to setup some model instances) in ``migrate_from``. Such plan will also produce database state really similar to state from our production environment just before new migrations are applied. Migrations plan for targets generated by Django's ``MigrationExecutor.migration_plan`` is minimum plan needed to apply targets migrations, it includes only migrations targets with all its dependencies, so it doesn't fit to our approach, that's why following function is needed. """ if not targets or not plan: return plan target_max_index = max(_get_index(target, plan) for target in targets) return plan[: (target_max_index + 1)] def _get_index(target: MigrationTarget, plan: MigrationPlan) -> int: try: index = next( index for index, (migration, _) in enumerate(plan) if _filter_predicate(target, migration) ) except StopIteration: raise MigrationNotInPlan(target) from None return index - (target[1] is None) def _filter_predicate(target: MigrationTarget, migration: Migration) -> bool: # when ``None`` passed as migration name then initial migration from # target's app will be chosen and handled properly in ``_get_index`` # so in final all target app migrations will be excluded from plan index = 2 - (target[1] is None) return (migration.app_label, migration.name)[:index] == target[:index] django-test-migrations-1.5.0/django_test_migrations/py.typed000066400000000000000000000000001500042301400243150ustar00rootroot00000000000000django-test-migrations-1.5.0/django_test_migrations/signals.py000066400000000000000000000012211500042301400246360ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager from typing import Any from django.db.models.signals import post_migrate, pre_migrate @contextmanager def mute_migrate_signals() -> Iterator[tuple[Any, Any]]: """Context manager to mute migration-related signals.""" pre_migrate_receivers = pre_migrate.receivers post_migrate_receivers = post_migrate.receivers pre_migrate.receivers = [] post_migrate.receivers = [] yield pre_migrate_receivers, post_migrate_receivers pre_migrate.receivers = pre_migrate_receivers post_migrate.receivers = post_migrate_receivers django-test-migrations-1.5.0/django_test_migrations/sql.py000066400000000000000000000031371500042301400240050ustar00rootroot00000000000000from typing import Final from django.core.management.color import Style, no_style from django.db import connections DJANGO_MIGRATIONS_TABLE_NAME: Final = 'django_migrations' def drop_models_tables( database_name: str, style: Style | None = None, ) -> None: """Drop all installed Django's models tables.""" style = style or no_style() connection = connections[database_name] tables = connection.introspection.django_table_names( only_existing=True, include_views=False, ) sql_drop_tables = [ connection.SchemaEditorClass.sql_delete_table % { 'table': style.SQL_FIELD(connection.ops.quote_name(table)), } for table in tables ] if sql_drop_tables: if connection.vendor == 'mysql': sql_drop_tables = [ 'SET FOREIGN_KEY_CHECKS = 0;', *sql_drop_tables, 'SET FOREIGN_KEY_CHECKS = 1;', ] connection.ops.execute_sql_flush(sql_drop_tables) def flush_django_migrations_table( database_name: str, style: Style | None = None, ) -> None: """Flush `django_migrations` table. `django_migrations` is not "regular" Django model, so its not returned by ``ConnectionRouter.get_migratable_models`` which is used e.g. to implement sequences reset. """ connection = connections[database_name] connection.ops.execute_sql_flush( connection.ops.sql_flush( style or no_style(), [DJANGO_MIGRATIONS_TABLE_NAME], allow_cascade=False, reset_sequences=True, ), ) django-test-migrations-1.5.0/django_test_migrations/types.py000066400000000000000000000012251500042301400243460ustar00rootroot00000000000000from typing import Any, TypeAlias, Union from django.db.backends.base.base import BaseDatabaseWrapper from django.db.migrations import Migration from django.utils.connection import ConnectionProxy # Migration target: (app_name, migration_name) # Regular or rollback migration: 0001 -> 0002, or 0002 -> 0001 # Rollback migration to initial state: 0001 -> None MigrationTarget: TypeAlias = tuple[str, str | None] MigrationSpec: TypeAlias = MigrationTarget | list[MigrationTarget] MigrationPlan: TypeAlias = list[tuple[Migration, bool]] AnyConnection: TypeAlias = Union['ConnectionProxy[Any]', BaseDatabaseWrapper] DatabaseSettingValue: TypeAlias = str | int django-test-migrations-1.5.0/docker-compose.yml000066400000000000000000000027671500042301400215440ustar00rootroot00000000000000--- version: '3.8' services: postgresql-db: image: postgres:13 ports: - "5432:5432" environment: - POSTGRES_USER=django - POSTGRES_PASSWORD=passwd123 - POSTGRES_DB=db postgresql-db-17: image: postgres:17 ports: - "5433:5432" environment: - POSTGRES_USER=django - POSTGRES_PASSWORD=passwd123 - POSTGRES_DB=db mysql-db: image: mysql:8 ports: - "3306:3306" restart: unless-stopped environment: - MYSQL_USER=django - MYSQL_PASSWORD=passwd123 # NOTE: MySQL container entrypoint gives user `${MYSQL_USER}` access # only to `${MYSQL_DATABASE}` database, so we are setting # `${MYSQL_DATABASE}` to Django default test database's name to avoid # overriding `ENTRYPOINT` or `CMD`. - MYSQL_DATABASE=test_db - MYSQL_ROOT_PASSWORD=superpasswd123 command: --default-authentication-plugin=mysql_native_password maria-db: image: mariadb:10 ports: - "3307:3306" restart: unless-stopped environment: - MARIADB_USER=django - MARIADB_PASSWORD=passwd123 # NOTE: MySQL container entrypoint gives user `${MYSQL_USER}` access # only to `${MYSQL_DATABASE}` database, so we are setting # `${MYSQL_DATABASE}` to Django default test database's name to avoid # overriding `ENTRYPOINT` or `CMD`. - MARIADB_DATABASE=test_db - MARIADB_ROOT_PASSWORD=superpasswd123 command: --default-authentication-plugin=mysql_native_password django-test-migrations-1.5.0/poetry.lock000066400000000000000000001152041500042301400202720ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "asgiref" version = "3.8.1" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, ] [package.dependencies] typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "attrs" version = "25.3.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, ] [package.extras] benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "coverage" version = "7.8.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"}, {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"}, {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"}, {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"}, {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "django" version = "5.2" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "Django-5.2-py3-none-any.whl", hash = "sha256:91ceed4e3a6db5aedced65e3c8f963118ea9ba753fc620831c77074e620e7d83"}, {file = "Django-5.2.tar.gz", hash = "sha256:1a47f7a7a3d43ce64570d350e008d2949abe8c7e21737b351b6a1611277c6d89"}, ] [package.dependencies] asgiref = ">=3.8.1" sqlparse = ">=0.3.1" tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] [[package]] name = "django-stubs" version = "5.1.3" description = "Mypy stubs for Django" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "django_stubs-5.1.3-py3-none-any.whl", hash = "sha256:716758ced158b439213062e52de6df3cff7c586f9f9ad7ab59210efbea5dfe78"}, {file = "django_stubs-5.1.3.tar.gz", hash = "sha256:8c230bc5bebee6da282ba8a27ad1503c84a0c4cd2f46e63d149e76d2a63e639a"}, ] [package.dependencies] asgiref = "*" django = "*" django-stubs-ext = ">=5.1.3" tomli = {version = "*", markers = "python_version < \"3.11\""} types-PyYAML = "*" typing-extensions = ">=4.11.0" [package.extras] compatible-mypy = ["mypy (>=1.12,<1.16)"] oracle = ["oracledb"] redis = ["redis"] [[package]] name = "django-stubs-ext" version = "5.1.3" description = "Monkey-patching and extensions for django-stubs" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "django_stubs_ext-5.1.3-py3-none-any.whl", hash = "sha256:64561fbc53e963cc1eed2c8eb27e18b8e48dcb90771205180fe29fc8a59e55fd"}, {file = "django_stubs_ext-5.1.3.tar.gz", hash = "sha256:3e60f82337f0d40a362f349bf15539144b96e4ceb4dbd0239be1cd71f6a74ad0"}, ] [package.dependencies] django = "*" typing-extensions = "*" [[package]] name = "exceptiongroup" version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] test = ["pytest (>=6)"] [[package]] name = "flake8" version = "7.2.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343"}, {file = "flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"}, ] [package.dependencies] mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.13.0,<2.14.0" pyflakes = ">=3.3.0,<3.4.0" [[package]] name = "iniconfig" version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] [[package]] name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] [[package]] name = "mypy" version = "1.15.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, ] [package.dependencies] mypy_extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing_extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] [[package]] name = "packaging" version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] name = "pycodestyle" version = "2.13.0" description = "Python style guide checker" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9"}, {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, ] [[package]] name = "pyflakes" version = "3.3.2" description = "passive checker of Python programs" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, ] [[package]] name = "pygments" version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, ] [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" version = "8.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-cov" version = "6.1.1" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, ] [package.dependencies] coverage = {version = ">=7.5", extras = ["toml"]} pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-django" version = "4.11.1" description = "A Django plugin for pytest." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10"}, {file = "pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991"}, ] [package.dependencies] pytest = ">=7.0.0" [package.extras] docs = ["sphinx", "sphinx_rtd_theme"] testing = ["Django", "django-configurations (>=2.0)"] [[package]] name = "pytest-mock" version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, ] [package.dependencies] pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-randomly" version = "3.16.0" description = "Pytest plugin to randomly order tests and control random.seed." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6"}, {file = "pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26"}, ] [package.dependencies] pytest = "*" [[package]] name = "ruff" version = "0.11.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "ruff-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1"}, {file = "ruff-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de"}, {file = "ruff-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a"}, {file = "ruff-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193"}, {file = "ruff-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e"}, {file = "ruff-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308"}, {file = "ruff-0.11.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55"}, {file = "ruff-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc"}, {file = "ruff-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2"}, {file = "ruff-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6"}, {file = "ruff-0.11.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2"}, {file = "ruff-0.11.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03"}, {file = "ruff-0.11.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b"}, {file = "ruff-0.11.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9"}, {file = "ruff-0.11.6-py3-none-win32.whl", hash = "sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287"}, {file = "ruff-0.11.6-py3-none-win_amd64.whl", hash = "sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e"}, {file = "ruff-0.11.6-py3-none-win_arm64.whl", hash = "sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79"}, {file = "ruff-0.11.6.tar.gz", hash = "sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79"}, ] [[package]] name = "sqlparse" version = "0.5.3" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, ] [package.extras] dev = ["build", "hatch"] doc = ["sphinx"] [[package]] name = "tomli" version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] markers = "python_full_version <= \"3.11.0a6\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] name = "types-pyyaml" version = "6.0.12.20250402" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "types_pyyaml-6.0.12.20250402-py3-none-any.whl", hash = "sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681"}, {file = "types_pyyaml-6.0.12.20250402.tar.gz", hash = "sha256:d7c13c3e6d335b6af4b0122a01ff1d270aba84ab96d1a1a1063ecba3e13ec075"}, ] [[package]] name = "typing-extensions" version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] [[package]] name = "tzdata" version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["dev"] markers = "sys_platform == \"win32\"" files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] [[package]] name = "wemake-python-styleguide" version = "1.1.0" description = "The strictest and most opinionated python linter ever" optional = false python-versions = "<4.0,>=3.10" groups = ["dev"] files = [ {file = "wemake_python_styleguide-1.1.0-py3-none-any.whl", hash = "sha256:32644cf35f6cd4c49c2d36e7b10336f8fe105250ba79365e27c5fa648bfc0616"}, {file = "wemake_python_styleguide-1.1.0.tar.gz", hash = "sha256:a9086e4867560c06fe47deb2101c72d1a1fd7ecb7a3235b297b6e02e9298e71e"}, ] [package.dependencies] attrs = "*" flake8 = ">=7.1,<8.0" pygments = ">=2.5,<3.0" [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" content-hash = "6b123052839c20942fd36494e743856fa58e2ee78898334340b629c846ecb251" django-test-migrations-1.5.0/pyproject.toml000066400000000000000000000074231500042301400210150ustar00rootroot00000000000000[build-system] requires = ["poetry-core>=2.1.0"] build-backend = "poetry.core.masonry.api" [project] name = "django-test-migrations" version = "1.5.0" requires-python = ">=3.10,<4.0" description = "Test django schema and data migrations, including ordering" license = "MIT" license-files = [ "LICENSE" ] authors = [ {name = "sobolevn", email = "mail@sobolevn.me"}, ] readme = "README.md" repository = "https://github.com/wemake-services/django-test-migrations" keywords = [ "django", "django-tests", "django-migrations", "django-orm", "migrations", "orm", "sql", "tests", "test", "pytest", "pytest-plugin" ] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Framework :: Django", "Framework :: Django :: 3.2", "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", ] dependencies = [ "typing_extensions>=4.0" ] [project.entry-points."pytest11"] django_test_migrations = "django_test_migrations.contrib.pytest_plugin" [tool.poetry.group.dev.dependencies] django = ">=4.2,<6.0" mypy = "^1.15" django-stubs = "^5.1" wemake-python-styleguide = "^1.1" ruff = "^0.11" pytest = "^8.2" pytest-cov = "^6.0" pytest-randomly = "^3.15" pytest-django = "^4.8" pytest-mock = "^3.14" [tool.ruff] # Ruff config: https://docs.astral.sh/ruff/settings target-version = "py310" line-length = 80 preview = true fix = true format.quote-style = "single" format.docstring-code-format = false lint.select = [ "A", # flake8-builtins "B", # flake8-bugbear "C4", # flake8-comprehensions "C90", # maccabe "COM", # flake8-commas "D", # pydocstyle "DTZ", # flake8-datetimez "E", # pycodestyle "ERA", # flake8-eradicate "EXE", # flake8-executable "F", # pyflakes "FBT", # flake8-boolean-trap "FLY", # pyflint "FURB", # refurb "G", # flake8-logging-format "I", # isort "ICN", # flake8-import-conventions "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "N", # pep8-naming "PERF", # perflint "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "Q", # flake8-quotes "RET", # flake8-return "RSE", # flake8-raise "RUF", # ruff "S", # flake8-bandit "SIM", # flake8-simpify "SLF", # flake8-self "SLOT", # flake8-slots "T100", # flake8-debugger "TRY", # tryceratops "UP", # pyupgrade "W", # pycodestyle "YTT", # flake8-2020 ] lint.ignore = [ "A005", # allow to shadow stdlib and builtin module names "COM812", # trailing comma, conflicts with `ruff format` # Different doc rules that we don't really care about: "D100", "D104", "D106", "D203", "D212", "D401", "D404", "D405", "ISC001", # implicit string concat conflicts with `ruff format` "ISC003", # prefer explicit string concat over implicit concat "PLC0414", # it is fine to not rename an import "PLR09", # we have our own complexity rules "PLR2004", # do not report magic numbers "PLR6301", # do not require classmethod / staticmethod when self not used "TRY003", # long exception messages from `tryceratops` "RUF012", # mutable class-level defaults are fine ] lint.external = [ "WPS" ] lint.flake8-quotes.inline-quotes = "single" lint.mccabe.max-complexity = 6 lint.pydocstyle.convention = "google" [tool.ruff.lint.per-file-ignores] "tests/*.py" = [ "S101", # asserts "S404", # subprocess calls are for tests "S603", # do not require `shell=True` "S607", # partial executable paths ] django-test-migrations-1.5.0/setup.cfg000066400000000000000000000041071500042301400177160ustar00rootroot00000000000000# All configuration for plugins and other utils is defined here. # Read more about `setup.cfg`: # https://docs.python.org/3/distutils/configfile.html [flake8] format = wemake show-source = true doctests = true statistics = false select = WPS, E999 extend-exclude = .venv build per-file-ignores = django_test_migrations/db/backends/__init__.py: WPS412 django_test_app/main_app/migrations/*.py: WPS102, WPS114, WPS432 django_test_app/django_test_app/settings.py: WPS407 tests/test_*.py: WPS118, WPS226, WPS432 [tool:pytest] # Django options: # https://pytest-django.readthedocs.io/en/latest/ DJANGO_SETTINGS_MODULE = django_test_app.settings # PYTHONPATH configuration: pythonpath = django_test_app # py.test options: norecursedirs = *.egg .eggs dist build docs .tox .git __pycache__ # Strict `@xfail` by default: xfail_strict = true # You will need to measure your tests speed with `-n auto` and without it, # so you can see whether it gives you any performance gain, or just gives # you an overhead. See `docs/template/development-process.rst`. addopts = --strict --doctest-modules --cov=django_test_migrations --cov-report=term-missing:skip-covered --cov-report=html --cov-report=xml --cov-branch --cov-fail-under=100 [coverage:run] # Why do we exclude this file from coverage? # Because coverage is not calculated correctly for pytest plugins. # And we completely test it anyway. omit = django_test_migrations/constants.py django_test_migrations/contrib/pytest_plugin.py django_test_migrations/types.py [coverage:report] skip_covered = True show_missing = True sort = Cover exclude_lines = pragma: no cover # type hinting related code if TYPE_CHECKING: [mypy] # mypy configurations: http://bit.ly/2zEl9WI enable_error_code = truthy-bool, truthy-iterable, redundant-expr, unused-awaitable, # ignore-without-code, possibly-undefined, redundant-self, # explicit-override, # mutable-override, unimported-reveal, deprecated, ignore_missing_imports = true strict = true warn_unreachable = true local_partial_types = true django-test-migrations-1.5.0/tests/000077500000000000000000000000001500042301400172355ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/conftest.py000066400000000000000000000006201500042301400214320ustar00rootroot00000000000000import pytest @pytest.fixture(scope='session') def django_db_use_migrations( request, django_db_use_migrations, ): """ Helper fixture to skip tests when ``--nomigrations`` were specified. Copied from https://github.com/pytest-dev/pytest-django """ if not django_db_use_migrations: pytest.skip('--nomigrations was specified') return django_db_use_migrations django-test-migrations-1.5.0/tests/test_checks/000077500000000000000000000000001500042301400215345ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_checks/test_autonames.py000066400000000000000000000027541500042301400251510ustar00rootroot00000000000000import pytest from django.core.checks import WARNING from django_test_migrations.checks.autonames import ( CHECK_NAME, check_migration_names, ) @pytest.mark.django_db def test_autonames(): """Here we check that bad migrations do produce warnings.""" warnings = check_migration_names() warnings_msgs = {warning.msg for warning in warnings} assert len(warnings) == 2 assert [warnings[0].level, warnings[1].level] == [WARNING, WARNING] assert all( [ warnings[0].id.startswith(CHECK_NAME), warnings[1].id.startswith(CHECK_NAME), ], ) assert warnings_msgs == { 'Migration main_app.0004_auto_20191119_2125 has an automatic name.', 'Migration main_app.0005_auto_20200329_1118 has an automatic name.', } @pytest.mark.django_db def test_autonames_with_ignore(settings): """Here we check that some migrations can be ignored.""" # patch settings to ignore two bad migrations settings.DTM_IGNORED_MIGRATIONS = { ('main_app', '0004_auto_20191119_2125'), ('main_app', '0005_auto_20200329_1118'), } warnings = check_migration_names() assert not warnings @pytest.mark.django_db def test_autonames_with_ignore_all_app_migrations(settings): """Here we check that all migrations ignored inside app.""" # patch settings to ignore all migrations in the app settings.DTM_IGNORED_MIGRATIONS = {('main_app', '*')} warnings = check_migration_names() assert not warnings django-test-migrations-1.5.0/tests/test_contrib/000077500000000000000000000000001500042301400217345ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_contrib/test_django_checks/000077500000000000000000000000001500042301400255555ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_contrib/test_django_checks/test_autonames_check.py000066400000000000000000000006501500042301400323200ustar00rootroot00000000000000import subprocess def test_managepy_check(): """Checks that checks do fail.""" proc = subprocess.Popen( [ 'python', 'django_test_app/manage.py', 'check', '--fail-level', 'WARNING', ], stderr=subprocess.STDOUT, universal_newlines=True, encoding='utf8', ) proc.communicate() assert proc.returncode == 1 django-test-migrations-1.5.0/tests/test_contrib/test_pytest_plugin/000077500000000000000000000000001500042301400257015ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_contrib/test_pytest_plugin/__init__.py000066400000000000000000000000001500042301400300000ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_contrib/test_pytest_plugin/test_plugin.py000066400000000000000000000033471500042301400306170ustar00rootroot00000000000000import re import subprocess from django_test_migrations.constants import MIGRATION_TEST_MARKER def test_call_pytest_setup_plan(): """Checks that module is registered and visible in the meta data.""" output_text = subprocess.check_output( [ 'pytest', '--setup-plan', # We need this part because otherwise check fails with `1` code: '--cov-fail-under', '0', ], stderr=subprocess.STDOUT, universal_newlines=True, encoding='utf8', ) assert 'migrator' in output_text assert 'migrator_factory' in output_text def test_pytest_registers_marker(): """Ensure ``MIGRATION_TEST_MARKER`` marker is registered.""" output_text = subprocess.check_output( ['pytest', '--markers'], stderr=subprocess.STDOUT, universal_newlines=True, encoding='utf8', ) assert MIGRATION_TEST_MARKER in output_text def test_pytest_markers(): """Ensure ``MIGRATION_TEST_MARKER`` markers are properly added.""" output_text = subprocess.check_output( [ 'pytest', '--collect-only', # Collect only tests marked with ``MIGRATION_TEST_MARKER`` marker '-m', MIGRATION_TEST_MARKER, # We need this part because otherwise check fails with `1` code: '--cov-fail-under', '0', ], stderr=subprocess.STDOUT, universal_newlines=True, encoding='utf8', ) search_result = re.search( r'(?P\d+)\s+selected', output_text, ) assert search_result assert int(search_result.group('selected_number') or 0) > 0 assert 'test_pytest_plugin' in output_text django-test-migrations-1.5.0/tests/test_contrib/test_pytest_plugin/test_pytest_plugin_direct.py000066400000000000000000000054611500042301400335600ustar00rootroot00000000000000""" This module covers simple direct migrations. We test both schema and data-migrations here. """ import pytest from django.core.exceptions import FieldError from django.db.utils import IntegrityError @pytest.mark.django_db def test_pytest_plugin_initial(migrator): """Ensures that the initial migration works.""" old_state = migrator.apply_initial_migration(('main_app', None)) with pytest.raises(LookupError): # Models does not yet exist: old_state.apps.get_model('main_app', 'SomeItem') new_state = migrator.apply_tested_migration(('main_app', '0001_initial')) # After the initial migration is done, we can use the model state: SomeItem = new_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.filter(string_field='').count() == 0 @pytest.mark.django_db def test_pytest_plugin0001(migrator): """Ensures that the first migration works.""" old_state = migrator.apply_initial_migration(('main_app', '0001_initial')) SomeItem = old_state.apps.get_model('main_app', 'SomeItem') with pytest.raises(FieldError): SomeItem.objects.filter(is_clean=True) new_state = migrator.apply_tested_migration( ('main_app', '0002_someitem_is_clean'), ) SomeItem = new_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.filter(is_clean=True).count() == 0 @pytest.mark.django_db def test_pytest_plugin0002(migrator): """Ensures that the second migration works.""" old_state = migrator.apply_initial_migration( ('main_app', '0002_someitem_is_clean'), ) SomeItem = old_state.apps.get_model('main_app', 'SomeItem') SomeItem.objects.create(string_field='a') SomeItem.objects.create(string_field='a b') assert SomeItem.objects.count() == 2 assert SomeItem.objects.filter(is_clean=True).count() == 2 new_state = migrator.apply_tested_migration( ('main_app', '0003_update_is_clean'), ) SomeItem = new_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.count() == 2 assert SomeItem.objects.filter(is_clean=True).count() == 1 @pytest.mark.django_db def test_pytest_plugin0003(migrator): """Ensures that the third migration works.""" old_state = migrator.apply_initial_migration( ('main_app', '0003_update_is_clean'), ) SomeItem = old_state.apps.get_model('main_app', 'SomeItem') SomeItem.objects.create(string_field='a') # default is still there assert SomeItem.objects.count() == 1 assert SomeItem.objects.filter(is_clean=True).count() == 1 new_state = migrator.apply_tested_migration( ('main_app', '0004_auto_20191119_2125'), ) SomeItem = new_state.apps.get_model('main_app', 'SomeItem') with pytest.raises(IntegrityError): SomeItem.objects.create(string_field='b') # no default anymore django-test-migrations-1.5.0/tests/test_contrib/test_pytest_plugin/test_pytest_plugin_reverse.py000066400000000000000000000030431500042301400337530ustar00rootroot00000000000000""" This module covers tests for migration rollbacks. It might be useful when something goes wrong and you need to switch back to the previous state. """ import pytest from django.core.exceptions import FieldError @pytest.mark.django_db def test_pytest_plugin0001(migrator): """Ensures that the first migration works.""" old_state = migrator.apply_initial_migration( ('main_app', '0002_someitem_is_clean'), ) SomeItem = old_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.filter(is_clean=True).count() == 0 new_state = migrator.apply_tested_migration(('main_app', '0001_initial')) SomeItem = new_state.apps.get_model('main_app', 'SomeItem') with pytest.raises(FieldError): SomeItem.objects.filter(is_clean=True) @pytest.mark.django_db def test_pytest_plugin0002(migrator): """Ensures that the second migration works.""" old_state = migrator.apply_initial_migration( ('main_app', '0003_update_is_clean'), ) SomeItem = old_state.apps.get_model('main_app', 'SomeItem') SomeItem.objects.create(string_field='a', is_clean=True) SomeItem.objects.create(string_field='a b', is_clean=False) assert SomeItem.objects.count() == 2 assert SomeItem.objects.filter(is_clean=True).count() == 1 new_state = migrator.apply_tested_migration( ('main_app', '0002_someitem_is_clean'), ) SomeItem = new_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.count() == 2 assert SomeItem.objects.filter(is_clean=True).count() == 1 django-test-migrations-1.5.0/tests/test_contrib/test_pytest_plugin/test_signals.py000066400000000000000000000072151500042301400307570ustar00rootroot00000000000000from typing import Final import django import pytest from django.apps import apps from django.core.management import call_command from django.db import DEFAULT_DB_ALIAS from django.db.models.signals import post_migrate, pre_migrate from django_test_migrations.signals import mute_migrate_signals # value for ``dispatch_uid`` is needed to disconnect signal receiver # registered for testing purposes to which we do not have any reference # outside of test function DISPATCH_UID: Final = 'test_migrate_signals' # Dummy signal receiver function def _my_callback(sender, **kwargs): """Mock that does nothing.""" @pytest.fixture def _disconnect_receivers(): """Disconnect testing receiver of ``pre_migrate`` or ``post_migrate``.""" yield main_app_config = apps.get_app_config('main_app') pre_migrate.disconnect(sender=main_app_config, dispatch_uid=DISPATCH_UID) post_migrate.disconnect(sender=main_app_config, dispatch_uid=DISPATCH_UID) @pytest.mark.parametrize('signal', [pre_migrate, post_migrate]) def test_migrate_signal_muted(signal): """Ensure the context manager does indeed silences the signals.""" signal.connect(_my_callback) assert signal.receivers with mute_migrate_signals(): assert not signal.receivers assert signal.receivers signal.disconnect(_my_callback) @pytest.mark.skipif( django.VERSION >= (4, 0), reason='requires `Django<4.0`', ) @pytest.mark.parametrize('signal', [pre_migrate, post_migrate]) @pytest.mark.usefixtures('migrator', '_disconnect_receivers') def test_signal_receiver_registered_in_test(mocker, signal): """Ensure migration signal receivers registered in tests are called.""" signal_receiver_mock = mocker.MagicMock() main_app_config = apps.get_app_config('main_app') signal.connect( signal_receiver_mock, sender=main_app_config, dispatch_uid=DISPATCH_UID, ) verbosity = 0 interactive = False # call `migrate` management command to trigger ``pre_migrate`` and # ``post_migrate`` signals call_command('migrate', verbosity=verbosity, interactive=interactive) signal_receiver_mock.assert_called_once_with( sender=main_app_config, app_config=main_app_config, apps=mocker.ANY, # we don't have any reference to this object using=DEFAULT_DB_ALIAS, verbosity=verbosity, interactive=interactive, plan=mocker.ANY, # not important for this test signal=signal, ) @pytest.mark.skipif( django.VERSION < (4, 0), reason='requires `Django>=4.0`', ) @pytest.mark.parametrize('signal', [pre_migrate, post_migrate]) @pytest.mark.usefixtures('migrator', '_disconnect_receivers') def test_signal_receiver_registered_in_test_django40(mocker, signal): """Ensure migration signal receivers registered in tests are called.""" signal_receiver_mock = mocker.MagicMock() main_app_config = apps.get_app_config('main_app') signal.connect( signal_receiver_mock, sender=main_app_config, dispatch_uid=DISPATCH_UID, ) verbosity = 0 interactive = False # call `migrate` management command to trigger ``pre_migrate`` and # ``post_migrate`` signals call_command('migrate', verbosity=verbosity, interactive=interactive) signal_receiver_mock.assert_called_once_with( sender=main_app_config, app_config=main_app_config, apps=mocker.ANY, # we don't have any reference to this object using=DEFAULT_DB_ALIAS, verbosity=verbosity, interactive=interactive, signal=signal, # following kwargs are not important for this test stdout=mocker.ANY, plan=mocker.ANY, ) django-test-migrations-1.5.0/tests/test_contrib/test_unittest_case/000077500000000000000000000000001500042301400256455ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_contrib/test_unittest_case/__init__.py000066400000000000000000000000001500042301400277440ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_contrib/test_unittest_case/test_signals.py000066400000000000000000000110641500042301400307200ustar00rootroot00000000000000from unittest import mock import django import pytest from django.apps import apps from django.core.management import call_command from django.db import DEFAULT_DB_ALIAS from django.db.models.signals import post_migrate, pre_migrate from django_test_migrations.contrib.unittest_case import MigratorTestCase class TestSignalMuting(MigratorTestCase): """Test that the `post_migrate` signal has been muted.""" migrate_from = ('main_app', '0002_someitem_is_clean') migrate_to = ('main_app', '0001_initial') def test_pre_migrate_muted(self): """Ensure ``pre_migrate`` signal has been muted.""" assert not pre_migrate.receivers def test_post_migrate_muted(self): """Ensure ``post_migrate`` signal has been muted.""" assert not post_migrate.receivers class TestSignalConnectInTest(MigratorTestCase): """Ensure test ``pre_migrate`` or ``post_migrate`` receiver are called. Ensure that ``pre_migrate`` or ``post_migrate`` signals receivers connected directly in tests are called. """ migrate_from = ('main_app', '0001_initial') migrate_to = ('main_app', '0002_someitem_is_clean') def tearDown(self): """Disconnect ``pre_migrate`` and ``post_migrate`` testing receivers.""" pre_migrate.disconnect( self.pre_migrate_receiver_mock, sender=self.main_app_config, ) post_migrate.disconnect( self.post_migrate_receiver_mock, sender=self.main_app_config, ) def prepare(self): """Connect testing ``pre_migrate`` and ``post_migrate`` receivers.""" self.pre_migrate_receiver_mock = mock.MagicMock() self.post_migrate_receiver_mock = mock.MagicMock() # ``old_apps`` is not real ``ProjectState`` instance, so we cannot use # it to get "original" main_app ``AppConfig`` instance needed to # connect signal receiver, that's the reason we are using # ``apps`` imported directly from ``django.apps`` self.main_app_config = apps.get_app_config('main_app') pre_migrate.connect( self.pre_migrate_receiver_mock, sender=self.main_app_config, ) post_migrate.connect( self.post_migrate_receiver_mock, sender=self.main_app_config, ) @pytest.mark.skipif( django.VERSION >= (4, 0), reason='requires `Django<4.0`', ) def test_signal_receivers_added_in_tests(self): """Ensure migration signals receivers connected in tests are called.""" verbosity = 0 interactive = False # call `migrate` management command to trigger ``pre_migrate`` and # ``post_migrate`` signals call_command('migrate', verbosity=verbosity, interactive=interactive) common_kwargs = { 'sender': self.main_app_config, 'app_config': self.main_app_config, 'apps': mock.ANY, # we don't have any reference to this object 'using': DEFAULT_DB_ALIAS, 'verbosity': verbosity, 'interactive': interactive, 'plan': mock.ANY, # not important for this test } self.pre_migrate_receiver_mock.assert_called_once_with( **common_kwargs, signal=pre_migrate, ) self.post_migrate_receiver_mock.assert_called_once_with( **common_kwargs, signal=post_migrate, ) @pytest.mark.skipif( django.VERSION < (4, 0), reason='requires `Django>=4.0`', ) def test_signal_receivers_added_in_tests_django40(self): """Ensure migration signals receivers connected in tests are called.""" verbosity = 0 interactive = False # call `migrate` management command to trigger ``pre_migrate`` and # ``post_migrate`` signals call_command('migrate', verbosity=verbosity, interactive=interactive) common_kwargs = { 'sender': self.main_app_config, 'app_config': self.main_app_config, 'apps': mock.ANY, # we don't have any reference to this object 'using': DEFAULT_DB_ALIAS, 'verbosity': verbosity, 'interactive': interactive, # following kwargs are not important for this test 'stdout': mock.ANY, 'plan': mock.ANY, } self.pre_migrate_receiver_mock.assert_called_once_with( **common_kwargs, signal=pre_migrate, ) self.post_migrate_receiver_mock.assert_called_once_with( **common_kwargs, signal=post_migrate, ) django-test-migrations-1.5.0/tests/test_contrib/test_unittest_case/test_unittest_case.py000066400000000000000000000035501500042301400321330ustar00rootroot00000000000000from django_test_migrations.constants import MIGRATION_TEST_MARKER from django_test_migrations.contrib.unittest_case import MigratorTestCase class TestDirectMigration(MigratorTestCase): """This class is used to test direct migrations.""" migrate_from = ('main_app', '0002_someitem_is_clean') migrate_to = ('main_app', '0003_update_is_clean') @classmethod def setUpClass(cls): """Override parent's setUpClass to trigger #503.""" super().setUpClass() def prepare(self): """Prepare some data before the migration.""" SomeItem = self.old_state.apps.get_model('main_app', 'SomeItem') SomeItem.objects.create(string_field='a') SomeItem.objects.create(string_field='a b') def test_migration_main0003(self): """Run the test itself.""" SomeItem = self.new_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.count() == 2 assert SomeItem.objects.filter(is_clean=True).count() == 1 class TestBackwardMigration(MigratorTestCase): """This class is used to test backward migrations.""" migrate_from = ('main_app', '0002_someitem_is_clean') migrate_to = ('main_app', '0001_initial') def prepare(self): """Prepare some data before the migration.""" SomeItem = self.old_state.apps.get_model('main_app', 'SomeItem') SomeItem.objects.create(string_field='a') SomeItem.objects.create(string_field='a b') def test_migration_main0001(self): """Run the test itself.""" SomeItem = self.new_state.apps.get_model('main_app', 'SomeItem') assert SomeItem.objects.count() == 2 def test_migration_test_marker_tag(): """Ensure ``MigratorTestCase`` subclasses are properly tagged.""" assert MIGRATION_TEST_MARKER in TestDirectMigration.tags assert MIGRATION_TEST_MARKER in TestBackwardMigration.tags django-test-migrations-1.5.0/tests/test_db/000077500000000000000000000000001500042301400206615ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_db/test_backends/000077500000000000000000000000001500042301400234725ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_db/test_backends/test_exceptions.py000066400000000000000000000012201500042301400272570ustar00rootroot00000000000000from django_test_migrations.db.backends import exceptions def test_database_configuration_not_found(): """Ensure exception returns proper string representation.""" vendor = 'ms_sql' exception = exceptions.DatabaseConfigurationNotFound(vendor) assert vendor in str(exception) def test_database_configuration_setting_not_found(): """Ensure exception returns proper string representation.""" vendor = 'ms_sql' setting_name = 'fake_setting' exception = exceptions.DatabaseConfigurationSettingNotFound( vendor, setting_name, ) assert vendor in str(exception) assert setting_name in str(exception) django-test-migrations-1.5.0/tests/test_db/test_backends/test_mysql/000077500000000000000000000000001500042301400256765ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_db/test_backends/test_mysql/__init__.py000066400000000000000000000000001500042301400277750ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_db/test_backends/test_mysql/test_configuration.py000066400000000000000000000051441500042301400321620ustar00rootroot00000000000000import pytest from pytest_mock import MockerFixture from django_test_migrations.db.backends import mysql from django_test_migrations.db.backends.exceptions import ( DatabaseConfigurationSettingNotFound, ) @pytest.mark.parametrize( ('version', 'setting_name'), [ ('8.0.33', 'MAX_EXECUTION_TIME'), ('10.11.2-MariaDB', 'MAX_STATEMENT_TIME'), ('10.11.2-MariaDB-1:10.11.2+maria~ubu2204', 'MAX_STATEMENT_TIME'), ], ) def test_statement_timeout( mocker: MockerFixture, version: str, setting_name: str, ) -> None: """Ensure expected setting name is returned.""" connection_mock = mocker.MagicMock() cursor_mock = connection_mock.cursor().__enter__() # noqa: PLC2801 cursor_mock.fetchone.return_value = (version,) database_configuration = mysql.configuration.MySQLDatabaseConfiguration( connection_mock, ) assert database_configuration.statement_timeout == setting_name def test_get_setting_value(mocker: MockerFixture) -> None: """Ensure expected SQL query is executed.""" setting_name = 'MAX_EXECUTION_TIME' connection_mock = mocker.MagicMock() connection_mock.ops.quote_name = lambda name: name database_configuration = mysql.configuration.MySQLDatabaseConfiguration( connection_mock, ) database_configuration.get_setting_value(setting_name) cursor_mock = connection_mock.cursor().__enter__() # noqa: PLC2801 cursor_mock.execute.assert_called_once_with( f'SELECT @@{setting_name};', ) def test_get_existing_setting_value(mocker: MockerFixture) -> None: """Ensure setting value is returned for existing setting.""" expected_setting_value = 74747 connection_mock = mocker.MagicMock() cursor_mock = connection_mock.cursor().__enter__() # noqa: PLC2801 cursor_mock.fetchone.return_value = (expected_setting_value,) database_configuration = mysql.configuration.MySQLDatabaseConfiguration( connection_mock, ) setting_value = database_configuration.get_setting_value('testing_setting') assert setting_value == expected_setting_value def test_get_not_existing_setting_value(mocker: MockerFixture) -> None: """Ensure exception is raised when setting does not exist.""" connection_mock = mocker.MagicMock() cursor_mock = connection_mock.cursor().__enter__() # noqa: PLC2801 cursor_mock.fetchone.return_value = None database_configuration = mysql.configuration.MySQLDatabaseConfiguration( connection_mock, ) with pytest.raises(DatabaseConfigurationSettingNotFound): database_configuration.get_setting_value('testing_setting') django-test-migrations-1.5.0/tests/test_db/test_backends/test_postgresql/000077500000000000000000000000001500042301400267345ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_db/test_backends/test_postgresql/__init__.py000066400000000000000000000000001500042301400310330ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_db/test_backends/test_postgresql/test_configuration.py000066400000000000000000000036461500042301400332250ustar00rootroot00000000000000import pytest from django_test_migrations.db.backends import postgresql from django_test_migrations.db.backends.exceptions import ( DatabaseConfigurationSettingNotFound, ) def test_get_setting_value(mocker): """Ensure expected SQL query is executed.""" setting_name = 'statement_timeout' connection_mock = mocker.MagicMock() connection_mock.ops.quote_name = lambda name: name database_configuration = ( postgresql.configuration.PostgreSQLDatabaseConfiguration( connection_mock, ) ) database_configuration.get_setting_value(setting_name) cursor_mock = connection_mock.cursor().__enter__() # noqa: PLC2801 cursor_mock.execute.assert_called_once_with( 'SELECT setting FROM pg_settings WHERE name = %s;', (setting_name,), ) def test_get_existing_setting_value(mocker): """Ensure setting value is returned for existing setting.""" expected_setting_value = 74747 connection_mock = mocker.MagicMock() cursor_mock = connection_mock.cursor().__enter__() # noqa: PLC2801 cursor_mock.fetchone.return_value = (expected_setting_value,) database_configuration = ( postgresql.configuration.PostgreSQLDatabaseConfiguration( connection_mock, ) ) setting_value = database_configuration.get_setting_value('testing_setting') assert setting_value == expected_setting_value def test_get_not_existing_setting_value(mocker): """Ensure exception is raised when setting does not exist.""" connection_mock = mocker.MagicMock() cursor_mock = connection_mock.cursor().__enter__() # noqa: PLC2801 cursor_mock.fetchone.return_value = None database_configuration = ( postgresql.configuration.PostgreSQLDatabaseConfiguration( connection_mock, ) ) with pytest.raises(DatabaseConfigurationSettingNotFound): database_configuration.get_setting_value('testing_setting') django-test-migrations-1.5.0/tests/test_db/test_backends/test_registry.py000066400000000000000000000041021500042301400267500ustar00rootroot00000000000000import pytest from django_test_migrations.db.backends import mysql, postgresql, registry from django_test_migrations.db.backends.base.configuration import ( BaseDatabaseConfiguration, ) from django_test_migrations.db.backends.exceptions import ( DatabaseConfigurationNotFound, ) def test_all_db_backends_registered(): """Ensures all database backends all registered.""" registered_vendors = list(registry.database_configuration_registry.keys()) assert sorted(registered_vendors) == ['mysql', 'postgresql'] def test_abc_subclasses_are_not_registered(): """Test registration of ``BaseDatabaseConfiguration`` abstract subclasses. Ensures ``BaseDatabaseConfiguration`` abstract subclasses are not registered. """ vendor = 'abstract_subclass' # creates abstract subclass type( 'DatabaseConfiguration', (BaseDatabaseConfiguration,), { 'vendor': vendor, }, ) assert vendor not in registry.database_configuration_registry @pytest.mark.parametrize( ('vendor', 'database_configuration_class'), [ ( 'postgresql', postgresql.configuration.PostgreSQLDatabaseConfiguration, ), ('mysql', mysql.configuration.MySQLDatabaseConfiguration), ], ) def test_get_database_configuration_vendor_registered( mocker, vendor, database_configuration_class, ): """Ensures database configuration is returned when vendor registered.""" connection_mock = mocker.Mock() connection_mock.vendor = vendor database_configuration = registry.get_database_configuration( connection_mock, ) assert isinstance(database_configuration, database_configuration_class) def test_get_database_configuration_vendor_not_registered(mocker): """Ensures proper exception is raised when vendor not registered.""" vendor = 'not_registered_vendor' connection_mock = mocker.Mock() connection_mock.vendor = vendor with pytest.raises(DatabaseConfigurationNotFound, match=vendor): registry.get_database_configuration(connection_mock) django-test-migrations-1.5.0/tests/test_db/test_checks/000077500000000000000000000000001500042301400231605ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_db/test_checks/test_statement_timeout_check.py000066400000000000000000000072271500042301400315100ustar00rootroot00000000000000import datetime import pytest from django_test_migrations.db.checks.statement_timeout import ( CHECK_NAME, check_statement_timeout_setting, ) from django_test_migrations.logic.datetime import timedelta_to_milliseconds ALL_CONNECTIONS_MOCK_PATH = ( 'django_test_migrations.db.checks.statement_timeout.connections.all' ) @pytest.fixture def connection_mock_factory(mocker): """Factory of DB connection mocks.""" def factory(vendor, fetch_one_result=None): connection_mock = mocker.MagicMock(vendor=vendor) cursor_mock = connection_mock.cursor.return_value cursor_mock = cursor_mock.__enter__.return_value cursor_mock.fetchone.return_value = fetch_one_result return connection_mock return factory @pytest.mark.parametrize('vendor', ['postgresql', 'mysql']) def test_correct_statement_timeout(mocker, connection_mock_factory, vendor): """Ensure empty list returned when ``statement_timeout`` value correct.""" connection_mock = connection_mock_factory(vendor, (20000,)) mocker.patch(ALL_CONNECTIONS_MOCK_PATH, return_value=[connection_mock]) assert not check_statement_timeout_setting() @pytest.mark.parametrize('vendor', ['postgresql', 'mysql']) def test_statement_timeout_not_set(mocker, connection_mock_factory, vendor): """Ensure W001 is returned in list when ``statement_timeout`` not set.""" connection_mock = connection_mock_factory(vendor, (0,)) mocker.patch(ALL_CONNECTIONS_MOCK_PATH, return_value=[connection_mock]) check_messages = check_statement_timeout_setting() assert len(check_messages) == 1 assert check_messages[0].id.endswith('W001') @pytest.mark.parametrize('vendor', ['postgresql', 'mysql']) def test_statement_timeout_too_high(mocker, connection_mock_factory, vendor): """Ensure W002 is returned in list when ``statement_timeout`` too high.""" connection_mock = connection_mock_factory( vendor, (timedelta_to_milliseconds(datetime.timedelta(hours=2)),), ) mocker.patch(ALL_CONNECTIONS_MOCK_PATH, return_value=[connection_mock]) check_messages = check_statement_timeout_setting() assert len(check_messages) == 1 assert check_messages[0].id.endswith('W002') def test_unsupported_vendors(mocker): """Ensure empty list returned when no connections vendors supported.""" vendors = ['sqlite3', 'custom'] connection_mocks = [mocker.MagicMock(vendor=vendor) for vendor in vendors] mocker.patch(ALL_CONNECTIONS_MOCK_PATH, return_value=connection_mocks) assert not check_statement_timeout_setting() @pytest.mark.parametrize('vendor', ['postgresql', 'mysql']) def test_statement_timeout_setting_not_found( mocker, connection_mock_factory, vendor, ): """Ensure empty list returned when ``statement_timeout`` not found.""" connection_mock = connection_mock_factory(vendor, None) mocker.patch(ALL_CONNECTIONS_MOCK_PATH, return_value=[connection_mock]) assert not check_statement_timeout_setting() def test_multiple_connections(mocker, connection_mock_factory): """Ensure list with many items returned when many connections present.""" connections_mocks = [ connection_mock_factory('sqlite', None), connection_mock_factory('postgresql', (0,)), connection_mock_factory( 'mysql', (timedelta_to_milliseconds(datetime.timedelta(hours=2)),), ), ] mocker.patch(ALL_CONNECTIONS_MOCK_PATH, return_value=connections_mocks) check_messages = check_statement_timeout_setting() expected_messages_ids = [ f'{CHECK_NAME}.W001', f'{CHECK_NAME}.W002', ] assert expected_messages_ids == [message.id for message in check_messages] django-test-migrations-1.5.0/tests/test_exceptions/000077500000000000000000000000001500042301400224555ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_exceptions/test_migration_not_found.py000066400000000000000000000010361500042301400301320ustar00rootroot00000000000000import pytest from django_test_migrations import exceptions @pytest.mark.parametrize( ('target', 'expected_str'), [ (('app', None), "Migration ('app', None) not found in migrations plan"), ( ('app', '0047_magic'), "Migration ('app', '0047_magic') not found in migrations plan", ), ], ) def test_representation(target, expected_str): """Ensure ``MigrationNotInPlan`` has expected string representation.""" assert str(exceptions.MigrationNotInPlan(target)) == expected_str django-test-migrations-1.5.0/tests/test_logic/000077500000000000000000000000001500042301400213715ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_logic/test_datetime.py000066400000000000000000000013541500042301400246010ustar00rootroot00000000000000import datetime import pytest from django_test_migrations.logic.datetime import timedelta_to_milliseconds @pytest.mark.parametrize( ('timedelta', 'expected_result'), [ (datetime.timedelta(seconds=1), 1000), (datetime.timedelta(minutes=3), 3 * 60 * 1000), (datetime.timedelta(hours=2.6), 2.6 * 60 * 60 * 1000), ( datetime.timedelta(days=4), 4 * 24 * 60 * 60 * 1000, ), ( datetime.timedelta(minutes=7.4, seconds=47), 7.4 * 60 * 1000 + 47 * 1000, ), ], ) def test_timedelta_to_milliseconds(timedelta, expected_result): """Ensure expected value is returned.""" assert timedelta_to_milliseconds(timedelta) == expected_result django-test-migrations-1.5.0/tests/test_logic/test_migrations.py000066400000000000000000000007141500042301400251600ustar00rootroot00000000000000from django_test_migrations.logic.migrations import normalize def test_normalize_raw_target(): """Ensure normalize works for ``MigrationTarget``.""" assert normalize(('app', '0074_magic')) == [('app', '0074_magic')] def test_normalize_list_of_targets(): """Ensure normalize works for list of ``MigrationTarget``.""" migration_targets = [('app1', None), ('app2', '0001_initial')] assert normalize(migration_targets) == migration_targets django-test-migrations-1.5.0/tests/test_migrator/000077500000000000000000000000001500042301400221205ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_migrator/test_migrator.py000066400000000000000000000016731500042301400253640ustar00rootroot00000000000000import pytest from django.db.migrations.state import ProjectState from django_test_migrations.migrator import Migrator @pytest.mark.django_db def test_migrator(transactional_db): """We only need this test for coverage.""" migrator = Migrator() old_state = migrator.apply_initial_migration(('main_app', None)) new_state = migrator.apply_tested_migration(('main_app', '0001_initial')) assert isinstance(old_state, ProjectState) assert isinstance(new_state, ProjectState) assert migrator.reset() is None @pytest.mark.django_db def test_migrator_list(transactional_db): """We only need this test for coverage.""" migrator = Migrator() old_state = migrator.apply_initial_migration([('main_app', None)]) new_state = migrator.apply_tested_migration([('main_app', '0001_initial')]) assert isinstance(old_state, ProjectState) assert isinstance(new_state, ProjectState) assert migrator.reset() is None django-test-migrations-1.5.0/tests/test_plan/000077500000000000000000000000001500042301400212265ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_plan/conftest.py000066400000000000000000000011451500042301400234260ustar00rootroot00000000000000import pytest from django.db.migrations import Migration @pytest.fixture def plan(): """Fake migrations plan for testing purposes.""" migrations_plan = [ Migration('0001_initial', 'app1'), Migration('0002_second', 'app1'), Migration('0001_initial', 'app2'), Migration('0003_third', 'app1'), Migration('0004_fourth', 'app1'), Migration('0002_second', 'app2'), Migration('0005_fifth', 'app1'), Migration('0001_initial', 'app3'), Migration('0006_sixth', 'app1'), ] return [(migration, False) for migration in migrations_plan] django-test-migrations-1.5.0/tests/test_plan/test_all_migrations.py000066400000000000000000000020671500042301400256500ustar00rootroot00000000000000import pytest from django_test_migrations.plan import all_migrations, nodes_to_tuples @pytest.mark.django_db def test_all_migrations_main(): """Testing migrations for a single app only.""" main_migrations = all_migrations('default', ['main_app']) assert nodes_to_tuples(main_migrations) == [ ('main_app', '0001_initial'), ('main_app', '0002_someitem_is_clean'), ('main_app', '0003_update_is_clean'), ('main_app', '0004_auto_20191119_2125'), ('main_app', '0005_auto_20200329_1118'), ] @pytest.mark.django_db def test_all_migrations_missing(): """Testing migrations for a missing app.""" with pytest.raises(LookupError): all_migrations('default', ['missing_app']) @pytest.mark.django_db def test_all_migrations_auth(): """Testing migrations for a builtin app.""" auth_migrations = all_migrations('default', ['auth']) assert len(auth_migrations) >= 10 @pytest.mark.django_db def test_all_migrations_all(): """Testing migrations for all apps.""" assert len(all_migrations()) >= 17 django-test-migrations-1.5.0/tests/test_plan/test_truncate_plan.py000066400000000000000000000025631500042301400255040ustar00rootroot00000000000000import pytest from django_test_migrations.exceptions import MigrationNotInPlan from django_test_migrations.plan import truncate_plan @pytest.mark.parametrize( ('targets', 'index'), [ ([], 9), # full plan for empty targets ([('app1', None)], 0), ([('app1', None), ('app3', None)], 7), ([('app2', '0002_second')], 6), ([('app1', '0002_second'), ('app2', None)], 2), ([('app1', '0003_third'), ('app2', None)], 4), ([('app1', '0003_third'), ('app1', '0005_fifth')], 7), ([('app1', '0003_third'), ('app2', None), ('app3', '0001_initial')], 8), ], ) def test_truncate_plan(plan, targets, index): """Ensure plan is properly truncated for both types migrations names.""" assert truncate_plan(targets, plan) == plan[:index] def test_empty_plan(): """Ensure function work when plan is empty.""" assert not truncate_plan([('app1', '0001_initial')], []) @pytest.mark.parametrize( 'targets', [ [('app4', None)], [('app1', '0047_magic')], [('app1', '0005_fifth'), ('app4', None)], [('app1', '0005_fifth'), ('app4', '0047_magic'), ('app3', None)], ], ) def test_migration_target_does_not_exist(plan, targets): """Ensure ``MigrationNotInPlan`` is raised when target not in plan.""" with pytest.raises(MigrationNotInPlan): truncate_plan(targets, plan) django-test-migrations-1.5.0/tests/test_sql/000077500000000000000000000000001500042301400210735ustar00rootroot00000000000000django-test-migrations-1.5.0/tests/test_sql/test_drop_models_table.py000066400000000000000000000034531500042301400261670ustar00rootroot00000000000000from django_test_migrations.sql import drop_models_tables TESTING_DATABASE_NAME = 'test' def test_drop_models_table_no_tables_detected(mocker): """Ensure any ``DROP TABLE`` statement executed when no tables detected.""" testing_connection_mock = mocker.MagicMock() testing_connection_mock.introspection.django_table_names.return_value = [] connections_mock = mocker.patch('django.db.connections._connections') connections_mock.test = testing_connection_mock drop_models_tables(TESTING_DATABASE_NAME) testing_connection_mock.ops.execute_sql_flush.assert_not_called() def test_drop_models_table_table_detected(mocker): """Ensure ``DROP TABLE`` statements are executed when any table detected.""" testing_connection_mock = mocker.MagicMock() testing_connection_mock.introspection.django_table_names.return_value = [ 'foo_bar', 'foo_baz', ] connections_mock = mocker.patch('django.db.connections._connections') connections_mock.test = testing_connection_mock drop_models_tables(TESTING_DATABASE_NAME) testing_connection_mock.ops.execute_sql_flush.assert_called_once() def test_drop_models_table_on_mysql(mocker): """Ensure queries disabling/enabling `FOREIGN_KEY_CHECKS` are executed.""" testing_connection_mock = mocker.MagicMock(vendor='mysql') testing_connection_mock.introspection.django_table_names.return_value = [ 'foo_bar', 'foo_baz', ] connections_mock = mocker.patch('django.db.connections._connections') connections_mock.test = testing_connection_mock drop_models_tables(TESTING_DATABASE_NAME) testing_connection_mock.ops.execute_sql_flush.assert_called_once_with([ 'SET FOREIGN_KEY_CHECKS = 0;', mocker.ANY, mocker.ANY, 'SET FOREIGN_KEY_CHECKS = 1;', ]) django-test-migrations-1.5.0/tests/test_sql/test_flush_utils.py000066400000000000000000000017551500042301400250550ustar00rootroot00000000000000import pytest from django.core.management.color import Style from django_test_migrations import sql @pytest.fixture def testing_connection_mock(mocker): """Mock Django connections to check the methods called.""" testing_connection_mock = mocker.MagicMock() testing_connection_mock.introspection.get_sequences.return_value = [] connections_mock = mocker.patch('django.db.connections._connections') connections_mock.test = testing_connection_mock return testing_connection_mock def test_flush_django_migration_table(mocker, testing_connection_mock): """Ensure expected ``connection`` methods are called.""" style = Style() sql.flush_django_migrations_table('test', style) testing_connection_mock.ops.sql_flush.assert_called_once_with( style, [sql.DJANGO_MIGRATIONS_TABLE_NAME], reset_sequences=True, allow_cascade=False, ) testing_connection_mock.ops.execute_sql_flush.assert_called_once_with( mocker.ANY, )