├── .github └── workflows │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── _templates └── python │ └── material │ └── labels.html ├── docs ├── api │ ├── dynamic_stubs │ │ └── rules.md │ ├── explicit_type_hints │ │ └── rules.md │ └── typing.md ├── assets │ ├── demo.svg │ └── mypy-vscode-extension.png ├── changelog.md ├── context.md ├── index.md └── usage │ ├── dynamic_stubs.md │ └── explicit_type_hints.md ├── mkdocs.yml ├── pyproject.toml ├── requirements ├── requirements-dev.in ├── requirements-dev.txt ├── requirements-docs.in ├── requirements-docs.txt ├── requirements-test.in ├── requirements-test.txt ├── requirements.in └── requirements.txt ├── setup.py ├── src └── django_autotyping │ ├── __init__.py │ ├── _compat.py │ ├── app_settings.py │ ├── apps.py │ ├── codemodding │ ├── __init__.py │ ├── codemods │ │ ├── __init__.py │ │ ├── base.py │ │ └── forward_relation_typing_codemod.py │ ├── django_context.py │ ├── main.py │ ├── models.py │ └── vendoring │ │ ├── __init__.py │ │ └── monkeytype │ │ ├── __init__.py │ │ └── type_checking_imports_transformer.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── _utils.py │ │ ├── add_type_hints.py │ │ └── generate_stubs.py │ ├── py.typed │ ├── stubbing │ ├── __init__.py │ ├── codemods │ │ ├── __init__.py │ │ ├── _global_settings_types.py │ │ ├── _model_creation.py │ │ ├── _utils.py │ │ ├── auth_functions_codemod.py │ │ ├── base.py │ │ ├── call_command_codemod.py │ │ ├── constants.py │ │ ├── create_overload_codemod.py │ │ ├── forward_relation_overload_codemod.py │ │ ├── get_model_overload_codemod.py │ │ ├── model_init_overload_codemod.py │ │ ├── query_lookups_overload_codemod.py │ │ ├── reverse_overload_codemod.py │ │ ├── settings_codemod.py │ │ └── template_loading_codemod.py │ └── django_context │ │ ├── __init__.py │ │ ├── _management_utils.py │ │ ├── _template_utils.py │ │ ├── _url_utils.py │ │ └── django_context.py │ └── typing.py ├── tests ├── codemodding │ └── test_dja001.py ├── codemodtestproj │ ├── codemodtestproj │ │ ├── __init__.py │ │ ├── firstapp │ │ │ ├── __init__.py │ │ │ ├── apps.py │ │ │ └── models.py │ │ ├── secondapp │ │ │ ├── __init__.py │ │ │ ├── apps.py │ │ │ └── models.py │ │ └── settings.py │ ├── manage.py │ └── urls.py ├── conftest.py ├── helpers │ ├── __init__.py │ └── utils.py ├── stubbing │ ├── __init__.py │ ├── test_stubs.py │ └── testfiles │ │ ├── djas001.py │ │ ├── djas001_allow_non_set_type.py │ │ ├── djas001_no_plain_references.py │ │ ├── djas002_003.py │ │ ├── djas002_003_no_model_fields_optional.py │ │ ├── djas010.py │ │ ├── djas011.py │ │ ├── djas016.py │ │ └── djas017.py ├── stubstestproj │ ├── __init__.py │ ├── accounts │ │ ├── __init__.py │ │ └── models.py │ ├── appwithoutmodelsmodule │ │ ├── __init__.py │ │ ├── apps.py │ │ └── extra_models │ │ │ └── __init__.py │ ├── customtemplatesdir │ │ ├── customtemplate.html │ │ └── nested │ │ │ └── template.html │ ├── firstapp │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── models.py │ │ └── templates │ │ │ └── firstapptemplate.html │ ├── secondapp │ │ ├── __init__.py │ │ ├── apps.py │ │ └── models.py │ ├── settings.py │ └── urls.py └── unit │ ├── __init__.py │ └── test_app_settings.py └── tox.ini /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | ruff-format: 7 | name: Check code formatting with Ruff 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Python 3.11 12 | uses: actions/setup-python@v5 13 | with: 14 | python-version: "3.11" 15 | - name: Install dependencies 16 | run: | 17 | pip install pip-tools 18 | pip-sync requirements/requirements.txt requirements/requirements-dev.txt 19 | - name: Run Ruff formatter 20 | run: ruff format --diff 21 | 22 | ruff-check: 23 | name: Check code linting with Ruff 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python 3.11 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.11" 31 | - name: Install dependencies 32 | run: | 33 | pip install pip-tools 34 | pip-sync requirements/requirements.txt requirements/requirements-dev.txt 35 | - name: Run Ruff formatter 36 | run: ruff check --output-format=github 37 | mypy: 38 | if: false # Disabled for now 39 | name: Check type hints with mypy 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-python@v5 44 | with: 45 | python-version: '3.11' 46 | - name: Install dependencies 47 | run: | 48 | pip install pip-tools 49 | pip-sync requirements/requirements.txt requirements/requirements-dev.txt 50 | - name: Run mypy 51 | run: | 52 | pip install -r requirements/requirements.txt requirements/requirements-dev.txt 53 | mypy src/ 54 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Run tests with pytest 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | pip install pip-tools 22 | pip-sync requirements/requirements.txt requirements/requirements-test.txt 23 | - name: Test with tox 24 | run: tox 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Ruff linter 153 | .ruff_cache/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.1 (2024-05-29) 4 | 5 | This release fixes two bugs: 6 | 7 | - Fallback to `inspect.getmodule` for models not exported in the app's models module (#66) 8 | - Fix type representation of `DateTimeField.__set__` types (#67) 9 | 10 | ## 0.5.0 (2024-02-18) 11 | 12 | This release brings basic support for template loading functions (e.g. `render_to_string`). 13 | 14 | - Fixed some typos in readme and docstrings (#50) 15 | - Fixes and tests for `DJAS002/3` (#53) 16 | - Add support for template loading functions (#56) 17 | - Remove outdated VSCode limitation in docs (#57) 18 | 19 | ## 0.4.0 (2024-02-04) 20 | 21 | This release brings improvements to the model creation related comemod: 22 | - `DJAS002` was refactored and now provides better types for fields (instead of `Any`). 23 | It is now split into two rules: `DJAS002` and `DJAS003`. 24 | 25 | - Fix README rendering for PyPI (#43) 26 | - Add draft implementation for `call_command` overloads (#45) 27 | - Refactor and improve support for model creation (#46) 28 | - Update to `ruff==0.2.0`, add new rules (#47) 29 | - Typos and updates to docs (#48) 30 | 31 | ## 0.3.0 (2024-01-23) 32 | 33 | - Add more tests for DJAS001, test DJAS010 (#34) 34 | - Add support for custom user model (#35) 35 | - Add animated example (#38) 36 | - Add support for settings typing (#37) 37 | 38 | ## 0.2.0 (2024-01-15) 39 | 40 | - Complete refactor of the library (no post migrate signal, management commands) 41 | - Added tests, docs 42 | 43 | ## 0.1.0 (2023-12-17) 44 | 45 | - Add support for custom dynamic stubs 46 | - Support duplicate models across apps 47 | - Various improvements to the codebase 48 | 49 | ## 0.0.1 (2023-12-05) 50 | 51 | - Initial release 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Viicos 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 Autotyping 2 | 3 | [![Python versions](https://img.shields.io/pypi/pyversions/django-autotyping.svg)](https://www.python.org/downloads/) 4 | [![PyPI version](https://img.shields.io/pypi/v/django-autotyping.svg)](https://pypi.org/project/django-autotyping/) 5 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 6 | 7 | `django-autotyping` enhances your developing experience with Django by providing accurate type hints, without the need for 8 | a custom IDE or mypy plugin: 9 | 10 | - Generates custom [type stubs](https://typing.readthedocs.io/en/latest/source/stubs.html#stubs) based on the current state of your Django application, enhancing your development experience by providing auto-completions and accurate type checking. 11 | - Automatically add explicit type hints to your source code when type stubs are not enough. 12 | 13 | To understand the *why* and *how*, you can refer to the [context section](https://viicos.github.io/django-autotyping/context/). 14 | 15 | `django-autotyping` is built with [LibCST](https://github.com/Instagram/LibCST/). 16 | 17 |

18 | 19 |

