├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── conftest.py ├── flake.lock ├── flake.nix ├── pyproject.toml ├── src └── extra_checks │ ├── __init__.py │ ├── apps.py │ ├── ast │ ├── __init__.py │ ├── ast.py │ ├── exceptions.py │ ├── protocols.py │ └── source_provider.py │ ├── check_id.py │ ├── checks │ ├── __init__.py │ ├── base_checks.py │ ├── drf_serializer_checks.py │ ├── model_checks.py │ ├── model_field_checks.py │ └── self_checks.py │ ├── forms.py │ ├── py.typed │ ├── registry.py │ └── utils.py ├── tests ├── __init__.py ├── example │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ └── serializers.py ├── settings.py ├── test_config.py ├── test_drf_serializer_checks.py ├── test_ignore.py ├── test_model_checks.py ├── test_model_field_checks.py ├── test_self_checks.py ├── test_utils.py ├── urls.py └── views.py └── uv.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Django Extra Checks CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | pull_request: 10 | schedule: 11 | - cron: "30 16 1 * *" 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 10 17 | env: 18 | PYTHONPATH: "src" 19 | strategy: 20 | matrix: 21 | # https://github.com/actions/python-versions/blob/main/versions-manifest.json 22 | python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] 23 | django-version: 24 | - "Django>=4.2,<5.0" 25 | - "Django>=5.0,<5.1" 26 | - "Django>=5.1,<5.2" 27 | - "Django==5.2a1" 28 | # - "https://github.com/django/django/archive/main.tar.gz" 29 | include: 30 | - drf: djangorestframework 31 | python-version: "3.12" 32 | django-version: "Django<5.2,>=5.0" # must be different from django-version 33 | exclude: 34 | - django-version: "Django>=5.0,<5.1" 35 | python-version: 3.9 36 | - django-version: "Django>=5.1,<5.2" 37 | python-version: 3.9 38 | - django-version: "Django==5.2a1" 39 | python-version: 3.9 40 | # - django-version: "https://github.com/django/django/archive/main.tar.gz" 41 | # python-version: 3.8 42 | # - django-version: "https://github.com/django/django/archive/main.tar.gz" 43 | # python-version: 3.9 44 | # 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-python@v5 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | - name: Install uv 51 | uses: astral-sh/setup-uv@v5 52 | - name: Patch pyproject.toml 53 | if: "matrix.python-version != '3.9'" 54 | run: | 55 | sed -i 's/requires-python = ">=3.9"/requires-python = ">=3.10"/' pyproject.toml 56 | - name: Install deps 57 | run: | 58 | uv add --group test "${{ matrix.django-version }}" ${{ matrix.drf }} 59 | uv sync --no-dev --group test 60 | - run: uv run pytest 61 | 62 | lint: 63 | runs-on: ubuntu-latest 64 | timeout-minutes: 10 65 | env: 66 | PYTHONPATH: "src" 67 | steps: 68 | - uses: actions/checkout@v4 69 | - uses: actions/setup-python@v5 70 | with: 71 | python-version: "3.12" 72 | - name: Install uv 73 | uses: astral-sh/setup-uv@v5 74 | - uses: actions/cache@v4 75 | with: 76 | path: ~/.cache/pre-commit 77 | key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} 78 | - run: uv sync 79 | - run: uv run pre-commit run --show-diff-on-failure --color=always --all-files 80 | 81 | package: 82 | runs-on: ubuntu-latest 83 | timeout-minutes: 5 84 | steps: 85 | - uses: actions/checkout@v4 86 | - name: Install uv 87 | uses: astral-sh/setup-uv@v5 88 | - name: Package 89 | run: uv build 90 | - name: Upload dist 91 | uses: actions/upload-artifact@v4 92 | with: 93 | name: dist 94 | path: dist 95 | 96 | publish: 97 | runs-on: ubuntu-latest 98 | needs: [package, tests, lint] 99 | if: startsWith(github.ref, 'refs/tags/v') 100 | timeout-minutes: 5 101 | environment: release 102 | permissions: 103 | id-token: write 104 | contents: write 105 | steps: 106 | - uses: actions/checkout@v4 107 | - name: Set release env 108 | id: release_output 109 | run: | 110 | VERSION="${GITHUB_REF:11}" 111 | BODY=$(awk -v RS='### ' '/'$VERSION'.*/ {print $0}' CHANGELOG.md) 112 | if [[ -z "$BODY" ]]; then 113 | echo "No changelog record for version $VERSION." 114 | fi 115 | BODY="${BODY//'%'/'%25'}" 116 | BODY="${BODY//$'\n'/'%0A'}" 117 | BODY="${BODY//$'\r'/'%0D'}" 118 | echo "::set-output name=VERSION::${VERSION}" 119 | echo "::set-output name=BODY::${BODY}" 120 | - name: Setup Python 121 | uses: actions/setup-python@v5 122 | with: 123 | python-version: "3.12" 124 | - name: Download dist 125 | uses: actions/download-artifact@v4 126 | with: 127 | name: dist 128 | path: dist 129 | - name: Publish package distributions to PyPI 130 | uses: pypa/gh-action-pypi-publish@release/v1 131 | - name: Create Release 132 | id: create_release 133 | uses: softprops/action-gh-release@v2 134 | with: 135 | tag_name: ${{ github.ref }} 136 | name: Release ${{ steps.release_output.outputs.VERSION }} 137 | body: ${{ steps.release_output.outputs.BODY }} 138 | draft: false 139 | prerelease: ${{ contains(steps.release_output.outputs.VERSION, 'rc') || contains(steps.release_output.outputs.VERSION, 'b') || contains(steps.release_output.outputs.VERSION, 'a') }} 140 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.envrc 2 | /.direnv 3 | __pycache__ 4 | *.pyc 5 | *.egg-info 6 | /.tox 7 | /.coverage* 8 | /build 9 | /dist 10 | /.mypy_cache 11 | /.env* 12 | /pyrightconfig.json 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.8.1 4 | hooks: 5 | - id: ruff 6 | language: system 7 | args: [--fix, --exit-non-zero-on-fix] 8 | - id: ruff-format 9 | language: system 10 | 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v5.0.0 13 | hooks: 14 | - id: trailing-whitespace 15 | - id: end-of-file-fixer 16 | - id: debug-statements 17 | - id: check-added-large-files 18 | - id: check-merge-conflict 19 | - id: mixed-line-ending 20 | args: ["--fix=lf"] 21 | 22 | - repo: local 23 | hooks: 24 | - id: mypy 25 | name: mypy 26 | language: system 27 | entry: mypy 28 | args: [src/extra_checks, tests] 29 | pass_filenames: false 30 | - id: django-check 31 | name: django check 32 | language: system 33 | entry: env PYTHONPATH=src:. DJANGO_SETTINGS_MODULE=tests.settings django-admin 34 | args: [check, --fail-level, CRITICAL] 35 | pass_filenames: false 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Change Log 2 | 3 | ### Unreleased 4 | 5 | ### 0.17.0a1 6 | 7 | - drop python 3.8 support 8 | - add django 5.2 to test matrix 9 | 10 | ### 0.16.1 11 | 12 | - fix `field-choices-constraint` 13 | 14 | ### 0.16.0 15 | 16 | - `field-choices-constraint` hint message uses `condition` on django 5.1 17 | 18 | ### 0.15.1 19 | 20 | - fix check constraint parsing 21 | 22 | ### 0.15.0 23 | 24 | - chores: Add python3.13 and django5.1 to test matrix 25 | - chores: remove `no-index-together` 26 | 27 | ### 0.14.1 28 | 29 | - fix(drf-serializer): IndexError on missing Meta attr, closes #34 30 | 31 | ### 0.14.0 32 | 33 | - Remove `field-boolean-null` 34 | - Deprecate `no-index-together` 35 | - Drop support for python < 3.8 36 | - Drop support for django < 4.2 37 | - Add python 3.12 to test matrix 38 | 39 | ### 0.13.3 40 | 41 | - Fix: handle not alphadigital chars in verbose text 42 | 43 | ### 0.13.2 44 | 45 | - Fix: OSError while trying to parse source code of a generated model #27 46 | 47 | ### 0.13.1 48 | 49 | - Fix: respect `empty_strings_allowed` in choices constraint #31 50 | 51 | ### 0.13.0 52 | 53 | - Use inheritance to determine check type 54 | - Deprecate `field-boolean-null` 55 | - Add checks: 56 | - `field-related-name` 57 | 58 | ### 0.12.0 59 | 60 | - Update test matrix: Django 3.2-4.1 X python 3.6-3.11 61 | - Remove deprecated `@ignore_checks` 62 | - Remove deprecated `ignore_types` 63 | 64 | ### 0.11.0 65 | 66 | - Remove `default_app_config`. 67 | 68 | ### 0.10.0 69 | 70 | - Add option `skipif` that accepts user function 71 | - Deprecate `ignore_types` 72 | 73 | ### 0.9.1 74 | 75 | - Replace DeprecationWarning with FutureWarning for `@ignore_checks` 76 | 77 | ### 0.9.0 78 | 79 | - Disable checks with `extra-checks-disable-next-line` comment 80 | - Deprecate `@ignore_checks` 81 | - Make ast parsing lazy 82 | - Add global log level 83 | 84 | ### 0.8.0 85 | 86 | - Add checks: 87 | - `field-choices-constraint` 88 | 89 | ### 0.7.1 90 | 91 | - Fix index checks level and message 92 | 93 | ### 0.7.0 94 | 95 | - Check `field-foreign-key-index` now accepts `when: indexes` instead of `when: unique_toegether` because now it search for duplicate indexes in `Meta.indexes`, `Meta.unique_toegether` and `Meta.constraints` 96 | - Add checks: 97 | - `no-unique-together` 98 | - `no-index-together` 99 | 100 | ### 0.6.0 101 | 102 | - Add checks: 103 | - `field-default-null` 104 | 105 | ### 0.5.0 106 | 107 | - Fix `ignore_checks` 108 | - Skip models fields not inherited from `fields.Field` 109 | - Add `ignore_types` option 110 | 111 | ### 0.4.1 112 | 113 | - Fix message for `field-verbose-name-gettext-case` 114 | 115 | ### 0.4.0 116 | 117 | - Add infra for rest framework serializers checks 118 | - Add checks: 119 | - `drf-model-serializer-extra-kwargs` 120 | - `drf-model-serializer-meta-attribute` 121 | - `model-admin` 122 | 123 | ### 0.3.0 124 | 125 | - Add `include_apps` option. 126 | - Fix ast crashes. 127 | 128 | ### 0.2.1 129 | 130 | - Fix ast parsing of indented block #1 131 | 132 | ### 0.2.0 133 | 134 | - First public release 135 | 136 | ### 0.1.0 137 | 138 | - First alpha 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Konstantin Alekseev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Extra Checks 2 | 3 | Collection of useful checks for Django Checks Frameworks 4 | 5 | ## Checks 6 | 7 | ### Models 8 | 9 | - **extra-checks-config** - settings.EXTRA_CHECKS is valid config for django-extra-checks (always enabled). 10 | - **model-attribute** - Each Model in the project must have all attributes from `attrs` setting specified. 11 | - **model-meta-attribute** - Each Model.Meta in the project must have all attributes from `attrs` setting specified. 12 | - **no-unique-together** - Use `UniqueConstraint` with the `constraints` option instead. 13 | - **model-admin** - Each model must be registered in admin. 14 | - **field-file-upload-to** - `FileField` / `ImageField` must have non empty `upload_to` argument. 15 | - **field-verbose-name** - All model's fields must have verbose name. 16 | - **field-verbose-name-gettext** - `verbose_name` must use gettext. 17 | - **field-verbose-name-gettext-case** - Words in text wrapped with gettext must be in one case. 18 | - **field-help-text-gettext** - `help_text` must use gettext. 19 | - **field-text-null** - text fields shouldn't use `null=True`. 20 | - **field-null** - don't pass `null=False` to model fields (this is django default). 21 | - **field-foreign-key-db-index** - ForeignKey fields must specify `db_index` explicitly (to apply only to fields in indexes: `when: indexes`). 22 | - **field-related-name** - Related fields must specify `related_name` explicitly. 23 | - **field-default-null** - If field nullable (`null=True`), then 24 | `default=None` argument is redundant and should be removed. 25 | **WARNING** Be aware that setting is database dependent, 26 | eg. Oracle interprets empty strings as nulls as a result 27 | django uses empty string instead of null as default. 28 | - **field-choices-constraint** - Fields with choices must have companion CheckConstraint to enforce choices on database level, [details](https://adamj.eu/tech/2020/01/22/djangos-field-choices-dont-constrain-your-data/). 29 | 30 | ### DRF Serializers 31 | 32 | - **drf-model-serializer-extra-kwargs** - ModelSerializer's extra_kwargs must not include fields that specified on serializer. 33 | - **drf-model-serializer-meta-attribute** - Each ModelSerializer.Meta must have all attributes specified in `attrs`, [use case](https://hakibenita.com/django-rest-framework-slow#bonus-forcing-good-habits). 34 | 35 | ## Installation 36 | 37 | Install with `pip install django-extra-checks` 38 | 39 | Add `extra_checks` to `INSTALLED_APPS` in your Django settings: 40 | ```py 41 | INSTALLED_APPS = [ 42 | ..., 43 | "django.contrib.admin", # make sure this comes before 'extra_checks' if you plan to use the `model-admin` check 44 | "extra_checks", 45 | ... 46 | ] 47 | ``` 48 | 49 | ## Settings 50 | 51 | To enable some check define `EXTRA_CHECKS` setting with a dict of checks and its settings: 52 | 53 | ```python 54 | EXTRA_CHECKS = { 55 | "checks": [ 56 | # require non empty `upload_to` argument. 57 | "field-file-upload-to", 58 | # use dict form if check need configuration 59 | # eg. all models must have fk to Site model 60 | {"id": "model-attribute", "attrs": ["site"]}, 61 | # require `db_table` for all models, increase level to CRITICAL 62 | {"id": "model-meta-attribute", "attrs": ["db_table"], "level": "CRITICAL"}, 63 | ] 64 | } 65 | ``` 66 | 67 | By default only your project apps are checked but you can use 68 | `include_apps` option to specify apps to check (including third party apps): 69 | 70 | ```python 71 | EXTRA_CHECKS = { 72 | # use same names as in INSTALLED_APPS 73 | "include_apps": ["django.contrib.sites", "my_app"], 74 | ... 75 | } 76 | ``` 77 | 78 | #### Ignoring check problems 79 | 80 | Use `extra-checks-disable-next-line` comment to disable checks: 81 | 82 | ```python 83 | # disable specific checks on model 84 | # extra-checks-disable-next-line model-attribute, model-admin 85 | class MyModel(models.Model): 86 | # disable all checks on image field 87 | # extra-checks-disable-next-line 88 | image = models.ImageField() 89 | 90 | # separate comments and check's codes are also supported 91 | # extra-checks-disable-next-line X014 92 | # extra-checks-disable-next-line no-unique-together 93 | class Meta: 94 | ... 95 | ``` 96 | 97 | Another way is to provide function that accepts field, model or 98 | serializer class as its first argument and returns `True` if it must be skipped. 99 | _Be aware that the more computation expensive your skipif functions the 100 | slower django check will run._ 101 | 102 | `skipif` example: 103 | 104 | ```python 105 | def skipif_streamfield(field, *args, **kwargs): 106 | return isinstance(field, wagtail.core.fields.StreamField) 107 | 108 | def skipif_non_core_app(model_cls, *args, **kwargs): 109 | return model_cls._meta.app_label != "my_core_app" 110 | 111 | EXTRA_CHECKS = { 112 | "checks": [ 113 | { 114 | "id": "field-verbose-name-gettext", 115 | # make this check skip wagtail's StreamField 116 | "skipif": skipif_streamfield 117 | }, 118 | { 119 | "id": "model-admin", 120 | # models from non core app shouldn't be registered in admin 121 | "skipif": skipif_non_core_app, 122 | }, 123 | ] 124 | } 125 | ``` 126 | 127 | ## Development 128 | 129 | Install dev deps in virtualenv `uv sync`, run tests `uv run pytest`. 130 | 131 | ## Credits 132 | 133 | The project was built using ideas and code snippets from: 134 | 135 | - [Haki Benita](https://medium.com/@hakibenita/automating-the-boring-stuff-in-django-using-the-check-framework-3495fb550a6a) 136 | - [Jon Dufresne](https://github.com/jdufresne/django-check-admin) 137 | - [Adam Johnson](https://adamj.eu/tech/2020/01/22/djangos-field-choices-dont-constrain-your-data/) 138 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from extra_checks.checks.self_checks import CheckConfig 4 | from extra_checks.registry import Registry 5 | 6 | 7 | @pytest.fixture 8 | def registry() -> Registry: 9 | registry = Registry() 10 | registry._register(["extra_checks_selfcheck"], CheckConfig) 11 | return registry 12 | 13 | 14 | class TestCase: 15 | TEST_TAG = "__test_case_check__" 16 | 17 | def __init__(self, settings, monkeypatch, registry): 18 | self._settings = settings 19 | self._monkeypatch = monkeypatch 20 | self._registry = registry 21 | 22 | def settings(self, settings): 23 | self._settings.EXTRA_CHECKS = settings 24 | return self 25 | 26 | def check(self, *checks): 27 | for check in checks: 28 | self._registry._register([self.TEST_TAG], check) 29 | return self 30 | 31 | def handler(self, handler): 32 | self._registry._add_handler(self.TEST_TAG, handler) 33 | return self 34 | 35 | def run(self): 36 | self._registry.enabled_checks = {} 37 | handlers = self._registry.bind() 38 | assert self._registry.is_healthy, ( 39 | f"Settings has errors: {self._registry._config.errors.as_text()}" 40 | ) 41 | return list(handlers[self.TEST_TAG]()) 42 | 43 | def models(self, *models): 44 | self._monkeypatch.setattr( 45 | "extra_checks.checks.model_checks._get_models_to_check", 46 | lambda *a, **kw: (m for m in models), 47 | ) 48 | return self 49 | 50 | def serializers(self, *serializers): 51 | import rest_framework.serializers 52 | 53 | n, m = [], [] 54 | for s in serializers: 55 | if issubclass(s, rest_framework.serializers.ModelSerializer): 56 | m.append(s) 57 | else: 58 | n.append(s) 59 | self._monkeypatch.setattr( 60 | "extra_checks.checks.drf_serializer_checks._get_serializers_to_check", 61 | lambda *a, **kw: (n, m), 62 | ) 63 | return self 64 | 65 | 66 | @pytest.fixture 67 | def test_case(settings, monkeypatch, registry): 68 | return TestCase(settings, monkeypatch, registry) 69 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1738410390, 24 | "narHash": "sha256-xvTo0Aw0+veek7hvEVLzErmJyQkEcRk6PSR4zsRQFEc=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "3a228057f5b619feb3186e986dbe76278d707b6e", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Django Extra Checks"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = 10 | { 11 | self, 12 | nixpkgs, 13 | flake-utils, 14 | }: 15 | flake-utils.lib.eachDefaultSystem ( 16 | system: 17 | let 18 | pkgs = import nixpkgs { inherit system; }; 19 | app-test = pkgs.writeShellScriptBin "app.test" ''pytest $@''; 20 | app-install = pkgs.writeShellScriptBin "app.install" ''uv sync && pre-commit install''; 21 | app-typecheck = pkgs.writeShellScriptBin "app.typecheck" ''mypy src/extra_checks tests''; 22 | app-lint = pkgs.writeShellScriptBin "app.lint" ''pre-commit run -a''; 23 | in 24 | { 25 | devShells.default = pkgs.mkShell { 26 | packages = [ 27 | pkgs.python313 28 | pkgs.pre-commit 29 | pkgs.uv 30 | ]; 31 | buildInputs = [ 32 | app-test 33 | app-install 34 | app-typecheck 35 | app-lint 36 | ]; 37 | shellHook = '' 38 | export PYTHONUNBUFFERED=1; 39 | export PYTHONPATH=src; 40 | export DJANGO_SETTINGS_MODULE=tests.settings; 41 | export VIRTUAL_ENV="$(pwd)/.venv" 42 | [[ -d $VIRTUAL_ENV ]] || uv -q venv $VIRTUAL_ENV 43 | export PATH="$VIRTUAL_ENV/bin":$PATH 44 | ''; 45 | }; 46 | } 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-extra-checks" 7 | dynamic = ["version"] 8 | description = "Collection of useful checks for Django Checks Framework" 9 | readme = "README.md" 10 | license = "MIT" 11 | requires-python = ">=3.9" 12 | authors = [ 13 | { name = "Konstantin Alekseev", email = "mail@kalekseev.com" }, 14 | ] 15 | keywords = [ 16 | "checks", 17 | "django", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Environment :: Web Environment", 22 | "Framework :: Django", 23 | "Framework :: Django :: 4.2", 24 | "Framework :: Django :: 5.0", 25 | "Framework :: Django :: 5.1", 26 | "Framework :: Django :: 5.2", 27 | "Intended Audience :: Developers", 28 | "License :: OSI Approved :: MIT License", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | ] 38 | 39 | [dependency-groups] 40 | dev = [ 41 | "Django", 42 | "django-stubs", 43 | "djangorestframework", 44 | "djangorestframework-stubs", 45 | "mypy", 46 | "pdbpp", 47 | "pre-commit", 48 | "ruff", 49 | {include-group = "test"}, 50 | ] 51 | test = [ 52 | "pytest", 53 | "pytest-cov", 54 | "pytest-django", 55 | ] 56 | 57 | [project.urls] 58 | Homepage = "https://github.com/kalekseev/django-extra-checks" 59 | 60 | [tool.hatch.version] 61 | source = "vcs" 62 | raw-options = { local_scheme = "no-local-version" } 63 | 64 | [tool.hatch.build.targets.sdist] 65 | include = ["/src"] 66 | [tool.hatch.build.targets.wheel] 67 | packages = ["src/extra_checks"] 68 | 69 | [tool.ruff] 70 | src = ["src"] 71 | target-version = "py39" 72 | [tool.ruff.lint] 73 | select = [ 74 | 'B', 75 | 'C', 76 | 'E', 77 | 'F', 78 | 'N', 79 | 'W', 80 | 'UP', 81 | 'RUF', 82 | 'INP', 83 | 'I', 84 | 'TCH', 85 | ] 86 | ignore = [ 87 | 'E501', 88 | 'B904', 89 | 'B905', 90 | 'RUF012', 91 | ] 92 | extend-safe-fixes = ["TCH"] 93 | 94 | [tool.pytest.ini_options] 95 | addopts = "-p no:doctest --cov=extra_checks --cov-branch --ds=tests.settings" 96 | django_find_project = false 97 | pythonpath = "." 98 | 99 | [tool.mypy] 100 | plugins = ["mypy_django_plugin.main"] 101 | 102 | disallow_untyped_defs = true 103 | check_untyped_defs = true 104 | ignore_missing_imports = true 105 | implicit_reexport = true 106 | strict_equality = true 107 | warn_unreachable = true 108 | show_error_codes = true 109 | 110 | no_implicit_optional = true 111 | strict_optional = true 112 | warn_no_return = true 113 | warn_redundant_casts = true 114 | warn_unused_ignores = true 115 | 116 | [[tool.mypy.overrides]] 117 | module = ["tests.*"] 118 | disallow_untyped_defs = false 119 | 120 | [tool.django-stubs] 121 | django_settings_module = "tests.settings" 122 | -------------------------------------------------------------------------------- /src/extra_checks/__init__.py: -------------------------------------------------------------------------------- 1 | from .check_id import CheckId 2 | 3 | __all__ = [ 4 | "CheckId", 5 | ] 6 | -------------------------------------------------------------------------------- /src/extra_checks/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from . import checks # noqa 4 | from .registry import registry 5 | 6 | 7 | class ExtraChecksConfig(AppConfig): 8 | name = "extra_checks" 9 | 10 | def ready(self) -> None: 11 | super().ready() 12 | registry.bind() 13 | -------------------------------------------------------------------------------- /src/extra_checks/ast/__init__.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Container 2 | 3 | from django.db import models 4 | 5 | from ..check_id import CheckId 6 | from .ast import ModelAST 7 | from .exceptions import MissingASTError 8 | from .protocols import ( 9 | ArgASTProtocol, 10 | FieldASTProtocol, 11 | ModelASTDisableCommentProtocol, 12 | ModelASTProtocol, 13 | ) 14 | 15 | 16 | def get_model_ast( 17 | model_cls: type[models.Model], 18 | meta_checks: Container[CheckId], 19 | ) -> ModelASTDisableCommentProtocol: 20 | return ModelAST(model_cls, meta_checks) 21 | 22 | 23 | __all__ = [ 24 | "ArgASTProtocol", 25 | "FieldASTProtocol", 26 | "MissingASTError", 27 | "ModelASTProtocol", 28 | "get_model_ast", 29 | ] 30 | -------------------------------------------------------------------------------- /src/extra_checks/ast/ast.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from collections.abc import Container, Iterable, Iterator 3 | from functools import partial 4 | from typing import ( 5 | TYPE_CHECKING, 6 | Callable, 7 | Optional, 8 | Union, 9 | cast, 10 | ) 11 | 12 | from django.db import models 13 | from django.utils.functional import SimpleLazyObject 14 | 15 | from extra_checks.check_id import CheckId 16 | 17 | from .exceptions import MissingASTError 18 | from .protocols import ( 19 | ArgASTProtocol, 20 | DisableCommentProtocol, 21 | FieldASTProtocol, 22 | ModelASTProtocol, 23 | ) 24 | from .source_provider import SourceProvider 25 | 26 | if TYPE_CHECKING: 27 | cached_property = property 28 | else: 29 | from django.utils.functional import cached_property 30 | 31 | 32 | class ModelAST(DisableCommentProtocol, ModelASTProtocol): 33 | def __init__(self, model_cls: type[models.Model], meta_checks: Container[CheckId]): 34 | self.model_cls = model_cls 35 | self.meta_checks = meta_checks 36 | self._assignment_nodes: list[ast.Assign] = [] 37 | self._meta: Optional[ast.ClassDef] = None 38 | 39 | @cached_property 40 | def _source_provider(self) -> SourceProvider: 41 | return SourceProvider(self.model_cls) 42 | 43 | @cached_property 44 | def _nodes(self) -> Iterator[ast.AST]: 45 | if self._source_provider.source is None: 46 | return iter([]) 47 | return iter(ast.parse(self._source_provider.source).body[0].body) # type: ignore 48 | 49 | def _parse(self, predicate: Optional[Callable[[ast.AST], bool]] = None) -> None: 50 | try: 51 | for node in self._nodes: 52 | if predicate and predicate(node): 53 | self._meta = cast(ast.ClassDef, node) 54 | break 55 | if isinstance(node, ast.Assign): 56 | self._assignment_nodes.append(node) 57 | except StopIteration: 58 | return 59 | return 60 | 61 | @cached_property 62 | def _meta_node(self) -> Optional[ast.ClassDef]: 63 | if not self._meta: 64 | self._parse( 65 | lambda node: isinstance(node, ast.ClassDef) and node.name == "Meta" 66 | ) 67 | return self._meta 68 | 69 | @cached_property 70 | def _meta_vars(self) -> dict[str, ast.Assign]: 71 | data: dict[str, ast.Assign] = {} 72 | if not self._meta_node: 73 | return data 74 | for node in ast.iter_child_nodes(self._meta_node): 75 | if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name): 76 | data[node.targets[0].id] = node 77 | return data 78 | 79 | @cached_property 80 | def _assignments(self) -> dict[str, ast.Assign]: 81 | self._parse() 82 | result = {} 83 | for node in self._assignment_nodes: 84 | if isinstance(node.targets[0], ast.Name): 85 | result[node.targets[0].id] = node 86 | return result 87 | 88 | @cached_property 89 | def field_nodes(self) -> Iterable[tuple[models.fields.Field, "FieldAST"]]: 90 | for field in self.model_cls._meta.get_fields(include_parents=False): 91 | if isinstance(field, models.Field): 92 | yield ( 93 | field, 94 | cast( 95 | FieldAST, SimpleLazyObject(partial(get_field_ast, self, field)) 96 | ), 97 | ) 98 | 99 | def has_meta_var(self, name: str) -> bool: 100 | return name in self._meta_vars 101 | 102 | def is_disabled_by_comment(self, check_id: str) -> bool: 103 | check = CheckId.find_check(check_id) 104 | if check in self.meta_checks: 105 | if not self._meta_node: 106 | # class Meta is not defined on model 107 | return False 108 | return check in self._source_provider.get_disabled_checks_for_line( 109 | self._meta_node.lineno 110 | ) 111 | return check in self._source_provider.get_disabled_checks_for_line(1) 112 | 113 | 114 | def get_field_ast(model_ast: ModelAST, field: models.Field) -> "FieldAST": 115 | try: 116 | return FieldAST( 117 | model_ast._assignments[field.name], field, model_ast._source_provider 118 | ) 119 | except KeyError: 120 | raise MissingASTError() 121 | 122 | 123 | class ArgAST(ArgASTProtocol): 124 | def __init__(self, node: ast.AST): 125 | self._node = node 126 | 127 | @cached_property 128 | def is_callable(self) -> bool: 129 | return isinstance(self._node, ast.Call) 130 | 131 | @cached_property 132 | def callable_func_name(self) -> Optional[str]: 133 | return ( 134 | getattr(self._node.func, "id", None) 135 | if isinstance(self._node, ast.Call) 136 | else None 137 | ) 138 | 139 | def get_call_first_args(self) -> str: 140 | return self._node.args[0].s # type: ignore 141 | 142 | 143 | class FieldAST(DisableCommentProtocol, FieldASTProtocol): 144 | def __init__( 145 | self, node: ast.Assign, field: models.Field, source_provider: SourceProvider 146 | ): 147 | self._node = node 148 | self._field = field 149 | self._source_provider = source_provider 150 | 151 | @cached_property 152 | def _args(self) -> list[ast.expr]: 153 | return self._node.value.args # type: ignore 154 | 155 | @cached_property 156 | def _kwargs(self) -> dict[str, ast.keyword]: 157 | return {kw.arg: kw for kw in self._node.value.keywords if kw.arg} # type: ignore 158 | 159 | def get_arg(self, name: str) -> Optional[ArgASTProtocol]: 160 | if name == "verbose_name": 161 | return ArgAST(self._verbose_name) if self._verbose_name else None 162 | return ArgAST(self._kwargs[name].value) if name in self._kwargs else None 163 | 164 | @cached_property 165 | def _verbose_name(self) -> Union[None, ast.Constant, ast.Call]: 166 | result = getattr(self._kwargs.get("verbose_name"), "value", None) 167 | if result: 168 | return result 169 | if isinstance(self._field, models.fields.related.RelatedField): 170 | return None 171 | if self._args: 172 | node = self._args[0] 173 | if isinstance(node, ast.Call) and hasattr(node.func, "id"): 174 | return node 175 | elif isinstance(node, (ast.Constant, ast.Str)): 176 | return node 177 | return None 178 | 179 | def is_disabled_by_comment(self, check_id: str) -> bool: 180 | return CheckId.find_check( 181 | check_id 182 | ) in self._source_provider.get_disabled_checks_for_line(self._node.lineno) 183 | -------------------------------------------------------------------------------- /src/extra_checks/ast/exceptions.py: -------------------------------------------------------------------------------- 1 | class MissingASTError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/extra_checks/ast/protocols.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from typing import Any, Optional, Protocol 3 | 4 | from django.db import models 5 | 6 | 7 | class ArgASTProtocol(Protocol): 8 | @property 9 | def is_callable(self) -> bool: ... 10 | 11 | @property 12 | def callable_func_name(self) -> Optional[str]: ... 13 | 14 | def get_call_first_args(self) -> Any: ... 15 | 16 | 17 | class FieldASTProtocol(Protocol): 18 | def get_arg(self, name: str) -> Optional[ArgASTProtocol]: ... 19 | 20 | 21 | class ModelASTProtocol(Protocol): 22 | @property 23 | def field_nodes( 24 | self, 25 | ) -> Iterable[tuple[models.fields.Field, "FieldASTDisableCommentProtocol"]]: ... 26 | 27 | def has_meta_var(self, name: str) -> bool: ... 28 | 29 | 30 | class DisableCommentProtocol(Protocol): 31 | def is_disabled_by_comment(self, check_id: str) -> bool: ... 32 | 33 | 34 | class ModelASTDisableCommentProtocol( 35 | ModelASTProtocol, DisableCommentProtocol, Protocol 36 | ): ... 37 | 38 | 39 | class FieldASTDisableCommentProtocol( 40 | FieldASTProtocol, DisableCommentProtocol, Protocol 41 | ): ... 42 | -------------------------------------------------------------------------------- /src/extra_checks/ast/source_provider.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import re 3 | import textwrap 4 | from collections.abc import Iterable 5 | from typing import TYPE_CHECKING, Optional 6 | 7 | from extra_checks.check_id import ALL_CHECKS_NAMES, CheckId 8 | 9 | if TYPE_CHECKING: 10 | cached_property = property 11 | else: 12 | from django.utils.functional import cached_property 13 | 14 | 15 | DISABLE_COMMENT_PATTERN = r"^#\s*extra-checks-disable-next-line(?:\s+(.*))?$" 16 | 17 | 18 | def _parse_comment(checks: Optional[str]) -> set[str]: 19 | if not checks: 20 | return ALL_CHECKS_NAMES # type: ignore 21 | result = set() 22 | for scheck in checks.split(","): 23 | check = CheckId.find_check(scheck.strip()) 24 | if check: 25 | result.add(check.value) 26 | return result 27 | 28 | 29 | def _find_disabled_checks(comments: Iterable[str]) -> set[str]: 30 | result = set() 31 | for line in comments: 32 | m = re.match(DISABLE_COMMENT_PATTERN, line) 33 | if m: 34 | result |= _parse_comment(m.groups()[0]) 35 | return result 36 | 37 | 38 | class SourceProvider: 39 | def __init__(self, obj: type) -> None: 40 | self._obj = obj 41 | self._comments_cache: dict[int, set[str]] = {} 42 | 43 | @cached_property 44 | def source(self) -> Optional[str]: 45 | try: 46 | return textwrap.dedent(inspect.getsource(self._obj)) 47 | except (TypeError, OSError): 48 | # TODO: add warning? 49 | return None 50 | 51 | @cached_property 52 | def _top_comments(self) -> Iterable[str]: 53 | for line in (inspect.getcomments(self._obj) or "").splitlines()[::-1]: 54 | yield line.strip() 55 | 56 | def _get_line_comments(self, line_no: int) -> Iterable[str]: 57 | line_no -= 2 58 | if line_no < 0: 59 | yield from self._top_comments 60 | for line in (self.source or "").splitlines()[line_no::-1]: 61 | sline = line.strip() 62 | if sline.startswith("#"): 63 | yield sline 64 | else: 65 | break 66 | 67 | def get_disabled_checks_for_line(self, line_no: int) -> set[str]: 68 | if line_no not in self._comments_cache: 69 | comments = self._get_line_comments(line_no) 70 | self._comments_cache[line_no] = _find_disabled_checks(comments) 71 | return self._comments_cache[line_no] 72 | -------------------------------------------------------------------------------- /src/extra_checks/check_id.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Optional, cast 3 | 4 | 5 | class CheckId(str, enum.Enum): 6 | X001 = "extra-checks-config" 7 | X010 = "model-attribute" 8 | X011 = "model-meta-attribute" 9 | X012 = "model-admin" 10 | X013 = "no-unique-together" 11 | # X014 = "no-index-together" - removed 12 | X050 = "field-verbose-name" 13 | X051 = "field-verbose-name-gettext" 14 | X052 = "field-verbose-name-gettext-case" 15 | X053 = "field-help-text-gettext" 16 | X054 = "field-file-upload-to" 17 | X055 = "field-text-null" 18 | # X056 = "field-boolean-null" - removed 19 | X057 = "field-null" 20 | X058 = "field-foreign-key-db-index" 21 | X059 = "field-default-null" 22 | X060 = "field-choices-constraint" 23 | X061 = "field-related-name" 24 | X301 = "drf-model-serializer-extra-kwargs" 25 | X302 = "drf-model-serializer-meta-attribute" 26 | 27 | @classmethod 28 | def find_check(cls, value: str) -> Optional["CheckId"]: 29 | try: 30 | return cls(value) 31 | except ValueError: 32 | pass 33 | try: 34 | return cast(CheckId, cls._member_map_[value]) 35 | except KeyError: 36 | pass 37 | return None 38 | 39 | 40 | ALL_CHECKS_NAMES = frozenset(CheckId._value2member_map_.keys()) 41 | -------------------------------------------------------------------------------- /src/extra_checks/checks/__init__.py: -------------------------------------------------------------------------------- 1 | from .model_checks import * # noqa 2 | from .model_field_checks import * # noqa 3 | from .self_checks import * # noqa 4 | 5 | try: 6 | from .drf_serializer_checks import * # noqa 7 | except ImportError: 8 | pass 9 | -------------------------------------------------------------------------------- /src/extra_checks/checks/base_checks.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from abc import ABC, abstractmethod 3 | from collections.abc import Iterator 4 | from typing import ( 5 | TYPE_CHECKING, 6 | Any, 7 | Callable, 8 | ClassVar, 9 | Optional, 10 | ) 11 | 12 | import django.core.checks 13 | 14 | from .. import CheckId, forms 15 | from ..ast.protocols import DisableCommentProtocol 16 | 17 | MESSAGE_MAP = { 18 | django.core.checks.DEBUG: django.core.checks.Debug, 19 | django.core.checks.INFO: django.core.checks.Info, 20 | django.core.checks.WARNING: django.core.checks.Warning, 21 | django.core.checks.ERROR: django.core.checks.Error, 22 | django.core.checks.CRITICAL: django.core.checks.Critical, 23 | } 24 | 25 | 26 | class BaseCheck(ABC): 27 | Id: CheckId 28 | settings_form_class: ClassVar[type[forms.BaseCheckForm]] = forms.BaseCheckForm 29 | level = django.core.checks.WARNING 30 | deprecation_warnings: list[str] = [] 31 | 32 | def __init__( 33 | self, 34 | level: Optional[int] = None, 35 | ignore_objects: Optional[set[Any]] = None, 36 | ignore_types: Optional[set] = None, 37 | skipif: Optional[Callable] = None, 38 | ) -> None: 39 | self.level = level or self.level 40 | self.ignore_objects = ignore_objects or set() 41 | self.ignore_types = ignore_types or set() 42 | self.skipif = skipif 43 | for warning in self.deprecation_warnings: 44 | warnings.warn(warning, FutureWarning, stacklevel=2) 45 | 46 | def __call__( 47 | self, obj: Any, ast: Optional[DisableCommentProtocol] = None, **kwargs: Any 48 | ) -> Iterator[django.core.checks.CheckMessage]: 49 | if not self.is_ignored(obj): 50 | for error in self.apply(obj, ast=ast, **kwargs): 51 | if not ast or (error.id and not ast.is_disabled_by_comment(error.id)): 52 | yield error 53 | 54 | def is_ignored(self, obj: Any) -> bool: 55 | if self.skipif and self.skipif(obj): 56 | return True 57 | return obj in self.ignore_objects or type(obj) in self.ignore_types 58 | 59 | def message( 60 | self, message: str, hint: Optional[str] = None, obj: Any = None 61 | ) -> django.core.checks.CheckMessage: 62 | return MESSAGE_MAP[self.level]( 63 | message + f" [{self.Id.value}]", hint=hint, obj=obj, id=self.Id.name 64 | ) 65 | 66 | @abstractmethod 67 | def apply( 68 | self, *args: Any, **kwargs: Any 69 | ) -> Iterator[django.core.checks.CheckMessage]: 70 | raise NotImplementedError() 71 | 72 | 73 | if TYPE_CHECKING: 74 | BaseCheckMixin = BaseCheck 75 | else: 76 | BaseCheckMixin = object 77 | -------------------------------------------------------------------------------- /src/extra_checks/checks/drf_serializer_checks.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import site 3 | from abc import abstractmethod 4 | from collections.abc import Iterable, Iterator 5 | from typing import ( 6 | TYPE_CHECKING, 7 | Any, 8 | Optional, 9 | Union, 10 | cast, 11 | ) 12 | 13 | import django.core.checks 14 | from rest_framework.serializers import ModelSerializer, Serializer 15 | 16 | from ..ast.protocols import DisableCommentProtocol 17 | from ..ast.source_provider import SourceProvider 18 | from ..check_id import CheckId 19 | from ..forms import AttrsForm 20 | from ..registry import ChecksConfig, registry 21 | from ..utils import collect_subclasses 22 | from .base_checks import BaseCheck 23 | 24 | if TYPE_CHECKING: 25 | cached_property = property 26 | else: 27 | from django.utils.functional import cached_property 28 | 29 | 30 | class DisableCommentProvider(DisableCommentProtocol): 31 | def __init__(self, serializer_class: type[Serializer]): 32 | self.serializer_class = serializer_class 33 | 34 | @cached_property 35 | def _source_provider(self) -> SourceProvider: 36 | return SourceProvider(self.serializer_class) 37 | 38 | def _get_line(self) -> Optional[int]: 39 | return 1 40 | 41 | def is_disabled_by_comment(self, check_id: str) -> bool: 42 | check = CheckId.find_check(check_id) 43 | line = self._get_line() 44 | return ( 45 | line is not None 46 | and check in self._source_provider.get_disabled_checks_for_line(line) 47 | ) 48 | 49 | 50 | class DisableMetaCommentProvider(DisableCommentProvider): 51 | def _get_line(self) -> Optional[int]: 52 | lines = (self._source_provider.source or "").splitlines() 53 | # find line starting with `class Meta` and lowest indent 54 | try: 55 | return sorted( 56 | ( 57 | (i, line) 58 | for i, line in enumerate(lines, 1) 59 | if line.strip().startswith(("class Meta(", "class Meta:")) 60 | ), 61 | key=lambda a: a[1].find("class Meta"), 62 | )[0][0] 63 | except (StopIteration, IndexError): 64 | return None 65 | 66 | 67 | def _filter_app_serializers( 68 | serializers: Iterable[type[Serializer]], 69 | include_apps: Optional[Iterable[str]] = None, 70 | ) -> Iterator[type[Serializer]]: 71 | site_prefixes = set(site.PREFIXES) 72 | if include_apps is not None: 73 | app_paths = { 74 | a.path for a in django.apps.apps.get_app_configs() if a.name in include_apps 75 | } 76 | for s in serializers: 77 | module = importlib.import_module(s.__module__) 78 | if any( 79 | module.__file__ and module.__file__.startswith(path) 80 | for path in app_paths 81 | ): 82 | yield s 83 | return 84 | for s in serializers: 85 | module = importlib.import_module(s.__module__) 86 | if not any( 87 | module.__file__ and module.__file__.startswith(path) 88 | for path in site_prefixes 89 | ): 90 | yield s 91 | 92 | 93 | def _get_serializers_to_check( 94 | include_apps: Optional[Iterable[str]] = None, 95 | ) -> tuple[Iterator[type[Serializer]], Iterator[type[ModelSerializer]]]: 96 | serializer_classes = _filter_app_serializers( 97 | collect_subclasses( 98 | s for s in Serializer.__subclasses__() if s is not ModelSerializer 99 | ), 100 | include_apps, 101 | ) 102 | model_serializer_classes = _filter_app_serializers( 103 | collect_subclasses(ModelSerializer.__subclasses__()), include_apps 104 | ) 105 | return ( 106 | serializer_classes, 107 | cast(Iterator[type[ModelSerializer]], model_serializer_classes), 108 | ) 109 | 110 | 111 | @registry.add_handler("extra_checks_drf_serializer") 112 | def check_drf_serializers( 113 | checks: Iterable[ 114 | Union[ 115 | "CheckDRFSerializer", 116 | "CheckDRFModelSerializer", 117 | "CheckDRFModelSerializerMeta", 118 | ] 119 | ], 120 | config: ChecksConfig, 121 | app_configs: Optional[list[Any]] = None, 122 | **kwargs: Any, 123 | ) -> Iterator[Any]: 124 | model_serializer_checks = [] 125 | model_meta_serializer_checks = [] 126 | serializer_checks = [] 127 | for check in checks: 128 | if isinstance(check, CheckDRFModelSerializerMeta): 129 | model_meta_serializer_checks.append(check) 130 | elif isinstance(check, CheckDRFModelSerializer): 131 | model_serializer_checks.append(check) 132 | else: 133 | serializer_checks.append(check) 134 | s_classes, m_classes = _get_serializers_to_check(config.include_apps) 135 | for s in s_classes: 136 | comment_provider = DisableCommentProvider(s) 137 | for check in serializer_checks: 138 | yield from check(s, comment_provider) 139 | for s in m_classes: 140 | comment_provider = DisableCommentProvider(s) 141 | for check in model_serializer_checks: 142 | yield from check(s, comment_provider) 143 | comment_provider = DisableMetaCommentProvider(s) 144 | for check in model_meta_serializer_checks: 145 | yield from check(s, comment_provider) 146 | 147 | 148 | class CheckDRFSerializer(BaseCheck): 149 | @abstractmethod 150 | def apply( 151 | self, serializer: Serializer, **kwargs: Any 152 | ) -> Iterator[django.core.checks.CheckMessage]: 153 | raise NotImplementedError() 154 | 155 | 156 | class CheckDRFModelSerializerMeta(BaseCheck): 157 | @abstractmethod 158 | def apply( 159 | self, serializer: ModelSerializer, **kwargs: Any 160 | ) -> Iterator[django.core.checks.CheckMessage]: 161 | raise NotImplementedError() 162 | 163 | 164 | class CheckDRFModelSerializer(BaseCheck): 165 | @abstractmethod 166 | def apply( 167 | self, serializer: ModelSerializer, **kwargs: Any 168 | ) -> Iterator[django.core.checks.CheckMessage]: 169 | raise NotImplementedError() 170 | 171 | 172 | @registry.register("extra_checks_drf_serializer") 173 | class CheckDRFSerializerExtraKwargs(CheckDRFModelSerializerMeta): 174 | Id = CheckId.X301 175 | level = django.core.checks.ERROR 176 | 177 | def apply( 178 | self, serializer: ModelSerializer, **kwargs: Any 179 | ) -> Iterator[django.core.checks.CheckMessage]: 180 | if not hasattr(serializer, "Meta") or not hasattr( 181 | serializer.Meta, "extra_kwargs" 182 | ): 183 | return 184 | invalid = serializer.Meta.extra_kwargs.keys() & serializer._declared_fields 185 | if invalid: 186 | yield self.message( 187 | "extra_kwargs mustn't include fields that declared on serializer.", 188 | hint=f"Remove extra_kwargs for fields: {', '.join(invalid)}", 189 | obj=serializer, 190 | ) 191 | 192 | 193 | @registry.register("extra_checks_drf_serializer") 194 | class CheckDRFSerializerMetaAttribute(CheckDRFModelSerializerMeta): 195 | Id = CheckId.X302 196 | settings_form_class = AttrsForm 197 | 198 | def __init__(self, attrs: list[str], **kwargs: Any) -> None: 199 | self.attrs = attrs 200 | super().__init__(**kwargs) 201 | 202 | def apply( 203 | self, serializer: ModelSerializer, **kwargs: Any 204 | ) -> Iterator[django.core.checks.CheckMessage]: 205 | meta = getattr(serializer, "Meta", None) 206 | for attr in self.attrs: 207 | if not hasattr(meta, attr): 208 | yield self.message( 209 | f"ModelSerializer must define `{attr}` in Meta.", 210 | hint=f"Add `{attr}` to serializer's Meta.", 211 | obj=serializer, 212 | ) 213 | -------------------------------------------------------------------------------- /src/extra_checks/checks/model_checks.py: -------------------------------------------------------------------------------- 1 | import site 2 | from abc import abstractmethod 3 | from collections.abc import Iterable, Iterator 4 | from typing import TYPE_CHECKING, Any, Optional, Union 5 | 6 | import django.core.checks 7 | from django import forms 8 | from django.apps import apps 9 | from django.contrib.admin.sites import all_sites 10 | from django.db import models 11 | from django.db.models.options import DEFAULT_NAMES as META_ATTRS 12 | 13 | from .. import CheckId 14 | from ..ast import ModelASTProtocol, get_model_ast 15 | from ..forms import AttrsForm, BaseCheckForm 16 | from ..registry import ChecksConfig, registry 17 | from .base_checks import BaseCheck 18 | 19 | if TYPE_CHECKING: 20 | from .model_field_checks import CheckModelField 21 | 22 | 23 | def _get_models_to_check( 24 | *, 25 | app_configs: Optional[list[Any]] = None, 26 | include_apps: Optional[Iterable[str]] = None, 27 | ) -> Iterator[type[models.Model]]: 28 | apps = django.apps.apps.get_app_configs() if app_configs is None else app_configs 29 | if include_apps is not None: 30 | for app in apps: 31 | if app.name in include_apps: 32 | yield from app.get_models() 33 | return 34 | for app in apps: 35 | if not any(app.path.startswith(path) for path in set(site.PREFIXES)): 36 | yield from app.get_models() 37 | 38 | 39 | @registry.add_handler(django.core.checks.Tags.models) 40 | def check_models( 41 | checks: Iterable[Union["CheckModel", "CheckModelField", "CheckModelMeta"]], 42 | config: ChecksConfig, 43 | app_configs: Optional[list[Any]] = None, 44 | **kwargs: Any, 45 | ) -> Iterator[Any]: 46 | model_checks: list[Union[CheckModel, CheckModelMeta]] = [] 47 | field_checks = [] 48 | meta_checks = [] 49 | for check in checks: 50 | if isinstance(check, CheckModelMeta): 51 | meta_checks.append(check) 52 | model_checks.append(check) 53 | elif isinstance(check, CheckModel): 54 | model_checks.append(check) 55 | else: 56 | field_checks.append(check) 57 | if not model_checks and not field_checks: 58 | return 59 | for model in _get_models_to_check( 60 | app_configs=app_configs, include_apps=config.include_apps 61 | ): 62 | model_ast = get_model_ast(model, [c.Id for c in meta_checks]) 63 | for check in model_checks: 64 | yield from check(model, ast=model_ast) 65 | if field_checks: 66 | for field, field_ast in model_ast.field_nodes: 67 | for check in field_checks: 68 | yield from check(field, ast=field_ast, model=model) 69 | 70 | 71 | class CheckModel(BaseCheck): 72 | @abstractmethod 73 | def apply( 74 | self, model: type[models.Model], ast: ModelASTProtocol 75 | ) -> Iterator[django.core.checks.CheckMessage]: 76 | raise NotImplementedError() 77 | 78 | 79 | class CheckModelMeta(BaseCheck): 80 | @abstractmethod 81 | def apply( 82 | self, model: type[models.Model], ast: ModelASTProtocol 83 | ) -> Iterator[django.core.checks.CheckMessage]: 84 | raise NotImplementedError() 85 | 86 | 87 | @registry.register(django.core.checks.Tags.models) 88 | class CheckModelAttribute(CheckModel): 89 | Id = CheckId.X010 90 | settings_form_class = AttrsForm 91 | 92 | def __init__(self, attrs: list[str], **kwargs: Any) -> None: 93 | self.attrs = attrs 94 | super().__init__(**kwargs) 95 | 96 | def apply( 97 | self, model: type[models.Model], ast: ModelASTProtocol 98 | ) -> Iterator[django.core.checks.CheckMessage]: 99 | for attr in self.attrs: 100 | if ( 101 | not model._meta.abstract 102 | and not model._meta.proxy 103 | and not hasattr(model, attr) 104 | ): 105 | yield self.message( 106 | f'Each model must specify "{attr}" attribute.', 107 | hint=f'Set "{attr}" attribute.', 108 | obj=model, 109 | ) 110 | 111 | 112 | @registry.register(django.core.checks.Tags.models) 113 | class CheckModelMetaAttribute(CheckModelMeta): 114 | Id = CheckId.X011 115 | 116 | class MetaAttrsForm(BaseCheckForm): 117 | attrs = forms.MultipleChoiceField(choices=[(o, o) for o in META_ATTRS]) 118 | 119 | settings_form_class = MetaAttrsForm 120 | 121 | def __init__(self, attrs: list[str], **kwargs: Any) -> None: 122 | self.attrs = attrs 123 | super().__init__(**kwargs) 124 | 125 | def apply( 126 | self, model: type[models.Model], ast: ModelASTProtocol 127 | ) -> Iterator[django.core.checks.CheckMessage]: 128 | for attr in self.attrs: 129 | if ( 130 | not model._meta.abstract 131 | and not model._meta.proxy 132 | and not ast.has_meta_var(attr) 133 | ): 134 | yield self.message( 135 | f'Each model must specify "{attr}" attribute in its Meta.', 136 | hint=f'Set "{attr}" attribute in Meta.', 137 | obj=model, 138 | ) 139 | 140 | 141 | @registry.register(django.core.checks.Tags.models) 142 | class CheckModelAdmin(CheckModel): 143 | Id = CheckId.X012 144 | 145 | class AdminForm(BaseCheckForm): 146 | def clean(self) -> dict: 147 | if not apps.is_installed("django.contrib.admin"): 148 | raise forms.ValidationError( 149 | "django.contrib.admin must be in INSTALLED_APPS." 150 | ) 151 | return super().clean() 152 | 153 | settings_form_class = AdminForm 154 | 155 | def __init__(self, **kwargs: Any) -> None: 156 | super().__init__(**kwargs) 157 | self.models_with_admin = set() 158 | for admin_site in all_sites: 159 | for model_cls, admin_cls in admin_site._registry.items(): 160 | self.models_with_admin.add(model_cls) 161 | for inline in admin_cls.inlines: 162 | self.models_with_admin.add(inline.model) 163 | 164 | def apply( 165 | self, model: type[models.Model], ast: ModelASTProtocol 166 | ) -> Iterator[django.core.checks.CheckMessage]: 167 | if model not in self.models_with_admin: 168 | yield self.message("The model is not registered in admin.", obj=model) 169 | 170 | 171 | @registry.register(django.core.checks.Tags.models) 172 | class CheckNoUniqueTogether(CheckModelMeta): 173 | Id = CheckId.X013 174 | 175 | def apply( 176 | self, model: type[models.Model], ast: ModelASTProtocol 177 | ) -> Iterator[django.core.checks.CheckMessage]: 178 | if ast.has_meta_var("unique_together"): 179 | yield self.message( 180 | "Use UniqueConstraint with the constraints option instead.", 181 | obj=model, 182 | ) 183 | -------------------------------------------------------------------------------- /src/extra_checks/checks/model_field_checks.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from collections.abc import Iterator 3 | from typing import Any, Optional 4 | 5 | import django 6 | import django.core.checks 7 | from django import forms 8 | from django.db import models 9 | 10 | from .. import CheckId 11 | from ..ast import FieldASTProtocol, MissingASTError 12 | from ..ast.protocols import DisableCommentProtocol 13 | from ..forms import BaseCheckForm 14 | from ..registry import registry 15 | from .base_checks import BaseCheck, BaseCheckMixin 16 | 17 | 18 | class CheckModelField(BaseCheck): 19 | @abstractmethod 20 | def apply( 21 | self, 22 | field: models.fields.Field, 23 | *, 24 | ast: FieldASTProtocol, 25 | model: type[models.Model], 26 | ) -> Iterator[django.core.checks.CheckMessage]: 27 | raise NotImplementedError() 28 | 29 | def __call__( 30 | self, obj: Any, ast: Optional[DisableCommentProtocol] = None, **kwargs: Any 31 | ) -> Iterator[django.core.checks.CheckMessage]: 32 | try: 33 | yield from super().__call__(obj, ast=ast, **kwargs) 34 | except MissingASTError: 35 | pass 36 | 37 | def is_ignored(self, obj: Any) -> bool: 38 | if self.skipif and self.skipif(obj): 39 | return True 40 | return obj.model in self.ignore_objects or type(obj) in self.ignore_types 41 | 42 | 43 | class GetTextMixin(BaseCheckMixin): 44 | class GettTextFuncForm(BaseCheckForm): 45 | gettext_func = forms.CharField(required=False) 46 | 47 | settings_form_class = GettTextFuncForm 48 | 49 | def __init__(self, gettext_func: str, **kwargs: Any) -> None: 50 | self.gettext_func = gettext_func or "_" 51 | super().__init__(**kwargs) 52 | 53 | 54 | @registry.register(django.core.checks.Tags.models) 55 | class CheckFieldVerboseName(CheckModelField): 56 | Id = CheckId.X050 57 | 58 | def apply( 59 | self, field: models.fields.Field, ast: FieldASTProtocol, **kwargs: Any 60 | ) -> Iterator[django.core.checks.CheckMessage]: 61 | if not ast.get_arg("verbose_name"): 62 | yield self.message( 63 | "Field has no verbose name.", 64 | hint="Set verbose name on the field.", 65 | obj=field, 66 | ) 67 | 68 | 69 | @registry.register(django.core.checks.Tags.models) 70 | class CheckFieldVerboseNameGettext(GetTextMixin, CheckModelField): 71 | Id = CheckId.X051 72 | 73 | def apply( 74 | self, field: models.fields.Field, ast: FieldASTProtocol, **kwargs: Any 75 | ) -> Iterator[django.core.checks.CheckMessage]: 76 | verbose_name = ast.get_arg("verbose_name") 77 | if verbose_name and not ( 78 | verbose_name.is_callable 79 | and verbose_name.callable_func_name == self.gettext_func 80 | ): 81 | yield self.message( 82 | "Verbose name should use gettext.", 83 | hint="Use gettext on the verbose name.", 84 | obj=field, 85 | ) 86 | 87 | 88 | @registry.register(django.core.checks.Tags.models) 89 | class CheckFieldVerboseNameGettextCase(GetTextMixin, CheckModelField): 90 | Id = CheckId.X052 91 | 92 | @classmethod 93 | def is_invalid(cls, value: object) -> bool: 94 | return bool( 95 | value 96 | and isinstance(value, str) 97 | and any(w != w.lower() and w != w.upper() for w in value.split(" ")) 98 | ) 99 | 100 | def apply( 101 | self, field: models.fields.Field, ast: FieldASTProtocol, **kwargs: Any 102 | ) -> Iterator[django.core.checks.CheckMessage]: 103 | verbose_name = ast.get_arg("verbose_name") 104 | if verbose_name and ( 105 | verbose_name.is_callable 106 | and verbose_name.callable_func_name == self.gettext_func 107 | ): 108 | value = verbose_name.get_call_first_args() 109 | if self.is_invalid(value): 110 | yield self.message( 111 | "Words in verbose name must be all upper case or all lower case.", 112 | hint=f'Change verbose name to "{value.lower()}".', 113 | obj=field, 114 | ) 115 | 116 | 117 | @registry.register(django.core.checks.Tags.models) 118 | class CheckFieldHelpTextGettext(GetTextMixin, CheckModelField): 119 | Id = CheckId.X053 120 | 121 | def apply( 122 | self, field: models.fields.Field, ast: FieldASTProtocol, **kwargs: Any 123 | ) -> Iterator[django.core.checks.CheckMessage]: 124 | help_text = ast.get_arg("help_text") 125 | if help_text and not ( 126 | help_text.is_callable and help_text.callable_func_name == self.gettext_func 127 | ): 128 | yield self.message( 129 | "Help text should use gettext.", 130 | hint="Use gettext on the help text.", 131 | obj=field, 132 | ) 133 | 134 | 135 | @registry.register(django.core.checks.Tags.models) 136 | class CheckFieldFileUploadTo(CheckModelField): 137 | Id = CheckId.X054 138 | 139 | def apply( 140 | self, field: models.fields.Field, **kwargs: Any 141 | ) -> Iterator[django.core.checks.CheckMessage]: 142 | if isinstance(field, models.FileField): 143 | if not field.upload_to: 144 | yield self.message( 145 | f'Field "{field.name}" must have non empty "upload_to" attribute.', 146 | hint='Set "upload_to" on the field.', 147 | obj=field, 148 | ) 149 | 150 | 151 | @registry.register(django.core.checks.Tags.models) 152 | class CheckFieldTextNull(CheckModelField): 153 | Id = CheckId.X055 154 | 155 | def apply( 156 | self, field: models.fields.Field, **kwargs: Any 157 | ) -> Iterator[django.core.checks.CheckMessage]: 158 | if isinstance(field, (models.CharField, models.TextField)): 159 | if field.null: 160 | yield self.message( 161 | f'Field "{field.name}" shouldn\'t use `null=True` ' 162 | "(django uses empty string for text fields).", 163 | hint="Remove `null=True` attribute from the field.", 164 | obj=field, 165 | ) 166 | 167 | 168 | @registry.register(django.core.checks.Tags.models) 169 | class CheckFieldNullFalse(CheckModelField): 170 | Id = CheckId.X057 171 | 172 | def apply( 173 | self, field: models.fields.Field, ast: FieldASTProtocol, **kwargs: Any 174 | ) -> Iterator[django.core.checks.CheckMessage]: 175 | if field.null is False and ast.get_arg("null"): 176 | yield self.message( 177 | "Argument `null=False` is default.", 178 | hint="Remove `null=False` from field arguments.", 179 | obj=field, 180 | ) 181 | 182 | 183 | @registry.register(django.core.checks.Tags.models) 184 | class CheckFieldForeignKeyIndex(CheckModelField): 185 | Id = CheckId.X058 186 | 187 | class CheckFieldForeignKeyIndexForm(BaseCheckForm): 188 | when = forms.ChoiceField( 189 | choices=[("indexes", "indexes"), ("always", "always")], 190 | required=False, 191 | ) 192 | 193 | settings_form_class = CheckFieldForeignKeyIndexForm 194 | 195 | def __init__(self, when: str, **kwargs: Any) -> None: 196 | self.when = when or "indexes" 197 | super().__init__(**kwargs) 198 | 199 | @classmethod 200 | def get_index_values_in_meta(cls, model: type[models.Model]) -> Iterator[str]: 201 | for entry in model._meta.unique_together: 202 | yield from entry 203 | if django.VERSION < (5, 1): 204 | for entry in model._meta.index_together: 205 | yield from entry 206 | for constraint in model._meta.constraints: 207 | if isinstance(constraint, models.UniqueConstraint): 208 | yield from constraint.fields 209 | for index in model._meta.indexes: 210 | yield from index.fields 211 | 212 | @classmethod 213 | def get_fields_with_indexes_in_meta( 214 | cls, model: type[models.Model] 215 | ) -> Iterator[str]: 216 | for entry in cls.get_index_values_in_meta(model): 217 | yield entry.lstrip("-") 218 | 219 | def apply( 220 | self, 221 | field: models.fields.Field, 222 | ast: FieldASTProtocol, 223 | model: type[models.Model], 224 | ) -> Iterator[django.core.checks.CheckMessage]: 225 | if isinstance(field, models.fields.related.RelatedField): 226 | if field.many_to_one and not ast.get_arg("db_index"): 227 | if self.when == "indexes": 228 | if field.name in self.get_fields_with_indexes_in_meta(model): 229 | yield self.message( 230 | "ForeignKey field must set `db_index` explicitly if it present in other indexes.", 231 | hint="Specify `db_index` field argument.", 232 | obj=field, 233 | ) 234 | else: 235 | yield self.message( 236 | "ForeignKey must set `db_index` explicitly.", 237 | hint="Specify `db_index` field argument.", 238 | obj=field, 239 | ) 240 | 241 | 242 | @registry.register(django.core.checks.Tags.models) 243 | class CheckFieldRelatedName(CheckModelField): 244 | Id = CheckId.X061 245 | 246 | def apply( 247 | self, 248 | field: models.fields.Field, 249 | ast: FieldASTProtocol, 250 | model: type[models.Model], 251 | ) -> Iterator[django.core.checks.CheckMessage]: 252 | if isinstance(field, models.fields.related.RelatedField): 253 | if not field.remote_field.related_name: 254 | yield self.message( 255 | "Related fields must set `related_name` explicitly.", 256 | hint="Specify `related_name` field argument. Use `related_name='+'` to not create a backwards relation.", 257 | obj=field, 258 | ) 259 | 260 | 261 | @registry.register(django.core.checks.Tags.models) 262 | class CheckFieldDefaultNull(CheckModelField): 263 | Id = CheckId.X059 264 | 265 | def apply( 266 | self, field: models.fields.Field, ast: FieldASTProtocol, **kwargs: Any 267 | ) -> Iterator[django.core.checks.CheckMessage]: 268 | if field.null and field.default is None and ast.get_arg("default"): 269 | yield self.message( 270 | "Argument `default=None` is redundant if `null=True` is set. (see docs about exceptions).", 271 | hint="Remove `default=None` from field arguments.", 272 | obj=field, 273 | ) 274 | 275 | 276 | @registry.register(django.core.checks.Tags.models) 277 | class CheckFieldChoicesConstraint(CheckModelField): 278 | Id = CheckId.X060 279 | 280 | @staticmethod 281 | def _repr_choice(value: Any) -> str: 282 | if isinstance(value, str): 283 | return f'"{value}"' 284 | return str(value) 285 | 286 | def apply( 287 | self, 288 | field: models.fields.Field, 289 | ast: FieldASTProtocol, 290 | model: type[models.Model], 291 | ) -> Iterator[django.core.checks.CheckMessage]: 292 | choices = field.flatchoices 293 | if choices: 294 | field_choices = [c[0] for c in choices] 295 | if field.empty_strings_allowed and field.blank and "" not in field_choices: 296 | field_choices.append("") 297 | in_name = f"{field.name}__in" 298 | for constraint in model._meta.constraints: 299 | if isinstance(constraint, models.CheckConstraint): 300 | condition = ( 301 | constraint.check 302 | if django.VERSION < (5, 1) 303 | else constraint.condition 304 | ) 305 | if not isinstance(condition, models.Q): 306 | continue 307 | for entry in condition.children: 308 | if ( 309 | isinstance(entry, tuple) 310 | and entry[0] == in_name 311 | and set(field_choices) == set(entry[1]) 312 | ): 313 | return 314 | check = f"models.Q({in_name}=[{', '.join([self._repr_choice(c) for c in field_choices])}])" 315 | arg_name = "condition" if django.VERSION >= (5, 1) else "check" 316 | yield self.message( 317 | "Field with choices must have companion CheckConstraint to enforce choices on database level.", 318 | hint=f'Add to Meta.constraints: `models.CheckConstraint(name="%(app_label)s_%(class)s_{field.name}_valid", {arg_name}={check})`', 319 | obj=field, 320 | ) 321 | -------------------------------------------------------------------------------- /src/extra_checks/checks/self_checks.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Iterator 2 | from typing import Any, Optional 3 | 4 | import django.core.checks 5 | 6 | from .. import CheckId 7 | from ..registry import ChecksConfig, registry 8 | from .base_checks import BaseCheck 9 | 10 | 11 | @registry.add_handler("extra_checks_selfcheck") 12 | def check_extra_checks_health( 13 | checks: Iterable["CheckConfig"], 14 | config: ChecksConfig, 15 | app_configs: Optional[list[Any]] = None, 16 | **kwargs: Any, 17 | ) -> Iterator[django.core.checks.CheckMessage]: 18 | for check in checks: 19 | yield from check(config, None) 20 | 21 | 22 | def dict_to_text(data: dict, indent_level: int = 0) -> str: 23 | output = [] 24 | for field, errors in data.items(): 25 | if not errors: 26 | continue 27 | output.append(f"{' ' * indent_level * 2}* {field}") 28 | if isinstance(errors, dict): 29 | output.append(dict_to_text(errors, indent_level + 1)) 30 | else: 31 | output.append( 32 | "\n".join(f"{' ' * (indent_level * 2 + 2)}* {e}" for e in errors) 33 | ) 34 | return "\n".join(output) 35 | 36 | 37 | @registry.register("extra_checks_selfcheck") 38 | class CheckConfig(BaseCheck): 39 | Id = CheckId.X001 40 | level = django.core.checks.CRITICAL 41 | 42 | def apply( 43 | self, obj: ChecksConfig, **kwargs: Any 44 | ) -> Iterator[django.core.checks.CheckMessage]: 45 | if obj.errors: 46 | yield self.message( 47 | "Invalid EXTRA_CHECKS config.", 48 | hint="Fix EXTRA_CHECKS in your settings. Errors:\n" 49 | + (dict_to_text(obj.errors) if obj.errors else "No details."), 50 | ) 51 | -------------------------------------------------------------------------------- /src/extra_checks/forms.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import django.core.checks 4 | from django import forms 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from . import CheckId 8 | 9 | 10 | class ListField(forms.Field): 11 | default_error_messages = { 12 | "invalid_list": _("Enter a list of values."), 13 | } 14 | 15 | def __init__(self, base_field: forms.Field, **kwargs: typing.Any) -> None: 16 | self.base_field = base_field 17 | super().__init__(**kwargs) 18 | 19 | def to_python(self, value: typing.Any) -> list: 20 | if not value: 21 | return [] 22 | if not isinstance(value, (list, tuple)): 23 | raise forms.ValidationError( 24 | self.error_messages["invalid_list"], code="invalid_list" 25 | ) 26 | return [self.base_field.to_python(val) for val in value] 27 | 28 | def validate(self, value: list) -> None: 29 | if self.required and not value: 30 | raise forms.ValidationError( 31 | self.error_messages["required"], code="required" 32 | ) 33 | for val in value: 34 | self.base_field.validate(val) 35 | 36 | 37 | class FilterField(forms.Field): 38 | default_error_messages = { 39 | "invalid_callable": _("%(value)s is not valid callable for skipif."), 40 | } 41 | 42 | def to_python(self, value: typing.Any) -> typing.Optional[typing.Callable]: 43 | if not value: 44 | return None 45 | if not callable(value): 46 | raise forms.ValidationError( 47 | self.error_messages["invalid_callable"], code="invalid_callable" 48 | ) 49 | return value 50 | 51 | 52 | class UnionField(forms.Field): 53 | default_error_messages = { 54 | "type_invalid": _("%(value)s is not one of the available types."), 55 | } 56 | 57 | def __init__( 58 | self, base_fields: dict[typing.Any, forms.Field], **kwargs: typing.Any 59 | ) -> None: 60 | assert isinstance(base_fields, dict) 61 | self.base_fields = base_fields 62 | super().__init__(**kwargs) 63 | 64 | def to_python(self, value: typing.Any) -> typing.Any: 65 | for type_, field in self.base_fields.items(): 66 | if isinstance(value, type_): 67 | return field.to_python(value) 68 | raise forms.ValidationError( 69 | self.error_messages["type_invalid"], 70 | code="type_invalid", 71 | params={"value": value}, 72 | ) 73 | 74 | def validate(self, value: typing.Any) -> None: 75 | for type_, field in self.base_fields.items(): 76 | if isinstance(value, type_): 77 | field.validate(value) 78 | return 79 | 80 | 81 | class DictField(forms.ChoiceField): 82 | default_error_messages = { 83 | "invalid_choice": _("ID %(value)s is not one of the available checks."), 84 | "invalid_dict": _("Must be a dict."), 85 | "id_required": _("`id` field is required."), 86 | } 87 | 88 | def __init__(self, id_choices: list[tuple[str, str]], **kwargs: typing.Any) -> None: 89 | super().__init__(choices=id_choices, **kwargs) 90 | 91 | def to_python(self, value: typing.Any) -> dict: 92 | if not value: 93 | return {} 94 | if not isinstance(value, dict): 95 | raise forms.ValidationError( 96 | self.error_messages["invalid_dict"], 97 | code="invalid_dict", 98 | ) 99 | return {str(k): v for k, v in value.items()} 100 | 101 | def validate(self, value: dict) -> None: 102 | if self.required and not value: 103 | raise forms.ValidationError( 104 | self.error_messages["required"], code="required" 105 | ) 106 | if "id" not in value: 107 | raise forms.ValidationError( 108 | self.error_messages["id_required"], code="id_required" 109 | ) 110 | if not self.valid_value(value["id"]): 111 | raise forms.ValidationError( 112 | self.error_messages["invalid_choice"], 113 | code="invalid_choice", 114 | params={"value": value["id"]}, 115 | ) 116 | 117 | 118 | class ConfigForm(forms.Form): 119 | errors: dict # type: ignore [assignment] 120 | include_apps = ListField(forms.CharField(), required=False) 121 | level = forms.ChoiceField( 122 | choices=[(c, c) for c in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]], 123 | required=False, 124 | ) 125 | checks = ListField( 126 | UnionField( 127 | { 128 | str: forms.ChoiceField( 129 | choices=[(v.value, v.value) for v in CheckId.__members__.values()], 130 | error_messages={ 131 | "invalid_choice": _( 132 | "%(value)s is not one of the available checks." 133 | ), 134 | }, 135 | ), 136 | dict: DictField( 137 | id_choices=[ 138 | (v.value, v.value) for v in CheckId.__members__.values() 139 | ] 140 | ), 141 | } 142 | ), 143 | required=False, 144 | ) 145 | 146 | def clean_checks(self) -> dict[str, dict]: 147 | result: dict[str, dict] = {} 148 | for check in self.cleaned_data["checks"]: 149 | if isinstance(check, str): 150 | result[check] = {} 151 | else: 152 | result[check["id"]] = check 153 | return result 154 | 155 | def clean(self) -> dict[str, typing.Any]: 156 | if ( 157 | "include_apps" in self.cleaned_data 158 | and not self.cleaned_data["include_apps"] 159 | and "include_apps" not in self.data 160 | ): 161 | del self.cleaned_data["include_apps"] 162 | if "level" in self.cleaned_data and "checks" in self.cleaned_data: 163 | for check in self.cleaned_data["checks"].values(): 164 | check.setdefault("level", self.cleaned_data["level"]) 165 | del self.cleaned_data["level"] 166 | return self.cleaned_data 167 | 168 | def is_valid( # type: ignore 169 | self, check_forms: dict[CheckId, "type[BaseCheckForm]"] 170 | ) -> bool: 171 | if not super().is_valid(): 172 | return False 173 | checks = self.cleaned_data.get("checks", {}) 174 | rforms = { 175 | name: check_forms[name](data=value) 176 | for name, value in checks.items() 177 | if name in check_forms 178 | } 179 | if forms.all_valid(rforms.values()): # type: ignore 180 | self.cleaned_data["checks"] = { 181 | name: f.cleaned_data for name, f in rforms.items() 182 | } 183 | return True 184 | self.errors["checks"] = {name: form.errors for name, form in rforms.items()} 185 | return False 186 | 187 | 188 | class BaseCheckForm(forms.Form): 189 | level = forms.ChoiceField( 190 | choices=[(c, c) for c in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]], 191 | required=False, 192 | ) 193 | skipif = FilterField(required=False) 194 | 195 | def clean_level(self) -> typing.Optional[int]: 196 | if self.cleaned_data["level"]: 197 | return getattr(django.core.checks, self.cleaned_data["level"]) 198 | return None 199 | 200 | def clean(self) -> dict[str, typing.Any]: 201 | if "skipif" in self.cleaned_data and not self.cleaned_data["skipif"]: 202 | del self.cleaned_data["skipif"] 203 | return self.cleaned_data 204 | 205 | 206 | class AttrsForm(BaseCheckForm): 207 | attrs = ListField(forms.CharField()) 208 | -------------------------------------------------------------------------------- /src/extra_checks/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalekseev/django-extra-checks/e7284289cab04be5703d00bac1689a51e5a80243/src/extra_checks/py.typed -------------------------------------------------------------------------------- /src/extra_checks/registry.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Iterator, Sequence 2 | from functools import partial 3 | from typing import ( 4 | TYPE_CHECKING, 5 | Any, 6 | Callable, 7 | Optional, 8 | Union, 9 | ) 10 | 11 | import django.apps 12 | import django.core.checks 13 | from django.conf import settings 14 | 15 | from . import CheckId 16 | from .forms import ConfigForm 17 | 18 | if TYPE_CHECKING: 19 | from .checks import BaseCheck 20 | 21 | 22 | class ChecksConfig: 23 | def __init__( 24 | self, 25 | *, 26 | errors: Optional[dict] = None, 27 | checks: Optional[dict[CheckId, dict]] = None, 28 | include_apps: Optional[Iterable[str]] = None, 29 | ignored_objects: Optional[dict[CheckId, set[Any]]] = None, 30 | ) -> None: 31 | self.checks: dict[CheckId, dict] = {**(checks or {}), CheckId.X001: {}} 32 | self.include_apps = include_apps 33 | self.errors = errors 34 | self.ignored_objects: dict[CheckId, set] = ignored_objects or {} 35 | 36 | @classmethod 37 | def create( 38 | cls, 39 | include_checks: dict["type[BaseCheck]", Sequence[str]], 40 | ignore_checks: Optional[dict[Any, set[Union[str, CheckId]]]] = None, 41 | ) -> "ChecksConfig": 42 | check_forms = {r.Id: r.settings_form_class for r in include_checks} 43 | if not hasattr(settings, "EXTRA_CHECKS"): 44 | return cls() 45 | form = ConfigForm(settings.EXTRA_CHECKS) 46 | if not form.is_valid(check_forms): 47 | return cls(errors=form.errors) 48 | ignored, errors = ChecksConfig._build_ignored(ignore_checks or {}) 49 | if errors: 50 | return cls(errors={"__all__": errors}) 51 | return cls(ignored_objects=ignored, **form.cleaned_data) 52 | 53 | @staticmethod 54 | def _build_ignored( 55 | ignore_checks: dict[Any, set[Union[str, CheckId]]], 56 | ) -> tuple[dict[CheckId, set[Any]], list[str]]: 57 | errors = [] 58 | ignored: dict[CheckId, set] = {} 59 | for obj, ids in ignore_checks.items(): 60 | for id_ in ids: 61 | check_id = CheckId.find_check(id_) 62 | if check_id: 63 | ignored.setdefault(check_id, set()).add(obj) 64 | else: 65 | errors.append( 66 | f"Unknown check ({id_}) provided to the 'ignore_checks'." 67 | ) 68 | return ignored, errors 69 | 70 | 71 | _ChecksHandler = Callable[[Optional[list[Any]], Any], Iterator[Any]] 72 | 73 | 74 | class Registry: 75 | def __init__(self) -> None: 76 | self.registered_checks: dict[type[BaseCheck], Sequence[str]] = {} 77 | self.enabled_checks: dict[str, list[BaseCheck]] = {} 78 | self.ignored_checks: dict[Any, set[Union[CheckId, str]]] = {} 79 | self.handlers: dict[str, _ChecksHandler] = {} 80 | self._config: Optional[ChecksConfig] = None 81 | 82 | def _register( 83 | self, tags: Sequence[str], check_class: "type[BaseCheck]" 84 | ) -> "type[BaseCheck]": 85 | self.registered_checks[check_class] = tags 86 | return check_class 87 | 88 | def _add_handler(self, tag: str, handler: _ChecksHandler) -> _ChecksHandler: 89 | self.handlers[tag] = handler 90 | return handler 91 | 92 | def _bind_handler( 93 | self, 94 | tag: str, 95 | handler: _ChecksHandler, 96 | checks: list["BaseCheck"], 97 | config: ChecksConfig, 98 | ) -> Optional[Callable]: 99 | if checks: 100 | f = partial(handler, checks, config) 101 | django.core.checks.register(f, tag) # pyright: ignore 102 | return f 103 | return None 104 | 105 | def register(self, *tags: str) -> Callable[["type[BaseCheck]"], "type[BaseCheck]"]: 106 | return partial(self._register, tags) 107 | 108 | def add_handler(self, tag: str) -> Callable[[Callable], Callable]: 109 | return partial(self._add_handler, tag) 110 | 111 | def bind(self) -> dict[str, Callable]: 112 | config = ChecksConfig.create(self.registered_checks, self.ignored_checks) 113 | for check_class, tags in self.registered_checks.items(): 114 | if check_class.Id in config.checks: 115 | check = check_class( 116 | ignore_objects=config.ignored_objects.get(check_class.Id, set()), 117 | **config.checks[check_class.Id], 118 | ) 119 | for tag in tags: 120 | self.enabled_checks.setdefault(tag, []).append(check) 121 | tag_handlers = {} 122 | for tag, handler in self.handlers.items(): 123 | bh = self._bind_handler( 124 | tag, handler, self.enabled_checks.get(tag, []), config 125 | ) 126 | if bh: 127 | tag_handlers[tag] = bh 128 | self._config = config 129 | return tag_handlers 130 | 131 | @property 132 | def is_healthy(self) -> bool: 133 | return True if self._config is None else not self._config.errors 134 | 135 | 136 | registry = Registry() 137 | -------------------------------------------------------------------------------- /src/extra_checks/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Iterator 2 | from typing import Optional, TypeVar 3 | 4 | TBase = TypeVar("TBase") 5 | 6 | 7 | def collect_subclasses( 8 | bases: Iterable[type[TBase]], 9 | visited: Optional[set[type[TBase]]] = None, 10 | ) -> Iterator[type[TBase]]: 11 | visited = visited or set() 12 | for cls in bases: 13 | if cls not in visited: 14 | visited.add(cls) 15 | yield from collect_subclasses(cls.__subclasses__(), visited) 16 | yield cls 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalekseev/django-extra-checks/e7284289cab04be5703d00bac1689a51e5a80243/tests/__init__.py -------------------------------------------------------------------------------- /tests/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalekseev/django-extra-checks/e7284289cab04be5703d00bac1689a51e5a80243/tests/example/__init__.py -------------------------------------------------------------------------------- /tests/example/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Article, Author 4 | 5 | 6 | class ArticleInline(admin.TabularInline): 7 | model = Article 8 | 9 | 10 | @admin.register(Author) 11 | class AuthorAdmin(admin.ModelAdmin): 12 | inlines = [ 13 | ArticleInline, 14 | ] 15 | -------------------------------------------------------------------------------- /tests/example/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ExampleConfig(AppConfig): 5 | name = "tests.example" 6 | -------------------------------------------------------------------------------- /tests/example/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalekseev/django-extra-checks/e7284289cab04be5703d00bac1689a51e5a80243/tests/example/migrations/__init__.py -------------------------------------------------------------------------------- /tests/example/models.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.contrib.sites.models import Site 5 | from django.db import models 6 | from django.utils.translation import gettext_lazy 7 | 8 | _ = gettext_lazy 9 | 10 | 11 | if True: 12 | # test that indentation doesn't break ast parser 13 | class Author(models.Model): 14 | first_name = models.CharField(max_length=100) 15 | last_name = models.CharField(max_length=100) 16 | 17 | 18 | class Article(models.Model): 19 | site = models.ForeignKey(Site, verbose_name="site", on_delete=models.CASCADE) 20 | title = models.CharField("article title", max_length=100) 21 | text = models.TextField(verbose_name="article text") 22 | author = models.ForeignKey( 23 | Author, related_name="articles", on_delete=models.CASCADE 24 | ) 25 | created = models.DateTimeField(auto_now=True) 26 | 27 | class Meta: 28 | verbose_name = "Site Article" 29 | get_latest_by = ["created"] 30 | 31 | 32 | class NestedField(models.Field): 33 | """Resembles fields like postgres ArrayField.""" 34 | 35 | def __init__(self, base_field, **kwargs): 36 | self.base_field = base_field 37 | super().__init__(**kwargs) 38 | 39 | 40 | class ModelFieldVerboseName(models.Model): 41 | first_arg_name = models.CharField("first arg name [test]", max_length=32) 42 | kwarg_name = models.CharField(verbose_name="kwarg name [test]", max_length=32) 43 | arg_gettext = models.CharField(_("arg name [test]"), max_length=32) 44 | kwargs_gettext = models.CharField( 45 | verbose_name=_("kwarg name [test]"), max_length=32 46 | ) 47 | gettext_case = models.CharField(verbose_name=_("Kwarg Name [test]"), max_length=32) 48 | gettext = models.CharField( 49 | verbose_name=gettext_lazy("kwarg name [test]"), max_length=32 50 | ) 51 | name_related = models.ForeignKey( 52 | Article, 53 | on_delete=models.CASCADE, 54 | related_name="+", 55 | verbose_name="name related test [X050]", 56 | ) 57 | nested_field = NestedField( 58 | models.CharField(max_length=100), verbose_name="nested field [X050]" 59 | ) 60 | no_name = models.CharField(max_length=32) 61 | no_name_related = models.ForeignKey( 62 | Article, on_delete=models.CASCADE, related_name="+" 63 | ) 64 | no_name_nested_field = NestedField(models.CharField(max_length=100)) 65 | 66 | 67 | class ModelFieldHelpTextGettext(models.Model): 68 | text = models.CharField(max_length=10, help_text=_("my help text")) 69 | text_fail = models.CharField(max_length=10, help_text="my help text") 70 | 71 | 72 | class ModelFieldFileUploadTo(models.Model): 73 | image = models.ImageField(upload_to="path/to/media") 74 | file = models.FileField(upload_to="path/to/files") 75 | image_fail = models.ImageField() 76 | file_fail = models.FileField() 77 | 78 | 79 | class CustomTextField(models.TextField): 80 | pass 81 | 82 | 83 | class ModelFieldTextNull(models.Model): 84 | text = models.TextField() 85 | chars = models.CharField(max_length=32) 86 | custom = CustomTextField() 87 | text_fail = models.TextField(null=True) 88 | chars_fail = models.CharField(null=True, max_length=32) 89 | custom_fail = CustomTextField(null=True) 90 | 91 | 92 | class ModelFieldNullFalse(models.Model): 93 | myfield = models.IntegerField() 94 | myfield_fail = models.IntegerField(null=False) 95 | null_fail = models.NullBooleanField() 96 | 97 | 98 | class ModelFieldNullDefault(models.Model): 99 | myfield = models.IntegerField(default=None) 100 | myfield_fail = models.IntegerField(null=True, default=None) 101 | 102 | 103 | class ModelFieldForeignKeyIndex(models.Model): 104 | article = models.ForeignKey( 105 | Article, on_delete=models.CASCADE, related_name="+", db_index=True 106 | ) 107 | author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="+") 108 | another_article = models.ForeignKey( 109 | Article, on_delete=models.CASCADE, related_name="+" 110 | ) 111 | another_author = models.ForeignKey( 112 | Article, on_delete=models.CASCADE, related_name="+", db_index=True 113 | ) 114 | field_one = models.ForeignKey( 115 | ModelFieldTextNull, on_delete=models.CASCADE, related_name="+" 116 | ) 117 | field_two = models.ForeignKey( 118 | ModelFieldNullFalse, on_delete=models.CASCADE, related_name="+", db_index=True 119 | ) 120 | field_three = models.ForeignKey( 121 | ModelFieldNullFalse, on_delete=models.CASCADE, related_name="+" 122 | ) 123 | field_in_indexes = models.ForeignKey( 124 | ModelFieldNullFalse, on_delete=models.CASCADE, related_name="+" 125 | ) 126 | field_index_desc = models.ForeignKey( 127 | ModelFieldNullFalse, on_delete=models.CASCADE, related_name="+" 128 | ) 129 | 130 | class Meta: 131 | unique_together = [("author", "article")] 132 | if django.VERSION < (5, 1): 133 | index_together = ("field_one", "field_two") 134 | constraints = [ 135 | models.UniqueConstraint( 136 | fields=("author", "field_three"), name="fi_author_field_unique" 137 | ) 138 | ] 139 | indexes = [ 140 | models.Index(fields=("field_in_indexes",)), 141 | models.Index(fields=("field_in_indexes", "-field_index_desc")), 142 | ] 143 | 144 | 145 | class GenericKeyOne(models.Model): 146 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 147 | object_id = models.PositiveIntegerField() 148 | service = GenericForeignKey("content_type", "object_id") 149 | 150 | 151 | class GenericKeyTwo(models.Model): 152 | ones = GenericRelation("GenericKeyOne") 153 | 154 | 155 | class ChoicesConstraint(models.Model): 156 | non_choice = models.IntegerField() 157 | empty = models.IntegerField(choices=[]) 158 | partial = models.CharField( 159 | choices=[("S", "simple"), ("C", "complex")], max_length=1 160 | ) 161 | missed = models.IntegerField(choices=[(1, "One"), (2, "Two")]) 162 | covered = models.CharField(choices=[("A", "a"), ("B", "b")]) 163 | blank = models.CharField(choices=[("A", "a"), ("B", "b")], blank=True) 164 | blank_missed = models.CharField(choices=[("A", "a"), ("B", "b")], blank=True) 165 | blank_included = models.CharField( 166 | choices=[("A", "a"), ("B", "b"), ("", "---")], blank=True 167 | ) 168 | grouped = models.IntegerField( 169 | choices=[("g1", ((1, "1"), (2, "2"))), ("g2", ((3, "3"),))] 170 | ) 171 | integer_blank = models.IntegerField( 172 | choices=[(1, "One"), (2, "Two")], blank=True, null=True 173 | ) 174 | integer_blank_invalid = models.IntegerField( 175 | choices=[(1, "One"), (2, "Two")], blank=True, null=True 176 | ) 177 | url = models.URLField(blank=True, null=True) 178 | 179 | class Meta: 180 | constraints = [ 181 | models.CheckConstraint( 182 | name="partial_valid", check=models.Q(name__in=["S"]) 183 | ), 184 | models.CheckConstraint( 185 | name="covered_valid", check=models.Q(covered__in=("A", "B")) 186 | ), 187 | models.CheckConstraint( 188 | name="blank_valid", check=models.Q(blank__in=("A", "B", "")) 189 | ), 190 | models.CheckConstraint( 191 | name="blank_missed_valid", check=models.Q(blank_missed__in=("A", "B")) 192 | ), 193 | models.CheckConstraint( 194 | name="grouped_valid", check=models.Q(grouped__in=(1, 2, 3)) 195 | ), 196 | models.CheckConstraint( 197 | name="integer_blank", check=models.Q(integer_blank__in=(1, 2)) 198 | ), 199 | models.CheckConstraint( 200 | name="integer_blank_invalid", 201 | check=models.Q(integer_blank_invalid__in=(1, 2, "")), 202 | ), 203 | models.CheckConstraint( 204 | name="unrelated_constraint", 205 | check=( 206 | models.Q(url=None) 207 | | ( 208 | ( 209 | models.Q(url__startswith="http://") 210 | | models.Q(url__startswith="https://") 211 | ) 212 | & models.Q(url__length__lte=2000) 213 | ) 214 | ), 215 | ), 216 | ] 217 | 218 | 219 | # model checks can be disabled by comment right before the model class 220 | # if your model is decorated than comment must be placed between 221 | # decorator and class definition. eg: 222 | # >>> @mydecorator 223 | # >>> # extra-checks-disable-next-line model-attribute 224 | # >>> class MyModel:... 225 | # 226 | # extra-checks-disable-next-line model-attribute 227 | class DisableCheckModel(models.Model): 228 | not_site = models.ForeignKey(Site, verbose_name="site", on_delete=models.CASCADE) 229 | # extra-checks-disable-next-line field-text-null 230 | text_fail = models.TextField(null=True) 231 | 232 | # extra-checks-disable-next-line no-unique-together 233 | class Meta: 234 | unique_together = ("text_fail", "no_site") 235 | 236 | 237 | class DisableManyChecksModel(models.Model): 238 | # disable two checks 239 | # extra-checks-disable-next-line field-text-null, field-verbose-name 240 | text_fail = models.TextField(null=True) 241 | # this disables all checks 242 | # extra-checks-disable-next-line 243 | text_fail2 = models.TextField(null=True) 244 | 245 | # disable two checks with separate comments 246 | # extra-checks-disable-next-line X014 247 | # extra-checks-disable-next-line no-unique-together 248 | class Meta: 249 | unique_together = ("text_fail", "text_fail2") 250 | -------------------------------------------------------------------------------- /tests/example/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Article, Author 4 | 5 | 6 | class ArticleSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Article 9 | read_only_fields: list = [] 10 | 11 | 12 | class InheritedArticleSerializer(ArticleSerializer): 13 | pass 14 | 15 | 16 | class AuthorSerializer(serializers.ModelSerializer): 17 | first_name = serializers.CharField() 18 | 19 | class Meta: 20 | model = Author 21 | extra_kwargs = {"first_name": {"read_only": True}} 22 | 23 | 24 | class InheritedAuthorSerializer(AuthorSerializer): 25 | pass 26 | 27 | 28 | class DisableCheckSerializer(serializers.ModelSerializer): 29 | first_name = serializers.CharField() 30 | 31 | def method_with_meta_class(self): 32 | class Meta: ... 33 | 34 | # extra-checks-disable-next-line drf-model-serializer-extra-kwargs 35 | class Meta: 36 | model = Author 37 | extra_kwargs = {"first_name": {"read_only": True}} 38 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from extra_checks import CheckId 2 | 3 | SECRET_KEY = "random" 4 | 5 | ALLOWED_HOSTS = ["*"] 6 | USE_TZ = True 7 | 8 | INSTALLED_APPS = [ 9 | "django.contrib.sites", 10 | "django.contrib.admin", 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "django.contrib.messages", 14 | "django.contrib.sessions", 15 | "tests.example", 16 | "extra_checks", 17 | ] 18 | 19 | EXTRA_CHECKS = { 20 | "include_apps": ["tests.example"], 21 | "level": "ERROR", 22 | "checks": [ 23 | # require non empty `upload_to` argument. 24 | "field-file-upload-to", 25 | # use dict form if check need configuration 26 | # eg. all models must have fk to Site model 27 | {"id": "model-attribute", "attrs": ["site"]}, 28 | # require `db_table` for all models, increase level to ERROR 29 | {"id": "model-meta-attribute", "attrs": ["db_table"], "level": "ERROR"}, 30 | {"id": "drf-model-serializer-meta-attribute", "attrs": ["read_only_fields"]}, 31 | ], 32 | } 33 | 34 | _checks = [c["id"] if isinstance(c, dict) else c for c in EXTRA_CHECKS["checks"]] 35 | EXTRA_CHECKS["checks"].extend(list(CheckId._value2member_map_.keys() - _checks)) # type: ignore 36 | 37 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}} 38 | TEMPLATES = [ 39 | { 40 | "BACKEND": "django.template.backends.django.DjangoTemplates", 41 | "OPTIONS": { 42 | "context_processors": [ 43 | "django.contrib.auth.context_processors.auth", 44 | "django.contrib.messages.context_processors.messages", 45 | ], 46 | }, 47 | } 48 | ] 49 | 50 | MIDDLEWARE = [ 51 | "django.contrib.auth.middleware.AuthenticationMiddleware", 52 | "django.contrib.messages.middleware.MessageMiddleware", 53 | "django.contrib.sessions.middleware.SessionMiddleware", 54 | ] 55 | 56 | ROOT_URLCONF = "tests.urls" 57 | 58 | SILENCED_SYSTEM_CHECKS = ["fields.E210"] 59 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from django.core import checks 2 | 3 | from extra_checks import CheckId 4 | from extra_checks.checks import ( 5 | CheckFieldFileUploadTo, 6 | CheckModelAttribute, 7 | CheckModelMetaAttribute, 8 | ) 9 | from extra_checks.forms import ConfigForm 10 | from extra_checks.registry import ChecksConfig 11 | from tests.example.models import Article, Author 12 | 13 | 14 | def test_config_form(): 15 | form = ConfigForm( 16 | data={ 17 | "checks": [ 18 | { 19 | "id": CheckModelAttribute.Id.value, 20 | "level": "WARNING", 21 | "attrs": ["db_table"], 22 | } 23 | ] 24 | } 25 | ) 26 | assert form.is_valid( 27 | {CheckModelAttribute.Id: CheckModelAttribute.settings_form_class} 28 | ) 29 | assert form.cleaned_data == { 30 | "checks": { 31 | CheckModelAttribute.Id.value: { 32 | "level": checks.WARNING, 33 | "attrs": ["db_table"], 34 | } 35 | }, 36 | } 37 | 38 | form = ConfigForm(data={"checks": [CheckFieldFileUploadTo.Id.value]}) 39 | assert form.is_valid( 40 | {CheckFieldFileUploadTo.Id: CheckFieldFileUploadTo.settings_form_class} 41 | ) 42 | assert form.cleaned_data == { 43 | "checks": {CheckFieldFileUploadTo.Id.value: {"level": None}}, 44 | } 45 | 46 | form = ConfigForm(data={}) 47 | assert form.is_valid({}) 48 | assert form.cleaned_data == {"checks": {}} 49 | 50 | form = ConfigForm( 51 | data={"checks": [{"id": CheckModelAttribute.Id.value, "attrs": ["db_table"]}]} 52 | ) 53 | assert form.is_valid( 54 | {CheckModelAttribute.Id: CheckModelAttribute.settings_form_class} 55 | ) 56 | assert form.cleaned_data == { 57 | "checks": { 58 | CheckModelAttribute.Id.value: {"level": None, "attrs": ["db_table"]} 59 | }, 60 | } 61 | 62 | form = ConfigForm(data={"checks": [{5}]}) 63 | assert not form.is_valid( 64 | {CheckModelAttribute.Id: CheckModelAttribute.settings_form_class} 65 | ) 66 | assert form.errors == { 67 | "checks": ["{5} is not one of the available types."], 68 | } 69 | 70 | form = ConfigForm(data={"checks": CheckModelAttribute.Id.value}) 71 | assert not form.is_valid( 72 | {CheckModelAttribute.Id: CheckModelAttribute.settings_form_class} 73 | ) 74 | assert form.errors == { 75 | "checks": ["Enter a list of values."], 76 | } 77 | 78 | form = ConfigForm(data={"checks": ["X000"]}) 79 | assert not form.is_valid( 80 | {CheckFieldFileUploadTo.Id: CheckFieldFileUploadTo.settings_form_class} 81 | ) 82 | assert form.errors == {"checks": ["X000 is not one of the available checks."]} 83 | 84 | form = ConfigForm( 85 | data={ 86 | "checks": [ 87 | { 88 | "id": CheckModelMetaAttribute.Id.value, 89 | "level": "FAKE", 90 | "attrs": ["random"], 91 | } 92 | ] 93 | } 94 | ) 95 | assert not form.is_valid( 96 | {CheckModelMetaAttribute.Id: CheckModelMetaAttribute.settings_form_class} 97 | ) 98 | assert form.errors == { 99 | "checks": { 100 | CheckModelMetaAttribute.Id.value: { 101 | "attrs": [ 102 | "Select a valid choice. random is not one of the available choices." 103 | ], 104 | "level": [ 105 | "Select a valid choice. FAKE is not one of the available choices." 106 | ], 107 | }, 108 | } 109 | } 110 | 111 | 112 | def test_config_include_apps(): 113 | form = ConfigForm(data={"checks": []}) 114 | assert form.is_valid({}) 115 | assert form.cleaned_data == {"checks": {}} 116 | 117 | form = ConfigForm(data={"include_apps": [], "checks": []}) 118 | assert form.is_valid({}) 119 | assert form.cleaned_data == {"include_apps": [], "checks": {}} 120 | 121 | form = ConfigForm(data={"include_apps": ["tests.example"], "checks": []}) 122 | assert form.is_valid({}) 123 | assert form.cleaned_data == {"include_apps": ["tests.example"], "checks": {}} 124 | 125 | 126 | def test_config_build_ignored(): 127 | ignored, errors = ChecksConfig._build_ignored( 128 | { 129 | Article: {"X010", "random-test-name"}, 130 | Author: {"model-meta-attribute", CheckId.X050}, 131 | } 132 | ) 133 | assert errors == [ 134 | "Unknown check (random-test-name) provided to the 'ignore_checks'." 135 | ] 136 | assert ignored == { 137 | CheckId.X010: {Article}, 138 | CheckId.X011: {Author}, 139 | CheckId.X050: {Author}, 140 | } 141 | -------------------------------------------------------------------------------- /tests/test_drf_serializer_checks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | try: 4 | import rest_framework.authtoken.serializers # import AuthTokenSerializer into the project 5 | import rest_framework.serializers 6 | except ImportError: 7 | pytest.skip("skipping rest_framework tests", allow_module_level=True) 8 | from extra_checks.checks.drf_serializer_checks import ( 9 | CheckDRFSerializerExtraKwargs, 10 | CheckDRFSerializerMetaAttribute, 11 | _get_serializers_to_check, 12 | check_drf_serializers, 13 | ) 14 | from tests.example.serializers import ( 15 | ArticleSerializer, 16 | AuthorSerializer, 17 | DisableCheckSerializer, 18 | InheritedArticleSerializer, 19 | InheritedAuthorSerializer, 20 | ) 21 | 22 | 23 | @pytest.fixture 24 | def test_case(test_case): 25 | return test_case.handler(check_drf_serializers) 26 | 27 | 28 | def test_model_serializer_extra_kwargs(test_case): 29 | messages = ( 30 | test_case.settings({"checks": [CheckDRFSerializerExtraKwargs.Id.value]}) 31 | .check(CheckDRFSerializerExtraKwargs) 32 | .serializers(ArticleSerializer) 33 | .run() 34 | ) 35 | assert not messages 36 | messages = test_case.serializers(AuthorSerializer).run() 37 | assert len(messages) == 1 38 | assert messages[0].id == CheckDRFSerializerExtraKwargs.Id.name 39 | 40 | 41 | def test_model_serializer_meta_attribute(test_case): 42 | test_case.settings( 43 | { 44 | "checks": [ 45 | { 46 | "id": CheckDRFSerializerMetaAttribute.Id.value, 47 | "attrs": ["read_only_fields"], 48 | } 49 | ] 50 | } 51 | ).check(CheckDRFSerializerMetaAttribute) 52 | 53 | messages = test_case.serializers(ArticleSerializer).run() 54 | assert not messages 55 | messages = test_case.serializers(InheritedArticleSerializer).run() 56 | assert not messages 57 | messages = test_case.serializers(AuthorSerializer).run() 58 | assert len(messages) == 1 59 | assert messages[0].id == CheckDRFSerializerMetaAttribute.Id.name 60 | messages = test_case.serializers(InheritedAuthorSerializer).run() 61 | assert len(messages) == 1 62 | assert messages[0].id == CheckDRFSerializerMetaAttribute.Id.name 63 | 64 | 65 | def test_get_serializers_to_check(): 66 | ss, ms = _get_serializers_to_check() 67 | module = "tests.example.serializers" 68 | assert all(s.__module__ == module for s in ss) 69 | assert all(m.__module__ == module for m in ms) 70 | 71 | 72 | def test_get_serializers_to_check_include_apps(settings): 73 | settings.INSTALLED_APPS += ["rest_framework.authtoken", "rest_framework"] 74 | ss, ms = (list(s) for s in _get_serializers_to_check(["rest_framework.authtoken"])) 75 | assert not ms 76 | assert len(ss) == 1 77 | assert ss[0] is rest_framework.authtoken.serializers.AuthTokenSerializer 78 | 79 | ss, ms = (list(s) for s in _get_serializers_to_check(["rest_framework"])) 80 | assert len(ms) == 1 81 | assert ms[0] is rest_framework.serializers.HyperlinkedModelSerializer 82 | # FIXME: the lines below actually a bug 83 | # include_apps must filter out rest_framework.authtoken app 84 | # but it's nested into rest_framework app that present in the 85 | # include_apps so it's included too 86 | assert len(ss) == 1 87 | assert ss[0] is rest_framework.authtoken.serializers.AuthTokenSerializer 88 | 89 | 90 | def test_drf_ignore_meta_checks(test_case): 91 | messages = ( 92 | test_case.settings({"checks": [CheckDRFSerializerExtraKwargs.Id.value]}) 93 | .check(CheckDRFSerializerExtraKwargs) 94 | .serializers(DisableCheckSerializer) 95 | .run() 96 | ) 97 | assert not messages 98 | -------------------------------------------------------------------------------- /tests/test_ignore.py: -------------------------------------------------------------------------------- 1 | import django.db.models 2 | import pytest 3 | 4 | from extra_checks.checks import model_checks, model_field_checks 5 | from tests.example import models 6 | 7 | 8 | @pytest.fixture 9 | def test_case(test_case): 10 | return test_case.handler(model_checks.check_models) 11 | 12 | 13 | def test_ignore_model_check(test_case): 14 | messages = ( 15 | test_case.models(models.DisableCheckModel) 16 | .settings( 17 | { 18 | "checks": [ 19 | { 20 | "id": model_checks.CheckModelAttribute.Id.value, 21 | "attrs": ["site"], 22 | }, 23 | model_field_checks.CheckFieldTextNull.Id.value, 24 | model_checks.CheckNoUniqueTogether.Id.value, 25 | ] 26 | } 27 | ) 28 | .check( 29 | model_checks.CheckModelAttribute, 30 | model_field_checks.CheckFieldTextNull, 31 | model_checks.CheckNoUniqueTogether, 32 | ) 33 | .run() 34 | ) 35 | assert not messages 36 | 37 | 38 | def test_ignore_many_model_check(test_case): 39 | messages = ( 40 | test_case.models(models.DisableManyChecksModel) 41 | .settings( 42 | { 43 | "checks": [ 44 | model_field_checks.CheckFieldTextNull.Id.value, 45 | model_field_checks.CheckFieldVerboseName.Id.value, 46 | model_checks.CheckNoUniqueTogether.Id.value, 47 | ] 48 | } 49 | ) 50 | .check( 51 | model_field_checks.CheckFieldTextNull, 52 | model_field_checks.CheckFieldVerboseName, 53 | model_checks.CheckNoUniqueTogether, 54 | ) 55 | .run() 56 | ) 57 | assert not messages 58 | 59 | 60 | def test_field_skipif(test_case): 61 | def skipif(field, *args, **kwargs): 62 | return isinstance(field, django.db.models.ImageField) 63 | 64 | messages = ( 65 | test_case.settings( 66 | { 67 | "checks": [ 68 | { 69 | "id": model_field_checks.CheckFieldFileUploadTo.Id.value, 70 | "skipif": skipif, 71 | } 72 | ], 73 | } 74 | ) 75 | .models(models.ModelFieldFileUploadTo) 76 | .check(model_field_checks.CheckFieldFileUploadTo) 77 | .run() 78 | ) 79 | assert len(messages) == 1 80 | assert {m.obj.name for m in messages} == {"file_fail"} 81 | 82 | 83 | def test_model_skipif(test_case): 84 | def skipif(model, *args, **kwargs): 85 | return not any( 86 | isinstance(f, django.db.models.DateTimeField) 87 | for f in model._meta.get_fields() 88 | ) 89 | 90 | messages = ( 91 | test_case.settings( 92 | { 93 | "checks": [ 94 | { 95 | "id": model_checks.CheckModelMetaAttribute.Id.value, 96 | "attrs": ["get_latest_by"], 97 | "skipif": skipif, 98 | }, 99 | ], 100 | } 101 | ) 102 | .models(models.Article, models.Author) 103 | .check(model_checks.CheckModelMetaAttribute) 104 | .run() 105 | ) 106 | assert len(messages) == 0 107 | -------------------------------------------------------------------------------- /tests/test_model_checks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from extra_checks.checks import model_checks 4 | from tests.example.models import ( 5 | Article, 6 | Author, 7 | ModelFieldForeignKeyIndex, 8 | ModelFieldTextNull, 9 | ) 10 | 11 | 12 | @pytest.fixture 13 | def test_case(test_case): 14 | return test_case.handler(model_checks.check_models) 15 | 16 | 17 | def test_get_models_to_check(): 18 | models = list(model_checks._get_models_to_check()) 19 | assert models 20 | for model in models: 21 | assert model._meta.app_label == "example" 22 | 23 | 24 | def test_get_models_to_check_include_apps(): 25 | models = list(model_checks._get_models_to_check(include_apps=[])) 26 | assert not models 27 | 28 | models = list( 29 | model_checks._get_models_to_check(include_apps=["django.contrib.sites"]) 30 | ) 31 | assert models 32 | for model in models: 33 | assert model._meta.app_label == "sites" 34 | 35 | 36 | def test_check_model_attrs(test_case): 37 | messages = ( 38 | test_case.models(Article) 39 | .settings( 40 | { 41 | "checks": [ 42 | {"id": model_checks.CheckModelAttribute.Id.value, "attrs": ["site"]} 43 | ] 44 | } 45 | ) 46 | .check(model_checks.CheckModelAttribute) 47 | .run() 48 | ) 49 | assert not messages 50 | messages = test_case.models(Author).run() 51 | assert len(messages) == 1 52 | assert messages[0].id == model_checks.CheckModelAttribute.Id.name 53 | 54 | 55 | def test_check_model_meta_attrs(test_case): 56 | messages = ( 57 | test_case.models(Article) 58 | .settings( 59 | { 60 | "checks": [ 61 | { 62 | "id": model_checks.CheckModelMetaAttribute.Id.value, 63 | "attrs": ["verbose_name"], 64 | } 65 | ] 66 | } 67 | ) 68 | .check(model_checks.CheckModelMetaAttribute) 69 | .run() 70 | ) 71 | assert not messages 72 | messages = test_case.models(Author).run() 73 | assert len(messages) == 1 74 | assert messages[0].id == model_checks.CheckModelMetaAttribute.Id.name 75 | 76 | 77 | def test_admin_models(test_case): 78 | messages = ( 79 | test_case.models(Article, Author) 80 | .settings({"checks": [model_checks.CheckModelAdmin.Id.value]}) 81 | .check(model_checks.CheckModelAdmin) 82 | .run() 83 | ) 84 | assert not messages 85 | messages = test_case.models(ModelFieldTextNull).run() 86 | assert len(messages) == 1 87 | assert messages[0].id == model_checks.CheckModelAdmin.Id.name 88 | 89 | 90 | def test_no_unique_together(test_case): 91 | messages = ( 92 | test_case.models(ModelFieldForeignKeyIndex) 93 | .settings({"checks": [model_checks.CheckNoUniqueTogether.Id.value]}) 94 | .check(model_checks.CheckNoUniqueTogether) 95 | .run() 96 | ) 97 | assert len(messages) == 1 98 | assert messages[0].id == model_checks.CheckNoUniqueTogether.Id.name 99 | -------------------------------------------------------------------------------- /tests/test_model_field_checks.py: -------------------------------------------------------------------------------- 1 | import django 2 | import pytest 3 | 4 | from extra_checks.checks import model_checks, model_field_checks 5 | from tests.example import models 6 | 7 | 8 | @pytest.fixture 9 | def test_case(test_case): 10 | return test_case.handler(model_checks.check_models) 11 | 12 | 13 | def test_check_field_verbose_name(test_case): 14 | messages = ( 15 | test_case.settings( 16 | {"checks": [model_field_checks.CheckFieldVerboseName.Id.value]} 17 | ) 18 | .models(models.ModelFieldVerboseName) 19 | .check(model_field_checks.CheckFieldVerboseName) 20 | .run() 21 | ) 22 | assert {m.obj.name for m in messages} == { 23 | "no_name", 24 | "no_name_related", 25 | "no_name_nested_field", 26 | } 27 | 28 | 29 | def test_check_field_verbose_name_gettext(test_case): 30 | messages = ( 31 | test_case.models(models.ModelFieldVerboseName) 32 | .settings( 33 | {"checks": [model_field_checks.CheckFieldVerboseNameGettext.Id.value]} 34 | ) 35 | .check(model_field_checks.CheckFieldVerboseNameGettext) 36 | .run() 37 | ) 38 | assert {m.obj.name for m in messages} == { 39 | "first_arg_name", 40 | "kwarg_name", 41 | "gettext", 42 | "name_related", 43 | "nested_field", 44 | } 45 | 46 | 47 | def test_check_field_verbose_name_gettext_case(test_case): 48 | messages = ( 49 | test_case.models(models.ModelFieldVerboseName) 50 | .settings( 51 | {"checks": [model_field_checks.CheckFieldVerboseNameGettextCase.Id.value]} 52 | ) 53 | .check(model_field_checks.CheckFieldVerboseNameGettextCase) 54 | .run() 55 | ) 56 | assert len(messages) == 1 57 | assert messages[0].id == model_field_checks.CheckFieldVerboseNameGettextCase.Id.name 58 | assert {m.obj.name for m in messages} == { 59 | "gettext_case", 60 | } 61 | 62 | 63 | def test_check_field_verbose_name_gettext_check_case(): 64 | is_invalid = model_field_checks.CheckFieldVerboseNameGettextCase.is_invalid 65 | assert is_invalid("Abc Def") 66 | assert not is_invalid("abc def") 67 | assert not is_invalid("ABC def") 68 | assert not is_invalid("ABC DEF") 69 | assert not is_invalid("abc123 ABC123") 70 | assert is_invalid("Abc123 AbC123") 71 | assert not is_invalid("abc / def") 72 | 73 | 74 | def test_check_field_help_text_gettext(test_case): 75 | messages = ( 76 | test_case.settings( 77 | {"checks": [model_field_checks.CheckFieldHelpTextGettext.Id.value]} 78 | ) 79 | .models(models.ModelFieldHelpTextGettext) 80 | .check(model_field_checks.CheckFieldHelpTextGettext) 81 | .run() 82 | ) 83 | assert len(messages) == 1 84 | assert messages[0].id == model_field_checks.CheckFieldHelpTextGettext.Id.name 85 | assert {m.obj.name for m in messages} == { 86 | "text_fail", 87 | } 88 | 89 | 90 | def test_check_field_file_upload_to(test_case): 91 | messages = ( 92 | test_case.models(models.ModelFieldFileUploadTo) 93 | .settings({"checks": [model_field_checks.CheckFieldFileUploadTo.Id.value]}) 94 | .check(model_field_checks.CheckFieldFileUploadTo) 95 | .run() 96 | ) 97 | assert {m.obj.name for m in messages} == { 98 | "image_fail", 99 | "file_fail", 100 | } 101 | 102 | 103 | def test_check_field_text_null(test_case): 104 | messages = ( 105 | test_case.settings({"checks": [model_field_checks.CheckFieldTextNull.Id.value]}) 106 | .models(models.ModelFieldTextNull) 107 | .check(model_field_checks.CheckFieldTextNull) 108 | .run() 109 | ) 110 | assert {m.obj.name for m in messages} == { 111 | "text_fail", 112 | "chars_fail", 113 | "custom_fail", 114 | } 115 | 116 | 117 | def test_check_field_null_false(test_case): 118 | messages = ( 119 | test_case.settings( 120 | {"checks": [model_field_checks.CheckFieldNullFalse.Id.value]} 121 | ) 122 | .models(models.ModelFieldNullFalse) 123 | .check(model_field_checks.CheckFieldNullFalse) 124 | .run() 125 | ) 126 | assert {m.obj.name for m in messages} == { 127 | "myfield_fail", 128 | } 129 | 130 | 131 | def test_check_field_foreign_key_index(test_case): 132 | messages = ( 133 | test_case.settings( 134 | {"checks": [model_field_checks.CheckFieldForeignKeyIndex.Id.value]} 135 | ) 136 | .models(models.ModelFieldForeignKeyIndex) 137 | .check(model_field_checks.CheckFieldForeignKeyIndex) 138 | .run() 139 | ) 140 | assert {m.obj.name for m in messages} == { 141 | *(["field_one"] if django.VERSION < (5, 1) else []), 142 | "author", 143 | "field_three", 144 | "field_in_indexes", 145 | "field_index_desc", 146 | } 147 | 148 | 149 | def test_check_field_foreign_key_index_always(test_case): 150 | settings = { 151 | "checks": [ 152 | { 153 | "id": model_field_checks.CheckFieldForeignKeyIndex.Id.value, 154 | "when": "always", 155 | } 156 | ] 157 | } 158 | messages = ( 159 | test_case.settings(settings) 160 | .models(models.ModelFieldForeignKeyIndex) 161 | .check(model_field_checks.CheckFieldForeignKeyIndex) 162 | .run() 163 | ) 164 | assert {m.obj.name for m in messages} == { 165 | "field_one", 166 | "author", 167 | "another_article", 168 | "field_three", 169 | "field_in_indexes", 170 | "field_index_desc", 171 | } 172 | 173 | 174 | def test_check_field_related_name(test_case): 175 | messages = ( 176 | test_case.settings( 177 | {"checks": [model_field_checks.CheckFieldRelatedName.Id.value]} 178 | ) 179 | .models(models.Article) 180 | .check(model_field_checks.CheckFieldRelatedName) 181 | .run() 182 | ) 183 | assert {m.obj.name for m in messages} == { 184 | "site", 185 | } 186 | 187 | 188 | def test_generic_key_null(test_case): 189 | """ensure custom fields without null attribute not checked by rule""" 190 | messages = ( 191 | test_case.settings( 192 | {"checks": [model_field_checks.CheckFieldNullFalse.Id.value]} 193 | ) 194 | .models(models.GenericKeyOne, models.GenericKeyTwo) 195 | .check(model_field_checks.CheckFieldNullFalse) 196 | .run() 197 | ) 198 | assert not messages 199 | 200 | 201 | def test_verbose_name_of_related_field(test_case): 202 | """if field inherit from RelatedField the first argument is not a verbose name.""" 203 | messages = ( 204 | test_case.settings( 205 | {"checks": [model_field_checks.CheckFieldVerboseNameGettext.Id.value]} 206 | ) 207 | .models(models.GenericKeyTwo) 208 | .check(model_field_checks.CheckFieldVerboseNameGettext) 209 | .run() 210 | ) 211 | assert not messages 212 | 213 | 214 | def test_field_null_default_null(test_case): 215 | messages = ( 216 | test_case.settings( 217 | {"checks": [model_field_checks.CheckFieldDefaultNull.Id.value]} 218 | ) 219 | .models(models.ModelFieldNullDefault) 220 | .check(model_field_checks.CheckFieldDefaultNull) 221 | .run() 222 | ) 223 | assert len(messages) == 1 224 | assert messages[0].obj.name == "myfield_fail" 225 | 226 | 227 | def test_field_choices_constraint(test_case): 228 | messages = ( 229 | test_case.settings( 230 | {"checks": [model_field_checks.CheckFieldChoicesConstraint.Id.value]} 231 | ) 232 | .models(models.ChoicesConstraint) 233 | .check(model_field_checks.CheckFieldChoicesConstraint) 234 | .run() 235 | ) 236 | errors = {m.obj.name: m for m in messages} 237 | assert errors.keys() == { 238 | "partial", 239 | "missed", 240 | "blank_missed", 241 | "blank_included", 242 | "integer_blank_invalid", 243 | } 244 | arg_name = "condition" if django.VERSION >= (5, 1) else "check" 245 | assert f'{arg_name}=models.Q(partial__in=["S", "C"]))' in errors["partial"].hint 246 | assert f"{arg_name}=models.Q(missed__in=[1, 2]))" in errors["missed"].hint 247 | assert ( 248 | f'{arg_name}=models.Q(blank_missed__in=["A", "B", ""])' 249 | in errors["blank_missed"].hint 250 | ) 251 | assert ( 252 | f'{arg_name}=models.Q(blank_included__in=["A", "B", ""])' 253 | in errors["blank_included"].hint 254 | ) 255 | assert ( 256 | f"{arg_name}=models.Q(integer_blank_invalid__in=[1, 2])" 257 | in errors["integer_blank_invalid"].hint 258 | ) 259 | -------------------------------------------------------------------------------- /tests/test_self_checks.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import django.core.checks 4 | import pytest 5 | 6 | from extra_checks.check_id import CheckId 7 | from extra_checks.checks import ( 8 | CheckConfig, 9 | CheckFieldFileUploadTo, 10 | CheckFieldForeignKeyIndex, 11 | check_extra_checks_health, 12 | ) 13 | from extra_checks.checks.base_checks import BaseCheck 14 | from extra_checks.registry import Registry 15 | from extra_checks.utils import collect_subclasses 16 | 17 | 18 | def test_empty_config(registry, settings): 19 | settings.EXTRA_CHECKS = {} 20 | registry.bind() 21 | assert registry.is_healthy 22 | assert len(registry.registered_checks) == 1 23 | assert len(registry.enabled_checks) == 1 24 | 25 | settings.EXTRA_CHECKS = {"checks": []} 26 | registry = Registry() 27 | registry._register(["extra_checks_selfcheck"], CheckConfig) 28 | registry.bind() 29 | assert registry.is_healthy 30 | assert len(registry.registered_checks) == 1 31 | assert len(registry.enabled_checks) == 1 32 | 33 | 34 | def test_error_formatting(registry, settings): 35 | settings.EXTRA_CHECKS = { 36 | "checks": [ 37 | CheckFieldFileUploadTo.Id.value, 38 | {"id": CheckFieldForeignKeyIndex.Id.value, "when": "random"}, 39 | ] 40 | } 41 | registry._register([django.core.checks.Tags.models], CheckFieldForeignKeyIndex) 42 | registry._register([django.core.checks.Tags.models], CheckFieldFileUploadTo) 43 | registry._add_handler("extra_checks_selfcheck", check_extra_checks_health) 44 | handlers = registry.bind() 45 | assert not registry.is_healthy 46 | messages = list(handlers["extra_checks_selfcheck"]()) 47 | assert len(messages) == 1 48 | assert ( 49 | messages[0].hint 50 | == textwrap.dedent( 51 | f""" 52 | Fix EXTRA_CHECKS in your settings. Errors: 53 | * checks 54 | * {CheckFieldForeignKeyIndex.Id.value} 55 | * when 56 | * Select a valid choice. random is not one of the available choices. 57 | """ 58 | ).strip() 59 | ) 60 | 61 | 62 | def test_unique_check_ids(): 63 | pytest.importorskip("rest_framework") 64 | used_checks = [ 65 | c.Id for c in collect_subclasses(BaseCheck.__subclasses__()) if hasattr(c, "Id") 66 | ] 67 | unused = CheckId._value2member_map_.keys() - set(used_checks) 68 | assert not unused, "Not all CheckIds used." 69 | dups = {c.value for c in used_checks if used_checks.count(c) > 1} 70 | assert not dups, "CheckIds must be unique per Check." 71 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from extra_checks.utils import collect_subclasses 2 | 3 | 4 | def test_collect_subclasses(): 5 | """test that we collect all descendants""" 6 | 7 | class Base: 8 | pass 9 | 10 | class One(Base): 11 | pass 12 | 13 | class Two(Base): 14 | pass 15 | 16 | class Three(One, Two): 17 | pass 18 | 19 | serializers = collect_subclasses(Base.__subclasses__()) 20 | assert set(serializers) == {One, Two, Three} 21 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("", views.index), 7 | ] 8 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def index(request): 5 | return render(request, "index.html") 6 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.9" 3 | resolution-markers = [ 4 | "python_full_version >= '3.10'", 5 | "python_full_version < '3.10'", 6 | ] 7 | 8 | [[package]] 9 | name = "asgiref" 10 | version = "3.8.1" 11 | source = { registry = "https://pypi.org/simple" } 12 | dependencies = [ 13 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 14 | ] 15 | sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } 16 | wheels = [ 17 | { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, 18 | ] 19 | 20 | [[package]] 21 | name = "attrs" 22 | version = "25.1.0" 23 | source = { registry = "https://pypi.org/simple" } 24 | sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562 } 25 | wheels = [ 26 | { url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152 }, 27 | ] 28 | 29 | [[package]] 30 | name = "certifi" 31 | version = "2025.1.31" 32 | source = { registry = "https://pypi.org/simple" } 33 | sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } 34 | wheels = [ 35 | { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, 36 | ] 37 | 38 | [[package]] 39 | name = "cfgv" 40 | version = "3.4.0" 41 | source = { registry = "https://pypi.org/simple" } 42 | sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } 43 | wheels = [ 44 | { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, 45 | ] 46 | 47 | [[package]] 48 | name = "charset-normalizer" 49 | version = "3.4.1" 50 | source = { registry = "https://pypi.org/simple" } 51 | sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } 52 | wheels = [ 53 | { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, 54 | { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, 55 | { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, 56 | { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, 57 | { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, 58 | { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, 59 | { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, 60 | { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, 61 | { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, 62 | { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, 63 | { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, 64 | { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, 65 | { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, 66 | { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, 67 | { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, 68 | { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, 69 | { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, 70 | { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, 71 | { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, 72 | { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, 73 | { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, 74 | { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, 75 | { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, 76 | { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, 77 | { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, 78 | { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, 79 | { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, 80 | { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, 81 | { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, 82 | { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, 83 | { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, 84 | { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, 85 | { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, 86 | { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, 87 | { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, 88 | { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, 89 | { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, 90 | { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, 91 | { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, 92 | { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, 93 | { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, 94 | { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, 95 | { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, 96 | { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, 97 | { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, 98 | { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, 99 | { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, 100 | { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, 101 | { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, 102 | { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, 103 | { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, 104 | { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, 105 | { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 }, 106 | { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 }, 107 | { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 }, 108 | { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 }, 109 | { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 }, 110 | { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 }, 111 | { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 }, 112 | { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 }, 113 | { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 }, 114 | { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 }, 115 | { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 }, 116 | { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 }, 117 | { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 }, 118 | { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, 119 | ] 120 | 121 | [[package]] 122 | name = "colorama" 123 | version = "0.4.6" 124 | source = { registry = "https://pypi.org/simple" } 125 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 126 | wheels = [ 127 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 128 | ] 129 | 130 | [[package]] 131 | name = "coverage" 132 | version = "7.6.10" 133 | source = { registry = "https://pypi.org/simple" } 134 | sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868 } 135 | wheels = [ 136 | { url = "https://files.pythonhosted.org/packages/c5/12/2a2a923edf4ddabdffed7ad6da50d96a5c126dae7b80a33df7310e329a1e/coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78", size = 207982 }, 137 | { url = "https://files.pythonhosted.org/packages/ca/49/6985dbca9c7be3f3cb62a2e6e492a0c88b65bf40579e16c71ae9c33c6b23/coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c", size = 208414 }, 138 | { url = "https://files.pythonhosted.org/packages/35/93/287e8f1d1ed2646f4e0b2605d14616c9a8a2697d0d1b453815eb5c6cebdb/coverage-7.6.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b204c11e2b2d883946fe1d97f89403aa1811df28ce0447439178cc7463448a", size = 236860 }, 139 | { url = "https://files.pythonhosted.org/packages/de/e1/cfdb5627a03567a10031acc629b75d45a4ca1616e54f7133ca1fa366050a/coverage-7.6.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32ee6d8491fcfc82652a37109f69dee9a830e9379166cb73c16d8dc5c2915165", size = 234758 }, 140 | { url = "https://files.pythonhosted.org/packages/6d/85/fc0de2bcda3f97c2ee9fe8568f7d48f7279e91068958e5b2cc19e0e5f600/coverage-7.6.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675cefc4c06e3b4c876b85bfb7c59c5e2218167bbd4da5075cbe3b5790a28988", size = 235920 }, 141 | { url = "https://files.pythonhosted.org/packages/79/73/ef4ea0105531506a6f4cf4ba571a214b14a884630b567ed65b3d9c1975e1/coverage-7.6.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4f620668dbc6f5e909a0946a877310fb3d57aea8198bde792aae369ee1c23b5", size = 234986 }, 142 | { url = "https://files.pythonhosted.org/packages/c6/4d/75afcfe4432e2ad0405c6f27adeb109ff8976c5e636af8604f94f29fa3fc/coverage-7.6.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4eea95ef275de7abaef630c9b2c002ffbc01918b726a39f5a4353916ec72d2f3", size = 233446 }, 143 | { url = "https://files.pythonhosted.org/packages/86/5b/efee56a89c16171288cafff022e8af44f8f94075c2d8da563c3935212871/coverage-7.6.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2f0280519e42b0a17550072861e0bc8a80a0870de260f9796157d3fca2733c5", size = 234566 }, 144 | { url = "https://files.pythonhosted.org/packages/f2/db/67770cceb4a64d3198bf2aa49946f411b85ec6b0a9b489e61c8467a4253b/coverage-7.6.10-cp310-cp310-win32.whl", hash = "sha256:bc67deb76bc3717f22e765ab3e07ee9c7a5e26b9019ca19a3b063d9f4b874244", size = 210675 }, 145 | { url = "https://files.pythonhosted.org/packages/8d/27/e8bfc43f5345ec2c27bc8a1fa77cdc5ce9dcf954445e11f14bb70b889d14/coverage-7.6.10-cp310-cp310-win_amd64.whl", hash = "sha256:0f460286cb94036455e703c66988851d970fdfd8acc2a1122ab7f4f904e4029e", size = 211518 }, 146 | { url = "https://files.pythonhosted.org/packages/85/d2/5e175fcf6766cf7501a8541d81778fd2f52f4870100e791f5327fd23270b/coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3", size = 208088 }, 147 | { url = "https://files.pythonhosted.org/packages/4b/6f/06db4dc8fca33c13b673986e20e466fd936235a6ec1f0045c3853ac1b593/coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43", size = 208536 }, 148 | { url = "https://files.pythonhosted.org/packages/0d/62/c6a0cf80318c1c1af376d52df444da3608eafc913b82c84a4600d8349472/coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132", size = 240474 }, 149 | { url = "https://files.pythonhosted.org/packages/a3/59/750adafc2e57786d2e8739a46b680d4fb0fbc2d57fbcb161290a9f1ecf23/coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f", size = 237880 }, 150 | { url = "https://files.pythonhosted.org/packages/2c/f8/ef009b3b98e9f7033c19deb40d629354aab1d8b2d7f9cfec284dbedf5096/coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994", size = 239750 }, 151 | { url = "https://files.pythonhosted.org/packages/a6/e2/6622f3b70f5f5b59f705e680dae6db64421af05a5d1e389afd24dae62e5b/coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99", size = 238642 }, 152 | { url = "https://files.pythonhosted.org/packages/2d/10/57ac3f191a3c95c67844099514ff44e6e19b2915cd1c22269fb27f9b17b6/coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd", size = 237266 }, 153 | { url = "https://files.pythonhosted.org/packages/ee/2d/7016f4ad9d553cabcb7333ed78ff9d27248ec4eba8dd21fa488254dff894/coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377", size = 238045 }, 154 | { url = "https://files.pythonhosted.org/packages/a7/fe/45af5c82389a71e0cae4546413266d2195c3744849669b0bab4b5f2c75da/coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8", size = 210647 }, 155 | { url = "https://files.pythonhosted.org/packages/db/11/3f8e803a43b79bc534c6a506674da9d614e990e37118b4506faf70d46ed6/coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609", size = 211508 }, 156 | { url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281 }, 157 | { url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514 }, 158 | { url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537 }, 159 | { url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572 }, 160 | { url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639 }, 161 | { url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072 }, 162 | { url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386 }, 163 | { url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054 }, 164 | { url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904 }, 165 | { url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692 }, 166 | { url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308 }, 167 | { url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565 }, 168 | { url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083 }, 169 | { url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235 }, 170 | { url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220 }, 171 | { url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847 }, 172 | { url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922 }, 173 | { url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783 }, 174 | { url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965 }, 175 | { url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719 }, 176 | { url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050 }, 177 | { url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321 }, 178 | { url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039 }, 179 | { url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758 }, 180 | { url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119 }, 181 | { url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597 }, 182 | { url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473 }, 183 | { url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737 }, 184 | { url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611 }, 185 | { url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781 }, 186 | { url = "https://files.pythonhosted.org/packages/40/41/473617aadf9a1c15bc2d56be65d90d7c29bfa50a957a67ef96462f7ebf8e/coverage-7.6.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:656c82b8a0ead8bba147de9a89bda95064874c91a3ed43a00e687f23cc19d53a", size = 207978 }, 187 | { url = "https://files.pythonhosted.org/packages/10/f6/480586607768b39a30e6910a3c4522139094ac0f1677028e1f4823688957/coverage-7.6.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccc2b70a7ed475c68ceb548bf69cec1e27305c1c2606a5eb7c3afff56a1b3b27", size = 208415 }, 188 | { url = "https://files.pythonhosted.org/packages/f1/af/439bb760f817deff6f4d38fe7da08d9dd7874a560241f1945bc3b4446550/coverage-7.6.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5e37dc41d57ceba70956fa2fc5b63c26dba863c946ace9705f8eca99daecdc4", size = 236452 }, 189 | { url = "https://files.pythonhosted.org/packages/d0/13/481f4ceffcabe29ee2332e60efb52e4694f54a402f3ada2bcec10bb32e43/coverage-7.6.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0aa9692b4fdd83a4647eeb7db46410ea1322b5ed94cd1715ef09d1d5922ba87f", size = 234374 }, 190 | { url = "https://files.pythonhosted.org/packages/c5/59/4607ea9d6b1b73e905c7656da08d0b00cdf6e59f2293ec259e8914160025/coverage-7.6.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa744da1820678b475e4ba3dfd994c321c5b13381d1041fe9c608620e6676e25", size = 235505 }, 191 | { url = "https://files.pythonhosted.org/packages/85/60/d66365723b9b7f29464b11d024248ed3523ce5aab958e4ad8c43f3f4148b/coverage-7.6.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0b1818063dc9e9d838c09e3a473c1422f517889436dd980f5d721899e66f315", size = 234616 }, 192 | { url = "https://files.pythonhosted.org/packages/74/f8/2cf7a38e7d81b266f47dfcf137fecd8fa66c7bdbd4228d611628d8ca3437/coverage-7.6.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:59af35558ba08b758aec4d56182b222976330ef8d2feacbb93964f576a7e7a90", size = 233099 }, 193 | { url = "https://files.pythonhosted.org/packages/50/2b/bff6c1c6b63c4396ea7ecdbf8db1788b46046c681b8fcc6ec77db9f4ea49/coverage-7.6.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7ed2f37cfce1ce101e6dffdfd1c99e729dd2ffc291d02d3e2d0af8b53d13840d", size = 234089 }, 194 | { url = "https://files.pythonhosted.org/packages/bf/b5/baace1c754d546a67779358341aa8d2f7118baf58cac235db457e1001d1b/coverage-7.6.10-cp39-cp39-win32.whl", hash = "sha256:4bcc276261505d82f0ad426870c3b12cb177752834a633e737ec5ee79bbdff18", size = 210701 }, 195 | { url = "https://files.pythonhosted.org/packages/b1/bf/9e1e95b8b20817398ecc5a1e8d3e05ff404e1b9fb2185cd71561698fe2a2/coverage-7.6.10-cp39-cp39-win_amd64.whl", hash = "sha256:457574f4599d2b00f7f637a0700a6422243b3565509457b2dbd3f50703e11f59", size = 211482 }, 196 | { url = "https://files.pythonhosted.org/packages/a1/70/de81bfec9ed38a64fc44a77c7665e20ca507fc3265597c28b0d989e4082e/coverage-7.6.10-pp39.pp310-none-any.whl", hash = "sha256:fd34e7b3405f0cc7ab03d54a334c17a9e802897580d964bd8c2001f4b9fd488f", size = 200223 }, 197 | ] 198 | 199 | [package.optional-dependencies] 200 | toml = [ 201 | { name = "tomli", marker = "python_full_version <= '3.11'" }, 202 | ] 203 | 204 | [[package]] 205 | name = "distlib" 206 | version = "0.3.9" 207 | source = { registry = "https://pypi.org/simple" } 208 | sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } 209 | wheels = [ 210 | { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, 211 | ] 212 | 213 | [[package]] 214 | name = "django" 215 | version = "4.2.18" 216 | source = { registry = "https://pypi.org/simple" } 217 | resolution-markers = [ 218 | "python_full_version < '3.10'", 219 | ] 220 | dependencies = [ 221 | { name = "asgiref", marker = "python_full_version < '3.10'" }, 222 | { name = "sqlparse", marker = "python_full_version < '3.10'" }, 223 | { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, 224 | ] 225 | sdist = { url = "https://files.pythonhosted.org/packages/1c/82/470d12df22d7b56b12812539ce7bed332d8cfda51a657ab2b59f3390cae3/Django-4.2.18.tar.gz", hash = "sha256:52ae8eacf635617c0f13b44f749e5ea13dc34262819b2cc8c8636abb08d82c4b", size = 10428204 } 226 | wheels = [ 227 | { url = "https://files.pythonhosted.org/packages/93/76/39c641b5787e5e61f35b9d29c6f19bf94506bf7be3e48249f72233c4625d/Django-4.2.18-py3-none-any.whl", hash = "sha256:ba52eff7e228f1c775d5b0db2ba53d8c49d2f8bfe6ca0234df6b7dd12fb25b19", size = 7993633 }, 228 | ] 229 | 230 | [[package]] 231 | name = "django" 232 | version = "5.1.5" 233 | source = { registry = "https://pypi.org/simple" } 234 | resolution-markers = [ 235 | "python_full_version >= '3.10'", 236 | ] 237 | dependencies = [ 238 | { name = "asgiref", marker = "python_full_version >= '3.10'" }, 239 | { name = "sqlparse", marker = "python_full_version >= '3.10'" }, 240 | { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, 241 | ] 242 | sdist = { url = "https://files.pythonhosted.org/packages/e4/17/834e3e08d590dcc27d4cc3c5cd4e2fb757b7a92bab9de8ee402455732952/Django-5.1.5.tar.gz", hash = "sha256:19bbca786df50b9eca23cee79d495facf55c8f5c54c529d9bf1fe7b5ea086af3", size = 10700031 } 243 | wheels = [ 244 | { url = "https://files.pythonhosted.org/packages/11/e6/e92c8c788b83d109f34d933c5e817095d85722719cb4483472abc135f44e/Django-5.1.5-py3-none-any.whl", hash = "sha256:c46eb936111fffe6ec4bc9930035524a8be98ec2f74d8a0ff351226a3e52f459", size = 8276957 }, 245 | ] 246 | 247 | [[package]] 248 | name = "django-extra-checks" 249 | source = { editable = "." } 250 | 251 | [package.dev-dependencies] 252 | dev = [ 253 | { name = "django", version = "4.2.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, 254 | { name = "django", version = "5.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 255 | { name = "django-stubs" }, 256 | { name = "djangorestframework" }, 257 | { name = "djangorestframework-stubs" }, 258 | { name = "mypy" }, 259 | { name = "pdbpp" }, 260 | { name = "pre-commit" }, 261 | { name = "pytest" }, 262 | { name = "pytest-cov" }, 263 | { name = "pytest-django" }, 264 | { name = "ruff" }, 265 | ] 266 | test = [ 267 | { name = "pytest" }, 268 | { name = "pytest-cov" }, 269 | { name = "pytest-django" }, 270 | ] 271 | 272 | [package.metadata] 273 | 274 | [package.metadata.requires-dev] 275 | dev = [ 276 | { name = "django" }, 277 | { name = "django-stubs" }, 278 | { name = "djangorestframework" }, 279 | { name = "djangorestframework-stubs" }, 280 | { name = "mypy" }, 281 | { name = "pdbpp" }, 282 | { name = "pre-commit" }, 283 | { name = "pytest" }, 284 | { name = "pytest-cov" }, 285 | { name = "pytest-django" }, 286 | { name = "ruff" }, 287 | ] 288 | test = [ 289 | { name = "pytest" }, 290 | { name = "pytest-cov" }, 291 | { name = "pytest-django" }, 292 | ] 293 | 294 | [[package]] 295 | name = "django-stubs" 296 | version = "5.1.2" 297 | source = { registry = "https://pypi.org/simple" } 298 | dependencies = [ 299 | { name = "asgiref" }, 300 | { name = "django", version = "4.2.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, 301 | { name = "django", version = "5.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 302 | { name = "django-stubs-ext" }, 303 | { name = "tomli", marker = "python_full_version < '3.11'" }, 304 | { name = "types-pyyaml" }, 305 | { name = "typing-extensions" }, 306 | ] 307 | sdist = { url = "https://files.pythonhosted.org/packages/32/d6/b29debed5527ead981b69cef404f7589cca7b6e4aa65fe3e60a478b4588e/django_stubs-5.1.2.tar.gz", hash = "sha256:a0fcb3659bab46a6d835cc2d9bff3fc29c36ccea41a10e8b1930427bc0f9f0df", size = 267374 } 308 | wheels = [ 309 | { url = "https://files.pythonhosted.org/packages/77/28/137af496de2419ac521b4530f4f6340adbf709befd7d63ce590537c7432a/django_stubs-5.1.2-py3-none-any.whl", hash = "sha256:04ddc778faded6fb48468a8da9e98b8d12b9ba983faa648d37a73ebde0f024da", size = 472598 }, 310 | ] 311 | 312 | [[package]] 313 | name = "django-stubs-ext" 314 | version = "5.1.2" 315 | source = { registry = "https://pypi.org/simple" } 316 | dependencies = [ 317 | { name = "django", version = "4.2.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, 318 | { name = "django", version = "5.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 319 | { name = "typing-extensions" }, 320 | ] 321 | sdist = { url = "https://files.pythonhosted.org/packages/ed/83/b673bf5131c61949f7840b70f9d25a52d90d27416fe2692f13ade14496f1/django_stubs_ext-5.1.2.tar.gz", hash = "sha256:421c0c3025a68e3ab8e16f065fad9ba93335ecefe2dd92a0cff97a665680266c", size = 9629 } 322 | wheels = [ 323 | { url = "https://files.pythonhosted.org/packages/73/c1/5df5231c5db00e3981e71f295c7e5269cbddb0a2c666b3a6f03831b24bd1/django_stubs_ext-5.1.2-py3-none-any.whl", hash = "sha256:6c559214538d6a26f631ca638ddc3251a0a891d607de8ce01d23d3201ad8ad6c", size = 9032 }, 324 | ] 325 | 326 | [[package]] 327 | name = "djangorestframework" 328 | version = "3.15.2" 329 | source = { registry = "https://pypi.org/simple" } 330 | dependencies = [ 331 | { name = "django", version = "4.2.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, 332 | { name = "django", version = "5.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 333 | ] 334 | sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420 } 335 | wheels = [ 336 | { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235 }, 337 | ] 338 | 339 | [[package]] 340 | name = "djangorestframework-stubs" 341 | version = "3.15.2" 342 | source = { registry = "https://pypi.org/simple" } 343 | dependencies = [ 344 | { name = "django-stubs" }, 345 | { name = "requests" }, 346 | { name = "types-pyyaml" }, 347 | { name = "types-requests" }, 348 | { name = "typing-extensions" }, 349 | ] 350 | sdist = { url = "https://files.pythonhosted.org/packages/7e/ba/190ac57f349b622ef794a7d9445318b79b746e5bbb93a29517bfc038cf70/djangorestframework_stubs-3.15.2.tar.gz", hash = "sha256:3df129845acac6c1b097bc7e5b360d53e32a02029d60b4f972dfbd3e2508f236", size = 34708 } 351 | wheels = [ 352 | { url = "https://files.pythonhosted.org/packages/52/cf/712000ded7661b943fad809c43b6cb456b8f3f50bdf93619ab0245366746/djangorestframework_stubs-3.15.2-py3-none-any.whl", hash = "sha256:0e72f1e8507bdb2acd99b304520494ea5d45bccba51a4877140cb65fd461adf0", size = 54522 }, 353 | ] 354 | 355 | [[package]] 356 | name = "exceptiongroup" 357 | version = "1.2.2" 358 | source = { registry = "https://pypi.org/simple" } 359 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 360 | wheels = [ 361 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 362 | ] 363 | 364 | [[package]] 365 | name = "fancycompleter" 366 | version = "0.9.1" 367 | source = { registry = "https://pypi.org/simple" } 368 | dependencies = [ 369 | { name = "pyreadline", marker = "sys_platform == 'win32'" }, 370 | { name = "pyrepl" }, 371 | ] 372 | sdist = { url = "https://files.pythonhosted.org/packages/a9/95/649d135442d8ecf8af5c7e235550c628056423c96c4bc6787348bdae9248/fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272", size = 10866 } 373 | wheels = [ 374 | { url = "https://files.pythonhosted.org/packages/38/ef/c08926112034d017633f693d3afc8343393a035134a29dfc12dcd71b0375/fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080", size = 9681 }, 375 | ] 376 | 377 | [[package]] 378 | name = "filelock" 379 | version = "3.17.0" 380 | source = { registry = "https://pypi.org/simple" } 381 | sdist = { url = "https://files.pythonhosted.org/packages/dc/9c/0b15fb47b464e1b663b1acd1253a062aa5feecb07d4e597daea542ebd2b5/filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e", size = 18027 } 382 | wheels = [ 383 | { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 }, 384 | ] 385 | 386 | [[package]] 387 | name = "identify" 388 | version = "2.6.6" 389 | source = { registry = "https://pypi.org/simple" } 390 | sdist = { url = "https://files.pythonhosted.org/packages/82/bf/c68c46601bacd4c6fb4dd751a42b6e7087240eaabc6487f2ef7a48e0e8fc/identify-2.6.6.tar.gz", hash = "sha256:7bec12768ed44ea4761efb47806f0a41f86e7c0a5fdf5950d4648c90eca7e251", size = 99217 } 391 | wheels = [ 392 | { url = "https://files.pythonhosted.org/packages/74/a1/68a395c17eeefb04917034bd0a1bfa765e7654fa150cca473d669aa3afb5/identify-2.6.6-py2.py3-none-any.whl", hash = "sha256:cbd1810bce79f8b671ecb20f53ee0ae8e86ae84b557de31d89709dc2a48ba881", size = 99083 }, 393 | ] 394 | 395 | [[package]] 396 | name = "idna" 397 | version = "3.10" 398 | source = { registry = "https://pypi.org/simple" } 399 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 400 | wheels = [ 401 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 402 | ] 403 | 404 | [[package]] 405 | name = "iniconfig" 406 | version = "2.0.0" 407 | source = { registry = "https://pypi.org/simple" } 408 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 409 | wheels = [ 410 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 411 | ] 412 | 413 | [[package]] 414 | name = "mypy" 415 | version = "1.14.1" 416 | source = { registry = "https://pypi.org/simple" } 417 | dependencies = [ 418 | { name = "mypy-extensions" }, 419 | { name = "tomli", marker = "python_full_version < '3.11'" }, 420 | { name = "typing-extensions" }, 421 | ] 422 | sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051 } 423 | wheels = [ 424 | { url = "https://files.pythonhosted.org/packages/9b/7a/87ae2adb31d68402da6da1e5f30c07ea6063e9f09b5e7cfc9dfa44075e74/mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb", size = 11211002 }, 425 | { url = "https://files.pythonhosted.org/packages/e1/23/eada4c38608b444618a132be0d199b280049ded278b24cbb9d3fc59658e4/mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0", size = 10358400 }, 426 | { url = "https://files.pythonhosted.org/packages/43/c9/d6785c6f66241c62fd2992b05057f404237deaad1566545e9f144ced07f5/mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d", size = 12095172 }, 427 | { url = "https://files.pythonhosted.org/packages/c3/62/daa7e787770c83c52ce2aaf1a111eae5893de9e004743f51bfcad9e487ec/mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b", size = 12828732 }, 428 | { url = "https://files.pythonhosted.org/packages/1b/a2/5fb18318a3637f29f16f4e41340b795da14f4751ef4f51c99ff39ab62e52/mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427", size = 13012197 }, 429 | { url = "https://files.pythonhosted.org/packages/28/99/e153ce39105d164b5f02c06c35c7ba958aaff50a2babba7d080988b03fe7/mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f", size = 9780836 }, 430 | { url = "https://files.pythonhosted.org/packages/da/11/a9422850fd506edbcdc7f6090682ecceaf1f87b9dd847f9df79942da8506/mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", size = 11120432 }, 431 | { url = "https://files.pythonhosted.org/packages/b6/9e/47e450fd39078d9c02d620545b2cb37993a8a8bdf7db3652ace2f80521ca/mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", size = 10279515 }, 432 | { url = "https://files.pythonhosted.org/packages/01/b5/6c8d33bd0f851a7692a8bfe4ee75eb82b6983a3cf39e5e32a5d2a723f0c1/mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", size = 12025791 }, 433 | { url = "https://files.pythonhosted.org/packages/f0/4c/e10e2c46ea37cab5c471d0ddaaa9a434dc1d28650078ac1b56c2d7b9b2e4/mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", size = 12749203 }, 434 | { url = "https://files.pythonhosted.org/packages/88/55/beacb0c69beab2153a0f57671ec07861d27d735a0faff135a494cd4f5020/mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", size = 12885900 }, 435 | { url = "https://files.pythonhosted.org/packages/a2/75/8c93ff7f315c4d086a2dfcde02f713004357d70a163eddb6c56a6a5eff40/mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", size = 9777869 }, 436 | { url = "https://files.pythonhosted.org/packages/43/1b/b38c079609bb4627905b74fc6a49849835acf68547ac33d8ceb707de5f52/mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", size = 11266668 }, 437 | { url = "https://files.pythonhosted.org/packages/6b/75/2ed0d2964c1ffc9971c729f7a544e9cd34b2cdabbe2d11afd148d7838aa2/mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", size = 10254060 }, 438 | { url = "https://files.pythonhosted.org/packages/a1/5f/7b8051552d4da3c51bbe8fcafffd76a6823779101a2b198d80886cd8f08e/mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", size = 11933167 }, 439 | { url = "https://files.pythonhosted.org/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", size = 12864341 }, 440 | { url = "https://files.pythonhosted.org/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89", size = 12972991 }, 441 | { url = "https://files.pythonhosted.org/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", size = 9879016 }, 442 | { url = "https://files.pythonhosted.org/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", size = 11252097 }, 443 | { url = "https://files.pythonhosted.org/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", size = 10239728 }, 444 | { url = "https://files.pythonhosted.org/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", size = 11924965 }, 445 | { url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660 }, 446 | { url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198 }, 447 | { url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276 }, 448 | { url = "https://files.pythonhosted.org/packages/ca/1f/186d133ae2514633f8558e78cd658070ba686c0e9275c5a5c24a1e1f0d67/mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35", size = 11200493 }, 449 | { url = "https://files.pythonhosted.org/packages/af/fc/4842485d034e38a4646cccd1369f6b1ccd7bc86989c52770d75d719a9941/mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc", size = 10357702 }, 450 | { url = "https://files.pythonhosted.org/packages/b4/e6/457b83f2d701e23869cfec013a48a12638f75b9d37612a9ddf99072c1051/mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9", size = 12091104 }, 451 | { url = "https://files.pythonhosted.org/packages/f1/bf/76a569158db678fee59f4fd30b8e7a0d75bcbaeef49edd882a0d63af6d66/mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb", size = 12830167 }, 452 | { url = "https://files.pythonhosted.org/packages/43/bc/0bc6b694b3103de9fed61867f1c8bd33336b913d16831431e7cb48ef1c92/mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60", size = 13013834 }, 453 | { url = "https://files.pythonhosted.org/packages/b0/79/5f5ec47849b6df1e6943d5fd8e6632fbfc04b4fd4acfa5a5a9535d11b4e2/mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c", size = 9781231 }, 454 | { url = "https://files.pythonhosted.org/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", size = 2752905 }, 455 | ] 456 | 457 | [[package]] 458 | name = "mypy-extensions" 459 | version = "1.0.0" 460 | source = { registry = "https://pypi.org/simple" } 461 | sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } 462 | wheels = [ 463 | { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, 464 | ] 465 | 466 | [[package]] 467 | name = "nodeenv" 468 | version = "1.9.1" 469 | source = { registry = "https://pypi.org/simple" } 470 | sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } 471 | wheels = [ 472 | { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, 473 | ] 474 | 475 | [[package]] 476 | name = "packaging" 477 | version = "24.2" 478 | source = { registry = "https://pypi.org/simple" } 479 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 480 | wheels = [ 481 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 482 | ] 483 | 484 | [[package]] 485 | name = "pdbpp" 486 | version = "0.10.3" 487 | source = { registry = "https://pypi.org/simple" } 488 | dependencies = [ 489 | { name = "fancycompleter" }, 490 | { name = "pygments" }, 491 | { name = "wmctrl" }, 492 | ] 493 | sdist = { url = "https://files.pythonhosted.org/packages/1f/a3/c4bd048256fd4b7d28767ca669c505e156f24d16355505c62e6fce3314df/pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5", size = 68116 } 494 | wheels = [ 495 | { url = "https://files.pythonhosted.org/packages/93/ee/491e63a57fffa78b9de1c337b06c97d0cd0753e88c00571c7b011680332a/pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1", size = 23961 }, 496 | ] 497 | 498 | [[package]] 499 | name = "platformdirs" 500 | version = "4.3.6" 501 | source = { registry = "https://pypi.org/simple" } 502 | sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } 503 | wheels = [ 504 | { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, 505 | ] 506 | 507 | [[package]] 508 | name = "pluggy" 509 | version = "1.5.0" 510 | source = { registry = "https://pypi.org/simple" } 511 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 512 | wheels = [ 513 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 514 | ] 515 | 516 | [[package]] 517 | name = "pre-commit" 518 | version = "4.1.0" 519 | source = { registry = "https://pypi.org/simple" } 520 | dependencies = [ 521 | { name = "cfgv" }, 522 | { name = "identify" }, 523 | { name = "nodeenv" }, 524 | { name = "pyyaml" }, 525 | { name = "virtualenv" }, 526 | ] 527 | sdist = { url = "https://files.pythonhosted.org/packages/2a/13/b62d075317d8686071eb843f0bb1f195eb332f48869d3c31a4c6f1e063ac/pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4", size = 193330 } 528 | wheels = [ 529 | { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560 }, 530 | ] 531 | 532 | [[package]] 533 | name = "pygments" 534 | version = "2.19.1" 535 | source = { registry = "https://pypi.org/simple" } 536 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } 537 | wheels = [ 538 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, 539 | ] 540 | 541 | [[package]] 542 | name = "pyreadline" 543 | version = "2.1" 544 | source = { registry = "https://pypi.org/simple" } 545 | sdist = { url = "https://files.pythonhosted.org/packages/bc/7c/d724ef1ec3ab2125f38a1d53285745445ec4a8f19b9bb0761b4064316679/pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1", size = 109189 } 546 | 547 | [[package]] 548 | name = "pyrepl" 549 | version = "0.9.0" 550 | source = { registry = "https://pypi.org/simple" } 551 | sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 } 552 | 553 | [[package]] 554 | name = "pytest" 555 | version = "8.3.4" 556 | source = { registry = "https://pypi.org/simple" } 557 | dependencies = [ 558 | { name = "colorama", marker = "sys_platform == 'win32'" }, 559 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 560 | { name = "iniconfig" }, 561 | { name = "packaging" }, 562 | { name = "pluggy" }, 563 | { name = "tomli", marker = "python_full_version < '3.11'" }, 564 | ] 565 | sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } 566 | wheels = [ 567 | { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, 568 | ] 569 | 570 | [[package]] 571 | name = "pytest-cov" 572 | version = "6.0.0" 573 | source = { registry = "https://pypi.org/simple" } 574 | dependencies = [ 575 | { name = "coverage", extra = ["toml"] }, 576 | { name = "pytest" }, 577 | ] 578 | sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } 579 | wheels = [ 580 | { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, 581 | ] 582 | 583 | [[package]] 584 | name = "pytest-django" 585 | version = "4.9.0" 586 | source = { registry = "https://pypi.org/simple" } 587 | dependencies = [ 588 | { name = "pytest" }, 589 | ] 590 | sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067 } 591 | wheels = [ 592 | { url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723 }, 593 | ] 594 | 595 | [[package]] 596 | name = "pyyaml" 597 | version = "6.0.2" 598 | source = { registry = "https://pypi.org/simple" } 599 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 600 | wheels = [ 601 | { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, 602 | { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, 603 | { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, 604 | { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, 605 | { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, 606 | { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, 607 | { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, 608 | { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, 609 | { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, 610 | { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, 611 | { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, 612 | { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, 613 | { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, 614 | { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, 615 | { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, 616 | { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, 617 | { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, 618 | { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, 619 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, 620 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, 621 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, 622 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, 623 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, 624 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, 625 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, 626 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, 627 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, 628 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, 629 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, 630 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, 631 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, 632 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, 633 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, 634 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, 635 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, 636 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, 637 | { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, 638 | { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, 639 | { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, 640 | { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, 641 | { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, 642 | { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, 643 | { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, 644 | { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, 645 | { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, 646 | ] 647 | 648 | [[package]] 649 | name = "requests" 650 | version = "2.32.3" 651 | source = { registry = "https://pypi.org/simple" } 652 | dependencies = [ 653 | { name = "certifi" }, 654 | { name = "charset-normalizer" }, 655 | { name = "idna" }, 656 | { name = "urllib3" }, 657 | ] 658 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } 659 | wheels = [ 660 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, 661 | ] 662 | 663 | [[package]] 664 | name = "ruff" 665 | version = "0.9.4" 666 | source = { registry = "https://pypi.org/simple" } 667 | sdist = { url = "https://files.pythonhosted.org/packages/c0/17/529e78f49fc6f8076f50d985edd9a2cf011d1dbadb1cdeacc1d12afc1d26/ruff-0.9.4.tar.gz", hash = "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7", size = 3599458 } 668 | wheels = [ 669 | { url = "https://files.pythonhosted.org/packages/b6/f8/3fafb7804d82e0699a122101b5bee5f0d6e17c3a806dcbc527bb7d3f5b7a/ruff-0.9.4-py3-none-linux_armv6l.whl", hash = "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706", size = 11668400 }, 670 | { url = "https://files.pythonhosted.org/packages/2e/a6/2efa772d335da48a70ab2c6bb41a096c8517ca43c086ea672d51079e3d1f/ruff-0.9.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf", size = 11628395 }, 671 | { url = "https://files.pythonhosted.org/packages/dc/d7/cd822437561082f1c9d7225cc0d0fbb4bad117ad7ac3c41cd5d7f0fa948c/ruff-0.9.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b", size = 11090052 }, 672 | { url = "https://files.pythonhosted.org/packages/9e/67/3660d58e893d470abb9a13f679223368ff1684a4ef40f254a0157f51b448/ruff-0.9.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137", size = 11882221 }, 673 | { url = "https://files.pythonhosted.org/packages/79/d1/757559995c8ba5f14dfec4459ef2dd3fcea82ac43bc4e7c7bf47484180c0/ruff-0.9.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e", size = 11424862 }, 674 | { url = "https://files.pythonhosted.org/packages/c0/96/7915a7c6877bb734caa6a2af424045baf6419f685632469643dbd8eb2958/ruff-0.9.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec", size = 12626735 }, 675 | { url = "https://files.pythonhosted.org/packages/0e/cc/dadb9b35473d7cb17c7ffe4737b4377aeec519a446ee8514123ff4a26091/ruff-0.9.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b", size = 13255976 }, 676 | { url = "https://files.pythonhosted.org/packages/5f/c3/ad2dd59d3cabbc12df308cced780f9c14367f0321e7800ca0fe52849da4c/ruff-0.9.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a", size = 12752262 }, 677 | { url = "https://files.pythonhosted.org/packages/c7/17/5f1971e54bd71604da6788efd84d66d789362b1105e17e5ccc53bba0289b/ruff-0.9.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214", size = 14401648 }, 678 | { url = "https://files.pythonhosted.org/packages/30/24/6200b13ea611b83260501b6955b764bb320e23b2b75884c60ee7d3f0b68e/ruff-0.9.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231", size = 12414702 }, 679 | { url = "https://files.pythonhosted.org/packages/34/cb/f5d50d0c4ecdcc7670e348bd0b11878154bc4617f3fdd1e8ad5297c0d0ba/ruff-0.9.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b", size = 11859608 }, 680 | { url = "https://files.pythonhosted.org/packages/d6/f4/9c8499ae8426da48363bbb78d081b817b0f64a9305f9b7f87eab2a8fb2c1/ruff-0.9.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6", size = 11485702 }, 681 | { url = "https://files.pythonhosted.org/packages/18/59/30490e483e804ccaa8147dd78c52e44ff96e1c30b5a95d69a63163cdb15b/ruff-0.9.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c", size = 12067782 }, 682 | { url = "https://files.pythonhosted.org/packages/3d/8c/893fa9551760b2f8eb2a351b603e96f15af167ceaf27e27ad873570bc04c/ruff-0.9.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0", size = 12483087 }, 683 | { url = "https://files.pythonhosted.org/packages/23/15/f6751c07c21ca10e3f4a51ea495ca975ad936d780c347d9808bcedbd7182/ruff-0.9.4-py3-none-win32.whl", hash = "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402", size = 9852302 }, 684 | { url = "https://files.pythonhosted.org/packages/12/41/2d2d2c6a72e62566f730e49254f602dfed23019c33b5b21ea8f8917315a1/ruff-0.9.4-py3-none-win_amd64.whl", hash = "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e", size = 10850051 }, 685 | { url = "https://files.pythonhosted.org/packages/c6/e6/3d6ec3bc3d254e7f005c543a661a41c3e788976d0e52a1ada195bd664344/ruff-0.9.4-py3-none-win_arm64.whl", hash = "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41", size = 10078251 }, 686 | ] 687 | 688 | [[package]] 689 | name = "sqlparse" 690 | version = "0.5.3" 691 | source = { registry = "https://pypi.org/simple" } 692 | sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } 693 | wheels = [ 694 | { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, 695 | ] 696 | 697 | [[package]] 698 | name = "tomli" 699 | version = "2.2.1" 700 | source = { registry = "https://pypi.org/simple" } 701 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } 702 | wheels = [ 703 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, 704 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, 705 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, 706 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, 707 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, 708 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, 709 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, 710 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, 711 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, 712 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, 713 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, 714 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, 715 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, 716 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, 717 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, 718 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, 719 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, 720 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, 721 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, 722 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, 723 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, 724 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, 725 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, 726 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, 727 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, 728 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, 729 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, 730 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, 731 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, 732 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, 733 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, 734 | ] 735 | 736 | [[package]] 737 | name = "types-pyyaml" 738 | version = "6.0.12.20241230" 739 | source = { registry = "https://pypi.org/simple" } 740 | sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078 } 741 | wheels = [ 742 | { url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029 }, 743 | ] 744 | 745 | [[package]] 746 | name = "types-requests" 747 | version = "2.32.0.20241016" 748 | source = { registry = "https://pypi.org/simple" } 749 | dependencies = [ 750 | { name = "urllib3" }, 751 | ] 752 | sdist = { url = "https://files.pythonhosted.org/packages/fa/3c/4f2a430c01a22abd49a583b6b944173e39e7d01b688190a5618bd59a2e22/types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95", size = 18065 } 753 | wheels = [ 754 | { url = "https://files.pythonhosted.org/packages/d7/01/485b3026ff90e5190b5e24f1711522e06c79f4a56c8f4b95848ac072e20f/types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747", size = 15836 }, 755 | ] 756 | 757 | [[package]] 758 | name = "typing-extensions" 759 | version = "4.12.2" 760 | source = { registry = "https://pypi.org/simple" } 761 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 762 | wheels = [ 763 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 764 | ] 765 | 766 | [[package]] 767 | name = "tzdata" 768 | version = "2025.1" 769 | source = { registry = "https://pypi.org/simple" } 770 | sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 } 771 | wheels = [ 772 | { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, 773 | ] 774 | 775 | [[package]] 776 | name = "urllib3" 777 | version = "2.3.0" 778 | source = { registry = "https://pypi.org/simple" } 779 | sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } 780 | wheels = [ 781 | { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, 782 | ] 783 | 784 | [[package]] 785 | name = "virtualenv" 786 | version = "20.29.1" 787 | source = { registry = "https://pypi.org/simple" } 788 | dependencies = [ 789 | { name = "distlib" }, 790 | { name = "filelock" }, 791 | { name = "platformdirs" }, 792 | ] 793 | sdist = { url = "https://files.pythonhosted.org/packages/a7/ca/f23dcb02e161a9bba141b1c08aa50e8da6ea25e6d780528f1d385a3efe25/virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35", size = 7658028 } 794 | wheels = [ 795 | { url = "https://files.pythonhosted.org/packages/89/9b/599bcfc7064fbe5740919e78c5df18e5dceb0887e676256a1061bb5ae232/virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779", size = 4282379 }, 796 | ] 797 | 798 | [[package]] 799 | name = "wmctrl" 800 | version = "0.5" 801 | source = { registry = "https://pypi.org/simple" } 802 | dependencies = [ 803 | { name = "attrs" }, 804 | ] 805 | sdist = { url = "https://files.pythonhosted.org/packages/60/d9/6625ead93412c5ce86db1f8b4f2a70b8043e0a7c1d30099ba3c6a81641ff/wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962", size = 5202 } 806 | wheels = [ 807 | { url = "https://files.pythonhosted.org/packages/13/ca/723e3f8185738d7947f14ee7dc663b59415c6dee43bd71575f8c7f5cd6be/wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7", size = 4268 }, 808 | ] 809 | --------------------------------------------------------------------------------