├── .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 | [](https://www.python.org/downloads/)
4 | [](https://pypi.org/project/django-autotyping/)
5 | [](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 | 
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 | [](https://www.python.org/downloads/)
4 | [](https://pypi.org/project/django-autotyping/)
5 | [](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 | 
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 |
--------------------------------------------------------------------------------