20 | 21 | > [!WARNING] 22 | > This project is still work in progress. It is meant to work with [`django-stubs`](https://github.com/typeddjango/django-stubs), but some improvements and changes are probably going to be 23 | > implemented in the stub definitions, and could potentially require some changes to the generated stubs. 24 | 25 | # Installation 26 | 27 | Through `pip`: 28 | 29 | ```sh 30 | pip install django-autotyping 31 | ``` 32 | 33 | To make use of the dynamic stubs feature, you will also need to install [`django-stubs`](https://github.com/typeddjango/django-stubs): 34 | 35 | ```sh 36 | pip install django-stubs 37 | ``` 38 | 39 | ## Configuration 40 | 41 | As any Django application, you will need to add `django_autotyping` to your [`INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/settings/#std-setting-INSTALLED_APPS) 42 | (preferably in your development or local settings, if you already have them separated). 43 | 44 | The application is configurable through the `AUTOTYPING` dict: 45 | 46 | ```python 47 | AUTOTYPING = { 48 | "STUBS_GENERATION": { 49 | "LOCAL_STUBS_DIR": Path(BASE_DIR, "typings"), 50 | } 51 | } 52 | ``` 53 | 54 | > [!TIP] 55 | > To get typing and auto-completion support, you can make use of the `AutotypingSettingsDict` helper: 56 | > 57 | > ```python 58 | > from django_autotyping.typing import AutotypingSettingsDict 59 | > 60 | > AUTOTYPING: AutotypingSettingsDict = { 61 | > ... 62 | > } 63 | > ``` 64 | 65 | `django-autotyping` provides several linting rules, identified with the pattern `DJA00X` or `DJAS00X`. Rules can be disabled 66 | using the `IGNORE` setting value. 67 | 68 | For a complete list of available configuration values, refer to the usage section of [dynamic stubs](https://viicos.github.io/django-autotyping/usage/dynamic_stubs/) 69 | and [explicit type hints](https://viicos.github.io/django-autotyping/usage/explicit_type_hints/). 70 | -------------------------------------------------------------------------------- /_templates/python/material/labels.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/_templates/python/material/labels.html -------------------------------------------------------------------------------- /docs/api/dynamic_stubs/rules.md: -------------------------------------------------------------------------------- 1 | ::: django_autotyping.stubbing.codemods.forward_relation_overload_codemod.ForwardRelationOverloadCodemod 2 | options: 3 | show_bases: false 4 | show_source: false 5 | show_root_heading: true 6 | show_root_full_path: false 7 | show_if_no_docstring: true 8 | members: 9 | - STUB_FILES 10 | 11 | ::: django_autotyping.stubbing.codemods.create_overload_codemod.CreateOverloadCodemod 12 | options: 13 | show_bases: false 14 | show_source: false 15 | show_root_heading: true 16 | show_root_full_path: false 17 | show_if_no_docstring: true 18 | members: 19 | - STUB_FILES 20 | 21 | ::: django_autotyping.stubbing.codemods.model_init_overload_codemod.ModelInitOverloadCodemod 22 | options: 23 | show_bases: false 24 | show_source: false 25 | show_root_heading: true 26 | show_root_full_path: false 27 | show_if_no_docstring: true 28 | members: 29 | - STUB_FILES 30 | 31 | ::: django_autotyping.stubbing.codemods.get_model_overload_codemod.GetModelOverloadCodemod 32 | options: 33 | show_bases: false 34 | show_source: false 35 | show_root_heading: true 36 | show_root_full_path: false 37 | show_if_no_docstring: true 38 | members: 39 | - STUB_FILES 40 | 41 | ::: django_autotyping.stubbing.codemods.auth_functions_codemod.AuthFunctionsCodemod 42 | options: 43 | show_bases: false 44 | show_source: false 45 | show_root_heading: true 46 | show_root_full_path: false 47 | show_if_no_docstring: true 48 | members: 49 | - STUB_FILES 50 | 51 | ::: django_autotyping.stubbing.codemods.reverse_overload_codemod.ReverseOverloadCodemod 52 | options: 53 | show_bases: false 54 | show_source: false 55 | show_root_heading: true 56 | show_root_full_path: false 57 | show_if_no_docstring: true 58 | members: 59 | - STUB_FILES 60 | 61 | ::: django_autotyping.stubbing.codemods.settings_codemod.SettingCodemod 62 | options: 63 | show_bases: false 64 | show_source: false 65 | show_root_heading: true 66 | show_root_full_path: false 67 | show_if_no_docstring: true 68 | members: 69 | - STUB_FILES 70 | 71 | ::: django_autotyping.stubbing.codemods.template_loading_codemod.TemplateLoadingCodemod 72 | options: 73 | show_bases: false 74 | show_source: false 75 | show_root_heading: true 76 | show_root_full_path: false 77 | show_if_no_docstring: true 78 | members: 79 | - STUB_FILES 80 | -------------------------------------------------------------------------------- /docs/api/explicit_type_hints/rules.md: -------------------------------------------------------------------------------- 1 | ::: django_autotyping.codemodding.codemods.forward_relation_typing_codemod.ForwardRelationTypingCodemod 2 | options: 3 | show_bases: false 4 | show_source: false 5 | show_root_heading: true 6 | show_root_full_path: false 7 | show_if_no_docstring: true 8 | members: 9 | - STUB_FILES 10 | -------------------------------------------------------------------------------- /docs/api/typing.md: -------------------------------------------------------------------------------- 1 | 2 | ::: django_autotyping.typing.AutotypingSettingsDict 3 | options: 4 | show_root_heading: true 5 | show_root_full_path: false 6 | show_if_no_docstring: true 7 | members: 8 | - STUB_FILES 9 | -------------------------------------------------------------------------------- /docs/assets/mypy-vscode-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/docs/assets/mypy-vscode-extension.png -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --8<-- "CHANGELOG.md" 2 | -------------------------------------------------------------------------------- /docs/context.md: -------------------------------------------------------------------------------- 1 | # Context 2 | 3 | !!! info 4 | This section expands on [this article](https://viicos.github.io/posts/an-alternative-to-the-django-mypy-plugin/), 5 | which goes into more technical details. 6 | 7 | This section describes how `django-autotyping` is able to provide features working with any type checker and without 8 | having to manually annotate your code. 9 | 10 | ## The current state of typing with Django 11 | 12 | Django being created way before type hints were a thing in Python, it wasn’t designed with typing support in mind. For this reason, 13 | external type stubs are maintained by the [TypedDjango](https://github.com/typeddjango/) organization. 14 | 15 | By installing `django-stubs`, you will be able to enforce type checking in your Django project, and also benefit from a nicer development experience assuming you are using a [LSP](https://microsoft.github.io/language-server-protocol/) implementation in your IDE. 16 | 17 | However, it is sometimes not possible to support all the dynamic features provided by Django at runtime. 18 | 19 | The most common (and probably annoying) example appears when dealing with models and foreign fields: 20 | 21 | ```python 22 | from django.db import models 23 | 24 | class Blog(models.Model): ... 25 | 26 | class BlogPost(models.Model): 27 | name = models.CharField( 28 | max_length=255, 29 | ) 30 | blog = models.ForeignKey( 31 | to="Blog", 32 | on_delete=models.CASCADE, 33 | ) 34 | ``` 35 | 36 | When accessing attributes of this model at runtime, the database type is mapped to a Python type: 37 | 38 | ```python 39 | >>> type(blog_post.name) 40 | #> 41 | >>> blog_post.blog 42 | #> 43 | ``` 44 | 45 | However, there is currently no way to describe the type of `blog_post.post` using native typing features (as it is being referenced as a string to avoid [circular imports][django.db.models.ForeignKey]). 46 | 47 | To overcome this issue, a [mypy](https://mypy.readthedocs.io/en/stable/) plugin is provided, that will make use of your 48 | project settings to discover the available models, so that mypy can understand string references, amongst other features. 49 | 50 | ## The drawbacks of using the mypy plugin 51 | 52 | Using a type checker can be beneficial to catch errors that would usually result in unhandled exceptions at runtime. 53 | To get immediate feedback on these errors along with the inferred types of your code, the type checker can be hooked up in your IDE via a [LSP](https://microsoft.github.io/language-server-protocol/) integration 54 | (for VSCode users, this is what [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) is essentially doing). 55 | 56 | Does this mean we can get all the nice auto-completions and features provided by mypy and the Django plugin? 57 | 58 | Not really. While LSP implementations for mypy [are available](https://github.com/python-lsp/pylsp-mypy/), they seem to be lacking 59 | important features that you would expect in an IDE. You do get the correct types from the mypy plugin, but you are missing all the highlights/auto-completions: 60 | 61 |
62 | ![mypy IDE integration](assets/mypy-vscode-extension.png) 63 |
The revealed type by Mypy is correct, but no auto-completion is provided on the 'blog' argument
64 |
65 | 66 | ## Using customized stubs for your project 67 | 68 | To overcome this issue, we need to find a solution that would ideally: 69 | 70 | - Be agnostic of any type checker, that is only using the existing Python typing logic. 71 | - Avoid having to manually annotate your code, if possible. 72 | 73 | `django-autotyping` will take advantage of the ability to use a custom type stubs directory (you might need to [configure your type checker](usage/dynamic_stubs.md#type-checker-configuration)), where a customized copy of `django-stubs` will be created. Because `django-autotyping` knows the current 74 | state of your application (e.g. the available models, views), it will generate additional overloads when possible, matching string 75 | references to your models for example. 76 | 77 | ???+ example 78 | A smart feature available in type checkers is to add [overloads][typing.overload] to the `__init__` method 79 | of a class to influence the constructed object type. With the following: 80 | 81 | ```python 82 | # __set__ value type 83 | _ST = TypeVar("_ST") 84 | # __get__ return type 85 | _GT = TypeVar("_GT") 86 | 87 | class ForeignKey(Generic[_ST, _GT]): 88 | @overload 89 | def __init__( 90 | self: ForeignKey[Blog | Combinable | None, Blog | None], 91 | to: Literal["Blog", "blogs.Blog"], 92 | ..., 93 | null: Literal[True], 94 | ... 95 | ): ... 96 | @overload 97 | def __init__( 98 | self: ForeignKey[Blog | Combinable, Blog], 99 | to: Literal["Blog", "blogs.Blog"], 100 | ..., 101 | null: Literal[False] = ..., 102 | ... 103 | ): ... 104 | ``` 105 | 106 | Without going into the details of the Django field descriptors, this enables the following: 107 | 108 | ```python 109 | class BlogPost(models.Model): 110 | blog = models.ForeignKey( 111 | to="Blog", 112 | on_delete=models.CASCADE, 113 | ) 114 | 115 | BlogPost().blog # typed as "Blog", or "Blog | None" if null was set to `True` 116 | ``` 117 | 118 | You get: 119 | 120 | - Complete support for typed foreign fields, without any manual annotations 121 | - Support for nullable fields 122 | - Complete IDE support! 123 | 124 | ## Dynamic stubs don't solve everything 125 | 126 | Even if the generated dynamic stubs cover a lot of cases, explicit annotations are still required sometimes. 127 | Consider the use case of reverse relationships: 128 | 129 | ```python 130 | # On a blog instance, the related blog posts can be accessed: 131 | blog.blogpost_set # Or with a custom attribute name, by specifying `related_name` 132 | ``` 133 | 134 | To make the type checker aware of this attribute, you have to explicitly annotate the `Blog` class: 135 | 136 | ```python 137 | class Blog(models.Model): 138 | # `BlogPost` also needs to be imported, and the `Manager` class 139 | # used might differ: 140 | blogpost_set: Manager[BlogPost] 141 | ``` 142 | 143 | For these kind of use cases, `django-autotyping` provides a `add_type_hints` command, that will take care 144 | of automatically add type hints to your source code. 145 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django Autotyping 2 | 3 | [![Python versions](https://img.shields.io/pypi/pyversions/django-autotyping.svg)](https://www.python.org/downloads/) 4 | [![PyPI version](https://img.shields.io/pypi/v/django-autotyping.svg)](https://pypi.org/project/django-autotyping/) 5 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 6 | 7 | `django-autotyping` enhances your developing experience with Django by providing accurate type hints, without the need for 8 | a custom IDE or mypy plugin: 9 | 10 | - Generates custom [type stubs][stubs] based on the current state of your Django application, enhancing your development experience by providing auto-completions and accurate type checking. 11 | - Automatically add explicit type hints to your source code when type stubs are not enough. 12 | 13 | To understand the *why* and *how*, you can refer to the [context section][context]. 14 | 15 | `django-autotyping` is built with [LibCST](https://github.com/Instagram/LibCST/). 16 | 17 |
18 | ![Demo](assets/demo.svg) 19 |
A live demo
20 |
21 | 22 | !!! warning "Still in development" 23 | This project is still work in progress. It is meant to work with [`django-stubs`](https://github.com/typeddjango/django-stubs), but some improvements and changes are probably going to be 24 | implemented in the stub definitions, and could potentially require some changes to the generated stubs. 25 | 26 | # Installation 27 | 28 | Through `pip`: 29 | 30 | ```sh 31 | pip install django-autotyping 32 | ``` 33 | 34 | To make use of the dynamic stubs feature, you will also need to install [`django-stubs`](https://github.com/typeddjango/django-stubs): 35 | 36 | ```sh 37 | pip install django-stubs 38 | ``` 39 | 40 | ## Configuration 41 | 42 | As any Django application, you will need to add `django_autotyping` to your [`INSTALLED_APPS`][INSTALLED_APPS] 43 | (preferably in your development or local settings, if you already have them separated). 44 | 45 | The application is configurable through the `AUTOTYPING` dict: 46 | 47 | ```python 48 | AUTOTYPING = { 49 | "STUBS_GENERATION": { 50 | "LOCAL_STUBS_DIR": Path(BASE_DIR, "typings"), 51 | } 52 | } 53 | ``` 54 | 55 | ??? tip "Type checking configuration" 56 | To get typing and auto-completion support, you can make use of the 57 | [`AutotypingSettingsDict`][django_autotyping.typing.AutotypingSettingsDict] helper: 58 | 59 | ```python 60 | from django_autotyping.typing import AutotypingSettingsDict 61 | 62 | AUTOTYPING: AutotypingSettingsDict = { 63 | ... 64 | } 65 | ``` 66 | 67 | `django-autotyping` provides several linting rules, identified with the pattern `DJA00X` or `DJAS00X`. Rules can be disabled 68 | using the `IGNORE` setting value. 69 | 70 | For a complete list of available configuration values, refer to the usage section of [dynamic stubs][dynamic-stubs] and [explicit type hints][explicit-type-hints]. 71 | -------------------------------------------------------------------------------- /docs/usage/dynamic_stubs.md: -------------------------------------------------------------------------------- 1 | # Dynamic stubs 2 | 3 | `django-autotyping` can generate customized type stubs depending on the current state of your Django project: 4 | 5 | ```sh 6 | python manage.py generate_stubs --local-stubs-dir typings/ --ignore DJAS001 7 | ``` 8 | 9 | ## Available rules 10 | 11 | The following is a list of the available rules related to dynamic stubs: 12 | 13 | - [`DJAS001`][django_autotyping.stubbing.codemods.forward_relation_overload_codemod.ForwardRelationOverloadCodemod]: add overloads to the `__init__` methods of related fields. 14 | - [`DJAS002`][django_autotyping.stubbing.codemods.create_overload_codemod.CreateOverloadCodemod]: Add overloads to the [`create`][django.db.models.query.QuerySet.create] and [`acreate`][django.db.models.query.QuerySet.acreate] methods. 15 | - [`DJAS003`][django_autotyping.stubbing.codemods.model_init_overload_codemod.ModelInitOverloadCodemod]: Add overloads to the [`Model.__init__`][django.db.models.Model] method. 16 | - [`DJAS010`][django_autotyping.stubbing.codemods.get_model_overload_codemod.GetModelOverloadCodemod]: Add overloads to the [`apps.get_model`][django.apps.apps.get_model] method. 17 | - [`DJAS011`][django_autotyping.stubbing.codemods.auth_functions_codemod.AuthFunctionsCodemod]: Add a custom return type to the to auth related functions. 18 | - [`DJAS015`][django_autotyping.stubbing.codemods.reverse_overload_codemod.ReverseOverloadCodemod]: Add overloads to the [`reverse`][django.urls.reverse] function. 19 | - [`DJAS016`][django_autotyping.stubbing.codemods.settings_codemod.SettingCodemod]: Add typing to the Django settings object. 20 | - [`DJAS017`][django_autotyping.stubbing.codemods.template_loading_codemod.TemplateLoadingCodemod]: Add overloads for template loading functions. 21 | 22 | 23 | ## Type checker configuration 24 | 25 | Before making use of this feature, you must configure your type checker to discover your custom stubs: 26 | 27 | - [`pyright`](https://github.com/microsoft/pyright/): will look for the `typings/` directory by default (see the [`stubPath` configuration option](https://microsoft.github.io/pyright/#/configuration?id=main-configuration-options)). 28 | - [`mypy`](https://github.com/python/mypy/): configurable via the [`mypy_path`][mypy_path] value (or use the `MYPY_PATH` environment variable). 29 | 30 | ## Configuration 31 | 32 | This section describes the available configuration options for stubs generation. These values must be set as a dictionary under 33 | the `STUBS_GENERATION` key: 34 | 35 | ```python 36 | AUTOTYPING = { 37 | "STUBS_GENERATION": { 38 | "LOCAL_STUBS_DIR": Path(...), 39 | } 40 | } 41 | ``` 42 | 43 | ::: django_autotyping.app_settings.StubsGenerationSettings 44 | options: 45 | show_source: false 46 | members_order: source 47 | inherited_members: true 48 | signature_crossrefs: true 49 | show_signature_annotations: true 50 | -------------------------------------------------------------------------------- /docs/usage/explicit_type_hints.md: -------------------------------------------------------------------------------- 1 | # Explicit type hints 2 | 3 | There are some cases where generating custom type stubs is not enough. In such cases, it might be required 4 | to add some explicit type annotations to your code. 5 | 6 | Fortunately, `django-autotyping` can automatically add these type hints in some places. 7 | 8 | ```sh 9 | python manage.py add_type_hints --project-dir src/ --diff --ignore DJA001 10 | ``` 11 | 12 | !!! warning "Still work in progress" 13 | This functionality is still work in progress. 14 | 15 | ## Available rules 16 | 17 | - [`DJA001`][django_autotyping.codemodding.codemods.forward_relation_typing_codemod.ForwardRelationTypingCodemod]: Add type annotations to forward relations. 18 | 19 | ## Configuration 20 | 21 | This section describes the available configuration options for stubs generation. These values must be set as a dictionary under 22 | the `CODE_GENERATION` key: 23 | 24 | ```python 25 | AUTOTYPING = { 26 | "STUBS_GENERATION": { 27 | "PROJECT_DIR": Path(...), 28 | } 29 | } 30 | ``` 31 | 32 | ::: django_autotyping.app_settings.CodeGenerationSettings 33 | options: 34 | show_source: false 35 | members_order: source 36 | inherited_members: true 37 | signature_crossrefs: true 38 | show_signature_annotations: true 39 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "Django Autotyping" 2 | site_description: "Automatically add type hints for Django powered applications." 3 | repo_url: https://github.com/Viicos/django-autotyping 4 | repo_name: Viicos/django-autotyping 5 | edit_uri: edit/main/docs/ 6 | watch: [mkdocs.yml, src/django_autotyping, docs] 7 | 8 | theme: 9 | name: material 10 | features: 11 | - content.action.view 12 | - content.code.copy 13 | palette: 14 | - scheme: default 15 | media: "(prefers-color-scheme: light)" 16 | primary: green 17 | toggle: 18 | icon: material/brightness-7 19 | name: Switch to dark mode 20 | - scheme: slate 21 | media: "(prefers-color-scheme: dark)" 22 | primary: green 23 | toggle: 24 | icon: material/brightness-4 25 | name: Switch to light mode 26 | icon: 27 | view: material/eye 28 | repo: fontawesome/brands/github 29 | 30 | nav: 31 | - Home: 32 | - Index: index.md 33 | - Context: context.md 34 | - Usage: 35 | - 'Dynamic stubs': usage/dynamic_stubs.md 36 | - 'Explicit type hints': usage/explicit_type_hints.md 37 | - API: 38 | - 'Dynamic stubs': 39 | - Rules: api/dynamic_stubs/rules.md 40 | - 'Explicit type hints': 41 | - Rules: api/explicit_type_hints/rules.md 42 | - Typing: api/typing.md 43 | - Changelog: changelog.md 44 | 45 | markdown_extensions: 46 | - admonition 47 | - pymdownx.highlight: 48 | anchor_linenums: true 49 | line_spans: __span 50 | pygments_lang_class: true 51 | - pymdownx.details 52 | - pymdownx.inlinehilite 53 | - pymdownx.superfences 54 | - pymdownx.snippets 55 | - toc: 56 | permalink: true 57 | - attr_list 58 | - md_in_html 59 | 60 | plugins: 61 | - search 62 | - autorefs 63 | - mkdocstrings: 64 | # TODO remove when https://github.com/mkdocstrings/python/pull/130 eventually gets merged: 65 | custom_templates: _templates 66 | handlers: 67 | python: 68 | paths: [src] 69 | import: 70 | - https://docs.python.org/3.13/objects.inv 71 | - url: https://docs.djangoproject.com/en/dev/_objects 72 | domains: [std,py] 73 | - url: https://typing.readthedocs.io/en/latest/objects.inv 74 | domains: [std] 75 | - url: https://mypy.readthedocs.io/en/stable/objects.inv 76 | domains: [std] 77 | - url: https://peps.python.org/objects.inv 78 | domains: [std] 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-autotyping" 7 | description = "Automatically add type hints for Django powered applications." 8 | authors = [ 9 | {name = "Viicos", email = "65306057+Viicos@users.noreply.github.com"} 10 | ] 11 | requires-python = ">=3.8" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Framework :: Django", 23 | "Typing :: Typed", 24 | "License :: OSI Approved :: MIT License", 25 | "Intended Audience :: Developers", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Topic :: Software Development :: Quality Assurance", 28 | ] 29 | dependencies = [ 30 | "django", 31 | "libcst>=0.4.10", 32 | "typing-extensions>=4.4.0; python_version < '3.12'", 33 | ] 34 | license = {file = "LICENSE"} 35 | dynamic = ["version", "readme"] 36 | 37 | [project.urls] 38 | Homepage = "https://github.com/Viicos/django-autotyping" 39 | Source = "https://github.com/Viicos/django-autotyping" 40 | Changelog = "https://github.com/Viicos/django-autotyping/blob/main/CHANGELOG.md" 41 | Documentation = "https://viicos.github.io/django-autotyping/" 42 | 43 | [tool.setuptools.dynamic] 44 | version = {attr = "django_autotyping.__version__"} 45 | 46 | [tool.setuptools] 47 | package-dir = {"" = "src"} 48 | 49 | [tool.setuptools.packages.find] 50 | where = ["src"] 51 | 52 | [tool.setuptools.package-data] 53 | "django_autotyping" = ["py.typed"] 54 | 55 | [tool.ruff] 56 | line-length = 120 57 | src = ["src"] 58 | exclude = ["tests/codemodtestproj", "tests/stubstestproj", "tests/stubbing/testfiles"] 59 | target-version = "py38" 60 | 61 | [tool.ruff.lint] 62 | preview = true 63 | explicit-preview-rules = true 64 | select = [ 65 | "E", # pycodestyle (E) 66 | "W", # pycodestyle (W) 67 | "F", # Pyflakes 68 | "UP", # pyupgrade 69 | "I", # isort 70 | "PL", # Pylint 71 | "RUF", # Ruff 72 | "RUF022", # Ruff-preview 73 | "YTT", # flake8-2020 74 | "B", # flake8-bugbear 75 | "C4", # flake8-comprehensions 76 | "T10", # flake8-debugger 77 | "PIE", # flake8-pie 78 | "T20", # flake8-print 79 | "RSE", # flake8-raise 80 | "PTH", # flake8-use-pathlib 81 | ] 82 | ignore = [ 83 | "PLR0912", 84 | "PLR0913", 85 | "PLW2901", 86 | "RUF012", 87 | ] 88 | 89 | [tool.ruff.lint.isort] 90 | known-first-party = ["django_autotyping"] 91 | 92 | [tool.pytest.ini_options] 93 | pythonpath = "src" 94 | norecursedirs = "tests/helpers" 95 | markers = [ 96 | "mypy: test generated stubs against mypy (deselect with '-m \"not mypy\"')", 97 | "pyright: test generated stubs against pyright (deselect with '-m \"not pyright\"')", 98 | ] 99 | -------------------------------------------------------------------------------- /requirements/requirements-dev.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | django-stubs 3 | mypy 4 | ruff 5 | -------------------------------------------------------------------------------- /requirements/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile requirements/requirements-dev.in 6 | # 7 | asgiref==3.7.2 8 | # via 9 | # -c requirements/requirements.txt 10 | # django 11 | django==4.2.9 12 | # via 13 | # -c requirements/requirements.txt 14 | # django-stubs 15 | # django-stubs-ext 16 | django-stubs==4.2.7 17 | # via -r requirements/requirements-dev.in 18 | django-stubs-ext==4.2.7 19 | # via django-stubs 20 | mypy==1.7.1 21 | # via -r requirements/requirements-dev.in 22 | mypy-extensions==1.0.0 23 | # via 24 | # -c requirements/requirements.txt 25 | # mypy 26 | ruff==0.2.0 27 | # via -r requirements/requirements-dev.in 28 | sqlparse==0.4.4 29 | # via 30 | # -c requirements/requirements.txt 31 | # django 32 | tomli==2.0.1 33 | # via 34 | # django-stubs 35 | # mypy 36 | types-pytz==2023.3.1.1 37 | # via django-stubs 38 | types-pyyaml==6.0.12.12 39 | # via django-stubs 40 | typing-extensions==4.9.0 41 | # via 42 | # -c requirements/requirements.txt 43 | # asgiref 44 | # django-stubs 45 | # django-stubs-ext 46 | # mypy 47 | -------------------------------------------------------------------------------- /requirements/requirements-docs.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | mkdocs 3 | mkdocstrings[python] 4 | mkdocs-material 5 | -------------------------------------------------------------------------------- /requirements/requirements-docs.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile requirements/requirements-docs.in 6 | # 7 | babel==2.14.0 8 | # via mkdocs-material 9 | certifi==2023.11.17 10 | # via requests 11 | charset-normalizer==3.3.2 12 | # via requests 13 | click==8.1.7 14 | # via 15 | # mkdocs 16 | # mkdocstrings 17 | colorama==0.4.6 18 | # via 19 | # griffe 20 | # mkdocs-material 21 | ghp-import==2.1.0 22 | # via mkdocs 23 | griffe==0.38.1 24 | # via mkdocstrings-python 25 | idna==3.6 26 | # via requests 27 | jinja2==3.1.2 28 | # via 29 | # mkdocs 30 | # mkdocs-material 31 | # mkdocstrings 32 | markdown==3.5.1 33 | # via 34 | # mkdocs 35 | # mkdocs-autorefs 36 | # mkdocs-material 37 | # mkdocstrings 38 | # pymdown-extensions 39 | markupsafe==2.1.3 40 | # via 41 | # jinja2 42 | # mkdocs 43 | # mkdocstrings 44 | mergedeep==1.3.4 45 | # via mkdocs 46 | mkdocs==1.5.3 47 | # via 48 | # -r requirements/requirements-docs.in 49 | # mkdocs-autorefs 50 | # mkdocs-material 51 | # mkdocstrings 52 | mkdocs-autorefs==0.5.0 53 | # via mkdocstrings 54 | mkdocs-material==9.5.7 55 | # via -r requirements/requirements-docs.in 56 | mkdocs-material-extensions==1.3.1 57 | # via mkdocs-material 58 | mkdocstrings[python]==0.24.0 59 | # via 60 | # -r requirements/requirements-docs.in 61 | # mkdocstrings-python 62 | mkdocstrings-python==1.8.0 63 | # via mkdocstrings 64 | packaging==23.2 65 | # via mkdocs 66 | paginate==0.5.6 67 | # via mkdocs-material 68 | pathspec==0.12.1 69 | # via mkdocs 70 | platformdirs==4.1.0 71 | # via 72 | # mkdocs 73 | # mkdocstrings 74 | pygments==2.17.2 75 | # via mkdocs-material 76 | pymdown-extensions==10.7 77 | # via 78 | # mkdocs-material 79 | # mkdocstrings 80 | python-dateutil==2.8.2 81 | # via ghp-import 82 | pyyaml==6.0.1 83 | # via 84 | # -c requirements/requirements.txt 85 | # mkdocs 86 | # pymdown-extensions 87 | # pyyaml-env-tag 88 | pyyaml-env-tag==0.1 89 | # via mkdocs 90 | regex==2023.12.25 91 | # via mkdocs-material 92 | requests==2.31.0 93 | # via mkdocs-material 94 | six==1.16.0 95 | # via python-dateutil 96 | urllib3==2.1.0 97 | # via requests 98 | watchdog==3.0.0 99 | # via mkdocs 100 | -------------------------------------------------------------------------------- /requirements/requirements-test.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | django-stubs 3 | mypy 4 | pyright 5 | pytest 6 | tox 7 | tox-gh-actions 8 | typing-extensions 9 | -------------------------------------------------------------------------------- /requirements/requirements-test.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile requirements/requirements-test.in 6 | # 7 | asgiref==3.7.2 8 | # via 9 | # -c requirements/requirements.txt 10 | # django 11 | cachetools==5.3.2 12 | # via tox 13 | chardet==5.2.0 14 | # via tox 15 | colorama==0.4.6 16 | # via tox 17 | distlib==0.3.7 18 | # via virtualenv 19 | django==4.2.9 20 | # via 21 | # -c requirements/requirements.txt 22 | # django-stubs 23 | # django-stubs-ext 24 | django-stubs==4.2.7 25 | # via -r requirements/requirements-test.in 26 | django-stubs-ext==4.2.7 27 | # via django-stubs 28 | exceptiongroup==1.2.0 29 | # via pytest 30 | filelock==3.13.1 31 | # via 32 | # tox 33 | # virtualenv 34 | iniconfig==2.0.0 35 | # via pytest 36 | mypy==1.8.0 37 | # via -r requirements/requirements-test.in 38 | mypy-extensions==1.0.0 39 | # via 40 | # -c requirements/requirements.txt 41 | # mypy 42 | nodeenv==1.8.0 43 | # via pyright 44 | packaging==23.2 45 | # via 46 | # pyproject-api 47 | # pytest 48 | # tox 49 | platformdirs==4.1.0 50 | # via 51 | # tox 52 | # virtualenv 53 | pluggy==1.3.0 54 | # via 55 | # pytest 56 | # tox 57 | pyproject-api==1.6.1 58 | # via tox 59 | pyright==1.1.345 60 | # via -r requirements/requirements-test.in 61 | pytest==7.4.3 62 | # via -r requirements/requirements-test.in 63 | sqlparse==0.4.4 64 | # via 65 | # -c requirements/requirements.txt 66 | # django 67 | tomli==2.0.1 68 | # via 69 | # django-stubs 70 | # mypy 71 | # pyproject-api 72 | # pytest 73 | # tox 74 | tox==4.11.4 75 | # via 76 | # -r requirements/requirements-test.in 77 | # tox-gh-actions 78 | tox-gh-actions==3.2.0 79 | # via -r requirements/requirements-test.in 80 | types-pytz==2023.3.1.1 81 | # via django-stubs 82 | types-pyyaml==6.0.12.12 83 | # via django-stubs 84 | typing-extensions==4.9.0 85 | # via 86 | # -c requirements/requirements.txt 87 | # -r requirements/requirements-test.in 88 | # asgiref 89 | # django-stubs 90 | # django-stubs-ext 91 | # mypy 92 | virtualenv==20.25.0 93 | # via tox 94 | 95 | # The following packages are considered to be unsafe in a requirements file: 96 | # setuptools 97 | -------------------------------------------------------------------------------- /requirements/requirements.in: -------------------------------------------------------------------------------- 1 | django~=4.2.0 2 | libcst 3 | typing_extensions 4 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile requirements/requirements.in 6 | # 7 | asgiref==3.7.2 8 | # via django 9 | django==4.2.9 10 | # via -r requirements/requirements.in 11 | libcst==1.1.0 12 | # via -r requirements/requirements.in 13 | mypy-extensions==1.0.0 14 | # via typing-inspect 15 | pyyaml==6.0.1 16 | # via libcst 17 | sqlparse==0.4.4 18 | # via django 19 | typing-extensions==4.9.0 20 | # via 21 | # -r requirements/requirements.in 22 | # asgiref 23 | # libcst 24 | # typing-inspect 25 | typing-inspect==0.9.0 26 | # via libcst 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from setuptools import setup 4 | 5 | DEMO_TAG = """ 6 |

7 | 8 |

9 | """ 10 | 11 | 12 | def dynamic_readme() -> str: 13 | readme = Path(__file__).parent / "README.md" 14 | content = readme.read_text() 15 | content = content.replace(DEMO_TAG, "").replace("> [!WARNING]", "").replace("> [!TIP]", "") 16 | return content 17 | 18 | 19 | setup( 20 | long_description=dynamic_readme(), 21 | long_description_content_type="text/markdown", 22 | ) 23 | -------------------------------------------------------------------------------- /src/django_autotyping/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.1" 2 | -------------------------------------------------------------------------------- /src/django_autotyping/_compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | if sys.version_info >= (3, 12): 5 | from typing import NotRequired, Required, Self, TypeAlias, TypeGuard, Unpack, override 6 | else: 7 | from typing_extensions import NotRequired, Required, Self, TypeAlias, TypeGuard, Unpack, override # noqa: F401 8 | 9 | if sys.version_info >= (3, 10): 10 | from types import NoneType 11 | else: 12 | NoneType = type(None) 13 | 14 | 15 | def is_relative_to(path: Path, other: Path) -> bool: 16 | if sys.version_info >= (3, 9): 17 | return path.is_relative_to(other) 18 | else: 19 | try: 20 | path.relative_to(other) 21 | return True 22 | except ValueError: 23 | return False 24 | -------------------------------------------------------------------------------- /src/django_autotyping/app_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from copy import deepcopy 4 | from dataclasses import dataclass, field 5 | from pathlib import Path 6 | 7 | from django.conf import LazySettings 8 | 9 | from ._compat import Self 10 | from .typing import AutotypingSettingsDict, RulesT 11 | 12 | 13 | @dataclass 14 | class CodeGenerationSettings: 15 | """Configuration for adding type annotations to Django user code.""" 16 | 17 | PROJECT_DIR: Path | None = None 18 | """The directory of the project, where code modifications should be applied.""" 19 | 20 | DIFF: bool = False 21 | """Show changes to be applied instead of modifying existing files.""" 22 | 23 | TYPE_CHECKING_BLOCK: bool = True 24 | """Whether newly added imports should be in an `if TYPE_CHECKING` block (avoids circular imports).""" 25 | 26 | ASSUME_CLASS_GETITEM: bool = False 27 | """Whether generic classes in stubs files but not at runtime should be assumed to have a 28 | `__class_getitem__` method. This can be achieved by using `django-stubs-ext` or manually. 29 | 30 | Affected rules: `DJA001`. 31 | """ 32 | 33 | 34 | @dataclass 35 | class StubsGenerationSettings: 36 | """Configuration for dynamic stubs generation.""" 37 | 38 | LOCAL_STUBS_DIR: Path | None = None 39 | """The directory of the local type stubs. If not set, this setting must be set as a CLI argument.""" 40 | 41 | SOURCE_STUBS_DIR: Path | None = None 42 | """The directory of the source `django-stubs` to be used. Will default 43 | to the first entry in site packages. 44 | """ 45 | 46 | ALLOW_PLAIN_MODEL_REFERENCES: bool = True 47 | """Whether string references in the form of `{model_name}` should be generated in overloads. 48 | 49 | If set to `True`, both `{model_name}` and `{app_label}.{model_name}` are allowed 50 | (unless the model name has a duplicate in a different app). 51 | 52 | Affected rules: `DJAS001`. 53 | """ 54 | 55 | ALLOW_NONE_SET_TYPE: bool = False 56 | """Whether to allow having the `__set__` type variable set to `None`, even if the field is not nullable. 57 | 58 | While Django allows setting most model instance fields to any value (before saving), 59 | it is generally a bad practice to do so. However, it might be beneficial to allow `None` 60 | to be set temporarly. 61 | 62 | This also works for foreign fields, where unlike standard fields, the Django descriptor used 63 | only allows model instances and `None` to be set. 64 | 65 | Affected rules: `DJAS001`. 66 | """ 67 | 68 | MODEL_FIELDS_OPTIONAL: bool = True 69 | """Whether all model fields should be considered optional when creating model instances. 70 | 71 | This affects the following signatures: 72 | 73 | - [`Manager.create/acreate`][django.db.models.Manager] 74 | - `__init__` methods of models 75 | 76 | A lot can happen behind the scenes when instantiating models. Even if a field doesn't have 77 | a default value provided, the database could have triggers implemented that would provide one. 78 | This is why, by default, this configuration attribute defaults to `True`. If set to `False`, 79 | `django-autotyping` will try its best to determine required fields, namely by checking if: 80 | 81 | - the field can be [`null`][django.db.models.Field.null] or [`blank`][django.db.models.Field.null] 82 | - the field is a primary key 83 | - the field has a default or a database default value set 84 | - the field is a subclass of [`DateField`][django.db.models.DateField] and has 85 | [`auto_now`][django.db.models.DateField.auto_now] or [`auto_now_add`][django.db.models.DateField.auto_now_add] 86 | set to `True`. 87 | 88 | Affected rules: `DJAS002`, `DJAS003`. 89 | """ 90 | 91 | ALLOW_REVERSE_ARGS: bool = False 92 | """Whether type checking should be added to the `args` argument of [`reverse`][django.urls.reverse]. 93 | 94 | By default, this is set to `False` to avoid having too many overloads being generated. 95 | Moreover, only tuples can be type checked, and most people are using lists for this argument. 96 | Instead, it is recommended to use the `kwargs` argument. 97 | 98 | Affected rules: `DJAS015`. 99 | """ 100 | 101 | 102 | @dataclass 103 | class AutotypingSettings: 104 | """A class holding the django-autotyping configuration.""" 105 | 106 | IGNORE: list[RulesT] = field(default_factory=list) 107 | """A list of ignored rules.""" 108 | 109 | STUBS_GENERATION: StubsGenerationSettings = field(default_factory=StubsGenerationSettings) 110 | """Stub related settings.""" 111 | 112 | CODE_GENERATION: CodeGenerationSettings = field(default_factory=CodeGenerationSettings) 113 | """Code generation related settings.""" 114 | 115 | @classmethod 116 | def from_django_settings(cls, settings: LazySettings) -> Self: 117 | # Not pretty, but we are limited by dataclasses 118 | autotyping_settings: AutotypingSettingsDict = deepcopy(getattr(settings, "AUTOTYPING", {})) 119 | stubs_generation_dct = autotyping_settings.pop("STUBS_GENERATION", {}) 120 | code_generation_dct = autotyping_settings.pop("CODE_GENERATION", {}) 121 | return cls( 122 | **autotyping_settings, 123 | STUBS_GENERATION=StubsGenerationSettings(**stubs_generation_dct), 124 | CODE_GENERATION=CodeGenerationSettings(**code_generation_dct), 125 | ) 126 | -------------------------------------------------------------------------------- /src/django_autotyping/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoAutotypingAppConfig(AppConfig): 5 | name = "django_autotyping" 6 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/__init__.py: -------------------------------------------------------------------------------- 1 | """This module holds the implementation to add explicit type annotations to user code.""" 2 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/codemods/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ("ForwardRelationTypingCodemod", "RulesT", "gather_codemods", "rules") 4 | 5 | from typing import Container, Literal 6 | 7 | from django_autotyping._compat import TypeAlias 8 | 9 | from .base import BaseVisitorBasedCodemod 10 | from .forward_relation_typing_codemod import ForwardRelationTypingCodemod 11 | 12 | RulesT: TypeAlias = Literal["DJA001"] 13 | 14 | rules: list[tuple[RulesT, type[BaseVisitorBasedCodemod]]] = [ 15 | ("DJA001", ForwardRelationTypingCodemod), 16 | ] 17 | 18 | 19 | def gather_codemods( 20 | ignore: Container[RulesT] = [], include: Container[RulesT] = [] 21 | ) -> list[type[BaseVisitorBasedCodemod]]: 22 | if include: 23 | return [rule[1] for rule in rules if rule[0] in include] 24 | return [rule[1] for rule in rules if rule[0] not in ignore] 25 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/codemods/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, cast 4 | 5 | from libcst.codemod import CodemodContext, VisitorBasedCodemodCommand 6 | 7 | if TYPE_CHECKING: 8 | from django_autotyping.app_settings import CodeGenerationSettings 9 | 10 | from ..django_context import DjangoCodemodContext 11 | 12 | 13 | class BaseVisitorBasedCodemod(VisitorBasedCodemodCommand): 14 | """The base class for all codemods used for Django user code.""" 15 | 16 | def __init__(self, context: CodemodContext) -> None: 17 | super().__init__(context) 18 | self.django_context = cast("DjangoCodemodContext", context.scratch["django_context"]) 19 | self.code_generation_settings = cast("CodeGenerationSettings", context.scratch["code_generation_settings"]) 20 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/codemods/forward_relation_typing_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Union # Old style Union required on decorated methods, see Instagram/LibCST#870. 5 | 6 | import libcst as cst 7 | import libcst.matchers as m 8 | from django.db.models.fields.related import RECURSIVE_RELATIONSHIP_CONSTANT 9 | from libcst.codemod import CodemodContext 10 | from libcst.codemod.visitors import AddImportsVisitor 11 | from libcst.metadata import ScopeProvider 12 | from libcst.metadata.scope_provider import ClassScope 13 | 14 | from ..models import ModelInfo 15 | from .base import BaseVisitorBasedCodemod 16 | 17 | ASSIGN_FOREIGN_FIELD_MATCHER = m.Assign( 18 | value=m.Call( 19 | args=m.OneOf( 20 | ( # String reference as a kw: Field(to="Model"): 21 | m.ZeroOrMore(), 22 | m.Arg(value=m.SaveMatchedNode(m.SimpleString(), "string_reference"), keyword=m.Name("to")), 23 | m.ZeroOrMore(), 24 | ), 25 | ( # String reference as the first positional arg: Field("Model"): 26 | m.Arg(value=m.SaveMatchedNode(m.SimpleString(), "string_reference"), keyword=None), 27 | m.ZeroOrMore(), 28 | ), 29 | ) 30 | ) 31 | ) 32 | 33 | # Either `class A` or `class A(object)` 34 | BARE_CLASS_DEF_MATCHER = m.ClassDef(bases=m.OneOf([m.AtMostN(n=0)], [m.Arg(value=m.Name(value="object"))])) 35 | 36 | 37 | class ForwardRelationTypingCodemod(BaseVisitorBasedCodemod): 38 | """A codemod that will add type annotations to forward relations. 39 | 40 | **Rule identifier**: `DJA001`. 41 | 42 | !!! warning "Outdated" 43 | This codemod is outdated and does not play well with [`django-stubs`](https://github.com/typeddjango/django-stubs). 44 | Instead, it is recommended to use the corresponding dynamic stub rule 45 | ([`DJAS001`][django_autotyping.stubbing.codemods.forward_relation_overload_codemod.ForwardRelationOverloadCodemod]). 46 | 47 | ```python 48 | from typing import TYPE_CHECKING 49 | 50 | from django.db import models 51 | 52 | # Model is imported in an `if TYPE_CHECKING` block if `--type-checking-block` is used. 53 | if TYPE_CHECKING: 54 | # Related model is imported from the corresponding apps models module: 55 | from myproject.reporters.models import Reporter 56 | 57 | 58 | class Article(models.Model): 59 | # If the field supports `__class_getitem__` at runtime, it is parametrized directly: 60 | reporter = models.ForeignKey["Reporter"]( 61 | "reporters.Reporter", 62 | on_delete=models.CASCADE, 63 | ) 64 | 65 | # Otherwise, an explicit annotation is used. No unnecessary import if model is in the same file. 66 | article_version: "models.OneToOneField[ArticleVersion]" = models.OneToOneField( 67 | "ArticleVersion", 68 | on_delete=models.CASCADE, 69 | ) 70 | ``` 71 | """ 72 | 73 | METADATA_DEPENDENCIES = {ScopeProvider} 74 | 75 | def __init__(self, context: CodemodContext) -> None: 76 | super().__init__(context) 77 | self.model_infos = [ 78 | model_info 79 | for model_info in self.django_context.model_infos 80 | if Path(model_info.filename) == Path(context.filename) # type: ignore[arg-type] 81 | ] 82 | self.current_model: ModelInfo | None = None 83 | 84 | def visit_ClassDef(self, node: cst.ClassDef) -> bool | None: 85 | scope = self.get_metadata(ScopeProvider, node) 86 | # Extra safety: 87 | # We avoid parsing nested classes definitions, or classes wihtout base classes 88 | if type(scope) is ClassScope or m.matches(node, BARE_CLASS_DEF_MATCHER): 89 | return False 90 | self.current_model = self.get_model_info(node) 91 | 92 | def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.ClassDef: 93 | self.current_model = None 94 | return updated_node 95 | 96 | @m.leave(ASSIGN_FOREIGN_FIELD_MATCHER) 97 | def type_any_to_one_field( 98 | self, original_node: cst.Assign, updated_node: cst.Assign 99 | ) -> Union[cst.Assign, cst.AnnAssign]: # noqa: UP007 100 | if self.current_model is None: 101 | return updated_node 102 | 103 | # TODO handle multiple targets? Unlikely in the context of a Django model 104 | target = updated_node.targets[0].target 105 | forward_relation = self.current_model.forward_relations.get(target.value) 106 | if forward_relation is None: 107 | return updated_node 108 | 109 | extracted = m.extract(updated_node, ASSIGN_FOREIGN_FIELD_MATCHER) 110 | string_reference: cst.SimpleString | None = extracted.get("string_reference") 111 | if not string_reference: 112 | return updated_node 113 | 114 | splitted = string_reference.raw_value.split(".") 115 | if len(splitted) == 1: 116 | # reference is in the same app 117 | class_ref = splitted[0] 118 | else: 119 | # reference is from an outside app, e.g. myapp.MyModel 120 | class_ref = splitted[1] 121 | if class_ref == RECURSIVE_RELATIONSHIP_CONSTANT: 122 | # Handle relationships with itself 123 | class_ref = self.current_model.class_name 124 | 125 | if self.current_model.module is not forward_relation.model_module: 126 | # TODO check if model is from the same app: do relative import 127 | if self.current_model.app_config is forward_relation.app_config: 128 | pass 129 | else: 130 | AddImportsVisitor.add_needed_import( 131 | self.context, 132 | module=forward_relation.app_models_module.__name__, 133 | obj=class_ref, 134 | ) 135 | 136 | if forward_relation.has_class_getitem or self.code_generation_settings.ASSUME_CLASS_GETITEM: 137 | # We can parametrize the field directly, we won't get runtime TypeErrors 138 | annotation_str = f'"{class_ref}"' # forward ref used here as it will be evaluated at runtime 139 | slice = cst.SubscriptElement(slice=cst.Index(value=cst.SimpleString(value=annotation_str))) 140 | if isinstance(updated_node.value.func, cst.Name): 141 | # e.g. `field = ForeignKey(...)` 142 | return updated_node.with_deep_changes( 143 | old_node=updated_node.value, 144 | func=cst.Subscript( 145 | value=updated_node.value.func, 146 | slice=[slice], 147 | ), 148 | ) 149 | else: 150 | # e.g. `field = models.ForeignKey(...)` 151 | return updated_node.with_deep_changes( 152 | old_node=updated_node.value.func, 153 | attr=cst.Subscript( 154 | value=updated_node.value.func.attr, 155 | slice=[slice], 156 | ), 157 | ) 158 | else: 159 | # We explicitly annotate to avoid runtime TypeErrors 160 | # e.g. from `field = ForeignKey(...)` to `field: ForeignKey[...] = ForeignKey(...)` 161 | annotation_str = f"{_get_attribute_path(updated_node.value.func)}[{class_ref}]" 162 | return cst.AnnAssign( 163 | target=target, 164 | annotation=cst.Annotation(annotation=cst.SimpleString(value=f'"{annotation_str}"')), 165 | value=updated_node.value, 166 | ) 167 | 168 | def get_model_info(self, node: cst.ClassDef) -> ModelInfo | None: 169 | # TODO use a provider instead? 170 | # Not really possible as of today (metadata providers can't be initialized with custom data). 171 | return next((model_info for model_info in self.model_infos if model_info.class_name == node.name.value), None) 172 | 173 | 174 | def _get_attribute_path(node: cst.Name | cst.Attribute) -> str: 175 | """Get the dotted path to an object from the `cst.Call.func` attribute. 176 | 177 | `node` can either be a `cst.Name` or a `cst.Attribute` node, meaning both use cases are supported: 178 | 179 | ```py 180 | field_1 = ForeignKey(...) # Returns 'ForeignKey' 181 | field_2 = models.ForeignKey(...) # Returns 'models.ForeignKey' 182 | ``` 183 | """ 184 | if isinstance(node, cst.Name): 185 | return node.value 186 | 187 | if isinstance(node.value, cst.Attribute): 188 | prefix = _get_attribute_path(node.value) 189 | else: 190 | prefix = node.value.value 191 | return f"{prefix}.{node.attr.value}" 192 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/django_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from django_autotyping._compat import is_relative_to 8 | 9 | from .models import ModelInfo 10 | 11 | if TYPE_CHECKING: 12 | from django.apps.registry import Apps 13 | from django.conf import LazySettings 14 | 15 | 16 | class DjangoCodemodContext: 17 | def __init__(self, apps: Apps, settings: LazySettings, project_dir: Path) -> None: 18 | self.apps = apps 19 | self.settings = settings 20 | self.project_dir = project_dir 21 | 22 | @property 23 | def model_infos(self) -> list[ModelInfo]: 24 | """A list of `ModelInfo` objects. 25 | 26 | Only the models defined in files relative to `self.project_dir` will be taken into account. 27 | """ 28 | return [ 29 | ModelInfo.from_model(model) 30 | for model in self.apps.get_models() 31 | if is_relative_to(Path(inspect.getabsfile(model)), self.project_dir.resolve()) 32 | ] 33 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import libcst as cst 6 | from libcst.codemod import CodemodContext, VisitorBasedCodemodCommand 7 | 8 | from django_autotyping.app_settings import CodeGenerationSettings 9 | 10 | from .django_context import DjangoCodemodContext 11 | from .vendoring.monkeytype import MoveImportsToTypeCheckingBlockVisitor, get_newly_imported_items 12 | 13 | 14 | def run_codemods( 15 | codemods: list[type[VisitorBasedCodemodCommand]], 16 | django_context: DjangoCodemodContext, 17 | code_generation_settings: CodeGenerationSettings, 18 | filename: str, 19 | ) -> str: 20 | context = CodemodContext( 21 | filename=filename, 22 | scratch={ 23 | "django_context": django_context, 24 | "code_generation_settings": code_generation_settings, 25 | }, 26 | ) 27 | 28 | input_code = Path(filename).read_text(encoding="utf-8") 29 | input_module = cst.parse_module(input_code) 30 | output_module = cst.parse_module(input_code) 31 | for codemod in codemods: 32 | transformer = codemod(context=context) 33 | 34 | output_module = transformer.transform_module(output_module) 35 | 36 | if code_generation_settings.TYPE_CHECKING_BLOCK: 37 | newly_imported_items = get_newly_imported_items(output_module, input_module) 38 | if newly_imported_items: 39 | context = CodemodContext() 40 | MoveImportsToTypeCheckingBlockVisitor.store_imports_in_context( 41 | context, 42 | newly_imported_items, 43 | ) 44 | type_checking_block_transformer = MoveImportsToTypeCheckingBlockVisitor(context) 45 | output_module = type_checking_block_transformer.transform_module(output_module) 46 | 47 | return output_module.code 48 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from dataclasses import dataclass, field 5 | from types import ModuleType 6 | from typing import cast 7 | 8 | from django.apps import AppConfig, apps 9 | from django.db.models.fields.related import RelatedField 10 | 11 | from ..typing import ModelType 12 | 13 | 14 | @dataclass 15 | class ForwardRelation: 16 | class_name: str 17 | nullable: bool 18 | has_class_getitem: bool 19 | model: ModelType 20 | 21 | @property 22 | def model_module(self) -> ModuleType: 23 | """The module object of the model linked to this relation.""" 24 | return cast(ModuleType, inspect.getmodule(self.model)) 25 | 26 | @property 27 | def app_config(self) -> AppConfig: 28 | """The `AppConfig` object where the model linked to this relation belongs.""" 29 | return apps.get_app_config(self.model._meta.app_label) 30 | 31 | @property 32 | def app_models_module(self) -> ModuleType | None: 33 | """The module object where models of the app of the model linked to this relation are stored.""" 34 | return self.app_config.models_module # type: ignore[return-value] 35 | 36 | @classmethod 37 | def from_field(cls, field: RelatedField) -> ForwardRelation: 38 | return cls( 39 | class_name=field.__class__.__name__, 40 | nullable=field.null, 41 | has_class_getitem=hasattr(type(field), "__class_getitem__"), 42 | model=field.related_model, 43 | ) 44 | 45 | 46 | @dataclass 47 | class ModelInfo: 48 | model: ModelType 49 | module: ModuleType 50 | forward_relations: dict[str, ForwardRelation] = field(default_factory=dict) 51 | 52 | @property 53 | def class_name(self) -> str: 54 | return self.model.__name__ 55 | 56 | @property 57 | def filename(self) -> str: 58 | return cast(str, inspect.getsourcefile(self.model)) 59 | 60 | @property 61 | def app_label(self) -> str: 62 | """The app label where the model belongs.""" 63 | return self.model._meta.app_label 64 | 65 | @property 66 | def app_config(self) -> AppConfig: 67 | """The `AppConfig` object where the model belongs.""" 68 | return self.model._meta.app_config 69 | 70 | @classmethod 71 | def from_model(cls, model: ModelType) -> ModelInfo: 72 | forward_relations = { 73 | field.name: ForwardRelation.from_field(field) 74 | for field in model._meta.get_fields() 75 | if isinstance(field, RelatedField) # TODO isinstance check on `Field`? 76 | # if field.many_to_one # TODO may be unnecessary? 77 | } 78 | 79 | return cls( 80 | model=model, 81 | module=inspect.getmodule(model), 82 | forward_relations=forward_relations, 83 | ) 84 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/vendoring/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/src/django_autotyping/codemodding/vendoring/__init__.py -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/vendoring/monkeytype/__init__.py: -------------------------------------------------------------------------------- 1 | """This is a vendorized version of MonkeyType. Source: https://github.com/Instagram/MonkeyType 2 | 3 | Original license: 4 | 5 | BSD License 6 | 7 | For MonkeyType software 8 | 9 | Copyright (c) 2017-present, Facebook, Inc. All rights reserved. 10 | 11 | Redistribution and use in source and binary forms, with or without modification, 12 | are permitted provided that the following conditions are met: 13 | 14 | * Redistributions of source code must retain the above copyright notice, this 15 | list of conditions and the following disclaimer. 16 | 17 | * Redistributions in binary form must reproduce the above copyright notice, 18 | this list of conditions and the following disclaimer in the documentation 19 | and/or other materials provided with the distribution. 20 | 21 | * Neither the name Facebook nor the names of its contributors may be used to 22 | endorse or promote products derived from this software without specific 23 | prior written permission. 24 | 25 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 26 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 27 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 28 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 29 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 30 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 31 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 32 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 33 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 34 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 35 | """ 36 | from __future__ import annotations 37 | 38 | from libcst import Module 39 | from libcst.codemod import CodemodContext 40 | from libcst.codemod.visitors import ( 41 | GatherImportsVisitor, 42 | ImportItem, 43 | ) 44 | 45 | from .type_checking_imports_transformer import MoveImportsToTypeCheckingBlockVisitor 46 | 47 | __all__ = ("MoveImportsToTypeCheckingBlockVisitor", "get_newly_imported_items") 48 | 49 | 50 | def get_newly_imported_items(stub_module: Module, source_module: Module) -> list[ImportItem]: 51 | context = CodemodContext() 52 | gatherer = GatherImportsVisitor(context) 53 | stub_module.visit(gatherer) 54 | stub_imports = list(gatherer.symbol_mapping.values()) 55 | 56 | context = CodemodContext() 57 | gatherer = GatherImportsVisitor(context) 58 | source_module.visit(gatherer) 59 | source_imports = list(gatherer.symbol_mapping.values()) 60 | 61 | return list(set(stub_imports).difference(set(source_imports))) 62 | -------------------------------------------------------------------------------- /src/django_autotyping/codemodding/vendoring/monkeytype/type_checking_imports_transformer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017-present, Facebook, Inc. 2 | # All rights reserved. 3 | # 4 | # This source code is licensed under the BSD-style license found in the 5 | # LICENSE file in the root directory of this source tree. 6 | from __future__ import annotations 7 | 8 | from typing import cast 9 | 10 | import libcst 11 | from libcst import ( 12 | BaseCompoundStatement, 13 | BaseSmallStatement, 14 | BaseSuite, 15 | CSTTransformer, 16 | FlattenSentinel, 17 | Import, 18 | ImportFrom, 19 | ImportStar, 20 | MaybeSentinel, 21 | Module, 22 | RemovalSentinel, 23 | RemoveFromParent, 24 | SimpleStatementLine, 25 | ) 26 | from libcst.codemod import CodemodContext, ContextAwareTransformer 27 | from libcst.codemod.visitors import AddImportsVisitor, GatherImportsVisitor, ImportItem 28 | from libcst.helpers import get_absolute_module_from_package_for_import 29 | 30 | 31 | class MoveImportsToTypeCheckingBlockVisitor(ContextAwareTransformer): 32 | CONTEXT_KEY = "MoveImportsToTypeCheckingBlockVisitor" 33 | 34 | def __init__( 35 | self, 36 | context: CodemodContext, 37 | ) -> None: 38 | super().__init__(context) 39 | 40 | self.import_items_to_be_moved: list[ImportItem] = [] 41 | 42 | @staticmethod 43 | def store_imports_in_context( 44 | context: CodemodContext, 45 | import_items_to_be_moved: list[ImportItem], 46 | ) -> None: 47 | context.scratch[MoveImportsToTypeCheckingBlockVisitor.CONTEXT_KEY] = (import_items_to_be_moved,) 48 | 49 | @staticmethod 50 | def _add_type_checking_import(source_module: Module) -> Module: 51 | context = CodemodContext() 52 | AddImportsVisitor.add_needed_import(context, "typing", "TYPE_CHECKING") 53 | transformer = AddImportsVisitor(context) 54 | transformed_source_module = transformer.transform_module(source_module) 55 | return transformed_source_module 56 | 57 | def _remove_imports(self, tree: Module) -> Module: 58 | transformer = RemoveImportsTransformer(self.import_items_to_be_moved) 59 | transformed_source_module = tree.visit(transformer) 60 | return transformed_source_module 61 | 62 | def _get_import_module(self) -> Module: 63 | empty_code = libcst.parse_module("") 64 | context = CodemodContext() 65 | context.scratch[AddImportsVisitor.CONTEXT_KEY] = self.import_items_to_be_moved 66 | transformer = AddImportsVisitor(context) 67 | transformed_source_module = transformer.transform_module(empty_code) 68 | return transformed_source_module 69 | 70 | @staticmethod 71 | def _replace_pass_with_imports(placeholder_module: Module, import_module: Module) -> Module: 72 | return placeholder_module.with_deep_changes( 73 | old_node=cast(BaseSuite, placeholder_module.body[0].body), 74 | body=import_module.body, 75 | ) 76 | 77 | def _split_module( 78 | self, module: Module 79 | ) -> tuple[ 80 | list[SimpleStatementLine | BaseCompoundStatement], 81 | list[SimpleStatementLine | BaseCompoundStatement], 82 | ]: 83 | type_checking_block_add_location = 0 84 | gatherer = GatherImportsVisitor(self.context) 85 | module.visit(gatherer) 86 | all_imports = gatherer.all_imports 87 | 88 | for i, statement in enumerate(module.body): 89 | if isinstance(statement, SimpleStatementLine): 90 | for possible_import in statement.body: 91 | for last_import in all_imports: 92 | if possible_import is last_import: 93 | type_checking_block_add_location = i + 1 94 | break 95 | 96 | return ( 97 | list(module.body[:type_checking_block_add_location]), 98 | list(module.body[type_checking_block_add_location:]), 99 | ) 100 | 101 | def _add_if_type_checking_block(self, module: Module) -> Module: 102 | if not self.import_items_to_be_moved: 103 | return module 104 | 105 | import_module = self._get_import_module() 106 | 107 | placeholder_module = libcst.parse_module("\nif TYPE_CHECKING:\n pass\n") 108 | type_checking_block_module = self._replace_pass_with_imports( 109 | placeholder_module, 110 | import_module, 111 | ) 112 | 113 | # Find the point of insertion for the TYPE_CHECKING block 114 | statements_before_imports, statements_after_imports = self._split_module(module) 115 | 116 | updated_body_list = [ 117 | *statements_before_imports, 118 | type_checking_block_module, 119 | *statements_after_imports, 120 | ] 121 | 122 | return module.with_changes(body=updated_body_list) 123 | 124 | @staticmethod 125 | def _remove_typing_module(import_item_list: list[ImportItem]) -> list[ImportItem]: 126 | ret: list[ImportItem] = [] 127 | for import_item in import_item_list: 128 | if import_item.module_name != "typing": 129 | ret.append(import_item) 130 | return ret 131 | 132 | def transform_module_impl( 133 | self, 134 | tree: Module, 135 | ) -> Module: 136 | # Add from typing import TYPE_CHECKING 137 | tree = self._add_type_checking_import(tree) 138 | 139 | context_contents = self.context.scratch.get(MoveImportsToTypeCheckingBlockVisitor.CONTEXT_KEY) 140 | if context_contents is not None: 141 | (import_items_to_be_moved,) = context_contents 142 | 143 | self.import_items_to_be_moved = import_items_to_be_moved 144 | 145 | # Remove typing library since we do not want it 146 | # to be imported inside the if TYPE_CHECKING block 147 | self.import_items_to_be_moved = self._remove_typing_module(self.import_items_to_be_moved) 148 | 149 | # Remove the newer imports since those are to be 150 | # shifted inside the if TYPE_CHECKING block 151 | tree = self._remove_imports(tree) 152 | 153 | # Add the new imports inside if TYPE_CHECKING block 154 | tree = self._add_if_type_checking_block(tree) 155 | 156 | return tree 157 | 158 | 159 | class RemoveImportsTransformer(CSTTransformer): 160 | def __init__( 161 | self, 162 | import_items_to_be_removed: list[ImportItem], 163 | ) -> None: 164 | super().__init__() 165 | self.import_items_to_be_removed = import_items_to_be_removed 166 | 167 | def leave_Import( 168 | self, original_node: Import, updated_node: Import 169 | ) -> BaseSmallStatement | FlattenSentinel[BaseSmallStatement] | RemovalSentinel: 170 | names_to_keep = [] 171 | for name in updated_node.names: 172 | module_name = name.evaluated_name 173 | found = False 174 | for import_item in self.import_items_to_be_removed: 175 | if import_item.module_name == module_name: 176 | found = True 177 | break 178 | if not found: 179 | names_to_keep.append(name.with_changes(comma=MaybeSentinel.DEFAULT)) 180 | 181 | if not names_to_keep: 182 | return RemoveFromParent() 183 | else: 184 | return updated_node.with_changes(names=names_to_keep) 185 | 186 | def leave_ImportFrom( 187 | self, original_node: ImportFrom, updated_node: ImportFrom 188 | ) -> BaseSmallStatement | FlattenSentinel[BaseSmallStatement] | RemovalSentinel: 189 | if isinstance(updated_node.names, ImportStar): 190 | return updated_node 191 | 192 | names_to_keep = [] 193 | module_name = get_absolute_module_from_package_for_import(None, updated_node) 194 | for name in updated_node.names: 195 | name_value = name.name.value 196 | found = False 197 | for import_item in self.import_items_to_be_removed: 198 | if import_item.module_name == module_name and import_item.obj_name == name_value: 199 | found = True 200 | break 201 | if not found: 202 | names_to_keep.append(name.with_changes(comma=MaybeSentinel.DEFAULT)) 203 | 204 | if not names_to_keep: 205 | return RemoveFromParent() 206 | else: 207 | return updated_node.with_changes(names=names_to_keep) 208 | -------------------------------------------------------------------------------- /src/django_autotyping/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/src/django_autotyping/management/__init__.py -------------------------------------------------------------------------------- /src/django_autotyping/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/src/django_autotyping/management/commands/__init__.py -------------------------------------------------------------------------------- /src/django_autotyping/management/commands/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from argparse import ArgumentTypeError 4 | from pathlib import Path 5 | from typing import Literal, TypedDict 6 | 7 | 8 | def dir_path(path_str: str) -> Path: 9 | if path_str and not (path := Path(path_str)).is_dir(): 10 | raise ArgumentTypeError(f"{path_str} must be an existing directory.") 11 | return path 12 | 13 | 14 | class BaseOptions(TypedDict): 15 | verbosity: Literal[0, 1, 2, 3] 16 | """Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output""" 17 | 18 | settings: str | None 19 | """The Python path to a settings module, e.g. "myproject.settings.main". If this isn't provided, 20 | the DJANGO_SETTINGS_MODULE environment variable will be used. 21 | """ 22 | 23 | pythonpath: str | None 24 | """A directory to add to the Python path, e.g. "/home/djangoprojects/myproject".""" 25 | 26 | traceback: bool 27 | """Raise on CommandError exceptions.""" 28 | 29 | no_color: bool 30 | """Don't colorize the command output.""" 31 | 32 | skip_checks: bool 33 | """Skip system checks.""" 34 | -------------------------------------------------------------------------------- /src/django_autotyping/management/commands/add_type_hints.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import difflib 4 | from pathlib import Path 5 | from typing import Any, Iterable 6 | 7 | from django.apps import apps 8 | from django.conf import settings 9 | from django.core.management.base import BaseCommand, CommandParser 10 | from django.utils.termcolors import colorize 11 | 12 | from django_autotyping._compat import Unpack 13 | from django_autotyping.app_settings import AutotypingSettings 14 | from django_autotyping.codemodding.codemods import RulesT, gather_codemods, rules 15 | from django_autotyping.codemodding.django_context import DjangoCodemodContext 16 | from django_autotyping.codemodding.main import run_codemods 17 | 18 | from ._utils import BaseOptions, dir_path 19 | 20 | at_settings = AutotypingSettings(settings) 21 | 22 | 23 | class CommandOptions(BaseOptions): 24 | project_dir: Path 25 | diff: bool 26 | ignore: list[RulesT] | None 27 | 28 | 29 | class Command(BaseCommand): 30 | help = "Add type annotations to your Django code." 31 | 32 | def add_arguments(self, parser: CommandParser) -> None: 33 | parser.add_argument( 34 | "-p", 35 | "--project-dir", 36 | type=dir_path, 37 | help="The directory of the project, where code modifications should be applied.", 38 | required=at_settings.CODE_GENERATION.PROJECT_DIR is None, 39 | default=at_settings.CODE_GENERATION.PROJECT_DIR, 40 | ) 41 | parser.add_argument( 42 | "--diff", 43 | action="store_true", 44 | help="Show changes to be applied instead of modifying existing files.", 45 | default=at_settings.CODE_GENERATION.DIFF, 46 | ) 47 | parser.add_argument( 48 | "--ignore", 49 | choices=[rule[0] for rule in rules], 50 | nargs="*", 51 | help="Rules to be ignored.", 52 | default=at_settings.IGNORE, 53 | ) 54 | 55 | def _colored_diff(self, lines: Iterable[str]) -> None: 56 | for line in lines: 57 | line_s = line.rstrip("\n") 58 | if line_s.startswith("+"): 59 | self.stdout.write(colorize(line_s, fg="green")) 60 | elif line_s.startswith("-"): 61 | self.stdout.write(colorize(line_s, fg="red")) 62 | elif line_s.startswith("^"): 63 | self.stdout.write(colorize(line_s, fg="blue")) 64 | else: 65 | self.stdout.write(line_s) 66 | 67 | def handle(self, *args: Any, **options: Unpack[CommandOptions]) -> None: 68 | django_context = DjangoCodemodContext(apps, settings, options["project_dir"]) 69 | codemods = gather_codemods(options["ignore"]) 70 | 71 | # TODO codemods should specify which type of file they apply to. 72 | model_filenames = {model_info.filename for model_info in django_context.model_infos} 73 | 74 | for filename in model_filenames: 75 | intput_source = Path(filename).read_text("utf-8") 76 | output_source = run_codemods(codemods, django_context, at_settings.CODE_GENERATION, filename) 77 | if intput_source != output_source: 78 | if options["diff"]: 79 | lines = difflib.unified_diff( 80 | intput_source.splitlines(keepends=True), 81 | output_source.splitlines(keepends=True), 82 | fromfile=filename, 83 | tofile=filename, 84 | ) 85 | self._colored_diff(lines) 86 | else: 87 | Path(filename).write_text(output_source, encoding="utf-8") 88 | -------------------------------------------------------------------------------- /src/django_autotyping/management/commands/generate_stubs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Any 5 | 6 | from django.apps import apps 7 | from django.conf import settings 8 | from django.core.management.base import BaseCommand, CommandParser 9 | 10 | from django_autotyping._compat import Unpack 11 | from django_autotyping.app_settings import AutotypingSettings 12 | from django_autotyping.stubbing import create_local_django_stubs, run_codemods 13 | from django_autotyping.stubbing.codemods import RulesT, gather_codemods, rules 14 | from django_autotyping.stubbing.django_context import DjangoStubbingContext 15 | 16 | from ._utils import BaseOptions, dir_path 17 | 18 | at_settings = AutotypingSettings.from_django_settings(settings) 19 | stubs_settings = at_settings.STUBS_GENERATION 20 | 21 | 22 | class CommandOptions(BaseOptions): 23 | local_stubs_dir: Path 24 | ignore: list[RulesT] | None 25 | 26 | 27 | class Command(BaseCommand): 28 | help = "Generate customized type stubs for your Django application." 29 | 30 | def add_arguments(self, parser: CommandParser) -> None: 31 | parser.add_argument( 32 | "-s", 33 | "--local-stubs-dir", 34 | type=dir_path, 35 | help="The directory of the local type stubs.", 36 | required=at_settings.STUBS_GENERATION.LOCAL_STUBS_DIR is None, 37 | default=at_settings.STUBS_GENERATION.LOCAL_STUBS_DIR, 38 | ) 39 | parser.add_argument( 40 | "--ignore", 41 | choices=[rule[0] for rule in rules], 42 | nargs="*", 43 | help="Rules to be ignored.", 44 | default=at_settings.IGNORE, 45 | ) 46 | 47 | def handle(self, *args: Any, **options: Unpack[CommandOptions]) -> None: 48 | create_local_django_stubs(options["local_stubs_dir"], stubs_settings.SOURCE_STUBS_DIR) 49 | codemods = gather_codemods(options["ignore"]) 50 | 51 | django_context = DjangoStubbingContext(apps, settings) 52 | run_codemods(codemods, django_context, stubs_settings) 53 | -------------------------------------------------------------------------------- /src/django_autotyping/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/src/django_autotyping/py.typed -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | import site 5 | from pathlib import Path 6 | 7 | import libcst as cst 8 | from libcst.codemod import CodemodContext 9 | 10 | from django_autotyping.app_settings import StubsGenerationSettings 11 | 12 | from .codemods import StubVisitorBasedCodemod 13 | from .django_context import DjangoStubbingContext 14 | 15 | 16 | def run_codemods( 17 | codemods: list[type[StubVisitorBasedCodemod]], 18 | django_context: DjangoStubbingContext, 19 | stubs_settings: StubsGenerationSettings, 20 | ) -> None: 21 | django_stubs_dir = stubs_settings.SOURCE_STUBS_DIR or _get_django_stubs_dir() 22 | 23 | for codemod in codemods: 24 | for stub_file in codemod.STUB_FILES: 25 | context = CodemodContext( 26 | filename=stub_file, scratch={"django_context": django_context, "stubs_settings": stubs_settings} 27 | ) 28 | transformer = codemod(context) 29 | source_file = django_stubs_dir / stub_file 30 | target_file = stubs_settings.LOCAL_STUBS_DIR / "django-stubs" / stub_file 31 | 32 | input_code = source_file.read_text(encoding="utf-8") 33 | input_module = cst.parse_module(input_code) 34 | output_module = transformer.transform_module(input_module) 35 | 36 | target_file.write_text(output_module.code, encoding="utf-8") 37 | 38 | 39 | def _get_django_stubs_dir() -> Path: 40 | # TODO should we use importlib.metadata.files instead? 41 | for dir in site.getsitepackages(): 42 | if (path := Path(dir, "django-stubs")).is_dir(): 43 | return path 44 | raise RuntimeError("Couldn't find 'django-stubs' in any of the site packages.") 45 | 46 | 47 | def create_local_django_stubs(stubs_dir: Path, source_django_stubs: Path | None = None) -> None: 48 | """Copy the `django-stubs` package into the specified local stubs directory. 49 | 50 | If `source_django_stubs` is not provided, the first entry in site packages will be used. 51 | """ 52 | stubs_dir.mkdir(exist_ok=True) 53 | source_django_stubs = source_django_stubs or _get_django_stubs_dir() 54 | if not (stubs_dir / "django-stubs").is_dir(): 55 | shutil.copytree(source_django_stubs, stubs_dir / "django-stubs") 56 | 57 | # for stub_file in django_stubs_dir.glob("**/*.pyi"): 58 | # # Make file relative to site packages, results in `Path("django-stubs/path/to/file.pyi")` 59 | # relative_stub_file = stub_file.relative_to(django_stubs_dir.parent) 60 | # symlinked_path = stubs_dir / relative_stub_file 61 | 62 | # stub_file.mkdir() 63 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Container, Literal 4 | 5 | from django_autotyping._compat import TypeAlias 6 | 7 | from .auth_functions_codemod import AuthFunctionsCodemod 8 | from .base import StubVisitorBasedCodemod 9 | from .call_command_codemod import CallCommandCodemod 10 | from .create_overload_codemod import CreateOverloadCodemod 11 | from .forward_relation_overload_codemod import ForwardRelationOverloadCodemod 12 | from .get_model_overload_codemod import GetModelOverloadCodemod 13 | from .model_init_overload_codemod import ModelInitOverloadCodemod 14 | from .query_lookups_overload_codemod import QueryLookupsOverloadCodemod 15 | from .reverse_overload_codemod import ReverseOverloadCodemod 16 | from .settings_codemod import SettingCodemod 17 | from .template_loading_codemod import TemplateLoadingCodemod 18 | 19 | __all__ = ( 20 | "AuthFunctionsCodemod", 21 | "CallCommandCodemod", 22 | "CreateOverloadCodemod", 23 | "ForwardRelationOverloadCodemod", 24 | "GetModelOverloadCodemod", 25 | "QueryLookupsOverloadCodemod", 26 | "ReverseOverloadCodemod", 27 | "RulesT", 28 | "SettingCodemod", 29 | "StubVisitorBasedCodemod", 30 | "TemplateLoadingCodemod", 31 | "gather_codemods", 32 | "rules", 33 | ) 34 | 35 | RulesT: TypeAlias = Literal["DJAS001", "DJAS002", "DJAS003", "DJAS010", "DJAS011", "DJAS015", "DJAS016", "DJAS017"] 36 | 37 | rules: list[tuple[RulesT, type[StubVisitorBasedCodemod]]] = [ 38 | ("DJAS001", ForwardRelationOverloadCodemod), 39 | ("DJAS002", CreateOverloadCodemod), 40 | ("DJAS003", ModelInitOverloadCodemod), 41 | # ("DJAS004", QueryLookupsOverloadCodemod), 42 | ("DJAS010", GetModelOverloadCodemod), 43 | ("DJAS011", AuthFunctionsCodemod), 44 | ("DJAS015", ReverseOverloadCodemod), 45 | ("DJAS016", SettingCodemod), 46 | ("DJAS017", TemplateLoadingCodemod), 47 | # ("DJAS017", CallCommandCodemod), 48 | ] 49 | 50 | 51 | def gather_codemods( 52 | ignore: Container[RulesT] = [], include: Container[RulesT] = [] 53 | ) -> list[type[StubVisitorBasedCodemod]]: 54 | if include: 55 | return [rule[1] for rule in rules if rule[0] in include] 56 | return [rule[1] for rule in rules if rule[0] not in ignore] 57 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/_model_creation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import ClassVar, TypedDict, cast 5 | 6 | import libcst as cst 7 | from django.db.models import ( 8 | AutoField, 9 | BooleanField, 10 | CharField, 11 | DateField, 12 | DateTimeField, 13 | DecimalField, 14 | Field, 15 | FloatField, 16 | GenericIPAddressField, 17 | IntegerField, 18 | IPAddressField, 19 | TextField, 20 | TimeField, 21 | UUIDField, 22 | ) 23 | from django.db.models.fields.reverse_related import ForeignObjectRel 24 | from libcst import helpers 25 | from libcst.codemod import CodemodContext 26 | from libcst.codemod.visitors import AddImportsVisitor, ImportItem 27 | from libcst.metadata import ScopeProvider 28 | 29 | from django_autotyping._compat import Required 30 | from django_autotyping.typing import FlattenFunctionDef 31 | 32 | from ._utils import TypedDictAttribute, build_typed_dict, get_param 33 | from .base import InsertAfterImportsVisitor, StubVisitorBasedCodemod 34 | from .constants import OVERLOAD_DECORATOR 35 | 36 | 37 | class FieldType(TypedDict): 38 | type: Required[str] 39 | """The stringified type annotation to be used.""" 40 | 41 | typing_imports: list[str] 42 | """A list of typing objects to be imported.""" 43 | 44 | extra_imports: list[ImportItem] 45 | """A list of extra import items to be included.""" 46 | 47 | 48 | # This types are taken from `django-stubs` 49 | # NOTE: Order matters! This dict is iterated in order to match field classes 50 | # against the keys. Be sure to define the most specific subclasses first 51 | # (e.g. `AutoField` is a subclass of `IntegerField`, so it is defined first). 52 | # NOTE: Maybe `get_args(field_instance.__orig_class__)` could be used to take into 53 | # account explicit parametrization. 54 | FIELD_SET_TYPES_MAP: dict[type[Field], FieldType] = { 55 | AutoField: { 56 | "type": "int | str | Combinable", 57 | }, 58 | IntegerField: {"type": "float | int | str | Combinable"}, 59 | FloatField: {"type": "float | int | str | Combinable"}, 60 | DecimalField: {"type": "str | float | Decimal | Combinable", "extra_imports": [ImportItem("decimal", "Decimal")]}, 61 | CharField: {"type": "str | int | Combinable"}, # TODO this and textfield seems to allow `SupportsStr` 62 | TextField: {"type": "str | Combinable"}, 63 | BooleanField: {"type": "bool | Combinable"}, 64 | IPAddressField: {"type": "str | Combinable"}, 65 | GenericIPAddressField: { 66 | "type": "str | int | Callable[..., Any] | Combinable", # TODO, Callable, really? 67 | "typing_imports": ["Any", "Callable"], 68 | }, 69 | # For datetime related fields, we use `datetime.x` because `datetime` 70 | # is already imported in `db/models/manager.pyi`: 71 | DateTimeField: { 72 | "type": "str | datetime.datetime | datetime.date | Combinable", 73 | "extra_imports": [ImportItem("datetime")], 74 | }, 75 | DateField: { 76 | "type": "str | datetime.date | Combinable", 77 | "extra_imports": [ImportItem("datetime")], 78 | }, 79 | TimeField: { 80 | "type": "str | datetime.time | datetime.datetime | Combinable", 81 | "extra_imports": [ImportItem("datetime")], 82 | }, 83 | UUIDField: {"type": "str | UUID", "extra_imports": [ImportItem("uuid", "UUID")]}, 84 | Field: {"type": "Any", "typing_imports": ["Any"]}, 85 | } 86 | """A mapping of field classes to the types they allow to be set to.""" 87 | 88 | 89 | class ModelCreationBaseCodemod(StubVisitorBasedCodemod, ABC): 90 | """A base codemod that can be used to add overloads for model creation. 91 | 92 | Useful for: `Model.__init__`, `BaseManager.create`. 93 | """ 94 | 95 | METADATA_DEPENDENCIES = {ScopeProvider} 96 | KWARGS_TYPED_DICT_NAME: ClassVar[str] 97 | """A templated string to render the name of the `TypedDict` for the `**kwargs` annotation. 98 | 99 | Should contain the template `{model_name}`. 100 | """ 101 | 102 | def __init__(self, context: CodemodContext) -> None: 103 | super().__init__(context) 104 | self.add_model_imports() 105 | 106 | model_typed_dicts = self.build_model_kwargs() 107 | InsertAfterImportsVisitor.insert_after_imports(context, model_typed_dicts) 108 | 109 | AddImportsVisitor.add_needed_import( 110 | self.context, 111 | module="django.db.models.expressions", 112 | obj="Combinable", 113 | ) 114 | 115 | # Even though most of them are likely included, we import them for safety: 116 | self.add_typing_imports(["TypedDict", "TypeVar", "Required", "Unpack", "overload"]) 117 | 118 | def build_model_kwargs(self) -> list[cst.ClassDef]: 119 | """Return a list of class definition representing the typed dicts to be used for overloads.""" 120 | 121 | contenttypes_installed = self.django_context.apps.is_installed("django.contrib.contenttypes") 122 | if contenttypes_installed: 123 | from django.contrib.contenttypes.fields import GenericForeignKey 124 | all_optional = self.stubs_settings.MODEL_FIELDS_OPTIONAL 125 | 126 | class_defs: list[cst.ClassDef] = [] 127 | 128 | for model in self.django_context.models: 129 | model_name = self.django_context.get_model_name(model) 130 | 131 | # This mostly follows the implementation of the Django's `Model.__init__` method: 132 | typed_dict_attributes = [] 133 | for field in cast(list[Field], model._meta.fields): 134 | if isinstance(field.remote_field, ForeignObjectRel): 135 | # TODO support for attname as well (i.e. my_foreign_field_id). 136 | # Issue is if this is a required field, we can't make both required at the same time 137 | attr_name = field.name 138 | if isinstance(field.remote_field.model, str): 139 | # This seems to happen when a string reference can't be resolved 140 | # It should be invalid at runtime but let's not error here. 141 | annotation = "Any" 142 | self.add_typing_imports(["Any"]) 143 | else: 144 | annotation = self.django_context.get_model_name( 145 | # As per `ForwardManyToOneDescriptor.__set__`: 146 | field.remote_field.model._meta.concrete_model 147 | ) 148 | annotation += " | Combinable" 149 | elif contenttypes_installed and isinstance(field, GenericForeignKey): 150 | # it's generic, so cannot set specific model 151 | attr_name = field.name 152 | annotation = "Any" 153 | self.add_typing_imports(["Any"]) 154 | else: 155 | attr_name = field.attname 156 | # Regular fields: 157 | field_set_type = next( 158 | (v for k, v in FIELD_SET_TYPES_MAP.items() if issubclass(type(field), k)), 159 | FieldType(type="Any", typing_imports=["Any"]), 160 | ) 161 | 162 | self.add_typing_imports(field_set_type.get("typing_imports", [])) 163 | if extra_imports := field_set_type.get("extra_imports"): 164 | imports = AddImportsVisitor._get_imports_from_context(self.context) 165 | imports.extend(extra_imports) 166 | self.context.scratch[AddImportsVisitor.CONTEXT_KEY] = imports 167 | 168 | annotation = field_set_type["type"] 169 | 170 | if not isinstance(field, GenericForeignKey) and self.django_context.is_nullable_field(field): 171 | annotation += " | None" 172 | 173 | typed_dict_attributes.append( 174 | TypedDictAttribute( 175 | attr_name, 176 | annotation=annotation, 177 | docstring=getattr(field, "help_text", None) or None, 178 | required=not all_optional and self.django_context.is_required_field(field), 179 | ) 180 | ) 181 | 182 | class_defs.append( 183 | build_typed_dict( 184 | self.KWARGS_TYPED_DICT_NAME.format(model_name=model_name), 185 | attributes=typed_dict_attributes, 186 | total=False, 187 | leading_line=True, 188 | ) 189 | ) 190 | 191 | return class_defs 192 | 193 | def mutate_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> FlattenFunctionDef: 194 | class_name = self.get_metadata(ScopeProvider, original_node).name 195 | 196 | overload = updated_node.with_changes(decorators=[OVERLOAD_DECORATOR]) 197 | overloads: list[cst.FunctionDef] = [] 198 | 199 | for model in self.django_context.models: 200 | model_name = self.django_context.get_model_name(model) 201 | 202 | # sets `self: BaseManager[model_name]/_QuerySet[model_name, _Row]/model_name` 203 | annotation = self.get_self_annotation(model_name, class_name) 204 | self_param = get_param(overload, "self") 205 | overload_ = overload.with_deep_changes( 206 | old_node=self_param, 207 | annotation=cst.Annotation(annotation), 208 | ) 209 | 210 | overload_ = overload_.with_deep_changes( 211 | old_node=overload_.params.star_kwarg, 212 | annotation=cst.Annotation( 213 | annotation=helpers.parse_template_expression( 214 | f"Unpack[{self.KWARGS_TYPED_DICT_NAME}]".format(model_name=model_name) 215 | ) 216 | ), 217 | ) 218 | 219 | overloads.append(overload_) 220 | 221 | return cst.FlattenSentinel(overloads) 222 | 223 | @abstractmethod 224 | def get_self_annotation(self, model_name: str, class_name: str) -> cst.BaseExpression: 225 | """Return the annotation to be set on the `self` parameter.""" 226 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import keyword 4 | import re 5 | from dataclasses import dataclass 6 | 7 | import libcst as cst 8 | from libcst import helpers 9 | from libcst import matchers as m 10 | 11 | 12 | def get_method_node(class_node: cst.ClassDef, method_name: str) -> cst.FunctionDef: 13 | method_def = m.FunctionDef(name=m.Name(method_name)) 14 | return helpers.ensure_type( 15 | next(node for node in class_node.body.body if m.matches(node, method_def)), cst.FunctionDef 16 | ) 17 | 18 | 19 | def get_param(node: cst.FunctionDef, param_name: str) -> cst.Param: 20 | """Get the `Param` node matching `param_name`.""" 21 | try: 22 | return next(param for param in node.params.params if param.name.value == param_name) 23 | except StopIteration as e: 24 | raise RuntimeError( 25 | f"The `FunctionDef` node with name {node.name.value!r} does not have any parameter named {param_name!r}" 26 | ) from e 27 | 28 | 29 | def get_kw_param(node: cst.FunctionDef, param_name: str) -> cst.Param: 30 | """Get the keyword only `Param` node matching `param_name`.""" 31 | return next(param for param in node.params.kwonly_params if param.name.value == param_name) 32 | 33 | 34 | def to_pascal(string: str) -> str: 35 | return re.sub("([0-9A-Za-z])_(?=[0-9A-Z])", lambda m: m.group(1), string.title()) 36 | 37 | 38 | def _indent(string: str, indent_size: int = 1) -> str: 39 | indent_ws = " " * indent_size 40 | return string.replace("\n", f"\n{indent_ws}").replace(f"\n{indent_ws}\n", "\n\n") 41 | 42 | 43 | @dataclass 44 | class TypedDictAttribute: 45 | name: str 46 | """The attribute name.""" 47 | 48 | annotation: str 49 | """The annotation of the field.""" 50 | 51 | docstring: str | None = None 52 | """The docstring of the field.""" 53 | 54 | required: bool = False 55 | """Whether the field should be marked as `Required`.""" 56 | 57 | not_required: bool = False 58 | """Whether the field should be marked as `NotRequired`.""" 59 | 60 | @property 61 | def marked_annotation(self) -> str: 62 | """The annotation additionally marked as required or not required.""" 63 | if self.required: 64 | return f"Required[{self.annotation}]" 65 | if self.not_required: 66 | return f"NotRequired[{self.annotation}]" 67 | return self.annotation 68 | 69 | def __post_init__(self): 70 | if self.required and self.not_required: 71 | raise ValueError("`required` and `not_required` can't be set together.") 72 | 73 | 74 | def build_typed_dict( 75 | name: str, attributes: list[TypedDictAttribute], total: bool = True, leading_line: bool = False 76 | ) -> cst.SimpleStatementLine | cst.ClassDef: 77 | """Build a `TypedDict` class definition. 78 | 79 | If one of the attribute's name is not a valid Python identifier, the alternative functional syntax 80 | will be used (a `SimpleStatementLine` will be created instead of a `ClassDef`). 81 | 82 | Args: 83 | name: The name of the resulting class. 84 | attributes: A list of `TypedDictAttribute` instances, representing attributes of the dict. 85 | total: Whether `total=True` should be used. 86 | leadind_line: Whether an empty leading line should be added before the class definition. 87 | 88 | """ 89 | functional = any(keyword.iskeyword(attr.name) for attr in attributes) 90 | leading_lines = [cst.EmptyLine(indent=False)] if leading_line else [] 91 | if not functional: 92 | body: list[cst.SimpleStatementLine] = [] 93 | 94 | for attr in attributes: 95 | ann_statement = helpers.parse_template_statement(f"{attr.name}: {attr.marked_annotation}") 96 | if attributes.index(attr) != 0: 97 | ann_statement = ann_statement.with_changes(leading_lines=[cst.EmptyLine(indent=False)]) 98 | body.append(ann_statement) 99 | 100 | if attr.docstring: 101 | docstring = f'"""{_indent(attr.docstring)}"""' 102 | body.append(cst.SimpleStatementLine(body=[cst.Expr(cst.SimpleString(docstring))])) 103 | 104 | return cst.ClassDef( 105 | name=cst.Name(name), 106 | bases=[cst.Arg(cst.Name("TypedDict"))], 107 | keywords=[ 108 | cst.Arg( 109 | keyword=cst.Name("total"), 110 | equal=cst.AssignEqual(cst.SimpleWhitespace(""), cst.SimpleWhitespace("")), 111 | value=cst.Name("False"), 112 | ) 113 | ] 114 | if not total 115 | else [], 116 | body=cst.IndentedBlock(body), 117 | leading_lines=leading_lines, 118 | ) 119 | 120 | # If some attributes aren't Python identifiers, we use the functional form: 121 | # name = TypedDict("name", {"x": int, "y": int}) 122 | return cst.SimpleStatementLine( 123 | body=[ 124 | cst.Assign( 125 | targets=[cst.AssignTarget(cst.Name(name))], 126 | value=cst.Call( 127 | func=cst.Name("TypedDict"), 128 | args=[ 129 | cst.Arg(cst.SimpleString(f'"{name}"')), 130 | cst.Arg( 131 | cst.Dict( 132 | elements=[ 133 | cst.DictElement( 134 | key=cst.SimpleString(f'"{attr.name}"'), 135 | value=helpers.parse_template_expression(attr.marked_annotation), 136 | ) 137 | for attr in attributes 138 | ] 139 | ) 140 | ), 141 | ], 142 | ), 143 | ) 144 | ], 145 | leading_lines=leading_lines, 146 | ) 147 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/auth_functions_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import libcst as cst 4 | import libcst.matchers as m 5 | from django.contrib.auth import get_user_model 6 | from libcst import helpers 7 | from libcst.codemod import CodemodContext 8 | from libcst.codemod.visitors import AddImportsVisitor 9 | 10 | from ._utils import get_param 11 | from .base import StubVisitorBasedCodemod 12 | 13 | # Matchers: 14 | 15 | AUTHENTICATE_DEF_MATCHER = m.FunctionDef(name=m.Name("authenticate")) 16 | """Matches the `authenticate` function definition.""" 17 | 18 | LOGIN_DEF_MATCHER = m.FunctionDef(name=m.Name("login")) 19 | """Matches the `login` function definition.""" 20 | 21 | GET_USER_MODEL_DEF_MATCHER = m.FunctionDef(name=m.Name("get_user_model")) 22 | """Matches the `get_user_model` function definition.""" 23 | 24 | GET_USER_DEF_MATCHER = m.FunctionDef(name=m.Name("get_user")) 25 | """Matches the `get_user` function definition.""" 26 | 27 | UPDATE_SESSION_AUTH_HASH_DEF_MATCHER = m.FunctionDef(name=m.Name("update_session_auth_hash")) 28 | """Matches the `update_session_auth_hash` function definition.""" 29 | 30 | 31 | class AuthFunctionsCodemod(StubVisitorBasedCodemod): 32 | """A codemod that will add a custom return type to the to auth related functions. 33 | 34 | The following functions are affected: 35 | 36 | - [`authenticate`][django.contrib.auth.authenticate] 37 | - [`login`][django.contrib.auth.login] 38 | - [`get_user_model`][django.contrib.auth.get_user_model] 39 | - [`get_user`][django.contrib.auth.get_user] 40 | - [`update_session_auth_hash`][django.contrib.auth.update_session_auth_hash] 41 | 42 | Rule identifier: `DJAS011`. 43 | 44 | ```python 45 | from django.contrib.auth import authenticate, get_user_model, get_user 46 | 47 | reveal_type(authenticate(rq, **creds)) # Revealed type is "YourCustomUser | None" 48 | reveal_type(get_user_model()) # Revealed type is "type[YourCustomUser]" 49 | reveal_type(get_user(rq)) # Revealed type is "YourCustomUser | AnonymousUser" 50 | ``` 51 | """ 52 | 53 | STUB_FILES = {"contrib/auth/__init__.pyi"} 54 | 55 | def __init__(self, context: CodemodContext) -> None: 56 | super().__init__(context) 57 | user_model = get_user_model() 58 | self.user_model_name = user_model.__name__ 59 | 60 | AddImportsVisitor.add_needed_import( 61 | self.context, 62 | module=user_model._meta.app_config.models_module.__name__, 63 | obj=self.user_model_name, 64 | ) 65 | 66 | @m.leave(AUTHENTICATE_DEF_MATCHER) 67 | def mutate_AuthenticateFunctionDef( 68 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 69 | ) -> cst.FunctionDef: 70 | return updated_node.with_changes( 71 | returns=cst.Annotation(helpers.parse_template_expression(f"{self.user_model_name} | None")) 72 | ) 73 | 74 | @m.leave(LOGIN_DEF_MATCHER) 75 | def mutate_LoginFunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef: 76 | user_param = get_param(updated_node, "user") 77 | return updated_node.with_deep_changes( 78 | user_param, annotation=cst.Annotation(helpers.parse_template_expression(f"{self.user_model_name} | None")) 79 | ) 80 | 81 | @m.leave(GET_USER_DEF_MATCHER) 82 | def mutate_GetUserFunctionDef( 83 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 84 | ) -> cst.FunctionDef: 85 | return updated_node.with_changes( 86 | returns=cst.Annotation(helpers.parse_template_expression(f"{self.user_model_name} | AnonymousUser")) 87 | ) 88 | 89 | @m.leave(GET_USER_MODEL_DEF_MATCHER) 90 | def mutate_GetUserModelFunctionDef( 91 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 92 | ) -> cst.FunctionDef: 93 | return updated_node.with_changes( 94 | returns=cst.Annotation(helpers.parse_template_expression(f"type[{self.user_model_name}]")) 95 | ) 96 | 97 | @m.leave(UPDATE_SESSION_AUTH_HASH_DEF_MATCHER) 98 | def mutate_UpdateSessionAuthHashFunctionDef( 99 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 100 | ) -> cst.FunctionDef: 101 | user_param = get_param(updated_node, "user") 102 | return updated_node.with_deep_changes( 103 | user_param, annotation=cst.Annotation(helpers.parse_template_expression(self.user_model_name)) 104 | ) 105 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC 4 | from typing import TYPE_CHECKING, ClassVar, Sequence, TypeVar, cast 5 | 6 | import libcst as cst 7 | import libcst.matchers as m 8 | from libcst.codemod import CodemodContext, ContextAwareTransformer, VisitorBasedCodemodCommand 9 | from libcst.codemod.visitors import AddImportsVisitor 10 | 11 | if TYPE_CHECKING: 12 | from django_autotyping.app_settings import StubsGenerationSettings 13 | 14 | from ..django_context import DjangoStubbingContext 15 | 16 | 17 | TYPING_EXTENSIONS_NAMES = ["Unpack", "Required", "NotRequired", "deprecated"] 18 | 19 | ModuleT = TypeVar("ModuleT", bound=cst.Module) 20 | 21 | 22 | IMPORT_MATCHER = m.SimpleStatementLine(body=[m.Import() | m.ImportFrom() | m.ImportAlias() | m.ImportStar()]) 23 | """Matches the definition of an import statement.""" 24 | 25 | 26 | class InsertAfterImportsVisitor(ContextAwareTransformer): 27 | """Insert a list of statements after imports.""" 28 | 29 | CONTEXT_KEY = "InsertAfterImportsVisitor" 30 | 31 | @classmethod 32 | def insert_after_imports( 33 | cls, 34 | context: CodemodContext, 35 | statements: Sequence[cst.SimpleStatementLine | cst.BaseCompoundStatement], 36 | ) -> None: 37 | """Insert a list of statements following the module imports. 38 | 39 | If no imports are to be found, statements will be added at the beginning of the module.""" 40 | ctx_statements = context.scratch.get(cls.CONTEXT_KEY, []) 41 | ctx_statements.extend(statements) 42 | context.scratch[cls.CONTEXT_KEY] = ctx_statements 43 | 44 | def leave_Module(self, original_node: cst.Module, updated_node: cst.Module) -> cst.Module: 45 | statements = self.context.scratch.get(self.CONTEXT_KEY, []) 46 | if not statements: 47 | return updated_node 48 | 49 | body = list(updated_node.body) 50 | 51 | last_import = next((node for node in reversed(body) if m.matches(node, IMPORT_MATCHER)), None) 52 | index = body.index(last_import) + 1 if last_import is not None else 0 53 | body[index:index] = statements 54 | 55 | return updated_node.with_changes( 56 | body=body, 57 | ) 58 | 59 | 60 | class StubVisitorBasedCodemod(VisitorBasedCodemodCommand, ABC): 61 | """The base class for all codemods used for custom stub files.""" 62 | 63 | STUB_FILES: ClassVar[set[str]] 64 | """A set of stub files the codemod should apply to.""" 65 | 66 | def __init__(self, context: CodemodContext) -> None: 67 | super().__init__(context) 68 | self.django_context = cast("DjangoStubbingContext", context.scratch["django_context"]) 69 | self.stubs_settings = cast("StubsGenerationSettings", context.scratch["stubs_settings"]) 70 | 71 | def add_model_imports(self) -> None: 72 | """Add the defined models in the Django context as imports to the current file.""" 73 | 74 | # TODO LibCST should support adding imports from `ImportItem` objects 75 | imports = AddImportsVisitor._get_imports_from_context(self.context) 76 | imports.extend(self.django_context.model_imports) 77 | self.context.scratch[AddImportsVisitor.CONTEXT_KEY] = imports 78 | 79 | def add_typing_imports(self, names: list[str]) -> None: 80 | """Add imports to the `typing` module (either from `typing` or `typing_extensions`).""" 81 | for name in names: 82 | AddImportsVisitor.add_needed_import( 83 | self.context, 84 | module="typing_extensions" if name in TYPING_EXTENSIONS_NAMES else "typing", 85 | obj=name, 86 | ) 87 | 88 | def transform_module(self, tree: cst.Module) -> cst.Module: 89 | # LibCST automatically runs `AddImportsVisitor` and `RemoveImportsVisitor`, 90 | # but this is hardcoded. So we manually add our visitor. 91 | tree = super().transform_module(tree) 92 | transform = InsertAfterImportsVisitor 93 | 94 | return self._instantiate_and_run(transform, tree) 95 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/call_command_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import libcst as cst 4 | import libcst.matchers as m 5 | from libcst import helpers 6 | from libcst.codemod import CodemodContext 7 | 8 | from django_autotyping.typing import FlattenFunctionDef 9 | 10 | from ._utils import TypedDictAttribute, build_typed_dict, get_param, to_pascal 11 | from .base import InsertAfterImportsVisitor, StubVisitorBasedCodemod 12 | from .constants import OVERLOAD_DECORATOR 13 | 14 | # Matchers: 15 | 16 | CALL_COMMAND_DEF_MATCHER = m.FunctionDef(name=m.Name("call_command")) 17 | """Matches the `call_command` function definition.""" 18 | 19 | 20 | class CallCommandCodemod(StubVisitorBasedCodemod): 21 | """A codemod that will add overloads for [`call_command`][django.core.management.call_command]. 22 | 23 | Rule identifier: `DJAS017`. 24 | 25 | ```python 26 | from django.core.management import call_command 27 | 28 | call_command("non_existing_cmd") # Type error 29 | call_command("cmd", non_existing_arg="foo") # Type error 30 | ``` 31 | 32 | !!! info "Limited support" 33 | TBD 34 | https://github.com/microsoft/pylance-release/discussions/4148 35 | """ 36 | 37 | STUB_FILES = {"core/management/__init__.pyi"} 38 | 39 | def __init__(self, context: CodemodContext) -> None: 40 | super().__init__(context) 41 | self.add_typing_imports(["Literal", "Required", "TextIO", "TypedDict", "Unpack", "overload"]) 42 | 43 | @m.leave(CALL_COMMAND_DEF_MATCHER) 44 | def mutate_CallCommandFunctionDef( 45 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 46 | ) -> FlattenFunctionDef: 47 | overload = updated_node.with_changes(decorators=[OVERLOAD_DECORATOR]) 48 | overloads: list[cst.FunctionDef] = [] 49 | 50 | for command_name, command_info in self.django_context.management_commands_info.items(): 51 | arg_info_list, options_info = command_info.actions_list[0] 52 | overload_ = overload.with_deep_changes( 53 | old_node=get_param(overload, "command_name"), 54 | annotation=cst.Annotation(helpers.parse_template_expression(f'Literal["{command_name}"]')), 55 | ) 56 | if not arg_info_list: 57 | # No positional arguments, signature will be: 58 | # `call_command("cmd", **kwargs: Unpack[...])` 59 | overload_ = overload_.with_deep_changes( 60 | old_node=overload_.params, 61 | star_arg=cst.MaybeSentinel.DEFAULT, 62 | ) 63 | 64 | # Build the kwargs annotation, with an unpacked TypedDict 65 | typed_dict_name = to_pascal(f"{command_name}") + "Options" 66 | options_typed_dict = build_typed_dict( 67 | name=typed_dict_name, 68 | attributes=[ 69 | TypedDictAttribute( 70 | name=option_name, 71 | annotation=option_info.type, 72 | docstring=option_info.help, 73 | required=option_info.required, 74 | ) 75 | for option_name, option_info in options_info.items() 76 | ], 77 | leading_line=True, 78 | total=False, 79 | ) 80 | InsertAfterImportsVisitor.insert_after_imports(self.context, [options_typed_dict]) 81 | 82 | overload_ = overload_.with_deep_changes( 83 | old_node=overload_.params.star_kwarg, 84 | annotation=cst.Annotation(helpers.parse_template_expression(f"Unpack[{typed_dict_name}]")), 85 | ) 86 | 87 | overloads.append(overload_) 88 | 89 | fallback_overload = overload.with_deep_changes( 90 | old_node=get_param(overload, "command_name"), annotation=cst.Annotation(cst.Name("BaseCommand")) 91 | ) 92 | 93 | return cst.FlattenSentinel([*overloads, fallback_overload]) 94 | 95 | def _mutate_CallCommandFunctionDef( 96 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 97 | ) -> FlattenFunctionDef: 98 | overload = updated_node.with_changes(decorators=[OVERLOAD_DECORATOR]) 99 | overloads: list[cst.FunctionDef] = [] 100 | 101 | for command_name, command_info in self.django_context.management_commands_info.items(): 102 | for i, (arg_info_list, options_info) in enumerate(command_info.actions_list, start=1): 103 | overload_ = overload.with_deep_changes( 104 | old_node=get_param(overload, "command_name"), 105 | annotation=cst.Annotation(helpers.parse_template_expression(f'Literal["{command_name}"]')), 106 | ) 107 | 108 | if not arg_info_list: 109 | # No positional arguments, signature will be: 110 | # `call_command("cmd", **kwargs: Unpack[...])` 111 | overload_ = overload_.with_deep_changes( 112 | old_node=overload_.params, 113 | star_arg=cst.MaybeSentinel.DEFAULT, 114 | ) 115 | elif command_info.use_star_args(arg_info_list): 116 | args_annotation = f"*tuple[{', '.join(a.type for a in arg_info_list)}]" 117 | overload_ = overload_.with_deep_changes( 118 | old_node=overload_.params.star_arg, 119 | annotation=cst.Annotation(helpers.parse_template_expression(args_annotation)), 120 | ) 121 | else: 122 | # Fixed number of positional arguments, signature will be: 123 | # `call_command("cmd", arg1: str, arg2: str, /, **kwargs: Unpack[...])` 124 | parameters = overload_.params 125 | 126 | # We move `command_name` to be pos only 127 | posonly_params = [get_param(overload_, "command_name")] 128 | 129 | for arg_info in arg_info_list: 130 | if arg_info.nargs in (1, None): 131 | posonly_params.append( 132 | cst.Param( 133 | name=cst.Name( 134 | arg_info.dest or "tbd" 135 | ), # "or" fallback if this is a subparser without `dest` 136 | # `arg_info.type` can safely be used here 137 | annotation=cst.Annotation(helpers.parse_template_expression(arg_info.type)), 138 | ) 139 | ) 140 | else: 141 | # only possible case is `nargs>=2` 142 | for i in range(arg_info.nargs): 143 | posonly_params.append( 144 | cst.Param( 145 | name=cst.Name(f"{arg_info.dest}_{i}"), 146 | annotation=cst.Annotation(cst.Name("str")), 147 | ) 148 | ) 149 | 150 | parameters = parameters.with_changes( 151 | star_arg=cst.MaybeSentinel.DEFAULT, 152 | params=[], 153 | posonly_params=posonly_params, 154 | ) 155 | 156 | overload_ = overload_.with_changes( 157 | params=parameters, 158 | ) 159 | 160 | # Build the kwargs annotation, with an unpacked TypedDict 161 | typed_dict_name = to_pascal(f"{command_name}_options{i if len(command_info.actions_list) >= 2 else ''}") # noqa: PLR2004 162 | options_typed_dict = build_typed_dict( 163 | name=typed_dict_name, 164 | attributes=[ 165 | TypedDictAttribute( 166 | name=option_name, 167 | annotation=option_info.type, 168 | docstring=option_info.help, 169 | required=option_info.required, 170 | ) 171 | for option_name, option_info in options_info.items() 172 | ], 173 | leading_line=True, 174 | total=False, 175 | ) 176 | InsertAfterImportsVisitor.insert_after_imports(self.context, [options_typed_dict]) 177 | 178 | overload_ = overload_.with_deep_changes( 179 | old_node=overload_.params.star_kwarg, 180 | annotation=cst.Annotation(helpers.parse_template_expression(f"Unpack[{typed_dict_name}]")), 181 | ) 182 | 183 | overloads.append(overload_) 184 | 185 | fallback_overload = overload.with_deep_changes( 186 | old_node=get_param(overload, "command_name"), annotation=cst.Annotation(cst.Name("BaseCommand")) 187 | ) 188 | 189 | return cst.FlattenSentinel([*overloads, fallback_overload]) 190 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/constants.py: -------------------------------------------------------------------------------- 1 | import libcst as cst 2 | 3 | OVERLOAD_DECORATOR = cst.Decorator(decorator=cst.Name("overload")) 4 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/create_overload_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import libcst as cst 4 | import libcst.matchers as m 5 | from libcst import helpers 6 | 7 | from django_autotyping._compat import override 8 | from django_autotyping.typing import FlattenFunctionDef 9 | 10 | from ._model_creation import ModelCreationBaseCodemod 11 | 12 | # Matchers: 13 | 14 | MANAGER_QS_CLASS_DEF_MATCHER = m.ClassDef( 15 | name=m.SaveMatchedNode(m.Name("BaseManager") | m.Name("_QuerySet"), "cls_name") 16 | ) 17 | """Matches the `BaseManager` and `_QuerySet` class definitions.""" 18 | 19 | 20 | CREATE_DEF_MATCHER = m.FunctionDef(name=m.Name("create") | m.Name("acreate")) 21 | """Matches the `create` and `acreate` method definitions.""" 22 | 23 | 24 | class CreateOverloadCodemod(ModelCreationBaseCodemod): 25 | """A codemod that will add overloads to the [`create`][django.db.models.query.QuerySet.create] 26 | and [`acreate`][django.db.models.query.QuerySet.acreate] methods. 27 | 28 | **Rule identifier**: `DJAS002`. 29 | 30 | **Related settings**: 31 | 32 | - [`MODEL_FIELDS_OPTIONAL`][django_autotyping.app_settings.StubsGenerationSettings.MODEL_FIELDS_OPTIONAL]. 33 | 34 | ```python 35 | MyModel.objects.create(...) # Signature is provided. 36 | ``` 37 | 38 | ??? abstract "Implementation" 39 | This codemod makes use of the [PEP 692][pep-0692]. If your type checker/LSP supports it, 40 | documentation is provided for each field if [`help_text`][django.db.models.Field.help_text] was set. 41 | """ 42 | 43 | KWARGS_TYPED_DICT_NAME = "{model_name}CreateKwargs" 44 | STUB_FILES = {"db/models/manager.pyi", "db/models/query.pyi"} 45 | 46 | @override 47 | def get_self_annotation(self, model_name: str, class_name: str) -> cst.BaseExpression: 48 | if class_name == "_QuerySet": 49 | return helpers.parse_template_expression(f"{class_name}[{model_name}, _Row]") 50 | elif class_name == "BaseManager": 51 | return helpers.parse_template_expression(f"{class_name}[{model_name}]") 52 | 53 | @m.call_if_inside(MANAGER_QS_CLASS_DEF_MATCHER) 54 | @m.leave(CREATE_DEF_MATCHER) 55 | def mutate_create_FunctionDef( 56 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 57 | ) -> FlattenFunctionDef: 58 | """Add overloads for `create`/`acreate` if in `BaseManager`/`_QuerSet`.""" 59 | return self.mutate_FunctionDef(original_node, updated_node) 60 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/get_model_overload_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import libcst as cst 4 | import libcst.matchers as m 5 | from libcst import helpers 6 | from libcst.codemod import CodemodContext 7 | 8 | from django_autotyping.typing import FlattenFunctionDef 9 | 10 | from ._utils import get_param 11 | from .base import StubVisitorBasedCodemod 12 | from .constants import OVERLOAD_DECORATOR 13 | 14 | # Matchers: 15 | 16 | CLASS_DEF_MATCHER = m.ClassDef(name=m.Name("Apps")) 17 | """Matches the `Apps` class definition.""" 18 | 19 | GET_MODEL_DEF_MATCHER = m.FunctionDef(name=m.Name("get_model")) 20 | """Matches the `get_model` method definition.""" 21 | 22 | 23 | class GetModelOverloadCodemod(StubVisitorBasedCodemod): 24 | """A codemod that will add overloads to the [`apps.get_model`][django.apps.apps.get_model] method. 25 | 26 | Rule identifier: `DJAS010`. 27 | 28 | ```python 29 | reveal_type(apps.get_model("app_name.ModelName")) # Revealed type is "type[ModelName]" 30 | reveal_type(apps.get_model("app_name", "ModelName")) # Revealed type is "type[ModelName]" 31 | ``` 32 | """ 33 | 34 | STUB_FILES = {"apps/registry.pyi"} 35 | 36 | def __init__(self, context: CodemodContext) -> None: 37 | super().__init__(context) 38 | self.add_model_imports() 39 | 40 | # Even though these are most likely included, we import them for safety: 41 | self.add_typing_imports(["Literal", "overload"]) 42 | 43 | @m.call_if_inside(CLASS_DEF_MATCHER) 44 | @m.leave(GET_MODEL_DEF_MATCHER) 45 | def mutate_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> FlattenFunctionDef: 46 | overload = updated_node.with_changes(decorators=[OVERLOAD_DECORATOR]) 47 | overloads: list[cst.FunctionDef] = [] 48 | 49 | for model in self.django_context.models: 50 | for use_shortcut in (True, False): 51 | model_name = self.django_context.get_model_name(model) 52 | app_label = model._meta.app_label 53 | 54 | # sets `app_label: Literal[...]` 55 | app_label_param = get_param(overload, "app_label") 56 | if use_shortcut: 57 | annotation = helpers.parse_template_expression(f'Literal["{app_label}.{model.__name__}"]') 58 | else: 59 | annotation = helpers.parse_template_expression(f'Literal["{app_label}"]') 60 | overload_ = overload.with_deep_changes( 61 | old_node=app_label_param, 62 | annotation=cst.Annotation(annotation), 63 | ) 64 | 65 | # sets `model_name: Literal[...]` 66 | model_name_param = get_param(overload_, "model_name") 67 | if use_shortcut: 68 | annotation = helpers.parse_template_expression("Literal[None]") 69 | else: 70 | annotation = helpers.parse_template_expression(f'Literal["{model.__name__}"]') 71 | 72 | overload_ = overload_.with_deep_changes( 73 | old_node=model_name_param, 74 | annotation=cst.Annotation(annotation), 75 | default=None if not use_shortcut else model_name_param.default, 76 | equal=cst.MaybeSentinel.DEFAULT if not use_shortcut else model_name_param.equal, 77 | ) 78 | 79 | # sets return value 80 | overload_ = overload_.with_changes( 81 | # This time use the imported model name! 82 | returns=cst.Annotation(helpers.parse_template_expression(f"type[{model_name}]")) 83 | ) 84 | 85 | overloads.append(overload_) 86 | 87 | return cst.FlattenSentinel(overloads) 88 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/model_init_overload_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import libcst as cst 4 | import libcst.matchers as m 5 | from libcst import helpers 6 | from libcst.codemod import CodemodContext 7 | 8 | from django_autotyping._compat import override 9 | from django_autotyping.typing import FlattenFunctionDef 10 | 11 | from ._model_creation import ModelCreationBaseCodemod 12 | from .base import InsertAfterImportsVisitor 13 | 14 | # Matchers: 15 | 16 | MODEL_CLASS_DEF_MATCHER = m.ClassDef(name=m.SaveMatchedNode(m.Name("Model"), "cls_name")) 17 | """Matches the `Model` class definition.""" 18 | 19 | INIT_DEF_MATCHER = m.FunctionDef(name=m.Name("__init__")) 20 | """Matches the `__init__` method definition.""" 21 | 22 | 23 | class ModelInitOverloadCodemod(ModelCreationBaseCodemod): 24 | """A codemod that will add overloads to the [`Model.__init__`][django.db.models.Model] method. 25 | 26 | **Rule identifier**: `DJAS003`. 27 | 28 | **Related settings**: 29 | 30 | - [`MODEL_FIELDS_OPTIONAL`][django_autotyping.app_settings.StubsGenerationSettings.MODEL_FIELDS_OPTIONAL]. 31 | 32 | ```python 33 | MyModel(...) # Signature is provided. 34 | ``` 35 | 36 | ??? abstract "Implementation" 37 | This codemod makes use of the [PEP 692][pep-0692]. If your type checker/LSP supports it, 38 | documentation is provided for each field if [`help_text`][django.db.models.Field.help_text] was set. 39 | """ 40 | 41 | KWARGS_TYPED_DICT_NAME = "{model_name}InitKwargs" 42 | STUB_FILES = {"db/models/base.pyi"} 43 | 44 | def __init__(self, context: CodemodContext) -> None: 45 | super().__init__(context) 46 | self.add_model_imports() 47 | model_typed_dicts = self.build_model_kwargs() 48 | InsertAfterImportsVisitor.insert_after_imports(context, model_typed_dicts) 49 | 50 | # Even though these are most likely included, we import them for safety: 51 | self.add_typing_imports(["TypedDict", "TypeVar", "Unpack", "overload"]) 52 | 53 | @override 54 | def mutate_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> FlattenFunctionDef: 55 | overloads = super().mutate_FunctionDef(original_node, updated_node) 56 | # Remove `*args` from the definition: 57 | return cst.FlattenSentinel( 58 | overload.with_deep_changes(old_node=overload.params, star_arg=cst.MaybeSentinel.DEFAULT) 59 | for overload in overloads.nodes 60 | ) 61 | 62 | @override 63 | def get_self_annotation(self, model_name: str, class_name: str) -> cst.BaseExpression: 64 | return helpers.parse_template_expression(model_name) 65 | 66 | @m.call_if_inside(MODEL_CLASS_DEF_MATCHER) 67 | @m.leave(INIT_DEF_MATCHER) 68 | def mutate_init_FunctionDef( 69 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 70 | ) -> FlattenFunctionDef: 71 | """Add overloads for `__init__` if in `Model`.""" 72 | return self.mutate_FunctionDef(original_node, updated_node) 73 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/query_lookups_overload_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import libcst as cst 6 | import libcst.matchers as m 7 | from libcst import helpers 8 | from libcst.codemod import CodemodContext 9 | 10 | from django_autotyping.typing import FlattenFunctionDef 11 | 12 | from ._utils import TypedDictAttribute, build_typed_dict, get_param 13 | from .base import InsertAfterImportsVisitor, StubVisitorBasedCodemod 14 | from .constants import OVERLOAD_DECORATOR 15 | 16 | if TYPE_CHECKING: 17 | from ..django_context import DjangoStubbingContext 18 | 19 | # Matchers: 20 | 21 | BASE_MANAGER_CLASS_DEF_MATCHER = m.ClassDef(name=m.Name("BaseManager")) 22 | """Matches the `ManyToManyField` class definition.""" 23 | 24 | GET_MODEL_DEF_MATCHER = m.FunctionDef(name=m.Name("get")) 25 | """Matches the `get` method definition.""" 26 | 27 | 28 | class QueryLookupsOverloadCodemod(StubVisitorBasedCodemod): 29 | """TODO 30 | 31 | Rule identifier: `DJAS003`. 32 | """ 33 | 34 | STUB_FILES = {"db/models/manager.pyi"} 35 | 36 | def __init__(self, context: CodemodContext) -> None: 37 | super().__init__(context) 38 | self.add_model_imports() 39 | model_typed_dicts = _build_model_kwargs(self.django_context.models) 40 | InsertAfterImportsVisitor.insert_after_imports(context, model_typed_dicts) 41 | 42 | # Even though these are most likely included, we import them for safety: 43 | self.add_typing_imports(["TypedDict", "TypeVar", "Unpack", "overload"]) 44 | 45 | @m.call_if_inside(BASE_MANAGER_CLASS_DEF_MATCHER) 46 | @m.leave(GET_MODEL_DEF_MATCHER) 47 | def mutate_classDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> FlattenFunctionDef: 48 | """Add the necessary overloads to foreign fields that supports 49 | that supports parametrization of the `__set__` and `__get__` types. 50 | """ 51 | 52 | overload_get = updated_node.with_changes(decorators=[OVERLOAD_DECORATOR]) 53 | overloads: list[cst.FunctionDef] = [] 54 | 55 | for model in self.django_context.models: 56 | model_name = self.django_context.get_model_name(model) 57 | 58 | # sets `self: ManyToManyField[model_name, _Through]` 59 | self_param = get_param(overload_get, "self") 60 | overload = overload_get.with_deep_changes( 61 | old_node=self_param, 62 | annotation=cst.Annotation(annotation=helpers.parse_template_expression(f"BaseManager[{model_name}]")), 63 | ) 64 | 65 | overload = overload.with_deep_changes( 66 | old_node=overload.params.star_kwarg, 67 | annotation=cst.Annotation(annotation=helpers.parse_template_expression(f"Unpack[{model_name}Kwargs]")), 68 | ) 69 | 70 | overloads.append(overload) 71 | 72 | return cst.FlattenSentinel(overloads) 73 | 74 | 75 | def _build_model_kwargs(django_context: DjangoStubbingContext) -> list[cst.ClassDef]: 76 | # TODO This needs to build the available lookups 77 | class_defs: list[cst.ClassDef] = [] 78 | 79 | for model in django_context.models: 80 | model_name = django_context.get_model_name(model) 81 | class_defs.append( 82 | build_typed_dict( 83 | f"{model_name}CreateKwargs", 84 | attributes=[ 85 | TypedDictAttribute( 86 | field.name, 87 | annotation="Any", 88 | docstring=getattr(field, "help_text", None) or None, 89 | ) 90 | for field in model._meta._get_fields(reverse=False) 91 | ], 92 | total=False, # TODO find a way to determine which fields are required. 93 | leading_line=True, 94 | ) 95 | ) 96 | 97 | return class_defs 98 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/reverse_overload_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from typing import TYPE_CHECKING 5 | 6 | import libcst as cst 7 | import libcst.matchers as m 8 | from libcst import helpers 9 | from libcst.codemod import CodemodContext 10 | 11 | from django_autotyping.typing import FlattenFunctionDef 12 | 13 | from ._utils import TypedDictAttribute, build_typed_dict, get_param 14 | from .base import InsertAfterImportsVisitor, StubVisitorBasedCodemod 15 | from .constants import OVERLOAD_DECORATOR 16 | 17 | if TYPE_CHECKING: 18 | from ..django_context._url_utils import PathInfo 19 | 20 | # `SupportsStr` is a Protocol that supports `__str__`. 21 | # This should be equivalent to `object`, but is used to be 22 | # more explicit. 23 | SUPPORTS_STR_DEF = helpers.parse_template_statement( 24 | """ 25 | class SupportsStr(Protocol): 26 | def __str__(self) -> str: 27 | ... 28 | """ 29 | ) 30 | 31 | EMPTY_DICT_DEF = helpers.parse_template_statement( 32 | """ 33 | class EmptyDict(TypedDict): 34 | pass 35 | """ 36 | ) 37 | 38 | LITERAL_NONE = helpers.parse_template_expression("Literal[None]") 39 | 40 | # Matchers: 41 | 42 | REVERSE_DEF_MATCHER = m.FunctionDef(name=m.Name("reverse")) 43 | """Matches the `reverse` function definition.""" 44 | 45 | 46 | class ReverseOverloadCodemod(StubVisitorBasedCodemod): 47 | """A codemod that will add overloads to the [`reverse`][django.urls.reverse] function. 48 | 49 | **Rule identifier**: `DJAS015`. 50 | 51 | **Related settings**: 52 | 53 | - [`ALLOW_REVERSE_ARGS`][django_autotyping.app_settings.StubsGenerationSettings.ALLOW_REVERSE_ARGS]. 54 | 55 | ```python 56 | reverse("my-view-name", kwargs={...}) # `kwargs` is typed with a `TypedDict`, providing auto-completion. 57 | ``` 58 | """ 59 | 60 | STUB_FILES = {"urls/base.pyi"} 61 | 62 | def __init__(self, context: CodemodContext) -> None: 63 | super().__init__(context) 64 | 65 | InsertAfterImportsVisitor.insert_after_imports(context, [EMPTY_DICT_DEF, SUPPORTS_STR_DEF]) 66 | self.add_typing_imports(["Literal", "TypedDict", "NotRequired", "Protocol", "overload"]) 67 | 68 | @m.leave(REVERSE_DEF_MATCHER) 69 | def mutate_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> FlattenFunctionDef: 70 | overload = updated_node.with_changes(decorators=[OVERLOAD_DECORATOR]) 71 | 72 | overloads: list[cst.FunctionDef] = [] 73 | seen_typeddict_names: list[str] = [] 74 | reversed_dict: defaultdict[PathInfo, list[str]] = defaultdict(list) 75 | 76 | # First, build a reverse dictionary: a mapping between PathInfos instances (shared between views) 77 | # and a list of viewnames 78 | 79 | for viewname, path_info in self.django_context.viewnames_lookups.items(): 80 | reversed_dict[path_info].append(viewname) 81 | 82 | for path_info, viewnames in reversed_dict.items(): 83 | # We do not support `current_app` for now, it would generate too many overloads 84 | overload_ = overload.with_deep_changes( 85 | old_node=get_param(overload, "current_app"), 86 | annotation=cst.Annotation(LITERAL_NONE), 87 | ) 88 | 89 | viewnames_literals = ", ".join(f'"{viewname}"' for viewname in viewnames) 90 | overload_ = overload_.with_deep_changes( 91 | old_node=get_param(overload_, "viewname"), 92 | annotation=cst.Annotation(helpers.parse_template_expression(f"Literal[{viewnames_literals}]")), 93 | ) 94 | 95 | if path_info.is_empty: 96 | # Calling `reverse` with `args` or `kwargs` will fail at runtime if the view has no arguments. 97 | # We create a special overload handling this case: 98 | overload_ = overload_.with_deep_changes( 99 | old_node=get_param(overload_, "args"), 100 | annotation=cst.Annotation(helpers.parse_template_expression("tuple[()] | None")), 101 | ) 102 | overload_ = overload_.with_deep_changes( 103 | old_node=get_param(overload_, "kwargs"), 104 | annotation=cst.Annotation(helpers.parse_template_expression("EmptyDict | None")), 105 | ) 106 | overloads.insert(0, overload_) 107 | continue 108 | 109 | use_args_options = (True, False) if self.stubs_settings.ALLOW_REVERSE_ARGS else (False,) 110 | 111 | for use_args in use_args_options: 112 | args_param = get_param(overload_, "args") 113 | if use_args: 114 | annotation = helpers.parse_template_expression(path_info.get_args_annotation()) 115 | else: 116 | annotation = LITERAL_NONE 117 | 118 | args_param = args_param.with_changes( 119 | annotation=cst.Annotation(annotation), 120 | default=None if use_args else args_param.default, 121 | equal=cst.MaybeSentinel.DEFAULT if use_args else args_param.equal, 122 | ) 123 | 124 | kwargs_param = get_param(overload_, "kwargs") 125 | if use_args: 126 | annotation = LITERAL_NONE 127 | else: 128 | annotation = helpers.parse_template_expression(path_info.get_kwargs_annotation()) 129 | 130 | # Add the TypedDict definition if not already done: 131 | for path_args in path_info.arguments_set: 132 | typeddict_name = path_args.typeddict_name 133 | if typeddict_name in seen_typeddict_names: 134 | continue 135 | 136 | seen_typeddict_names.append(typeddict_name) 137 | typed_dict = build_typed_dict( 138 | typeddict_name, 139 | attributes=[ 140 | TypedDictAttribute( 141 | name=arg_name, 142 | annotation="SupportsStr", 143 | not_required=True if not required else None, 144 | # TODO, any docstring? 145 | ) 146 | for arg_name, required in path_args.arguments 147 | ], 148 | leading_line=True, 149 | ) 150 | 151 | InsertAfterImportsVisitor.insert_after_imports(self.context, [typed_dict]) 152 | 153 | kwargs_param = kwargs_param.with_changes( 154 | annotation=cst.Annotation(annotation), 155 | default=None if not use_args else kwargs_param.default, 156 | equal=cst.MaybeSentinel.DEFAULT if not use_args else kwargs_param.equal, 157 | ) 158 | 159 | # Finally, add a `ParamStar` after `urlconf`, to have valid signatures. 160 | # Also move the necessary arguments as kwonly_params: 161 | 162 | overload_param_star = overload_.with_deep_changes( 163 | old_node=overload_.params, 164 | star_arg=cst.ParamStar(), 165 | params=[p for p in overload_.params.params if p.name.value in ("viewname", "urlconf")], 166 | kwonly_params=[args_param, kwargs_param, get_param(overload_, "current_app")], 167 | ) 168 | 169 | overloads.append(overload_param_star) 170 | 171 | # Remove the `str` annotation from `viewname` in the fallback overloads, so that 172 | # only literals will match: 173 | overload = overload.with_deep_changes( 174 | old_node=get_param(overload, "viewname"), 175 | annotation=cst.Annotation(helpers.parse_template_expression("Callable[..., Any] | None")), 176 | ) 177 | 178 | return cst.FlattenSentinel([*overloads, overload]) 179 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/settings_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import warnings 5 | 6 | import libcst as cst 7 | import libcst.matchers as m 8 | from django import VERSION as DJANGO_VERSION 9 | from libcst import helpers 10 | from libcst.codemod.visitors import AddImportsVisitor 11 | 12 | from django_autotyping._compat import NoneType 13 | 14 | from ._global_settings_types import GLOBAL_SETTINGS, SettingTypingConfiguration 15 | from ._utils import _indent 16 | from .base import InsertAfterImportsVisitor, StubVisitorBasedCodemod 17 | 18 | # Matchers: 19 | 20 | CLASS_DEF_MATCHER = m.ClassDef(name=m.Name("LazySettings")) 21 | """Matches the `LazySettings` class definition.""" 22 | 23 | DEPRECATED_SETTING_NO_DOCSTRING = """ 24 | @property 25 | @deprecated({message}) 26 | def {setting_name}(self) -> {type}: ... 27 | """.strip() 28 | 29 | DEPRECATED_SETTING_NO_DOCSTRING = """ 30 | @property 31 | @deprecated({message}) 32 | def {setting_name}(self) -> {type}: 33 | {docstring} 34 | """.strip() 35 | 36 | TYPE_MAP = { 37 | int: "int", 38 | str: "str", 39 | NoneType: "Literal[None]", 40 | } 41 | 42 | 43 | class SettingCodemod(StubVisitorBasedCodemod): 44 | """A codemod that will add typing to the Django settings object. 45 | 46 | Depending on the Django version being used when running the stubs generation, 47 | the available settings might differ. The [`@deprecated`][warnings.deprecated] 48 | decorator will be used if necessary, thus making your type checker aware of 49 | the deprecation notice. 50 | 51 | Rule identifier: `DJAS016`. 52 | 53 | ```python 54 | from django.conf import settings 55 | 56 | reveal_type(settings.ADMINS) # Revealed type is "list[tuple[str, str]]" 57 | reveal_type(settings.CUSTOM_SETTING) # Revealed type is "str" 58 | reveal_type(settings.USE_DEPRECATED_PYTZ) # Will be marked as deprecated by the type checker. 59 | ``` 60 | 61 | !!! info "Experimental" 62 | Type hints might not reflect the actual type being used at runtime. 63 | For Django settings, all the possible types are taken into account (e.g. the 64 | `EMAIL_TIMEOUT` setting might be set to `10`, but as the default value is `None`, 65 | the reflected type hint will be `int | None`). 66 | 67 | For custom settings, only simple types are inferred. See 68 | [this issue](https://github.com/Viicos/django-autotyping/issues/40) for more details. 69 | """ 70 | 71 | STUB_FILES = {"conf/__init__.pyi"} 72 | 73 | def _get_statement_lines( 74 | self, setting_name: str, setting_typing_conf: SettingTypingConfiguration 75 | ) -> list[cst.SimpleStatementLine]: 76 | docstring = setting_typing_conf.get("docs") 77 | 78 | if setting_typing_conf.get("deprecated_since", (float("inf"),)) <= DJANGO_VERSION: 79 | if not docstring: 80 | stmt = helpers.parse_template_statement( 81 | DEPRECATED_SETTING_NO_DOCSTRING, 82 | message=cst.SimpleString(f'"{setting_typing_conf.get("deprecated_message", "")}"'), 83 | setting_name=cst.Name(setting_name), 84 | type=cst.Annotation(cst.Name(setting_typing_conf["type"])), 85 | ) 86 | else: 87 | _docstring = f'"""{_indent(docstring.strip(), indent_size=2)}"""' 88 | stmt = helpers.parse_template_statement( 89 | DEPRECATED_SETTING_NO_DOCSTRING, 90 | message=cst.SimpleString(f'"{setting_typing_conf.get("deprecated_message", "")}"'), 91 | setting_name=cst.Name(setting_name), 92 | type=cst.Annotation(cst.Name(setting_typing_conf["type"])), 93 | docstring=cst.SimpleString(_docstring), 94 | ) 95 | 96 | lines = [stmt] 97 | self.add_typing_imports(["deprecated"]) 98 | 99 | else: 100 | lines = [ 101 | cst.SimpleStatementLine( 102 | [ 103 | cst.AnnAssign( 104 | target=cst.Name(setting_name), 105 | annotation=cst.Annotation(helpers.parse_template_expression(setting_typing_conf["type"])), 106 | ) 107 | ] 108 | ), 109 | ] 110 | if docstring: 111 | docstring = f'"""{_indent(docstring.strip())}"""' 112 | lines.append(cst.SimpleStatementLine([cst.Expr(cst.SimpleString(docstring))])) 113 | 114 | return lines 115 | 116 | @m.leave(CLASS_DEF_MATCHER) 117 | def mutate_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.ClassDef: 118 | body = list(updated_node.body.body) 119 | 120 | with warnings.catch_warnings(): # py3.11: `with warnings.catch_warnings(action="ignore")` 121 | warnings.simplefilter("ignore", category=DeprecationWarning) 122 | warnings.simplefilter("ignore", category=PendingDeprecationWarning) 123 | all_settings = { 124 | setting_name: getattr(self.django_context.settings, setting_name) 125 | for setting_name in dir(self.django_context.settings._wrapped) 126 | if setting_name != "SETTINGS_MODULE" 127 | if setting_name.isupper() 128 | } 129 | custom_settings = {k: v for k, v in all_settings.items() if k not in GLOBAL_SETTINGS} 130 | 131 | for setting_name, setting_typing_conf in GLOBAL_SETTINGS.items(): 132 | if ( 133 | (setting_typing_conf.get("no_default") and setting_name not in all_settings) 134 | or setting_typing_conf.get("added_in", (0,)) > DJANGO_VERSION 135 | or setting_typing_conf.get("removed_in", (float("inf"),)) <= DJANGO_VERSION 136 | ): 137 | continue 138 | 139 | if setting_name == "AUTH_USER_MODEL": 140 | setting_typing_conf = setting_typing_conf.copy() 141 | setting_typing_conf["type"] = f'Literal["{all_settings["AUTH_USER_MODEL"]}"]' 142 | 143 | if typing_imports := setting_typing_conf.get("typing_imports"): 144 | self.add_typing_imports(typing_imports) 145 | if extra_imports := setting_typing_conf.get("extra_imports"): 146 | imports = AddImportsVisitor._get_imports_from_context(self.context) 147 | imports.extend(extra_imports) 148 | self.context.scratch[AddImportsVisitor.CONTEXT_KEY] = imports 149 | if extra_defs := setting_typing_conf.get("extra_definitions"): 150 | parsed_defs = [cst.parse_statement(inspect.getsource(obj)) for obj in extra_defs] 151 | InsertAfterImportsVisitor.insert_after_imports(self.context, parsed_defs) 152 | 153 | body.extend(self._get_statement_lines(setting_name, setting_typing_conf)) 154 | 155 | for setting_name, setting_value in custom_settings.items(): 156 | ann_str = TYPE_MAP.get(type(setting_value), "Any") # TODO, better way? 157 | if ann_str == "Any": 158 | self.add_typing_imports(["Any"]) 159 | 160 | body.append( 161 | cst.SimpleStatementLine( 162 | [ 163 | cst.AnnAssign( 164 | target=cst.Name(setting_name), 165 | annotation=cst.Annotation(helpers.parse_template_expression(ann_str)), 166 | ) 167 | ] 168 | ) 169 | ) 170 | 171 | return updated_node.with_deep_changes( 172 | old_node=updated_node.body, 173 | body=body, 174 | ) 175 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/codemods/template_loading_codemod.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import libcst as cst 4 | import libcst.matchers as m 5 | from libcst.codemod import CodemodContext 6 | 7 | from django_autotyping.typing import FlattenFunctionDef 8 | 9 | from ._utils import get_kw_param, get_param, to_pascal 10 | from .base import InsertAfterImportsVisitor, StubVisitorBasedCodemod 11 | from .constants import OVERLOAD_DECORATOR 12 | 13 | 14 | class _All: 15 | pass 16 | 17 | 18 | ALL = _All() 19 | """Sentinel value to indicate an overload should include all template names.""" 20 | 21 | # Matchers: 22 | 23 | GET_TEMPLATE_DEF_MATCHER = m.FunctionDef(name=m.Name("get_template")) 24 | """Matches the `get_template` function definition.""" 25 | 26 | SELECT_TEMPLATE_DEF_MATCHER = m.FunctionDef(name=m.Name("select_template")) 27 | """Matches the `select_template` function definition.""" 28 | 29 | RENDER_TO_STRING_DEF_MATCHER = m.FunctionDef(name=m.Name("render_to_string")) 30 | """Matches the `render_to_string` function definition.""" 31 | 32 | 33 | class TemplateLoadingCodemod(StubVisitorBasedCodemod): 34 | """A codemod that will add overloads for template loading functions: 35 | 36 | - [`get_template`][django.template.loader.get_template] 37 | - [`select_template`][django.template.loader.select_template] 38 | - [`render_to_string`][django.template.loader.render_to_string] 39 | 40 | Rule identifier: `DJAS017`. 41 | 42 | ```python 43 | from django.template.loader import get_template 44 | 45 | get_template("a_template.html") # 'a_template.html' can be from any engine 46 | get_template("a_django_template.html", using="django") 47 | get_template("not_a_jinja2_template.html", using="my_jinja2_engine") # Type error 48 | ``` 49 | 50 | !!! warning "Limited support" 51 | Engines other that Django and custom loaders are not supported yet. 52 | """ 53 | 54 | STUB_FILES = {"template/loader.pyi"} 55 | 56 | def __init__(self, context: CodemodContext) -> None: 57 | super().__init__(context) 58 | self.add_typing_imports(["Literal", "TypeAlias", "overload"]) 59 | self.engines_literal_names = self.get_engines_literal_names() 60 | 61 | def get_engines_literal_names(self) -> dict[str | _All, str]: 62 | engines_info = self.django_context.template_engines_info 63 | 64 | engines_literal_names: dict[str | _All, str] = {} 65 | 66 | for engine_name, engine_info in engines_info.items(): 67 | literal_name = f"{to_pascal(engine_name)}Templates" 68 | literals = ", ".join(f'"{name}"' for name in engine_info["template_names"]) 69 | 70 | InsertAfterImportsVisitor.insert_after_imports( 71 | self.context, [cst.parse_statement(f"{literal_name}: TypeAlias = Literal[{literals}]")] 72 | ) 73 | 74 | engines_literal_names[engine_name] = literal_name 75 | 76 | if len(engines_info) >= 2: # noqa: PLR2004 77 | all_names: set[str] = set() 78 | for engine_info in reversed(engines_info.values()): 79 | all_names.update(engine_info["template_names"]) 80 | 81 | # Ideally `AllTemplates` but 'all' might be an engine name already 82 | literal_name = "TemplatesAll" 83 | 84 | literals = ", ".join(f'"{name}"' for name in all_names) 85 | 86 | InsertAfterImportsVisitor.insert_after_imports( 87 | self.context, [cst.parse_statement(f"{literal_name}: TypeAlias = Literal[{literals}]")] 88 | ) 89 | 90 | engines_literal_names[ALL] = literal_name 91 | 92 | return engines_literal_names 93 | 94 | @m.leave(GET_TEMPLATE_DEF_MATCHER | SELECT_TEMPLATE_DEF_MATCHER | RENDER_TO_STRING_DEF_MATCHER) 95 | def mutate_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> FlattenFunctionDef: 96 | is_render_to_string = updated_node.name.value == "render_to_string" 97 | is_select_template = updated_node.name.value == "select_template" 98 | template_name_arg = "template_name_list" if is_select_template else "template_name" 99 | 100 | if len(self.engines_literal_names) == 1: 101 | # One engine: no overloads needed. 102 | engine_name, literal_name = next(iter(self.engines_literal_names.items())) 103 | 104 | new_node = updated_node.with_deep_changes( 105 | old_node=get_param(updated_node, "using"), 106 | annotation=cst.Annotation(cst.parse_expression(f'Literal["{engine_name}"] | None')), 107 | ) 108 | 109 | annotation = cst.parse_expression(f"list[{literal_name}]") if is_select_template else cst.Name(literal_name) 110 | 111 | new_node = new_node.with_deep_changes( 112 | old_node=get_param(new_node, template_name_arg), annotation=cst.Annotation(annotation) 113 | ) 114 | 115 | return new_node 116 | 117 | overload = updated_node.with_changes(decorators=[OVERLOAD_DECORATOR]) 118 | overloads: list[cst.FunctionDef] = [] 119 | 120 | for engine_name, literal_name in self.engines_literal_names.items(): 121 | annotation = cst.parse_expression(f"list[{literal_name}]") if is_select_template else cst.Name(literal_name) 122 | 123 | overload_ = overload.with_deep_changes( 124 | old_node=get_param(overload, template_name_arg), annotation=cst.Annotation(annotation) 125 | ) 126 | 127 | if engine_name is ALL: 128 | overload_ = overload_.with_deep_changes( 129 | old_node=get_param(overload_, "using"), 130 | annotation=cst.Annotation(cst.Name("None")), 131 | ) 132 | else: 133 | get_param_func = get_param 134 | if is_render_to_string: 135 | # Make all params following 'template_name' kw-only: 136 | overload_ = overload_.with_deep_changes( 137 | old_node=overload_.params, 138 | star_arg=cst.ParamStar(), 139 | params=[get_param(overload_, "template_name")], 140 | kwonly_params=[p for p in overload_.params.params if p.name.value != "template_name"], 141 | ) 142 | get_param_func = get_kw_param 143 | 144 | overload_ = overload_.with_deep_changes( 145 | old_node=get_param_func(overload_, "using"), 146 | annotation=cst.Annotation(cst.parse_expression(f'Literal["{engine_name}"]')), 147 | default=None, 148 | equal=cst.MaybeSentinel.DEFAULT, 149 | ) 150 | 151 | overloads.append(overload_) 152 | 153 | return cst.FlattenSentinel(overloads) 154 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/django_context/__init__.py: -------------------------------------------------------------------------------- 1 | from .django_context import DjangoStubbingContext 2 | 3 | __all__ = ("DjangoStubbingContext",) 4 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/django_context/_template_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Callable, TypedDict 5 | 6 | from django.template.backends.base import BaseEngine 7 | from django.template.backends.django import DjangoTemplates 8 | 9 | try: 10 | from django.template.backends.jinja2 import Jinja2 11 | 12 | has_jinja2 = True 13 | except ImportError: 14 | has_jinja2 = False 15 | 16 | 17 | class EngineInfo(TypedDict): 18 | backend_class: str 19 | template_names: list[str] 20 | 21 | 22 | def _get_django_template_names(engine: DjangoTemplates) -> list[str]: 23 | # would benefit from an ordered set 24 | ordered_template_names: dict[str, None] = {} 25 | 26 | for dir in engine.template_dirs: 27 | ordered_template_names.update( 28 | {str(k.relative_to(dir)): None for k in filter(lambda p: p.is_file(), Path(dir).rglob("*"))} 29 | ) 30 | 31 | return list(ordered_template_names) 32 | 33 | 34 | if has_jinja2: 35 | 36 | def _get_jinja2_template_names(engine: Jinja2) -> list[str]: 37 | # TODO Make use of `BaseLoader.list_templates()` 38 | return [] 39 | 40 | 41 | ENGINE_HANDLERS: dict[type[BaseEngine], Callable[[BaseEngine], list[str]]] = { 42 | DjangoTemplates: _get_django_template_names, 43 | } 44 | 45 | if has_jinja2: 46 | ENGINE_HANDLERS[Jinja2] = _get_jinja2_template_names 47 | 48 | 49 | def get_template_names(engine: BaseEngine) -> list[str]: 50 | if type(engine) not in ENGINE_HANDLERS: 51 | return [] 52 | return ENGINE_HANDLERS[type(engine)](engine) 53 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/django_context/_url_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | from collections import defaultdict 5 | from dataclasses import dataclass, field, replace 6 | 7 | from django.urls import URLPattern, URLResolver 8 | 9 | 10 | @dataclass(eq=True, frozen=True) 11 | class PathArguments: 12 | """Describes the available arguments for a specific Django view.""" 13 | 14 | arguments: frozenset[tuple[str, bool]] = field(default_factory=frozenset) 15 | 16 | def __len__(self) -> int: 17 | return len(self.arguments) 18 | 19 | def __bool__(self) -> bool: 20 | return bool(len(self)) 21 | 22 | @property 23 | def sha1(self) -> str: 24 | stringified = "".join(f"{k}={v}" for k, v in sorted(self.arguments, key=lambda arg: arg[0])) 25 | return hashlib.sha1(stringified.encode("utf-8")).hexdigest() 26 | 27 | @property 28 | def typeddict_name(self) -> str: 29 | return f"_{self.sha1[:6].upper()}Kwargs" 30 | 31 | def is_mergeable(self, arguments: dict[str, bool]) -> bool: 32 | """Return whether the keys of the provided arguments are the same as the current instance.""" 33 | return {arg[0] for arg in self.arguments} == set(arguments) 34 | 35 | def with_new_arguments(self, arguments: dict[str, bool]) -> PathArguments: 36 | new_arguments = frozenset((k, False if not arguments[k] else is_required) for k, is_required in self.arguments) 37 | return replace(self, arguments=new_arguments) 38 | 39 | 40 | @dataclass(eq=True, frozen=True) 41 | class PathInfo: 42 | """Describes the set of available arguments for a Django view. 43 | 44 | At its core, this class holds a set of the possible combinations of arguments for a view. 45 | If multiple views are registered with the same name, a call to `reverse` can match any of 46 | these views depending on the provided arguments and the order they appear in the URL patterns list. 47 | """ 48 | 49 | arguments_set: frozenset[PathArguments] = field(default_factory=frozenset) 50 | 51 | @property 52 | def is_empty(self) -> bool: 53 | return all(not args for args in self.arguments_set) 54 | 55 | def with_new_arguments(self, arguments: dict[str, bool]) -> PathInfo: 56 | unfrozen_set = set(self.arguments_set) 57 | 58 | for args in self.arguments_set: 59 | if args.is_mergeable(arguments): 60 | new_args = args.with_new_arguments(arguments) 61 | unfrozen_set.remove(args) 62 | unfrozen_set.add(new_args) 63 | break 64 | else: 65 | # Provided arguments aren't mergeable, add a new entry 66 | unfrozen_set.add(PathArguments(frozenset(arguments.items()))) 67 | return replace(self, arguments_set=frozenset(unfrozen_set)) 68 | 69 | def get_kwargs_annotation(self) -> str: 70 | """Return the type annotation for the `kwargs` argument of `reverse`.""" 71 | tds_str = [args.typeddict_name for args in self.arguments_set if args] 72 | if any(not args for args in self.arguments_set): 73 | tds_str.extend(["EmptyDict", "None"]) 74 | 75 | return " | ".join(tds_str) 76 | 77 | def get_args_annotation(self, list_fallback: bool = True) -> str: 78 | """Return the type annotation for the `args` argument of `reverse`. 79 | 80 | If multiple URL patterns are available for a specific URL name (i.e. `arguments_set` contains 81 | multiple entries), the generated annotation will be the union of the possible tuple shapes. 82 | 83 | Args: 84 | list_fallback: Whether to include a `list[Any]` fallback type. 85 | """ 86 | args_lengths = sorted([len(args) for args in self.arguments_set], reverse=True) 87 | tuples_str = [ 88 | f"tuple[{', '.join('SupportsStr' for _ in range(length))}]" for length in args_lengths if length > 0 89 | ] 90 | if 0 in args_lengths: 91 | tuples_str.append("tuple[()]") 92 | if list_fallback: 93 | tuples_str.append("list[Any]") 94 | if 0 in args_lengths: 95 | tuples_str.append("None") 96 | 97 | return " | ".join(tuples_str) 98 | 99 | 100 | def get_paths_infos( 101 | url_resolver: URLResolver, 102 | parent_namespaces: list[str] | None = None, 103 | ) -> defaultdict[str, PathInfo]: 104 | parent_namespaces = parent_namespaces or [] 105 | paths_info: defaultdict[str, PathInfo] = defaultdict(PathInfo) 106 | 107 | for pattern in reversed(url_resolver.url_patterns): # Parsing in reverse is important! 108 | if isinstance(pattern, URLPattern) and pattern.name: 109 | key = ":".join(parent_namespaces) 110 | if key: 111 | key += ":" 112 | key += pattern.name 113 | 114 | reverse_entries = url_resolver.reverse_dict.getlist(pattern.name) 115 | 116 | for possibility, _, defaults, _ in reverse_entries: 117 | for _, params in possibility: 118 | # TODO should `defaults` really be taken into account? 119 | # something weird is happening in `_reverse_with_prefix`: 120 | # if any(kwargs.get(k, v) != v for k, v in defaults.items()): skip candidate 121 | paths_info[key] = paths_info[key].with_new_arguments({k: (k not in defaults) for k in params}) 122 | elif isinstance(pattern, URLResolver): 123 | new_parent_namespaces = parent_namespaces.copy() 124 | if pattern.namespace: 125 | new_parent_namespaces.append(pattern.namespace) 126 | 127 | paths_info = defaultdict(PathInfo, **paths_info, **get_paths_infos(pattern, new_parent_namespaces)) 128 | return paths_info 129 | -------------------------------------------------------------------------------- /src/django_autotyping/stubbing/django_context/django_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from collections import defaultdict 5 | from types import ModuleType 6 | 7 | from django.apps.registry import Apps 8 | from django.conf import LazySettings 9 | from django.core.management import get_commands 10 | from django.db.models import NOT_PROVIDED, DateField, Field 11 | from django.template import engines 12 | from django.urls import get_resolver 13 | from libcst.codemod.visitors import ImportItem 14 | 15 | from django_autotyping.typing import ModelType 16 | 17 | from ..codemods._utils import to_pascal 18 | from ._management_utils import CommandInfo, get_commands_infos 19 | from ._template_utils import EngineInfo, get_template_names 20 | from ._url_utils import PathInfo, get_paths_infos 21 | 22 | 23 | class DjangoStubbingContext: 24 | def __init__(self, apps: Apps, settings: LazySettings) -> None: 25 | self.apps = apps 26 | self.settings = settings 27 | 28 | @staticmethod 29 | def _get_model_alias(model: ModelType) -> str: 30 | """Return an alias of the model. 31 | 32 | The alias is constructed by converting the app label to PascalCase and joining 33 | the app label to the model name. 34 | """ 35 | app_label = to_pascal(model._meta.app_label) 36 | return f"{app_label}{model.__name__}" 37 | 38 | @staticmethod 39 | def _get_model_module(model: ModelType) -> ModuleType: 40 | """Return the module object where the model class is exported or defined. 41 | 42 | Use the models module of the app where the model is defined, and fallback 43 | to the actual module of the model class in case the model is not exported 44 | or present in the models module. 45 | """ 46 | if model._meta.app_config.models_module is not None: 47 | return model._meta.app_config.models_module 48 | return inspect.getmodule(model) # type: ignore 49 | 50 | @property 51 | def models(self) -> list[ModelType]: 52 | """All the defined models. Abstract models are not included.""" 53 | return self.apps.get_models() 54 | 55 | @property 56 | def model_imports(self) -> list[ImportItem]: 57 | """A list of `ImportItem` instances. 58 | 59 | Can be used to easily import all models in a stub file. 60 | """ 61 | 62 | return [ 63 | ImportItem( 64 | module_name=self._get_model_module(model).__name__, 65 | obj_name=model.__name__, 66 | alias=self._get_model_alias(model) if self.is_duplicate(model) else None, 67 | ) 68 | for model in self.models 69 | ] 70 | 71 | @property 72 | def viewnames_lookups(self) -> defaultdict[str, PathInfo]: 73 | """A mapping between viewnames to be used with `reverse` and the available lookup arguments.""" 74 | return get_paths_infos(get_resolver()) 75 | 76 | @property 77 | def management_commands_info(self) -> dict[str, CommandInfo]: 78 | return get_commands_infos(get_commands()) 79 | 80 | @property 81 | def template_engines_info(self) -> dict[str, EngineInfo]: 82 | return { 83 | engine_name: { 84 | "backend_class": template["BACKEND"], 85 | "template_names": get_template_names(engines[engine_name]), 86 | } 87 | for engine_name, template in engines.templates.items() 88 | } 89 | 90 | def is_duplicate(self, model: ModelType) -> bool: 91 | """Whether the model has a duplicate name with another model in a different app.""" 92 | return len([m for m in self.models if m.__name__ == model.__name__]) >= 2 # noqa: PLR2004 93 | 94 | def get_model_name(self, model: ModelType) -> str: 95 | """Return the name of the model in the context of a stub file. 96 | 97 | If the model has a duplicate name, an alias is returned. 98 | """ 99 | return self._get_model_alias(model) if self.is_duplicate(model) else model.__name__ 100 | 101 | def is_required_field(self, field: Field) -> bool: 102 | """Determine if a field requires a value to be provided when instantiating a model. 103 | 104 | In practice, there isn't any reliable way to determine this (even if Django does not provide 105 | a default, things could be set at the database level). However, we can make some assumptions 106 | regarding the field instance, see https://forum.djangoproject.com/t/26357 for more details. 107 | """ 108 | return not ( 109 | field.null 110 | or field.blank 111 | or field.primary_key 112 | or field.has_default() 113 | or getattr(field, "db_default", NOT_PROVIDED) is not NOT_PROVIDED # No `has_db_default` method :/ 114 | or (isinstance(field, DateField) and (field.auto_now or field.auto_now_add)) 115 | ) 116 | 117 | def is_nullable_field(self, field: Field) -> bool: 118 | """Determine if a field can be set to `None` when instantiating a model.""" 119 | 120 | return field.null 121 | -------------------------------------------------------------------------------- /src/django_autotyping/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING, Literal, Type, TypedDict 5 | 6 | import libcst as cst 7 | from django.db import models 8 | 9 | from ._compat import TypeAlias 10 | 11 | ModelType = Type[models.Model] 12 | 13 | if TYPE_CHECKING: 14 | # See https://github.com/Instagram/LibCST/issues/1075 15 | FlattenFunctionDef = cst.FlattenSentinel[cst.FunctionDef] 16 | else: 17 | FlattenFunctionDef = cst.FunctionDef 18 | 19 | 20 | RulesT: TypeAlias = Literal[ 21 | "DJA001", "DJAS001", "DJAS002", "DJAS003", "DJAS010", "DJAS011", "DJAS015", "DJAS016", "DJAS017" 22 | ] 23 | 24 | 25 | class AutotypingSettingsDict(TypedDict, total=False): 26 | """A utility `TypedDict` to be used in user code settings. 27 | 28 | ```python 29 | AUTOTYPING: AutotypingSettingsDict = { 30 | "IGNORE": ["DJA001"], 31 | ... 32 | } 33 | ``` 34 | """ 35 | 36 | IGNORE: list[RulesT] 37 | """A list of ignored rules.""" 38 | 39 | STUBS_GENERATION: StubsGenerationSettingsDict 40 | """Stub related settings.""" 41 | 42 | CODE_GENERATION: CodeGenerationSettingsDict 43 | """Code generation related settings.""" 44 | 45 | 46 | class StubsGenerationSettingsDict(TypedDict, total=False): 47 | """Configuration for dynamic stubs generation.""" 48 | 49 | LOCAL_STUBS_DIR: Path | None 50 | """The directory of the local type stubs. If not set, this setting must be set as a CLI argument.""" 51 | 52 | SOURCE_STUBS_DIR: Path | None 53 | """The directory of the source `django-stubs` to be used. Will default 54 | to the first entry in site packages. 55 | """ 56 | 57 | ALLOW_PLAIN_MODEL_REFERENCES: bool 58 | """Whether string references in the form of `{model_name}` should be generated in overloads. 59 | 60 | If set to `True`, both `{model_name}` and `{app_label}.{model_name}` are allowed 61 | (unless the model name has a duplicate in a different app). 62 | 63 | Affected rules: `DJAS001`. 64 | """ 65 | 66 | ALLOW_NONE_SET_TYPE: bool 67 | """Whether to allow having the `__set__` type variable set to `None`, even if the field is not nullable. 68 | 69 | While Django allows setting most model instance fields to any value (before saving), 70 | it is generally a bad practice to do so. However, it might be beneficial to allow `None` 71 | to be set temporarly. 72 | 73 | This also works for foreign fields, where unlike standard fields, the Django descriptor used 74 | only allows model instances and `None` to be set. 75 | 76 | Affected rules: `DJAS001`. 77 | """ 78 | 79 | MODEL_FIELDS_OPTIONAL: bool 80 | """Whether all model fields should be considered optional when creating model instances. 81 | 82 | This affects the following signatures: 83 | 84 | - `Manager.create/acreate` 85 | - `__init__` methods of models 86 | 87 | A lot can happen behind the scenes when instantiating models. Even if a field doesn't have 88 | a default value provided, the database could have triggers implemented that would provide one. 89 | This is why, by default, this configuration attribute defaults to `True`. If set to `False`, 90 | `django-autotyping` will try its best to determine required fields, namely by checking if: 91 | 92 | - the field can be `null` or `blank` 93 | - the field is a primary key 94 | - the field has a default or a database default value set 95 | - the field is a subclass of `DateField` and has `auto_now` or `auto_now_add` set to `True`. 96 | 97 | Affected rules: `DJAS002`, `DJAS003`. 98 | """ 99 | 100 | ALLOW_REVERSE_ARGS: bool 101 | """Whether type checking should be added to the `args` argument of `reverse`. 102 | 103 | By default, this is set to `False` to avoid having too many overloads being generated. 104 | Moreover, only tuples can be type checked, and most people are using lists for this argument. 105 | Instead, it is recommended to use the `kwargs` argument. 106 | 107 | Affected rules: `DJAS015`. 108 | """ 109 | 110 | 111 | class CodeGenerationSettingsDict(TypedDict, total=False): 112 | """Configuration for adding type annotations to Django user code.""" 113 | 114 | PROJECT_DIR: Path | None 115 | """The directory of your project, where code modifications should be applied. 116 | If not set, this setting must be set as a CLI argument. 117 | """ 118 | 119 | DIFF: bool 120 | """Show changes to be applied instead of modifying existing files.""" 121 | 122 | TYPE_CHECKING_BLOCK: bool 123 | """Whether newly added imports should be in an `if TYPE_CHECKING` block (avoids circular imports).""" 124 | 125 | ASSUME_CLASS_GETITEM: bool 126 | """Whether generic classes in stubs files but not at runtime should be assumed to have a 127 | `__class_getitem__` method. This can be achieved by using `django-stubs-ext` or manually. 128 | 129 | Affected rules: `DJA001`. 130 | """ 131 | -------------------------------------------------------------------------------- /tests/codemodding/test_dja001.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from libcst.codemod import CodemodTest 5 | 6 | from django_autotyping.app_settings import CodeGenerationSettings 7 | from django_autotyping.codemodding.codemods import ForwardRelationTypingCodemod 8 | from django_autotyping.codemodding.django_context import DjangoCodemodContext 9 | from django_autotyping.codemodding.main import run_codemods 10 | 11 | expected_no_type_checking_block = """ 12 | from django.db import models 13 | from django.db.models import OneToOneField 14 | from codemodtestproj.secondapp.models import ModelTwo 15 | 16 | 17 | class ModelOne: 18 | a = models.ForeignKey("secondapp.ModelTwo", on_delete=models.CASCADE) 19 | 20 | 21 | class ModelOne(object): 22 | a = models.ForeignKey("secondapp.ModelTwo", on_delete=models.CASCADE) 23 | 24 | 25 | # Only this one should be transformed 26 | class ModelOne(models.Model): 27 | a = models.ForeignKey["ModelTwo"]("secondapp.ModelTwo", on_delete=models.CASCADE) 28 | 29 | b = OneToOneField["ModelTwo"]("secondapp.ModelTwo", on_delete=models.CASCADE) 30 | 31 | c = models.ForeignKey["ModelOne"]("self", on_delete=models.CASCADE) 32 | """ 33 | 34 | expected_type_checking_block = """ 35 | from django.db import models 36 | from django.db.models import OneToOneField 37 | from typing import TYPE_CHECKING 38 | 39 | if TYPE_CHECKING: 40 | from codemodtestproj.secondapp.models import ModelTwo 41 | 42 | 43 | class ModelOne: 44 | a = models.ForeignKey("secondapp.ModelTwo", on_delete=models.CASCADE) 45 | 46 | 47 | class ModelOne(object): 48 | a = models.ForeignKey("secondapp.ModelTwo", on_delete=models.CASCADE) 49 | 50 | 51 | # Only this one should be transformed 52 | class ModelOne(models.Model): 53 | a = models.ForeignKey["ModelTwo"]("secondapp.ModelTwo", on_delete=models.CASCADE) 54 | 55 | b = OneToOneField["ModelTwo"]("secondapp.ModelTwo", on_delete=models.CASCADE) 56 | 57 | c = models.ForeignKey["ModelOne"]("self", on_delete=models.CASCADE) 58 | """ 59 | 60 | 61 | @pytest.mark.parametrize( 62 | ["type_checking_block", "expected"], 63 | [ 64 | (True, expected_type_checking_block), 65 | (False, expected_no_type_checking_block), 66 | ], 67 | ) 68 | def test_dja001(codemodtestproj_context: DjangoCodemodContext, type_checking_block: bool, expected: str): 69 | inpath = Path(__file__).parents[1] / "codemodtestproj" / "codemodtestproj" / "firstapp" / "models.py" 70 | 71 | code_generation_settings = CodeGenerationSettings( 72 | TYPE_CHECKING_BLOCK=type_checking_block, 73 | ASSUME_CLASS_GETITEM=True, 74 | ) 75 | 76 | outcode = run_codemods( 77 | codemods=[ForwardRelationTypingCodemod], 78 | django_context=codemodtestproj_context, 79 | code_generation_settings=code_generation_settings, 80 | filename=str(inpath), 81 | ) 82 | 83 | assert CodemodTest.make_fixture_data(expected) == CodemodTest.make_fixture_data(outcode) 84 | -------------------------------------------------------------------------------- /tests/codemodtestproj/codemodtestproj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/codemodtestproj/codemodtestproj/__init__.py -------------------------------------------------------------------------------- /tests/codemodtestproj/codemodtestproj/firstapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/codemodtestproj/codemodtestproj/firstapp/__init__.py -------------------------------------------------------------------------------- /tests/codemodtestproj/codemodtestproj/firstapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FirstappConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "codemodtestproj.firstapp" 7 | -------------------------------------------------------------------------------- /tests/codemodtestproj/codemodtestproj/firstapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import OneToOneField 3 | 4 | 5 | class ModelOne: 6 | a = models.ForeignKey("secondapp.ModelTwo", on_delete=models.CASCADE) 7 | 8 | 9 | class ModelOne(object): 10 | a = models.ForeignKey("secondapp.ModelTwo", on_delete=models.CASCADE) 11 | 12 | 13 | # Only this one should be transformed 14 | class ModelOne(models.Model): 15 | a = models.ForeignKey("secondapp.ModelTwo", on_delete=models.CASCADE) 16 | 17 | b = OneToOneField("secondapp.ModelTwo", on_delete=models.CASCADE) 18 | 19 | c = models.ForeignKey("self", on_delete=models.CASCADE) 20 | -------------------------------------------------------------------------------- /tests/codemodtestproj/codemodtestproj/secondapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/codemodtestproj/codemodtestproj/secondapp/__init__.py -------------------------------------------------------------------------------- /tests/codemodtestproj/codemodtestproj/secondapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SecondappConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "codemodtestproj.secondapp" 7 | -------------------------------------------------------------------------------- /tests/codemodtestproj/codemodtestproj/secondapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ModelTwo(models.Model): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/codemodtestproj/codemodtestproj/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for codemodtestproj project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.8. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = "django-insecure-g5x1ag=i7!z*1cxfs)a9@8)noduv+d8n#)ona)-ltljj3+_0$3" 21 | 22 | USE_TZ = True 23 | # Application definition 24 | 25 | INSTALLED_APPS = [ 26 | "django.contrib.admin", 27 | "django.contrib.auth", 28 | "django.contrib.contenttypes", 29 | "django.contrib.sessions", 30 | "django.contrib.messages", 31 | "django.contrib.staticfiles", 32 | "codemodtestproj.firstapp", 33 | "codemodtestproj.secondapp", 34 | ] 35 | 36 | MIDDLEWARE = [ 37 | "django.middleware.security.SecurityMiddleware", 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.middleware.csrf.CsrfViewMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 44 | ] 45 | 46 | ROOT_URLCONF = "codemodtestproj.urls" 47 | 48 | TEMPLATES = [ 49 | { 50 | "BACKEND": "django.template.backends.django.DjangoTemplates", 51 | "DIRS": [], 52 | "APP_DIRS": True, 53 | "OPTIONS": { 54 | "context_processors": [ 55 | "django.template.context_processors.debug", 56 | "django.template.context_processors.request", 57 | "django.contrib.auth.context_processors.auth", 58 | "django.contrib.messages.context_processors.messages", 59 | ], 60 | }, 61 | }, 62 | ] 63 | 64 | # Database 65 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 66 | 67 | DATABASES = { 68 | "default": { 69 | "ENGINE": "django.db.backends.sqlite3", 70 | "NAME": BASE_DIR / "db.sqlite3", 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/codemodtestproj/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "codemodtestproj.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /tests/codemodtestproj/urls.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/codemodtestproj/urls.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from helpers import initialize_django 5 | 6 | from django_autotyping.codemodding.django_context import DjangoCodemodContext 7 | from django_autotyping.stubbing.django_context import DjangoStubbingContext 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def codemodtestproj_context() -> DjangoCodemodContext: 12 | project_dir = Path(__file__).parent / "codemodtestproj" 13 | apps, settings = initialize_django("codemodtestproj.settings", project_dir) 14 | return DjangoCodemodContext(apps, settings, project_dir) 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | def stubstestproj_context() -> DjangoStubbingContext: 19 | project_dir = Path(__file__).parent / "stubstestproj" 20 | apps, settings = initialize_django("settings", project_dir) 21 | return DjangoStubbingContext(apps, settings) 22 | -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .utils import initialize_django 2 | 3 | __all__ = ("initialize_django",) 4 | -------------------------------------------------------------------------------- /tests/helpers/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from contextlib import contextmanager 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING, Iterator 8 | 9 | from django.conf import ENVIRONMENT_VARIABLE as DJANGO_SETTINGS_MODULE_ENV_KEY 10 | 11 | if TYPE_CHECKING: 12 | from django.apps.registry import Apps 13 | from django.conf import LazySettings 14 | 15 | 16 | @contextmanager 17 | def _temp_environ() -> Iterator[None]: 18 | """Allow the ability to set os.environ temporarily""" 19 | environ = dict(os.environ) 20 | try: 21 | yield 22 | finally: 23 | os.environ.clear() 24 | os.environ.update(environ) 25 | 26 | 27 | def initialize_django(settings_module: str, app_path: Path) -> tuple[Apps, LazySettings]: 28 | with _temp_environ(): 29 | os.environ[DJANGO_SETTINGS_MODULE_ENV_KEY] = settings_module 30 | 31 | # Patching `sys.path` to allow Django to setup correctly 32 | sys.path.append(str(app_path)) 33 | 34 | from django.apps import apps 35 | from django.conf import settings 36 | 37 | apps.get_swappable_settings_name.cache_clear() 38 | apps.clear_cache() 39 | 40 | if not settings.configured: 41 | settings._setup() 42 | apps.populate(settings.INSTALLED_APPS) 43 | 44 | assert apps.apps_ready, "Apps are not ready" 45 | assert settings.configured, "Settings are not configured" 46 | 47 | return apps, settings 48 | -------------------------------------------------------------------------------- /tests/stubbing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubbing/__init__.py -------------------------------------------------------------------------------- /tests/stubbing/test_stubs.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E501 2 | from __future__ import annotations 3 | 4 | import dataclasses 5 | import json 6 | import os 7 | from pathlib import Path 8 | 9 | import pytest 10 | from mypy.api import run as run_mypy 11 | from pyright import main as run_pyright 12 | 13 | from django_autotyping.app_settings import StubsGenerationSettings 14 | from django_autotyping.stubbing import create_local_django_stubs, run_codemods 15 | from django_autotyping.stubbing.codemods import gather_codemods 16 | 17 | TESTFILES = Path(__file__).parent / "testfiles" 18 | STUBSTESTPROJ = Path(__file__).parents[1].joinpath("stubstestproj").absolute() 19 | 20 | # fmt: off 21 | testfiles_params = pytest.mark.parametrize( 22 | ["testfile", "rules", "stubs_settings"], 23 | [ 24 | ("djas001.py", ["DJAS001"], StubsGenerationSettings()), 25 | ("djas001_no_plain_references.py", ["DJAS001"], StubsGenerationSettings(ALLOW_PLAIN_MODEL_REFERENCES=False)), 26 | ("djas001_allow_non_set_type.py", ["DJAS001"], StubsGenerationSettings(ALLOW_NONE_SET_TYPE=True)), 27 | ("djas002_003.py", ["DJAS002", "DJAS003"], StubsGenerationSettings()), 28 | ("djas002_003_no_model_fields_optional.py", ["DJAS002", "DJAS003"], StubsGenerationSettings(MODEL_FIELDS_OPTIONAL=False)), 29 | ("djas010.py", ["DJAS010"], StubsGenerationSettings()), 30 | ("djas011.py", ["DJAS011"], StubsGenerationSettings()), 31 | ("djas016.py", ["DJAS016"], StubsGenerationSettings()), 32 | ("djas017.py", ["DJAS017"], StubsGenerationSettings()), 33 | ], 34 | ) 35 | # fmt: on 36 | 37 | 38 | @pytest.fixture 39 | def local_stubs(tmp_path) -> Path: 40 | create_local_django_stubs(tmp_path) 41 | return tmp_path 42 | 43 | 44 | @pytest.mark.xfail(reason="mypy does not support setting the MYPYPATH without specifying a module or package to test.") 45 | @pytest.mark.mypy 46 | @testfiles_params 47 | def test_mypy( 48 | monkeypatch, 49 | local_stubs, 50 | stubstestproj_context, 51 | # testfiles_params: 52 | testfile: Path, 53 | rules: list[str], 54 | stubs_settings: StubsGenerationSettings, 55 | ): 56 | testfile = TESTFILES / testfile 57 | stubs_settings = dataclasses.replace(stubs_settings, LOCAL_STUBS_DIR=local_stubs) 58 | 59 | codemods = gather_codemods(include=rules) 60 | run_codemods(codemods, stubstestproj_context, stubs_settings) 61 | 62 | # TODO this does not work for now: https://github.com/python/mypy/issues/16775 63 | monkeypatch.setenv("MYPYPATH", os.pathsep.join(map(str, [local_stubs.absolute(), STUBSTESTPROJ]))) 64 | 65 | _, _, exit_code = run_mypy([str(testfile.absolute())]) 66 | 67 | assert exit_code == 0 68 | 69 | 70 | @pytest.mark.pyright 71 | @testfiles_params 72 | def test_pyright( 73 | tmp_path, 74 | local_stubs, 75 | stubstestproj_context, 76 | # testfiles_params: 77 | testfile: Path, 78 | rules: list[str], 79 | stubs_settings: StubsGenerationSettings, 80 | ): 81 | testfile = TESTFILES / testfile 82 | stubs_settings = dataclasses.replace(stubs_settings, LOCAL_STUBS_DIR=local_stubs) 83 | 84 | codemods = gather_codemods(include=rules) 85 | run_codemods(codemods, stubstestproj_context, stubs_settings) 86 | 87 | config_file = tmp_path / "pyrightconfig.json" 88 | config_file.write_text( 89 | json.dumps( 90 | { 91 | "stubPath": str(local_stubs.absolute()), 92 | "extraPaths": [str(STUBSTESTPROJ.parent)], 93 | "reportUnnecessaryTypeIgnoreComment": True, 94 | "reportDeprecated": True, 95 | } 96 | ) 97 | ) 98 | 99 | exit_code = run_pyright(["--project", str(config_file), str(testfile)]) 100 | 101 | assert exit_code == 0 102 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas001.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import assert_type 2 | 3 | from django.db.models.expressions import Combinable 4 | from django.db.models.fields.related_descriptors import ManyRelatedManager 5 | 6 | from stubstestproj.firstapp.models import ModelOne, DuplicateModel as FirstAppDuplicateModel 7 | from stubstestproj.secondapp.models import ModelTwo, DuplicateModel as SecondAppDuplicateModel 8 | 9 | assert_type(ModelOne().model_two, ModelTwo) 10 | assert_type(ModelOne().model_two_plain_reference, ModelTwo) 11 | assert_type(ModelOne().model_two_nullable, ModelTwo | None) 12 | assert_type(ModelOne().model_duplicate_firstapp, FirstAppDuplicateModel) 13 | assert_type(ModelOne().model_duplicate_secondapp, SecondAppDuplicateModel) 14 | assert_type(ModelOne().many_to_many_model_two, ManyRelatedManager[ModelTwo]) 15 | 16 | ModelOne().model_two = ModelTwo() 17 | ModelOne().model_two = Combinable() 18 | ModelOne().model_two_nullable = None 19 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas001_allow_non_set_type.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import assert_type 2 | 3 | from stubstestproj.firstapp.models import ModelOne 4 | from stubstestproj.secondapp.models import ModelTwo 5 | 6 | assert_type(ModelOne().model_two, ModelTwo) 7 | 8 | ModelOne().model_two = None 9 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas001_no_plain_references.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import assert_type 2 | 3 | from stubstestproj.firstapp.models import ModelOne 4 | from stubstestproj.secondapp.models import ModelTwo 5 | 6 | assert_type(ModelOne().model_two, ModelTwo) 7 | # At runtime, this is invalid: 8 | assert_type(ModelOne().model_two_plain_reference, ModelTwo) # type: ignore 9 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas002_003.py: -------------------------------------------------------------------------------- 1 | # Test with `MODEL_FIELDS_OPTIONAL=True` (the default). 2 | 3 | from datetime import date, datetime, time 4 | from decimal import Decimal 5 | from uuid import UUID 6 | 7 | from django.db.models.expressions import Combinable 8 | from stubstestproj.firstapp.models import ( 9 | AllFieldsModel, 10 | AltNameModel, 11 | CharFieldsModel, 12 | DateFieldsModel, 13 | DateTimeFieldsModel, 14 | ForeignKeyModel, 15 | ModelOne, 16 | ) 17 | 18 | CharFieldsModel() 19 | CharFieldsModel.objects.create() 20 | CharFieldsModel(char_field="") 21 | CharFieldsModel.objects.create(char_field="") 22 | CharFieldsModel(char_field=1) 23 | CharFieldsModel.objects.create(char_field=1) 24 | CharFieldsModel(char_field_null=None) 25 | CharFieldsModel.objects.create(char_field_null=None) 26 | CharFieldsModel(char_field=Combinable()) 27 | CharFieldsModel.objects.create(char_field=Combinable()) 28 | 29 | DateFieldsModel(date_field="") 30 | DateFieldsModel.objects.create(date_field="") 31 | DateFieldsModel(date_field=date.today()) 32 | DateFieldsModel.objects.create(date_field=date.today()) 33 | 34 | DateTimeFieldsModel(datetime_field="") 35 | DateTimeFieldsModel.objects.create(datetime_field="") 36 | DateTimeFieldsModel(datetime_field=datetime.now()) 37 | DateTimeFieldsModel.objects.create(datetime_field=datetime.now()) 38 | DateTimeFieldsModel(datetime_field=date.today()) 39 | DateTimeFieldsModel.objects.create(datetime_field=date.today()) 40 | 41 | AllFieldsModel(integer_field=1) 42 | AllFieldsModel.objects.create(integer_field=1) 43 | AllFieldsModel(integer_field=1.0) 44 | AllFieldsModel.objects.create(integer_field=1.0) 45 | AllFieldsModel(integer_field="1") 46 | AllFieldsModel.objects.create(integer_field="1") 47 | 48 | AllFieldsModel(float_field=1) 49 | AllFieldsModel.objects.create(float_field=1) 50 | AllFieldsModel(float_field=1.0) 51 | AllFieldsModel.objects.create(float_field=1.0) 52 | AllFieldsModel(float_field="1") 53 | AllFieldsModel.objects.create(float_field="1") 54 | 55 | AllFieldsModel(decimal_field=Decimal(1)) 56 | AllFieldsModel.objects.create(decimal_field=Decimal(1)) 57 | AllFieldsModel(decimal_field=1) 58 | AllFieldsModel.objects.create(decimal_field=1) 59 | AllFieldsModel(decimal_field=1.0) 60 | AllFieldsModel.objects.create(decimal_field=1.0) 61 | AllFieldsModel(decimal_field="1") 62 | AllFieldsModel.objects.create(decimal_field="1") 63 | 64 | AllFieldsModel(text_field="") 65 | AllFieldsModel.objects.create(text_field="") 66 | # TODO why is this different from char_field? 67 | AllFieldsModel(text_field=1) # type: ignore 68 | AllFieldsModel.objects.create(text_field=1) # type: ignore 69 | 70 | AllFieldsModel(boolean_field=False) 71 | AllFieldsModel.objects.create(boolean_field=False) 72 | AllFieldsModel(boolean_field=True) 73 | AllFieldsModel.objects.create(boolean_field=True) 74 | AllFieldsModel(boolean_field=False) 75 | AllFieldsModel.objects.create(boolean_field=False) 76 | 77 | AllFieldsModel(ipadress_field="") 78 | AllFieldsModel.objects.create(ipadress_field="") 79 | 80 | AllFieldsModel(datetime_field="") 81 | AllFieldsModel.objects.create(datetime_field="") 82 | AllFieldsModel(datetime_field=datetime.now()) 83 | AllFieldsModel.objects.create(datetime_field=datetime.now()) 84 | AllFieldsModel(datetime_field=date.today()) 85 | AllFieldsModel.objects.create(datetime_field=date.today()) 86 | 87 | AllFieldsModel(time_field="") 88 | AllFieldsModel.objects.create(time_field="") 89 | AllFieldsModel(time_field=datetime.now()) 90 | AllFieldsModel.objects.create(time_field=datetime.now()) 91 | AllFieldsModel(time_field=time()) 92 | AllFieldsModel.objects.create(time_field=time()) 93 | 94 | AllFieldsModel(uuid_field="") 95 | AllFieldsModel.objects.create(uuid_field="") 96 | AllFieldsModel(uuid_field=UUID()) 97 | AllFieldsModel.objects.create(uuid_field=UUID()) 98 | 99 | ForeignKeyModel(model_one=ModelOne()) 100 | ForeignKeyModel.objects.create(model_one=ModelOne()) 101 | ForeignKeyModel(model_one_null=ModelOne()) 102 | ForeignKeyModel.objects.create(model_one_null=ModelOne()) 103 | ForeignKeyModel(model_one=None) # type: ignore 104 | ForeignKeyModel.objects.create(model_one=None) # type: ignore 105 | ForeignKeyModel(model_one_null=None) 106 | ForeignKeyModel.objects.create(model_one_null=None) 107 | 108 | AltNameModel(field="") # type: ignore 109 | AltNameModel(alt_name="") 110 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas002_003_no_model_fields_optional.py: -------------------------------------------------------------------------------- 1 | # Test with `MODEL_FIELDS_OPTIONAL=False`. 2 | 3 | from datetime import date 4 | 5 | from django.db.models.expressions import Combinable 6 | from stubstestproj.firstapp.models import CharFieldsModel, DateFieldsModel, ForeignKeyModel, ModelOne, PrimaryKeyModel 7 | 8 | CharFieldsModel() # type: ignore 9 | CharFieldsModel.objects.create() # type: ignore 10 | CharFieldsModel(char_field="") 11 | CharFieldsModel.objects.create(char_field="") 12 | CharFieldsModel(char_field=1) 13 | CharFieldsModel.objects.create(char_field=1) 14 | CharFieldsModel(char_field="", char_field_null=None) 15 | CharFieldsModel.objects.create(char_field="", char_field_null=None) 16 | CharFieldsModel(char_field=Combinable()) 17 | CharFieldsModel.objects.create(char_field=Combinable()) 18 | 19 | DateFieldsModel() # type: ignore 20 | DateFieldsModel.objects.create() # type: ignore 21 | DateFieldsModel(date_field=date.today()) 22 | DateFieldsModel.objects.create(date_field=date.today()) 23 | 24 | # We create an instance here and ignore the required fields: 25 | model_one = ModelOne() # type: ignore 26 | 27 | ForeignKeyModel() # type: ignore 28 | ForeignKeyModel.objects.create() # type: ignore 29 | ForeignKeyModel(model_one=model_one) 30 | ForeignKeyModel.objects.create(model_one=model_one) 31 | ForeignKeyModel(model_one=model_one, model_one_null=model_one) 32 | ForeignKeyModel.objects.create(model_one=model_one, model_one_null=model_one) 33 | ForeignKeyModel(model_one=None) # type: ignore 34 | ForeignKeyModel.objects.create(model_one=None) # type: ignore 35 | ForeignKeyModel(model_one=model_one, model_one_null=None) 36 | ForeignKeyModel.objects.create(model_one=model_one, model_one_null=None) 37 | 38 | PrimaryKeyModel() 39 | PrimaryKeyModel.objects.create() 40 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas010.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import assert_type 2 | 3 | from django.apps import apps 4 | 5 | from stubstestproj.firstapp.models import ModelOne 6 | 7 | assert_type(apps.get_model("firstapp.ModelOne"), type[ModelOne]) 8 | assert_type(apps.get_model("firstapp", "ModelOne"), type[ModelOne]) 9 | 10 | apps.get_model("nonExisting") # type: ignore 11 | apps.get_model("nonExisting", "nonExisting") # type: ignore 12 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas011.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import assert_type 2 | 3 | from django.contrib.auth import authenticate, login, get_user_model, get_user, update_session_auth_hash 4 | from django.contrib.auth.models import AbstractBaseUser, AnonymousUser 5 | from django.http.request import HttpRequest 6 | 7 | from stubstestproj.accounts.models import User 8 | 9 | req = HttpRequest() 10 | abstract_user = AbstractBaseUser() 11 | 12 | assert_type(authenticate(req), User | None) 13 | assert_type(get_user_model(), type[User]) 14 | assert_type(get_user(req), User | AnonymousUser) 15 | 16 | login(req, abstract_user) # type: ignore 17 | update_session_auth_hash(req, abstract_user) # type: ignore 18 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas016.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from typing_extensions import assert_type 4 | 5 | from django.conf import settings 6 | 7 | assert_type(settings.INSTALLED_APPS, list[str]) 8 | assert_type(settings.CUSTOM_SETTING, str) 9 | 10 | # Special cased: 11 | assert_type(settings.AUTH_USER_MODEL, Literal["accounts.User"]) 12 | 13 | # Added in Django 4.0, we currently test against 4.2: 14 | assert_type(settings.SECURE_CROSS_ORIGIN_OPENER_POLICY, str | None) 15 | 16 | # Deprecated since 4.2: 17 | settings.STATICFILES_STORAGE # type: ignore 18 | -------------------------------------------------------------------------------- /tests/stubbing/testfiles/djas017.py: -------------------------------------------------------------------------------- 1 | from django.template.loader import get_template, render_to_string, select_template 2 | 3 | get_template("customtemplate.html") 4 | get_template("nested/template.html") 5 | get_template("firstapptemplate.html") 6 | 7 | get_template("customtemplate.html", using="django_dir") 8 | get_template("nested/template.html", using="django_dir") 9 | get_template("firstapptemplate.html", using="django_app_dirs") 10 | 11 | get_template("customtemplate.html", using="django_app_dirs") # type: ignore 12 | get_template("nested/template.html", using="django_app_dirs") # type: ignore 13 | get_template("firstapptemplate.html", using="django_dir") # type: ignore 14 | 15 | select_template(["customtemplate.html", "nested/template.html", "firstapptemplate.html"]) 16 | select_template(["firstapptemplate.html"], using="django_app_dirs") 17 | select_template(["customtemplate.html", "nested/template.html"], using="django_dir") 18 | 19 | select_template(["customtemplate.html"], using="django_app_dis") # type: ignore 20 | select_template(["firstapptemplate.html"], using="django_dir") # type: ignore 21 | 22 | render_to_string("customtemplate.html") 23 | render_to_string("nested/template.html") 24 | render_to_string("firstapptemplate.html") 25 | 26 | render_to_string("customtemplate.html", using="django_dir") 27 | render_to_string("nested/template.html", using="django_dir") 28 | render_to_string("firstapptemplate.html", using="django_app_dirs") 29 | 30 | render_to_string("customtemplate.html", using="django_app_dirs") # type: ignore 31 | render_to_string("nested/template.html", using="django_app_dirs") # type: ignore 32 | render_to_string("firstapptemplate.html", using="django_dir") # type: ignore 33 | -------------------------------------------------------------------------------- /tests/stubstestproj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubstestproj/__init__.py -------------------------------------------------------------------------------- /tests/stubstestproj/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubstestproj/accounts/__init__.py -------------------------------------------------------------------------------- /tests/stubstestproj/accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractBaseUser 2 | 3 | 4 | class User(AbstractBaseUser): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/stubstestproj/appwithoutmodelsmodule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubstestproj/appwithoutmodelsmodule/__init__.py -------------------------------------------------------------------------------- /tests/stubstestproj/appwithoutmodelsmodule/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppwithoutmodelsmoduleConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "stubstestproj.appwithoutmodelsmodule" 7 | 8 | def ready(self) -> None: 9 | # See https://github.com/Viicos/django-autotyping/issues/59 for more context: 10 | from .extra_models import ExtraModel 11 | -------------------------------------------------------------------------------- /tests/stubstestproj/appwithoutmodelsmodule/extra_models/__init__.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ExtraModel(models.Model): 5 | # `ModelBase.__new__` registers every model to the default `Apps` class, 6 | # even if the model is not defined/exported in the `AppConfig`'s models module. 7 | # This can lead to cases where `AppConfig.models_module` is `None` because 8 | # no models module exists, however models are still registered under this 9 | # specific `AppConfig`. 10 | # See https://github.com/Viicos/django-autotyping/issues/59 as an example. 11 | pass 12 | -------------------------------------------------------------------------------- /tests/stubstestproj/customtemplatesdir/customtemplate.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubstestproj/customtemplatesdir/customtemplate.html -------------------------------------------------------------------------------- /tests/stubstestproj/customtemplatesdir/nested/template.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubstestproj/customtemplatesdir/nested/template.html -------------------------------------------------------------------------------- /tests/stubstestproj/firstapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubstestproj/firstapp/__init__.py -------------------------------------------------------------------------------- /tests/stubstestproj/firstapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FirstappConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "stubstestproj.firstapp" 7 | -------------------------------------------------------------------------------- /tests/stubstestproj/firstapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ModelOne(models.Model): 5 | model_two = models.ForeignKey( 6 | "secondapp.ModelTwo", 7 | on_delete=models.CASCADE, 8 | ) 9 | model_two_plain_reference = models.ForeignKey( 10 | "ModelTwo", 11 | on_delete=models.CASCADE, 12 | ) 13 | model_two_nullable = models.ForeignKey( 14 | "secondapp.ModelTwo", 15 | on_delete=models.CASCADE, 16 | null=True, 17 | ) 18 | model_duplicate_firstapp = models.ForeignKey( 19 | "firstapp.DuplicateModel", 20 | on_delete=models.CASCADE, 21 | ) 22 | model_duplicate_secondapp = models.ForeignKey( 23 | "secondapp.DuplicateModel", 24 | on_delete=models.CASCADE, 25 | ) 26 | 27 | many_to_many_model_two = models.ManyToManyField("secondapp.ModelTwo") 28 | 29 | 30 | class DuplicateModel(models.Model): 31 | """This model is also defined in `secondapp`, and is here to test _as imports_ in stubs.""" 32 | 33 | 34 | class CharFieldsModel(models.Model): 35 | char_field = models.CharField() 36 | char_field_blank = models.CharField(blank=True) 37 | char_field_null = models.CharField(null=True) 38 | char_field_default = models.CharField(default="") 39 | 40 | class DateFieldsModel(models.Model): 41 | date_field = models.DateField() 42 | date_field_auto_now = models.DateField(auto_now=True) 43 | date_field_auto_now_add = models.DateField(auto_now_add=True) 44 | 45 | class DateTimeFieldsModel(models.Model): 46 | datetime_field = models.DateTimeField() 47 | datetime_field_auto_now = models.DateTimeField(auto_now=True) 48 | datetime_field_auto_now_add = models.DateTimeField(auto_now_add=True) 49 | 50 | class AllFieldsModel(models.Model): 51 | integer_field = models.IntegerField() 52 | float_field = models.FloatField() 53 | decimal_field = models.DecimalField() 54 | text_field = models.TextField() 55 | boolean_field = models.BooleanField() 56 | ipadress_field = models.IPAddressField() 57 | datetime_field = models.DateTimeField() 58 | time_field = models.TimeField() 59 | uuid_field = models.UUIDField() 60 | 61 | class ForeignKeyModel(models.Model): 62 | model_one = models.ForeignKey("firstapp.ModelOne", on_delete=models.CASCADE) 63 | model_one_null = models.ForeignKey("firstapp.ModelOne", on_delete=models.CASCADE, null=True) 64 | 65 | class PrimaryKeyModel(models.Model): 66 | pk_field = models.CharField(primary_key=True) 67 | 68 | class AltNameModel(models.Model): 69 | field = models.CharField(name="alt_name") 70 | -------------------------------------------------------------------------------- /tests/stubstestproj/firstapp/templates/firstapptemplate.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubstestproj/firstapp/templates/firstapptemplate.html -------------------------------------------------------------------------------- /tests/stubstestproj/secondapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/stubstestproj/secondapp/__init__.py -------------------------------------------------------------------------------- /tests/stubstestproj/secondapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SecondappConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "stubstestproj.secondapp" 7 | -------------------------------------------------------------------------------- /tests/stubstestproj/secondapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ModelTwo(models.Model): 5 | pass 6 | 7 | 8 | class DuplicateModel(models.Model): 9 | """This model is also defined in `secondapp`, and is here to test _as imports_ in stubs.""" 10 | -------------------------------------------------------------------------------- /tests/stubstestproj/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for stubstestproj project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.8. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = "django-insecure-g5x1ag=i7!z*1cxfs)a9@8)noduv+d8n#)ona)-ltljj3+_0$3" 21 | 22 | USE_TZ = True 23 | # Application definition 24 | 25 | INSTALLED_APPS = [ 26 | "django.contrib.admin", 27 | "django.contrib.auth", 28 | "django.contrib.contenttypes", 29 | "django.contrib.sessions", 30 | "django.contrib.messages", 31 | "django.contrib.staticfiles", 32 | "stubstestproj.accounts", 33 | "stubstestproj.firstapp", 34 | "stubstestproj.secondapp", 35 | "stubstestproj.appwithoutmodelsmodule", 36 | ] 37 | 38 | MIDDLEWARE = [ 39 | "django.middleware.security.SecurityMiddleware", 40 | "django.contrib.sessions.middleware.SessionMiddleware", 41 | "django.middleware.common.CommonMiddleware", 42 | "django.middleware.csrf.CsrfViewMiddleware", 43 | "django.contrib.auth.middleware.AuthenticationMiddleware", 44 | "django.contrib.messages.middleware.MessageMiddleware", 45 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 46 | ] 47 | 48 | ROOT_URLCONF = "stubstestproj.urls" 49 | 50 | TEMPLATES = [ 51 | { 52 | "BACKEND": "django.template.backends.django.DjangoTemplates", 53 | "NAME": "django_app_dirs", 54 | "DIRS": [], 55 | "APP_DIRS": True, 56 | "OPTIONS": {}, 57 | }, 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "NAME": "django_dir", 61 | "DIRS": [Path(__file__).parent / "customtemplatesdir"], 62 | "APP_DIRS": False, 63 | "OPTIONS": {}, 64 | }, 65 | ] 66 | 67 | # Database 68 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 69 | 70 | DATABASES = { 71 | "default": { 72 | "ENGINE": "django.db.backends.sqlite3", 73 | "NAME": BASE_DIR / "db.sqlite3", 74 | } 75 | } 76 | 77 | # For DJAS011/DJAS016 78 | AUTH_USER_MODEL = "accounts.User" 79 | 80 | # For DJAS016 81 | CUSTOM_SETTING = "custom_setting" 82 | -------------------------------------------------------------------------------- /tests/stubstestproj/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Viicos/django-autotyping/9a53a5034e78b589c1f118f27bed56dca8e30714/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_app_settings.py: -------------------------------------------------------------------------------- 1 | from django_autotyping.app_settings import AutotypingSettings 2 | 3 | 4 | class MockSettings: 5 | """A mock class that supports getattr.""" 6 | 7 | AUTOTYPING = { 8 | "IGNORE": ["DJA001"], 9 | "STUBS_GENERATION": { 10 | "ALLOW_PLAIN_MODEL_REFERENCES": False, # Default is `True` 11 | }, 12 | "CODE_GENERATION": { 13 | "TYPE_CHECKING_BLOCK": False, # Default is `True` 14 | }, 15 | } 16 | 17 | 18 | def test_autotyping_settings(): 19 | settings = AutotypingSettings.from_django_settings(MockSettings()) 20 | 21 | assert settings.IGNORE == ["DJA001"] 22 | assert settings.STUBS_GENERATION.ALLOW_PLAIN_MODEL_REFERENCES is False 23 | assert settings.CODE_GENERATION.TYPE_CHECKING_BLOCK is False 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>4 4 | envlist = py3{8,9,10,11,12}, pyright 5 | 6 | [gh-actions] 7 | python = 8 | 3.8: py38 9 | 3.9: py39 10 | 3.10: py310 11 | 3.11: py311, mypy, pyright 12 | 3.12: py312 13 | 14 | [testenv] 15 | deps = 16 | -r requirements/requirements.txt 17 | -r requirements/requirements-test.txt 18 | commands = pytest --basetemp={envtmpdir} -m "not pyright and not mypy" {posargs} 19 | 20 | [testenv:mypy] 21 | basepython = py311 22 | commands = pytest --basetemp={envtmpdir} -m "mypy" {posargs} 23 | 24 | [testenv:pyright] 25 | basepython = py311 26 | commands = pytest --basetemp={envtmpdir} -m "pyright" {posargs} 27 | --------------------------------------------------------------------------------