From 7a9585233a58ad1fbb593a3fc41f21e543563478 Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Mon, 9 Nov 2026 23:47:30 +0200 Subject: [PATCH 01/12] Add Django guide page with setup and example project --- README.md | 42 ++ docs/django.md | 520 ++++++++++++++++++ docs/features.md | 1 + docs/index.md | 4 + tests/documented_examples/test_django.py | 401 ++++++++++++++ .../test_documented_features.py | 1 + tests/files/django/settings.py | 35 ++ tests/files/django/urls.py | 12 + tests/files/django/views.py | 22 + 9 files changed, 1038 insertions(+) create mode 100644 docs/django.md create mode 100644 tests/documented_examples/test_django.py create mode 100644 tests/files/django/settings.py create mode 100644 tests/files/django/urls.py create mode 100644 tests/files/django/views.py diff --git a/README.md b/README.md index c88cc09..ffaf88e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,48 @@ ignore-bodies-if-inherits-from = ["BaseModel"] Read the [FastAPI guide](https://deadcode.readthedocs.io/en/latest/fastapi.html) for common use cases, the complete example application, the dependency injection strategy, and the expected output with no wrong detections. +## Using deadcode on a Django project + +Django registers models, views, admin classes, and signal handlers through base classes, decorators, settings strings, and URLconfs, so +framework entry points look unused to a static analyser. Configure deadcode like this: + +```toml +[tool.deadcode] +ignore-names-if-decorated-with = [ + "@admin.*", + "@receiver", + "@register.*", + "@login_required", + "@permission_required", + "@require_http_methods", + "@require_POST", + "@method_decorator", +] +ignore-bodies-if-inherits-from = [ + "models.Model", + "ModelAdmin", + "forms.Form", + "forms.ModelForm", + "View", + "BaseCommand", + "Migration", + "AppConfig", + "MiddlewareMixin", +] +ignore-names-in-files = ["*/settings.py", "*/urls.py", "*/migrations/*"] +ignore-definitions = ["*Config", "*Middleware", "Command"] +ignore-names = [ + "setUpTestData", + "_pre_setup", + "_post_teardown", + "_fixture_setup", + "_fixture_teardown", +] +``` + +Read the [Django guide](https://deadcode.readthedocs.io/en/latest/django.html) for the common use cases, every framework area, the complete +example application, and the expected output with no wrong detections. + ## Pre-commit hook Create a `.pre-commit-config.yaml` file in the root of your project directory, if it doesn't exist, and add the following to the file: diff --git a/docs/django.md b/docs/django.md new file mode 100644 index 0000000..2b6b2ae --- /dev/null +++ b/docs/django.md @@ -0,0 +1,520 @@ +# Django + +deadcode is framework-agnostic: it parses source code and never imports it. Django registers models, views, admin classes, and signal handlers at +runtime, so much of a Django project's code is reachable only through a base class, a decorator, a settings string, or a URLconf. A static analyser +that only follows name loads sees those framework entry points as unused. This page shows the recommended configuration, documents the common Django +mechanisms, and gives a complete example application that produces no wrong detections. + +## Recommended configuration + +Add these options to `pyproject.toml` in the directory where you run deadcode: + +```toml +[tool.deadcode] +ignore-names-if-decorated-with = [ + "@admin.*", + "@receiver", + "@register.*", + "@login_required", + "@permission_required", + "@require_http_methods", + "@require_POST", + "@method_decorator", +] +ignore-bodies-if-inherits-from = [ + "models.Model", + "ModelAdmin", + "forms.Form", + "forms.ModelForm", + "View", + "BaseCommand", + "Migration", + "AppConfig", + "MiddlewareMixin", +] +ignore-names-in-files = ["*/settings.py", "*/urls.py", "*/migrations/*"] +ignore-definitions = ["*Config", "*Middleware", "Command"] +ignore-names = [ + "setUpTestData", + "_pre_setup", + "_post_teardown", + "_fixture_setup", + "_fixture_teardown", +] +``` + +The same options are available as command-line flags, with one occurrence per list item: + +```shell +deadcode . \ + --ignore-names-if-decorated-with='@admin.*' \ + --ignore-names-if-decorated-with='@receiver' \ + --ignore-names-if-decorated-with='@register.*' \ + --ignore-names-if-decorated-with='@login_required' \ + --ignore-names-if-decorated-with='@permission_required' \ + --ignore-names-if-decorated-with='@require_http_methods' \ + --ignore-names-if-decorated-with='@require_POST' \ + --ignore-names-if-decorated-with='@method_decorator' \ + --ignore-bodies-if-inherits-from=models.Model \ + --ignore-bodies-if-inherits-from=ModelAdmin \ + --ignore-bodies-if-inherits-from=forms.Form \ + --ignore-bodies-if-inherits-from=forms.ModelForm \ + --ignore-bodies-if-inherits-from=View \ + --ignore-bodies-if-inherits-from=BaseCommand \ + --ignore-bodies-if-inherits-from=Migration \ + --ignore-bodies-if-inherits-from=AppConfig \ + --ignore-bodies-if-inherits-from=MiddlewareMixin \ + --ignore-names-in-files='*/settings.py' \ + --ignore-names-in-files='*/urls.py' \ + --ignore-names-in-files='*/migrations/*' \ + --ignore-definitions='*Config' \ + --ignore-definitions='*Middleware' \ + --ignore-definitions=Command \ + --ignore-names=setUpTestData \ + --ignore-names=_pre_setup \ + --ignore-names=_post_teardown \ + --ignore-names=_fixture_setup \ + --ignore-names=_fixture_teardown +``` + +deadcode reads configuration only from `./pyproject.toml` in the current working directory. It does not search parent directories and there is no +`--config` option, so run the command from the project root or pass the options on the command line. + +`ignore-names-in-files` matches the reported path with `fnmatch`, where `*` also spans directory separators. The patterns therefore match both +relative (`project/settings.py`) and absolute (`/home/me/project/settings.py`) paths. See {doc}`usage` for the full option semantics and +{doc}`rules` for the rule codes. + +## Common use cases + +| Django mechanism | Configuration needed | +|------------------|----------------------| +| Settings variables such as `SECRET_KEY`, `DEBUG`, `INSTALLED_APPS`, `MIDDLEWARE`, and `TEMPLATES` | Covered by `ignore-names-in-files = ["*/settings.py"]` | +| URLconf variables such as `app_name`, `urlpatterns`, and route name constants | Covered by `ignore-names-in-files = ["*/urls.py"]` | +| Migration modules, `Migration` attributes, and `operations` | Covered by `ignore-names-in-files = ["*/migrations/*"]` and `Migration` in `ignore-bodies-if-inherits-from` | +| Model fields and nested `Meta` options | Covered by `models.Model` in `ignore-bodies-if-inherits-from` | +| Model properties and methods called by templates or the framework | Covered by `models.Model` in `ignore-bodies-if-inherits-from` | +| Managers and `QuerySet` methods | Add the manager or queryset base class to `ignore-bodies-if-inherits-from`, or use `# noqa` | +| Admin options and action methods | Covered by `ModelAdmin` in `ignore-bodies-if-inherits-from` | +| `@admin.register` and other admin decorators | Covered by `@admin.*` in `ignore-names-if-decorated-with` | +| Forms and `ModelForm` fields and validation methods | Covered by `forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` | +| Class-based views and HTTP-method handlers | Covered by `View` in `ignore-bodies-if-inherits-from` | +| Middleware classes and hooks | Covered by `*Middleware` in `ignore-definitions` and `MiddlewareMixin` in `ignore-bodies-if-inherits-from` | +| `@receiver` signal handlers | Covered by `@receiver` in `ignore-names-if-decorated-with` | +| `@register.simple_tag` and `@register.filter` | Covered by `@register.*` in `ignore-names-if-decorated-with` | +| Management commands | Covered by `Command` in `ignore-definitions` and `BaseCommand` in `ignore-bodies-if-inherits-from` | +| `AppConfig` subclasses | Covered by `*Config` in `ignore-definitions` and `AppConfig` in `ignore-bodies-if-inherits-from` | +| Django test lifecycle names such as `setUpTestData` and `_pre_setup` | Covered by the explicit `ignore-names` entries | +| Context processors and other string-referenced callables | `None` in the base block; use `# noqa: DC02` or add the name to `ignore-names` | +| `@login_required` and the other security decorators | Covered by the decorator entries in `ignore-names-if-decorated-with` | + +A context processor is named only as a string in the `TEMPLATES` setting, so deadcode cannot see the load. Mark the definition directly: + +```python +def context_processor(request): # noqa: DC02 + return {'site_name': 'Example'} +``` + +## Model layer + +Models are the largest source of Django false positives. A model field or nested `Meta` option is a class-level assignment with no Python +name load, and Django calls model methods and properties at runtime. `models.Model` in `ignore-bodies-if-inherits-from` removes all findings +from the body of a model class while the class name itself is still reported when nothing uses it. + +| Mechanism | Finding without configuration | Remedy | +|-----------|-------------------------------|--------| +| Model fields such as `CharField`, `ForeignKey`, and `BooleanField` | `DC06` for each field name | `models.Model` in `ignore-bodies-if-inherits-from` | +| Nested `class Meta` and its options such as `ordering` and `verbose_name_plural` | `DC03` for `Meta`, `DC06` for the options | `models.Model` in `ignore-bodies-if-inherits-from` | +| Model methods such as `__str__`, and `@property` methods called by templates | `DC04` for methods, `DC08` for properties | `models.Model` in `ignore-bodies-if-inherits-from` | +| Relations and related managers such as `category.articles` | `DC06` when the relation attribute is assigned only | `models.Model` in `ignore-bodies-if-inherits-from` | +| Custom managers and `QuerySet` methods | `DC04` for methods that only other code calls | Add the manager or queryset base class to `ignore-bodies-if-inherits-from`, or mark each method with `# noqa` | +| Migration attributes and operations | `DC06` for `initial`, `dependencies`, and `operations` | `Migration` in `ignore-bodies-if-inherits-from` | +| Migration module helpers and imports | `DC01`, `DC06`, `DC07`, or `DC12` depending on the code | `ignore-names-in-files = ["*/migrations/*"]` | + +A custom manager or `QuerySet` is not covered by `models.Model`. Add its base class to `ignore-bodies-if-inherits-from` when its methods are called +only by other code, or mark the individual method with `# noqa: DC04`. The same rule applies to a model mixin that is not itself a `models.Model` +subclass. + +Migrations need both options. `Migration` in `ignore-bodies-if-inherits-from` removes the body attributes, and the file pattern for +`*/migrations/*` removes module-level findings and keeps generated migration code out of the report. Raw SQL through `connection.cursor()`, +transactions through `atomic()` and `transaction.on_commit()`, and aggregation through `annotate()` and `aggregate()` are ordinary function and +method calls, so they need no additional configuration beyond the base block. + +## View layer + +A URLconf names views as attributes and strings, so deadcode sees only part of the wiring. `urls.py` uses `path()`, `re_path()`, and `include()`, and +the module-level `urlpatterns` list is assigned without a name load. `ignore-names-in-files = ["*/urls.py"]` removes the URLconf variables; a project +that keeps URLs outside a file named `urls.py` needs its own pattern. Views referenced through `include()` or a dotted string keep their class and +function definitions reachable only by name, so a view class that nothing imports is reported as `DC03`. + +| Mechanism | Finding without configuration | Remedy | +|-----------|-------------------------------|--------| +| URLconf variables such as `app_name`, `urlpatterns`, and route name constants | `DC01` for each variable | `ignore-names-in-files = ["*/urls.py"]` | +| Function views and decorators such as `@login_required` and `@require_POST` | `DC02` for the decorated function, `DC04` when only the framework calls it | `@login_required`, `@permission_required`, `@require_http_methods`, `@require_POST`, and `@method_decorator` in `ignore-names-if-decorated-with` | +| Class-based views referenced only by a string or `include()` | `DC03` for the class | A name load, or `# noqa: DC03` when the class is genuinely used | +| HTTP-method handlers such as `get`, `post`, and `dispatch` | `DC04` for each handler | `View` in `ignore-bodies-if-inherits-from` | +| Middleware classes named only in `MIDDLEWARE` | `DC03` for the class, `DC04` for hooks such as `process_request` | `*Middleware` in `ignore-definitions` and `MiddlewareMixin` in `ignore-bodies-if-inherits-from` | + +Function views are ordinary functions. Deadcode follows their name loads, so `path('', views.index)` keeps `index` alive and a decorator that it does not +know would otherwise make the function look unused. The decorator entries in the recommended configuration cover the Django decorators that wrap a +view at definition time. + +Class-based views inherit their handlers from `View`, `TemplateView`, `DetailView`, `ListView`, `FormView`, or another generic base class. Django calls +`dispatch()` and then the matching `get()` or `post()` method by name, and no Python code loads them. `View` in `ignore-bodies-if-inherits-from` +removes those findings from every class-based view while the class name itself is still reported when nothing references it: + +```python +from django.views import View +from django.views.generic import ListView + +from .models import Article + + +class ArticleView(View): + def get(self, request): + return HttpResponse('articles') + + +class ArticleListView(ListView): + model = Article + template_name = 'blog/article_list.html' +``` + +`TemplateResponse`, `render()`, `redirect()`, and the request and response objects are ordinary calls and attributes, so they need no extra +configuration. File uploads through `request.FILES`, `FileField`, and a storage backend are likewise ordinary attribute loads; the model-field rule +covers the field and the storage class needs a name load or `# noqa` when it is configured only as a string. + +The same rule applies to middleware. `MIDDLEWARE` lists classes as dotted strings, and Django calls `process_request`, `process_response`, and the other +hooks at runtime. `*Middleware` in `ignore-definitions` removes the class-name finding, and `MiddlewareMixin` in `ignore-bodies-if-inherits-from` removes +the hook and option findings: + +```python +from django.utils.deprecation import MiddlewareMixin + + +class SimpleMiddleware(MiddlewareMixin): + def process_request(self, request): + return None +``` + +A middleware class that does not inherit `MiddlewareMixin` needs its hooks covered by a base class in `ignore-bodies-if-inherits-from` or by +`# noqa: DC04`. A view or middleware helper that is genuinely dead is still reported when the configuration is applied to a different base class. + +## Template layer + +The template language is data, not Python: deadcode does not parse templates, so a context variable, a `{% url %}` reference, or a filter used only in a +`.html` file is invisible. The `TEMPLATES` setting in `settings.py` is covered by the settings file pattern, and the template backend is named as a +string. Built-in tags and filters such as `{% for %}`, `{% if %}`, `{{ value|date }}`, and `{{ value|default }}` need no configuration because +deadcode never sees the template. + +The template API in Python is ordinary code. `loader.get_template()`, `render_to_string()`, `Template`, `Context`, and `TemplateResponse` are normal +function and class calls. The interesting findings come from custom tags and filters, which are registered at import time: + +```python +from django import template + +register = template.Library() + + +@register.simple_tag +def greeting(name): + return f'Hello {name}' + + +@register.filter +def shout(value): + return value.upper() + + +def unused_tag(value): + return value +``` + +`@register.simple_tag`, `@register.filter`, `@register.inclusion_tag`, and `@register.tag` are all covered by `@register.*` in +`ignore-names-if-decorated-with`, so `greeting` and `shout` are not reported. The undecorated `unused_tag` is genuinely unused and still reported as +`DC02`. A tag registered with a different library decorator needs its own pattern or a name load. + +A context processor is named only as a string in the `TEMPLATES` setting, so deadcode cannot see the call: + +```python +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'project.context_processors.site_name', + ], + }, + }, +] +``` + +Mark the definition directly with `# noqa: DC02` or add its name to `ignore-names`. The `# noqa` form keeps the suppression next to the function, which +is clearer than a project-wide name entry when a project has several context processors: + +```python +def site_name(request): # noqa: DC02 + return {'site_name': 'Example'} +``` + +A template backend configured through a dotted path is a string reference too. The built-in Django backend needs no configuration because deadcode does +not analyse Django itself; a custom backend class in the project needs a name load or `# noqa`. + +## Forms + +Django forms declare fields as class attributes, and a `ModelForm` reads its model and field list from a nested `Meta` class. Deadcode reports each +field as `DC06`, the nested `Meta` class as `DC03`, and validation hooks such as `clean_title()` and `clean()` as `DC04` because Django calls them by +name after the form is bound. + +| Mechanism | Finding without configuration | Remedy | +|-----------|-------------------------------|--------| +| Form fields and widgets such as `CharField` and `Select` | `DC06` for each field name | `forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` | +| Nested `class Meta` and its `model` and `fields` options | `DC03` for `Meta`, `DC06` for the options | `forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` | +| Validation hooks such as `clean_title()` and `clean()` | `DC04` for each method | `forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` | +| Formsets, `media` definitions, and custom validators | None when the code is called directly | Add a name load, or `# noqa` for a callable referenced only by a string | + +`forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` remove every finding from the form body, while the class name itself is still +reported as `DC03` when nothing references it. A form that inherits a project mixin instead of `forms.Form` needs that mixin's base class added to the +same list, or each validation method marked with `# noqa: DC04`. A form set is created with `formset_factory()`, and its `clean()` method is an ordinary +method on the form; neither needs extra configuration when the call site loads the class. Custom validators are ordinary functions, so a validator named +only as a string in a field definition needs a name load, `ignore-names`, or `# noqa`. + +## Admin + +The admin site discovers `ModelAdmin` subclasses when it imports a project's `admin` module. `@admin.register` registers the class and, because it is a +decorator, is covered by `@admin.*` in `ignore-names-if-decorated-with`. A class registered without the decorator is referenced only by the admin site +and is reported as `DC03` unless it has a name load. + +| Mechanism | Finding without configuration | Remedy | +|-----------|-------------------------------|--------| +| `@admin.register` and other admin decorators | `DC03` for the decorated class | `@admin.*` in `ignore-names-if-decorated-with` | +| Options such as `list_display`, `search_fields`, `list_filter`, and `actions` | `DC06` for each option | `ModelAdmin` in `ignore-bodies-if-inherits-from` | +| Admin action methods named in `actions` | `DC04` for each action method | `ModelAdmin` in `ignore-bodies-if-inherits-from` | +| Inline classes such as `StackedInline` and `TabularInline` | `DC03` for the class, `DC06` for its options | Add the inline base class to `ignore-bodies-if-inherits-from`, or use `# noqa` | + +The options and the action method are attributes of the `ModelAdmin` body, so `ModelAdmin` in `ignore-bodies-if-inherits-from` removes them together. The +`actions` tuple contains the action names as strings, such as `'mark_featured'`, and deadcode does not analyse string contents; the method itself +is covered by the base-class rule, not by a name load. An inline class inherits `InlineModelAdmin` rather than `ModelAdmin` in a typical project, so check +its base class and add the missing entry when its body is reported. The admin site's documentation generator is an ordinary command and needs no +configuration beyond the base block. + +## Development process + +A Django settings module is a sequence of module-level assignments that nothing in the project imports by name. The settings file pattern in the +recommended configuration removes those findings. Projects that split settings into a package, such as `settings/base.py` and `settings/production.py`, +need a broader pattern such as `*/settings/*.py`; a differently named environment module needs its own entry. `manage.py` and `django-admin` are ordinary +scripts, and the settings module and application entry points they name are strings, so deadcode never sees those loads. + +`AppConfig` subclasses are discovered through `INSTALLED_APPS`, which names them as dotted strings. `*Config` in `ignore-definitions` covers the class +name, and `AppConfig` in `ignore-bodies-if-inherits-from` covers `default_auto_field`, `name`, and the `ready()` hook. A project with a differently named +application configuration class needs an additional definition pattern or a name load. + +Custom management commands are classes named `Command` in a `management/commands` package. The `Command` entry in `ignore-definitions` covers the class +name, and `BaseCommand` in `ignore-bodies-if-inherits-from` covers `help`, `add_arguments()`, and `handle()`. `django-admin` imports the module and +constructs the command by convention, so there is no name load to follow. A helper command class with another name is still reported unless it has a name +load; the example application keeps one intentionally unused command to show that difference. + +Django's `TestCase` lifecycle methods are called by the framework, not by project code. The recommended configuration adds `setUpTestData`, `_pre_setup`, +`_post_teardown`, `_fixture_setup`, and `_fixture_teardown` to `ignore-names`. The local base commit does not suppress those names, so all five are +reported in a test file without the entries. Releases containing the Django lifecycle suppression upstream (PR #104) already ignore these names in test +files, which makes the entries redundant there. A genuine helper that is not a lifecycle name and is not called is still reported. + +Deployment modules have the same string-reference problem as settings. A WSGI or ASGI module assigns `application` without loading it, so add the module +pattern to `ignore-names-in-files` or mark the assignment with `# noqa: DC01`. Static files are collected through the `staticfiles` application and +settings, and error reporting is configured through `ADMINS`, `SERVER_EMAIL`, and logging settings; all of those are string or module assignments +covered by the settings pattern. A custom handler class named only as a string needs a name load, `# noqa`, or an entry in `ignore-names`. + +## Security + +Most Django security configuration is settings: `SECRET_KEY`, `ALLOWED_HOSTS`, `SECURE_*`, `CSRF_*`, `SESSION_COOKIE_*`, and the password hashers are +module-level assignments covered by the settings file pattern. CSRF tokens, clickjacking protection, and cryptographic signing are ordinary template +tags, middleware, and function calls such as `signing.dumps()` and `signing.loads()`, so they need no additional configuration. + +The security decorators on views and helpers are covered by `ignore-names-if-decorated-with`. The recommended entries include `@csrf_exempt`, +`@login_required`, `@permission_required`, `@require_http_methods`, `@require_POST`, and `@method_decorator`. An undecorated function that is genuinely +unused is still reported, and a custom security decorator needs its own pattern. + +## Internationalization and localization + +`gettext`, `gettext_lazy`, `ngettext`, and their aliases are ordinary function calls, so a wrapped literal and a translated name are normal name loads. +The `LANGUAGES`, `LOCALE_PATHS`, and `USE_I18N` settings are covered by the settings file pattern, and locale directories contain data files rather +than Python modules. Per-site and per-user time zone settings are also settings assignments; code that calls `timezone.localtime()` or +`activate()` needs no extra configuration beyond the base block. + +## Performance and optimization + +Caching through `cache.get()`, `cache.set()`, `cache_page`, and a configured cache backend is ordinary code and settings. `select_related()` and +`prefetch_related()` are ordinary queryset method calls, and `django-debug-toolbar`-style tooling is a third-party package that deadcode parses like +any other dependency. No special configuration is needed beyond the base block; a custom cache backend named only through a string setting needs a name +load, `ignore-names`, or `# noqa`. + +## Geographic framework + +GeoDjango models are still `models.Model` subclasses and spatial fields are still `Field` subclasses, so the model base rule applies and the class name +is reported only when nothing references it. `GeoManager` and `GeoQuerySet` follow the manager rule: add the base class to +`ignore-bodies-if-inherits-from` when their methods are called only by the framework. GDAL and GEOS are optional and are imported by Django at runtime; +deadcode never imports the project, so a missing native library does not affect the report. + +## Common web application tools + +| Tool | Configuration needed | +|------|----------------------| +| Authentication and passwords | `None`; ordinary views, models, and settings | +| Caching | `None` beyond the base block; string backend settings use the settings pattern | +| Logging | `None`; `LOGGING` is a settings assignment covered by the settings pattern | +| Email | `None`; message classes and settings are ordinary code | +| Syndication feeds | `None`; feed classes are ordinary classes and views | +| Pagination | `None`; `Paginator` and `Page` are ordinary classes | +| Messages | `None`; the messages framework is middleware and ordinary calls | +| Serialization | `None`; serializers are ordinary functions and classes | +| Sessions | `None`; session backends are configured through settings strings | +| Sitemaps | `None`; sitemap classes are ordinary classes | +| Static files | `None` beyond the base block; `STATICFILES_*` settings use the settings pattern | +| Data validation | `None`; validators are ordinary callables | + +## Other core functionality + +Conditional content processing through `{% if %}`, content types and generic relations through `ContentType` and `GenericForeignKey`, flatpages, +redirects, system checks, and the sites framework are ordinary models, functions, and settings. Their class-level attributes follow the model rule, +and their configuration strings follow the settings pattern. + +Signals have their own rule. A handler decorated with `@receiver` is covered by `@receiver` in `ignore-names-if-decorated-with`. A handler connected +with `post_save.connect(article_saved)` has an ordinary name load and is not reported. A handler that has no decorator and no call is reported as +`DC02` and needs an explicit `ignore-names` entry or `# noqa`; the example application keeps one such handler to show the intentional finding. Unicode +handling and the remaining utility helpers are ordinary Python code with no Django-specific configuration. + +## Example application + +The acceptance fixture in `tests/files/django` is a small multi-module Django project. It uses `tests/files/django/settings.py`, +`tests/files/django/urls.py`, `tests/files/django/views.py`, `tests/files/django/middleware.py`, `tests/files/django/models.py`, +`tests/files/django/managers.py`, `tests/files/django/migrations/0001_initial.py`, `tests/files/django/templatetags/blog_tags.py`, +`tests/files/django/forms.py`, `tests/files/django/admin.py`, `tests/files/django/apps.py`, `tests/files/django/signals.py`, +`tests/files/django/management/commands/import_data.py`, and `tests/files/django/tests.py`. + +`settings.py` defines the usual module-level settings plus one intentionally unused setting, and it names the application configuration in +`INSTALLED_APPS`. `urls.py` defines `app_name`, `urlpatterns`, and one intentionally unused URLconf variable. `views.py` defines a function view loaded +by the URLconf, a class-based view loaded through `.as_view()`, one intentionally unused setting, one intentionally unused function view, and one +intentionally unused class-based view. `middleware.py` defines a `MiddlewareMixin` subclass referenced only by the `MIDDLEWARE` string. `models.py` +defines a `Category`/`Article` pair with fields, nested `Meta` classes, and properties, plus one intentionally unused model. `managers.py` defines a +custom `QuerySet` and manager with one intentionally unused manager method. `migrations/0001_initial.py` is a generated-style migration. +`templatetags/blog_tags.py` registers a simple tag and a filter and leaves one intentionally unused function. `forms.py` defines a `ModelForm` with a +nested `Meta` and a validation hook, plus one intentionally unused form. `admin.py` registers an `ArticleAdmin` with options and an action method, +plus one intentionally unused admin class. `apps.py` defines a `BlogConfig` with `default_auto_field` and a `ready()` hook, and `signals.py` defines a +`@receiver` handler plus one intentionally unused signal handler. `management/commands/import_data.py` defines a `Command` with `help`, +`add_arguments()`, and `handle()`, plus one intentionally unused command. `tests.py` defines a `TestCase` with Django's five lifecycle hooks, a real +test, and one intentionally unused helper. + +Without configuration, the settings and URLconf variables, the middleware and its hook, the class-based view handlers, the registered template tags, +the form fields and validation hooks, the admin options and action method, the application configuration, the management command members, the +decorated signal handler, and the test lifecycle hooks are reported together with the intentionally unused code: + +```text +project/admin.py:8:0: DC03 Class `ArticleAdmin` is never used +project/admin.py:9:4: DC06 Name `form` is never used +project/admin.py:10:4: DC06 Name `list_display` is never used +project/admin.py:11:4: DC06 Name `search_fields` is never used +project/admin.py:12:4: DC06 Name `list_filter` is never used +project/admin.py:13:4: DC06 Name `actions` is never used +project/admin.py:15:4: DC04 Method `mark_featured` is never used +project/admin.py:19:0: DC03 Class `UnusedAdmin` is never used +project/admin.py:20:4: DC06 Name `list_display` is never used +project/apps.py:4:0: DC03 Class `BlogConfig` is never used +project/apps.py:5:4: DC06 Name `default_auto_field` is never used +project/apps.py:8:4: DC04 Method `ready` is never used +project/forms.py:7:4: DC03 Class `Meta` is never used +project/forms.py:8:8: DC06 Name `model` is never used +project/forms.py:9:8: DC06 Name `fields` is never used +project/forms.py:11:4: DC04 Method `clean_title` is never used +project/forms.py:15:0: DC03 Class `UnusedForm` is never used +project/management/commands/import_data.py:4:0: DC03 Class `Command` is never used +project/management/commands/import_data.py:5:4: DC06 Name `help` is never used +project/management/commands/import_data.py:7:4: DC04 Method `add_arguments` is never used +project/management/commands/import_data.py:10:4: DC04 Method `handle` is never used +project/management/commands/import_data.py:14:0: DC03 Class `UnusedCommand` is never used +project/management/commands/import_data.py:15:4: DC04 Method `handle` is never used +project/managers.py:13:4: DC04 Method `unused_manager_method` is never used +project/middleware.py:4:0: DC03 Class `SimpleMiddleware` is never used +project/middleware.py:5:4: DC04 Method `process_request` is never used +project/migrations/0001_initial.py:5:4: DC06 Name `initial` is never used +project/migrations/0001_initial.py:7:4: DC06 Name `dependencies` is never used +project/migrations/0001_initial.py:9:4: DC06 Name `operations` is never used +project/models.py:9:4: DC03 Class `Meta` is never used +project/models.py:10:8: DC06 Name `verbose_name_plural` is never used +project/models.py:18:4: DC06 Name `category` is never used +project/models.py:23:4: DC03 Class `Meta` is never used +project/models.py:24:8: DC06 Name `ordering` is never used +project/models.py:27:4: DC08 Property `display_name` is never used +project/models.py:31:4: DC08 Property `published_titles` is never used +project/models.py:35:0: DC03 Class `UnusedModel` is never used +project/settings.py:1:0: DC01 Variable `SECRET_KEY` is never used +project/settings.py:2:0: DC01 Variable `DEBUG` is never used +project/settings.py:4:0: DC01 Variable `INSTALLED_APPS` is never used +project/settings.py:10:0: DC01 Variable `MIDDLEWARE` is never used +project/settings.py:15:0: DC01 Variable `TEMPLATES` is never used +project/settings.py:27:0: DC01 Variable `DATABASES` is never used +project/settings.py:33:0: DC01 Variable `ROOT_URLCONF` is never used +project/settings.py:35:0: DC01 Variable `UNUSED_SETTING` is never used +project/signals.py:8:0: DC02 Function `article_saved` is never used +project/signals.py:12:0: DC02 Function `unused_signal_handler` is never used +project/templatetags/blog_tags.py:7:0: DC02 Function `greeting` is never used +project/templatetags/blog_tags.py:12:0: DC02 Function `shout` is never used +project/templatetags/blog_tags.py:16:0: DC02 Function `unused_tag` is never used +project/tests.py:8:4: DC04 Method `setUpTestData` is never used +project/tests.py:11:4: DC04 Method `_pre_setup` is never used +project/tests.py:14:4: DC04 Method `_post_teardown` is never used +project/tests.py:17:4: DC04 Method `_fixture_setup` is never used +project/tests.py:20:4: DC04 Method `_fixture_teardown` is never used +project/tests.py:26:4: DC04 Method `unused_test_helper` is never used +project/urls.py:5:0: DC01 Variable `app_name` is never used +project/urls.py:7:0: DC01 Variable `urlpatterns` is never used +project/urls.py:12:0: DC01 Variable `UNUSED_URL_NAME` is never used +project/views.py:4:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used +project/views.py:11:0: DC02 Function `unused_view` is never used +project/views.py:16:4: DC04 Method `get` is never used +project/views.py:20:0: DC03 Class `UnusedView` is never used +project/views.py:21:4: DC04 Method `get` is never used +``` + +With the recommended configuration, only the intentionally unused code in `views.py`, `models.py`, `managers.py`, `forms.py`, `admin.py`, +`templatetags/blog_tags.py`, `signals.py`, `management/commands/import_data.py`, and `tests.py` remains. The settings, URLconf, model-field, +nested-`Meta`, property, migration, middleware-hook, `@register.*`, form, admin, application-configuration, management-command, receiver, and +lifecycle findings disappear because the base-class, decorator, definition, and file options cover them. The final report is: + +```text +project/admin.py:19:0: DC03 Class `UnusedAdmin` is never used +project/forms.py:15:0: DC03 Class `UnusedForm` is never used +project/management/commands/import_data.py:14:0: DC03 Class `UnusedCommand` is never used +project/managers.py:13:4: DC04 Method `unused_manager_method` is never used +project/models.py:35:0: DC03 Class `UnusedModel` is never used +project/signals.py:12:0: DC02 Function `unused_signal_handler` is never used +project/templatetags/blog_tags.py:16:0: DC02 Function `unused_tag` is never used +project/tests.py:26:4: DC04 Method `unused_test_helper` is never used +project/views.py:4:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used +project/views.py:11:0: DC02 Function `unused_view` is never used +project/views.py:20:0: DC03 Class `UnusedView` is never used +``` + +## Verifying the recommended configuration + +Copy the configuration into `pyproject.toml`, then run: + +```shell +deadcode . --no-color +``` + +The command exits with status `0` when there are no findings, `1` when findings remain, and `2` for argument errors. See {doc}`usage` for the exit +status details and the other command-line options. If a framework entry point is still reported, compare its decorator, base class, or file path +with the patterns above and add the missing pattern. + +## Limitations + +- deadcode does not detect Django automatically. Configuration is explicit and applies to any framework that uses decorators, base classes, and + file conventions. +- String references are invisible. Settings such as `MIDDLEWARE`, `TEMPLATES`, and `ROOT_URLCONF` name callables and modules as strings, so the + referenced code needs a name load, a file pattern, or `# noqa`. +- Base-class overrides are not resolved. A method that Django calls but that no Python code loads needs its base class in + `ignore-bodies-if-inherits-from` or an explicit `# noqa`. +- Dynamic registration through `getattr`, `importlib`, string paths, or code generation cannot be seen by a static analyser. +- A different decorator or base-class spelling needs its own pattern. For example, a custom admin decorator or an aliased `ModelAdmin` import does + not match the entries above until the pattern or the imported name is added. diff --git a/docs/features.md b/docs/features.md index 714cb63..9638d2e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -44,6 +44,7 @@ for every row. | `# noqa` comments | `## Ignoring checks with noqa comments` | `deadcode/visitor/noqa.py` | `tests/noqa/test_noqa_comments.py` | 3 | | Unittest lifecycle methods | `## Ignoring unittest lifecycle methods` | `deadcode/visitor/ignore.py` | `tests/visitor/test_unittest_lifecycle_methods.py` | 3 | | FastAPI guide | `## Using deadcode on a FastAPI project` | `deadcode/actions/parse_arguments.py` | `tests/documented_examples/test_fastapi.py`, `tests/documented_examples/test_fastapi_documentation.py` | 3 | +| Django guide | `## Using deadcode on a Django project` | `deadcode/actions/parse_arguments.py` | `tests/documented_examples/test_django.py` | 3 | | Pre-commit hook | `## Pre-commit hook` | `.pre-commit-hooks.yaml` | `tests/documented_examples/test_pre_commit_hook_configuration.py` | 3 | | Wildcard imports and `__all__` | `## Known limitations` | `deadcode/visitor/module_exports.py` | `tests/visitor/test_wildcard_imports.py` | 3 | | Syntax-error files | `## Known limitations` | `deadcode/actions/parse_abstract_syntax_tree.py` | `tests/visitor/test_imports.py`, `tests/cli/test_deadcode.py` | 3 | diff --git a/docs/index.md b/docs/index.md index ac1b79e..5fe3192 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,9 @@ Read the {doc}`rules` page for the implemented rules and for ignoring findings w Read the {doc}`fastapi` page for running deadcode on a FastAPI project, including common use cases, dependency injection, and a complete example application. +Read the {doc}`django` page for running deadcode on a Django project, including settings, models, views, templates, forms, the admin, and a +complete example application. + Read the {doc}`pre_commit` page for setting up and using the pre-commit hook. Read the {doc}`contributing` page for the contribution workflow and the feature-request checklist. @@ -42,6 +45,7 @@ installation usage rules fastapi +django pre_commit contributing security diff --git a/tests/documented_examples/test_django.py b/tests/documented_examples/test_django.py new file mode 100644 index 0000000..062ee48 --- /dev/null +++ b/tests/documented_examples/test_django.py @@ -0,0 +1,401 @@ +import re +import shutil + +from pathlib import Path + +from deadcode.cli import main, print_main + +FIXTURE_DIRECTORY = Path(__file__).resolve().parents[1] / 'files' / 'django' + +CONFIGURATION = """[tool.deadcode] +ignore-names-if-decorated-with = [ + "@admin.*", + "@receiver", + "@register.*", + "@login_required", + "@permission_required", + "@require_http_methods", + "@require_POST", + "@method_decorator", +] +ignore-bodies-if-inherits-from = [ + "models.Model", + "ModelAdmin", + "forms.Form", + "forms.ModelForm", + "View", + "BaseCommand", + "Migration", + "AppConfig", + "MiddlewareMixin", +] +ignore-names-in-files = ["*/settings.py", "*/urls.py", "*/migrations/*"] +ignore-definitions = ["*Config", "*Middleware", "Command"] +ignore-names = [ + "setUpTestData", + "_pre_setup", + "_post_teardown", + "_fixture_setup", + "_fixture_teardown", +] +""" + +COMMAND_LINE_OPTIONS = [ + '--ignore-names-if-decorated-with=@admin.*', + '--ignore-names-if-decorated-with=@receiver', + '--ignore-names-if-decorated-with=@register.*', + '--ignore-names-if-decorated-with=@login_required', + '--ignore-names-if-decorated-with=@permission_required', + '--ignore-names-if-decorated-with=@require_http_methods', + '--ignore-names-if-decorated-with=@require_POST', + '--ignore-names-if-decorated-with=@method_decorator', + '--ignore-bodies-if-inherits-from=models.Model', + '--ignore-bodies-if-inherits-from=ModelAdmin', + '--ignore-bodies-if-inherits-from=forms.Form', + '--ignore-bodies-if-inherits-from=forms.ModelForm', + '--ignore-bodies-if-inherits-from=View', + '--ignore-bodies-if-inherits-from=BaseCommand', + '--ignore-bodies-if-inherits-from=Migration', + '--ignore-bodies-if-inherits-from=AppConfig', + '--ignore-bodies-if-inherits-from=MiddlewareMixin', + '--ignore-names-in-files=*/settings.py', + '--ignore-names-in-files=*/urls.py', + '--ignore-names-in-files=*/migrations/*', + '--ignore-definitions=*Config', + '--ignore-definitions=*Middleware', + '--ignore-definitions=Command', + '--ignore-names=setUpTestData', + '--ignore-names=_pre_setup', + '--ignore-names=_post_teardown', + '--ignore-names=_fixture_setup', + '--ignore-names=_fixture_teardown', +] + +FINDING_PATTERN = re.compile(r'^(?P.+):\d+:\d+: (?PDC\d+) \w+ `(?P[^`]+)` is never used$') + +BASELINE_FINDINGS = { + ('project/settings.py', 'DC01', 'SECRET_KEY'), + ('project/settings.py', 'DC01', 'DEBUG'), + ('project/settings.py', 'DC01', 'INSTALLED_APPS'), + ('project/settings.py', 'DC01', 'MIDDLEWARE'), + ('project/settings.py', 'DC01', 'TEMPLATES'), + ('project/settings.py', 'DC01', 'DATABASES'), + ('project/settings.py', 'DC01', 'ROOT_URLCONF'), + ('project/settings.py', 'DC01', 'UNUSED_SETTING'), + ('project/urls.py', 'DC01', 'app_name'), + ('project/urls.py', 'DC01', 'urlpatterns'), + ('project/urls.py', 'DC01', 'UNUSED_URL_NAME'), + ('project/apps.py', 'DC03', 'BlogConfig'), + ('project/apps.py', 'DC06', 'default_auto_field'), + ('project/apps.py', 'DC04', 'ready'), + ('project/management/commands/import_data.py', 'DC03', 'Command'), + ('project/management/commands/import_data.py', 'DC06', 'help'), + ('project/management/commands/import_data.py', 'DC04', 'add_arguments'), + ('project/management/commands/import_data.py', 'DC04', 'handle'), + ('project/management/commands/import_data.py', 'DC03', 'UnusedCommand'), + ('project/management/commands/import_data.py', 'DC04', 'handle'), + ('project/signals.py', 'DC02', 'article_saved'), + ('project/signals.py', 'DC02', 'unused_signal_handler'), + ('project/tests.py', 'DC04', 'setUpTestData'), + ('project/tests.py', 'DC04', '_pre_setup'), + ('project/tests.py', 'DC04', '_post_teardown'), + ('project/tests.py', 'DC04', '_fixture_setup'), + ('project/tests.py', 'DC04', '_fixture_teardown'), + ('project/tests.py', 'DC04', 'unused_test_helper'), + ('project/admin.py', 'DC03', 'ArticleAdmin'), + ('project/admin.py', 'DC03', 'UnusedAdmin'), + ('project/admin.py', 'DC04', 'mark_featured'), + ('project/admin.py', 'DC06', 'actions'), + ('project/admin.py', 'DC06', 'form'), + ('project/admin.py', 'DC06', 'list_display'), + ('project/admin.py', 'DC06', 'list_filter'), + ('project/admin.py', 'DC06', 'search_fields'), + ('project/forms.py', 'DC03', 'Meta'), + ('project/forms.py', 'DC03', 'UnusedForm'), + ('project/forms.py', 'DC04', 'clean_title'), + ('project/forms.py', 'DC06', 'fields'), + ('project/forms.py', 'DC06', 'model'), + ('project/views.py', 'DC01', 'UNUSED_VIEW_SETTING'), + ('project/views.py', 'DC02', 'unused_view'), + ('project/views.py', 'DC03', 'UnusedView'), + ('project/views.py', 'DC04', 'get'), + ('project/middleware.py', 'DC03', 'SimpleMiddleware'), + ('project/middleware.py', 'DC04', 'process_request'), + ('project/templatetags/blog_tags.py', 'DC02', 'greeting'), + ('project/templatetags/blog_tags.py', 'DC02', 'shout'), + ('project/templatetags/blog_tags.py', 'DC02', 'unused_tag'), + ('project/managers.py', 'DC04', 'unused_manager_method'), + ('project/models.py', 'DC03', 'Meta'), + ('project/models.py', 'DC03', 'UnusedModel'), + ('project/models.py', 'DC06', 'category'), + ('project/models.py', 'DC06', 'ordering'), + ('project/models.py', 'DC06', 'verbose_name_plural'), + ('project/models.py', 'DC08', 'display_name'), + ('project/models.py', 'DC08', 'published_titles'), + ('project/migrations/0001_initial.py', 'DC06', 'dependencies'), + ('project/migrations/0001_initial.py', 'DC06', 'initial'), + ('project/migrations/0001_initial.py', 'DC06', 'operations'), +} + +CONFIGURED_FINDINGS = { + ('project/admin.py', 'DC03', 'UnusedAdmin'), + ('project/forms.py', 'DC03', 'UnusedForm'), + ('project/views.py', 'DC01', 'UNUSED_VIEW_SETTING'), + ('project/views.py', 'DC02', 'unused_view'), + ('project/views.py', 'DC03', 'UnusedView'), + ('project/templatetags/blog_tags.py', 'DC02', 'unused_tag'), + ('project/models.py', 'DC03', 'UnusedModel'), + ('project/managers.py', 'DC04', 'unused_manager_method'), + ('project/management/commands/import_data.py', 'DC03', 'UnusedCommand'), + ('project/signals.py', 'DC02', 'unused_signal_handler'), + ('project/tests.py', 'DC04', 'unused_test_helper'), +} + +MODEL_LAYER_FINDINGS = CONFIGURED_FINDINGS + + +def _copy_fixture(tmp_path: Path) -> None: + shutil.copytree(FIXTURE_DIRECTORY, tmp_path / 'project') + + +def _findings(output: str | None) -> set[tuple[str, str, str]]: + assert output is not None + findings = set() + for line in output.splitlines(): + match = FINDING_PATTERN.match(line) + assert match is not None, line + findings.add((match.group('path'), match.group('code'), match.group('name'))) + return findings + + +def _assert_no_parse_warning(output: str | None) -> None: + assert output is not None + assert 'could not parse' not in output + + +def test_django_fixture_without_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + assert _findings(output) == BASELINE_FINDINGS + assert print_main(['project', '--no-color']) == 1 + + +def test_django_fixture_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + (project_directory / 'pyproject.toml').write_text(CONFIGURATION) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + assert _findings(output) == CONFIGURED_FINDINGS + assert print_main(['project', '--no-color']) == 1 + + +def test_django_fixture_with_documented_command_line_options(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color', *COMMAND_LINE_OPTIONS]) + + # Then + _assert_no_parse_warning(output) + assert _findings(output) == CONFIGURED_FINDINGS + assert print_main(['project', '--no-color', *COMMAND_LINE_OPTIONS]) == 1 + + +def test_model_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + (project_directory / 'pyproject.toml').write_text(CONFIGURATION) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + assert findings == MODEL_LAYER_FINDINGS + assert ('project/models.py', 'DC03', 'UnusedModel') in findings + assert ('project/managers.py', 'DC04', 'unused_manager_method') in findings + assert not any(path == 'project/models.py' and code == 'DC06' for path, code, _ in findings) + assert not any(name == 'Meta' for _, _, name in findings) + assert not any(name == 'display_name' for _, _, name in findings) + assert not any(path.startswith('project/migrations/') for path, _, _ in findings) + + +def test_model_layer_false_positives_without_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + assert any(name == 'Meta' for _, _, name in findings) + assert any(name == 'category' for _, _, name in findings) + assert any(name == 'display_name' for _, _, name in findings) + assert any(path.startswith('project/migrations/') for path, _, _ in findings) + + +def test_view_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + (project_directory / 'pyproject.toml').write_text(CONFIGURATION) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + names = {name for _, _, name in findings} + assert 'ArticleListView' not in names + assert 'get' not in names + assert 'process_request' not in names + assert 'SimpleMiddleware' not in names + assert ('project/views.py', 'DC03', 'UnusedView') in findings + assert not any( + code == 'DC04' and path in {'project/views.py', 'project/middleware.py'} for path, code, _ in findings + ) + + +def test_template_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + (project_directory / 'pyproject.toml').write_text(CONFIGURATION) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + assert not any(name == 'greeting' for _, _, name in findings) + assert not any(name == 'shout' for _, _, name in findings) + tag_findings = [finding for finding in findings if finding[0] == 'project/templatetags/blog_tags.py'] + assert tag_findings == [('project/templatetags/blog_tags.py', 'DC02', 'unused_tag')] + + +def test_view_and_template_layers_without_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + names = {name for _, _, name in findings} + assert {'SimpleMiddleware', 'process_request', 'get', 'greeting', 'shout', 'unused_tag'} <= names + assert ('project/views.py', 'DC03', 'UnusedView') in findings + + +def test_forms_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + (project_directory / 'pyproject.toml').write_text(CONFIGURATION) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + names = {name for _, _, name in findings} + assert 'Meta' not in names + assert 'model' not in names + assert 'fields' not in names + assert 'clean_title' not in names + assert 'ArticleForm' not in names + assert ('project/forms.py', 'DC03', 'UnusedForm') in findings + + +def test_admin_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + (project_directory / 'pyproject.toml').write_text(CONFIGURATION) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + names = {name for _, _, name in findings} + assert 'list_display' not in names + assert 'search_fields' not in names + assert 'list_filter' not in names + assert 'actions' not in names + assert 'mark_featured' not in names + assert 'ArticleAdmin' not in names + assert ('project/admin.py', 'DC03', 'UnusedAdmin') in findings + + +def test_forms_and_admin_without_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + names = {name for _, _, name in findings} + assert {'Meta', 'model', 'fields', 'clean_title'} <= names + assert {'form', 'list_display', 'search_fields', 'list_filter', 'actions', 'mark_featured'} <= names + assert ('project/admin.py', 'DC03', 'UnusedAdmin') in findings + assert ('project/forms.py', 'DC03', 'UnusedForm') in findings + + +def test_development_process_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + (project_directory / 'pyproject.toml').write_text(CONFIGURATION) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + assert not any(path == 'project/apps.py' for path, _, _ in findings) + assert not any(name in {'Command', 'help', 'add_arguments', 'handle', 'article_saved'} for _, _, name in findings) + assert not any( + path == 'project/tests.py' + and name in {'setUpTestData', '_pre_setup', '_post_teardown', '_fixture_setup', '_fixture_teardown'} + for path, _, name in findings + ) + assert ('project/signals.py', 'DC02', 'unused_signal_handler') in findings + assert ('project/management/commands/import_data.py', 'DC03', 'UnusedCommand') in findings + assert ('project/tests.py', 'DC04', 'unused_test_helper') in findings + + +def test_development_process_without_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + names = {name for _, _, name in findings} + assert {'BlogConfig', 'default_auto_field', 'ready'} <= names + assert {'Command', 'help', 'add_arguments', 'handle'} <= names + assert 'article_saved' in names + assert {'setUpTestData', '_pre_setup', '_post_teardown', '_fixture_setup', '_fixture_teardown'} <= names diff --git a/tests/documented_examples/test_documented_features.py b/tests/documented_examples/test_documented_features.py index d787033..e67e4a0 100644 --- a/tests/documented_examples/test_documented_features.py +++ b/tests/documented_examples/test_documented_features.py @@ -40,6 +40,7 @@ '# noqa comments', 'Unittest lifecycle methods', 'FastAPI guide', + 'Django guide', 'Pre-commit hook', 'Wildcard imports and __all__', 'Syntax-error files', diff --git a/tests/files/django/settings.py b/tests/files/django/settings.py new file mode 100644 index 0000000..c4540f0 --- /dev/null +++ b/tests/files/django/settings.py @@ -0,0 +1,35 @@ +SECRET_KEY = 'django-insecure-example' # noqa: S105 +DEBUG = True + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'project.apps.BlogConfig', +] +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'project.middleware.SimpleMiddleware', +] +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + ], + }, + }, +] +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'db.sqlite3', + }, +} +ROOT_URLCONF = 'project.urls' + +UNUSED_SETTING = 'unused' diff --git a/tests/files/django/urls.py b/tests/files/django/urls.py new file mode 100644 index 0000000..9718e48 --- /dev/null +++ b/tests/files/django/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +app_name = 'django_example' + +urlpatterns = [ + path('', views.index, name='index'), + path('articles/', views.ArticleListView.as_view(), name='articles'), +] + +UNUSED_URL_NAME = 'unused' diff --git a/tests/files/django/views.py b/tests/files/django/views.py new file mode 100644 index 0000000..27bdf48 --- /dev/null +++ b/tests/files/django/views.py @@ -0,0 +1,22 @@ +from django.http import HttpResponse +from django.views import View + +UNUSED_VIEW_SETTING = 'unused' + + +def index(request): + return HttpResponse('ok') + + +def unused_view(request): + return HttpResponse('unused') + + +class ArticleListView(View): + def get(self, request): + return HttpResponse('articles') + + +class UnusedView(View): + def get(self, request): + return HttpResponse('unused') From 1905af58dfde225deaee646a2b75423192892d0d Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 00:02:11 +0200 Subject: [PATCH 02/12] Add a Django example application covering all major framework areas --- tests/files/django/admin.py | 20 +++++++++++ tests/files/django/apps.py | 9 +++++ tests/files/django/forms.py | 16 +++++++++ .../django/management/commands/import_data.py | 16 +++++++++ tests/files/django/managers.py | 14 ++++++++ tests/files/django/middleware.py | 6 ++++ tests/files/django/migrations/0001_initial.py | 18 ++++++++++ tests/files/django/models.py | 36 +++++++++++++++++++ tests/files/django/signals.py | 13 +++++++ tests/files/django/templatetags/blog_tags.py | 17 +++++++++ tests/files/django/tests.py | 27 ++++++++++++++ 11 files changed, 192 insertions(+) create mode 100644 tests/files/django/admin.py create mode 100644 tests/files/django/apps.py create mode 100644 tests/files/django/forms.py create mode 100644 tests/files/django/management/commands/import_data.py create mode 100644 tests/files/django/managers.py create mode 100644 tests/files/django/middleware.py create mode 100644 tests/files/django/migrations/0001_initial.py create mode 100644 tests/files/django/models.py create mode 100644 tests/files/django/signals.py create mode 100644 tests/files/django/templatetags/blog_tags.py create mode 100644 tests/files/django/tests.py diff --git a/tests/files/django/admin.py b/tests/files/django/admin.py new file mode 100644 index 0000000..1d8e12f --- /dev/null +++ b/tests/files/django/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from .forms import ArticleForm +from .models import Article + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + form = ArticleForm + list_display = ('title', 'published') + search_fields = ('title',) + list_filter = ('published',) + actions = ('mark_featured',) + + def mark_featured(self, request, queryset): + queryset.update(featured=True) + + +class UnusedAdmin(admin.ModelAdmin): + list_display = ('title',) diff --git a/tests/files/django/apps.py b/tests/files/django/apps.py new file mode 100644 index 0000000..bba9dec --- /dev/null +++ b/tests/files/django/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'project' + + def ready(self): + from . import signals # noqa: F401, PLC0415 diff --git a/tests/files/django/forms.py b/tests/files/django/forms.py new file mode 100644 index 0000000..b0ed347 --- /dev/null +++ b/tests/files/django/forms.py @@ -0,0 +1,16 @@ +from django import forms + +from .models import Article + + +class ArticleForm(forms.ModelForm): + class Meta: + model = Article + fields = ['title', 'category'] + + def clean_title(self): + return self.cleaned_data['title'].strip() + + +class UnusedForm(forms.Form): + name = forms.CharField(max_length=100) diff --git a/tests/files/django/management/commands/import_data.py b/tests/files/django/management/commands/import_data.py new file mode 100644 index 0000000..cf66dd7 --- /dev/null +++ b/tests/files/django/management/commands/import_data.py @@ -0,0 +1,16 @@ +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'Import article data from a file.' + + def add_arguments(self, parser): + parser.add_argument('path') + + def handle(self, *args, **options: object): + pass + + +class UnusedCommand(BaseCommand): + def handle(self, *args, **options: object): + pass diff --git a/tests/files/django/managers.py b/tests/files/django/managers.py new file mode 100644 index 0000000..226cb76 --- /dev/null +++ b/tests/files/django/managers.py @@ -0,0 +1,14 @@ +from django.db import models + + +class ArticleQuerySet(models.QuerySet): + def published(self): + return self.filter(published=True) + + +class ArticleManager(models.Manager.from_queryset(ArticleQuerySet)): + def published_articles(self): + return self.published() + + def unused_manager_method(self): + return self.none() diff --git a/tests/files/django/middleware.py b/tests/files/django/middleware.py new file mode 100644 index 0000000..f815880 --- /dev/null +++ b/tests/files/django/middleware.py @@ -0,0 +1,6 @@ +from django.utils.deprecation import MiddlewareMixin + + +class SimpleMiddleware(MiddlewareMixin): + def process_request(self, request): + return None diff --git a/tests/files/django/migrations/0001_initial.py b/tests/files/django/migrations/0001_initial.py new file mode 100644 index 0000000..7d73132 --- /dev/null +++ b/tests/files/django/migrations/0001_initial.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('published', models.BooleanField(default=False)), + ], + ), + ] diff --git a/tests/files/django/models.py b/tests/files/django/models.py new file mode 100644 index 0000000..583b5f2 --- /dev/null +++ b/tests/files/django/models.py @@ -0,0 +1,36 @@ +from django.db import models + +from .managers import ArticleManager + + +class Category(models.Model): + name = models.CharField(max_length=100) + + class Meta: + verbose_name_plural = 'categories' + + def __str__(self): + return self.name + + +class Article(models.Model): # noqa: DJ008 + title = models.CharField(max_length=100) + category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='articles') + published = models.BooleanField(default=False) + + objects = ArticleManager() + + class Meta: + ordering = ['title'] + + @property + def display_name(self): + return self.title + + @property + def published_titles(self): + return list(Article.objects.published_articles().values_list('title', flat=True)) + + +class UnusedModel(models.Model): # noqa: DJ008 + value = models.IntegerField() diff --git a/tests/files/django/signals.py b/tests/files/django/signals.py new file mode 100644 index 0000000..f85a66d --- /dev/null +++ b/tests/files/django/signals.py @@ -0,0 +1,13 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import Article + + +@receiver(post_save, sender=Article) +def article_saved(sender, instance, **kwargs: object): + pass + + +def unused_signal_handler(sender, **kwargs: object): + pass diff --git a/tests/files/django/templatetags/blog_tags.py b/tests/files/django/templatetags/blog_tags.py new file mode 100644 index 0000000..9e28c04 --- /dev/null +++ b/tests/files/django/templatetags/blog_tags.py @@ -0,0 +1,17 @@ +from django import template + +register = template.Library() + + +@register.simple_tag +def greeting(name): + return f'Hello {name}' + + +@register.filter +def shout(value): + return value.upper() + + +def unused_tag(value): + return value diff --git a/tests/files/django/tests.py b/tests/files/django/tests.py new file mode 100644 index 0000000..12497b3 --- /dev/null +++ b/tests/files/django/tests.py @@ -0,0 +1,27 @@ +from django.test import TestCase + +from .models import Article + + +class ArticleTests(TestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.article = Article.objects.create(title='Example') + + def _pre_setup(self): + pass + + def _post_teardown(self): + pass + + def _fixture_setup(self): + pass + + def _fixture_teardown(self): + pass + + def test_article(self): + assert self.article.title == 'Example' + + def unused_test_helper(self): + return None From 03caebb98a12600670cc4938e314c79db8967fb0 Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 00:02:11 +0200 Subject: [PATCH 03/12] record release notes --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ffaf88e..539aa12 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,7 @@ A checked entry is implemented on `main` and is included in the next release; pu - Audit runtime and development dependencies for known vulnerabilities in CI on every pull request and weekly. - Declare the package development status as Beta. - **v2.6.0** `2026-11-08`: + - Document how to run deadcode on a Django project, covering models, admin, views, forms and more. - Features: - Add support for Python 3.13, 3.14 and 3.15. - Documentation is now published on Read the Docs at [deadcode.readthedocs.io](https://deadcode.readthedocs.io/). From edcbdb532c6bdf0c1fe5c8f5b5aeadad3187d6b3 Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 00:05:07 +0200 Subject: [PATCH 04/12] Fix release notes placement for Django guide --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 539aa12..9b2d334 100644 --- a/README.md +++ b/README.md @@ -336,8 +336,8 @@ A checked entry is implemented on `main` and is included in the next release; pu - Features: - Audit runtime and development dependencies for known vulnerabilities in CI on every pull request and weekly. - Declare the package development status as Beta. -- **v2.6.0** `2026-11-08`: - Document how to run deadcode on a Django project, covering models, admin, views, forms and more. +- **v2.6.0** `2026-11-08`: - Features: - Add support for Python 3.13, 3.14 and 3.15. - Documentation is now published on Read the Docs at [deadcode.readthedocs.io](https://deadcode.readthedocs.io/). From 0590e2995affa3d994e81991989df78d878ac0aa Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 07:00:50 +0200 Subject: [PATCH 05/12] Document Django and DRF usage with a complete example app --- README.md | 58 +- docs/django.md | 940 +++++++++++++++-------- tests/documented_examples/test_django.py | 500 ++++++++---- tests/files/django/api.py | 46 ++ tests/files/django/import_forms.py | 34 + tests/files/django/middleware.py | 15 + tests/files/django/settings.py | 3 + tests/files/django/signals.py | 7 + tests/files/django/urls.py | 13 +- tests/files/django/views.py | 34 +- 10 files changed, 1148 insertions(+), 502 deletions(-) create mode 100644 tests/files/django/api.py create mode 100644 tests/files/django/import_forms.py diff --git a/README.md b/README.md index b62e0da..58c23f8 100644 --- a/README.md +++ b/README.md @@ -77,38 +77,58 @@ the dependency injection strategy, and the expected output with no wrong detecti ## Using deadcode on a Django project -Django registers models, views, admin classes, and signal handlers through base classes, decorators, settings strings, and URLconfs, so +Django registers models, views, admin classes, middleware, and signal handlers through base classes, decorators, settings strings, and URLconfs, so framework entry points look unused to a static analyser. Configure deadcode like this: ```toml [tool.deadcode] -ignore-names-if-decorated-with = [ - "@admin.*", - "@receiver", - "@register.*", - "@login_required", - "@permission_required", - "@require_http_methods", - "@require_POST", - "@method_decorator", +ignore-definitions-if-decorated-with = [ + "@*admin.*", + "@*register", + "@*display", + "@*action", + "@*receiver", + "@*register.*", + "@*require_*", + "@*login_required", + "@*permission_required", + "@*user_passes_test", + "@*method_decorator", + "@*api_view", + "@*_classes", + "@*schema", ] -ignore-bodies-if-inherits-from = [ - "models.Model", +ignore-definitions-if-inherits-from = [ + "Model", "ModelAdmin", - "forms.Form", - "forms.ModelForm", - "View", + "Form", + "ModelForm", + "*Inline", "BaseCommand", "Migration", "AppConfig", "MiddlewareMixin", + "*View", + "*ViewSet", + "*Serializer", + "*Permission", + "*Authentication", + "*Pagination", + "*FilterBackend", + "*Throttle", ] -ignore-names-in-files = ["*/settings.py", "*/urls.py", "*/migrations/*"] -ignore-definitions = ["*Config", "*Middleware", "Command"] +ignore-names = ["*Middleware", "*middleware"] +ignore-names-in-files = ["*/settings.py", "*/urls.py"] ``` -Read the [Django guide](https://deadcode.readthedocs.io/en/latest/django.html) for the common use cases, every framework area, the complete -example application, and the expected output with no wrong detections. +The example targets Django 5.2 LTS (5.2.19) and Django REST Framework 3.18.3 and uses APIs present in Django 5.2 through 6.1. The four keys replace the +older overlapping name, body, and definition options: one inheritance entry hides a framework subclass name and body, and one decorator entry hides a +decorated definition name and body. Command-line flags accept the same values repeatedly, space-separated, or as one comma-separated string; a pipe is +not a separator. + +Read the [Django guide](https://deadcode.readthedocs.io/en/latest/django.html) for the common use cases, every framework area, the current and legacy +middleware forms, both import forms, database signals, Django REST Framework, the complete example application, and the expected output with no wrong +detections. ## Pre-commit hook Create a `.pre-commit-config.yaml` file in the root of your project directory, if it doesn't exist, and add the following to the file: diff --git a/docs/django.md b/docs/django.md index 2651799..a949bdb 100644 --- a/docs/django.md +++ b/docs/django.md @@ -1,179 +1,342 @@ # Django -deadcode is framework-agnostic: it parses source code and never imports it. Django registers models, views, admin classes, and signal handlers at -runtime, so much of a Django project's code is reachable only through a base class, a decorator, a settings string, or a URLconf. A static analyser -that only follows name loads sees those framework entry points as unused. This page shows the recommended configuration, documents the common Django -mechanisms, and gives a complete example application that produces no wrong detections. +deadcode is framework-agnostic: it parses source code and never imports it. Django registers models, views, admin classes, middleware, and signal +handlers at runtime, so much of a Django project's code is reachable only through a base class, a decorator, a settings string, or a URLconf. A static +analyser that only follows name loads sees those framework entry points as unused. This page shows one small configuration for Django and Django REST +Framework, documents the current middleware forms and both import forms, and gives a complete example application whose reports you can reproduce. ## Recommended configuration -Add these options to `pyproject.toml` in the directory where you run deadcode: +Add these four option keys to `pyproject.toml` in the directory where you run deadcode: ```toml [tool.deadcode] -ignore-names-if-decorated-with = [ - "@admin.*", - "@receiver", - "@register.*", - "@login_required", - "@permission_required", - "@require_http_methods", - "@require_POST", - "@method_decorator", +ignore-definitions-if-decorated-with = [ + "@*admin.*", + "@*register", + "@*display", + "@*action", + "@*receiver", + "@*register.*", + "@*require_*", + "@*login_required", + "@*permission_required", + "@*user_passes_test", + "@*method_decorator", + "@*api_view", + "@*_classes", + "@*schema", ] -ignore-bodies-if-inherits-from = [ - "models.Model", +ignore-definitions-if-inherits-from = [ + "Model", "ModelAdmin", - "forms.Form", - "forms.ModelForm", - "View", + "Form", + "ModelForm", + "*Inline", "BaseCommand", "Migration", "AppConfig", "MiddlewareMixin", + "*View", + "*ViewSet", + "*Serializer", + "*Permission", + "*Authentication", + "*Pagination", + "*FilterBackend", + "*Throttle", ] -ignore-names-in-files = ["*/settings.py", "*/urls.py", "*/migrations/*"] -ignore-definitions = ["*Config", "*Middleware", "Command"] +ignore-names = ["*Middleware", "*middleware"] +ignore-names-in-files = ["*/settings.py", "*/urls.py"] ``` -The same options are available as command-line flags, with one occurrence per list item: +Every entry has one job. `ignore-definitions-if-inherits-from` suppresses the name and the whole body of a framework subclass, so one `Model` entry +replaces a model-name pattern, a field pattern, and a nested-`Meta` pattern. `ignore-definitions-if-decorated-with` does the same for a decorated +definition, so one `@*receiver` entry covers the handler name and its body. `ignore-names` covers the two middleware forms that have no framework base +class. `ignore-names-in-files` covers settings and URLconf modules, whose module-level names are configuration data rather than ordinary code. + +The same options are available as command-line flags, with one occurrence per value: ```shell deadcode . \ - --ignore-names-if-decorated-with='@admin.*' \ - --ignore-names-if-decorated-with='@receiver' \ - --ignore-names-if-decorated-with='@register.*' \ - --ignore-names-if-decorated-with='@login_required' \ - --ignore-names-if-decorated-with='@permission_required' \ - --ignore-names-if-decorated-with='@require_http_methods' \ - --ignore-names-if-decorated-with='@require_POST' \ - --ignore-names-if-decorated-with='@method_decorator' \ - --ignore-bodies-if-inherits-from=models.Model \ - --ignore-bodies-if-inherits-from=ModelAdmin \ - --ignore-bodies-if-inherits-from=forms.Form \ - --ignore-bodies-if-inherits-from=forms.ModelForm \ - --ignore-bodies-if-inherits-from=View \ - --ignore-bodies-if-inherits-from=BaseCommand \ - --ignore-bodies-if-inherits-from=Migration \ - --ignore-bodies-if-inherits-from=AppConfig \ - --ignore-bodies-if-inherits-from=MiddlewareMixin \ + --ignore-definitions-if-decorated-with=@*admin.* \ + --ignore-definitions-if-decorated-with=@*register \ + --ignore-definitions-if-decorated-with=@*display \ + --ignore-definitions-if-decorated-with=@*action \ + --ignore-definitions-if-decorated-with=@*receiver \ + --ignore-definitions-if-decorated-with=@*register.* \ + --ignore-definitions-if-decorated-with=@*require_* \ + --ignore-definitions-if-decorated-with=@*login_required \ + --ignore-definitions-if-decorated-with=@*permission_required \ + --ignore-definitions-if-decorated-with=@*user_passes_test \ + --ignore-definitions-if-decorated-with=@*method_decorator \ + --ignore-definitions-if-decorated-with=@*api_view \ + --ignore-definitions-if-decorated-with=@*_classes \ + --ignore-definitions-if-decorated-with=@*schema \ + --ignore-definitions-if-inherits-from=Model \ + --ignore-definitions-if-inherits-from=ModelAdmin \ + --ignore-definitions-if-inherits-from=Form \ + --ignore-definitions-if-inherits-from=ModelForm \ + --ignore-definitions-if-inherits-from='*Inline' \ + --ignore-definitions-if-inherits-from=BaseCommand \ + --ignore-definitions-if-inherits-from=Migration \ + --ignore-definitions-if-inherits-from=AppConfig \ + --ignore-definitions-if-inherits-from=MiddlewareMixin \ + --ignore-definitions-if-inherits-from='*View' \ + --ignore-definitions-if-inherits-from='*ViewSet' \ + --ignore-definitions-if-inherits-from='*Serializer' \ + --ignore-definitions-if-inherits-from='*Permission' \ + --ignore-definitions-if-inherits-from='*Authentication' \ + --ignore-definitions-if-inherits-from='*Pagination' \ + --ignore-definitions-if-inherits-from='*FilterBackend' \ + --ignore-definitions-if-inherits-from='*Throttle' \ + --ignore-names='*Middleware' \ + --ignore-names='*middleware' \ --ignore-names-in-files='*/settings.py' \ - --ignore-names-in-files='*/urls.py' \ - --ignore-names-in-files='*/migrations/*' \ - --ignore-definitions='*Config' \ - --ignore-definitions='*Middleware' \ - --ignore-definitions=Command + --ignore-names-in-files='*/urls.py' ``` -deadcode reads configuration only from `./pyproject.toml` in the current working directory. It does not search parent directories and there is no -`--config` option, so run the command from the project root or pass the options on the command line. +A list flag accepts space-separated values, repeated flags, and one comma-separated value. The comma form is shorter to type: + +```shell +deadcode . \ + --ignore-definitions-if-decorated-with='@*admin.*,@*register,@*display,@*action,@*receiver,@*register.*,@*require_*,@*login_required,@*permission_required,@*user_passes_test,@*method_decorator,@*api_view,@*_classes,@*schema' \ + --ignore-definitions-if-inherits-from='Model,ModelAdmin,Form,ModelForm,*Inline,BaseCommand,Migration,AppConfig,MiddlewareMixin,*View,*ViewSet,*Serializer,*Permission,*Authentication,*Pagination,*FilterBackend,*Throttle' \ + --ignore-names='*Middleware,*middleware' \ + --ignore-names-in-files='*/settings.py,*/urls.py' +``` -`ignore-names-in-files` matches the reported path with `fnmatch`, where `*` also spans directory separators. The patterns therefore match both -relative (`project/settings.py`) and absolute (`/home/me/project/settings.py`) paths. See {doc}`usage` for the full option semantics and -{doc}`rules` for the rule codes. +The comma is the only separator. `|` is not a separator. A TOML array is not comma-split either: write one quoted value per line, because +`["Model,Form"]` is the single literal pattern `Model,Form`. deadcode reads `./pyproject.toml` in the current working directory; it does not search parent +directories and there is no `--config` option. See {doc}`usage` for the full option semantics and {doc}`rules` for the rule codes. ## Common use cases -| Django mechanism | Configuration needed | -|------------------|----------------------| -| Settings variables such as `SECRET_KEY`, `DEBUG`, `INSTALLED_APPS`, `MIDDLEWARE`, and `TEMPLATES` | Covered by `ignore-names-in-files = ["*/settings.py"]` | -| URLconf variables such as `app_name`, `urlpatterns`, and route name constants | Covered by `ignore-names-in-files = ["*/urls.py"]` | -| Migration modules, `Migration` attributes, and `operations` | Covered by `ignore-names-in-files = ["*/migrations/*"]` and `Migration` in `ignore-bodies-if-inherits-from` | -| Model fields and nested `Meta` options | Covered by `models.Model` in `ignore-bodies-if-inherits-from` | -| Model properties and methods called by templates or the framework | Covered by `models.Model` in `ignore-bodies-if-inherits-from` | -| Managers and `QuerySet` methods | Add the manager or queryset base class to `ignore-bodies-if-inherits-from`, or use `# noqa` | -| Admin options and action methods | Covered by `ModelAdmin` in `ignore-bodies-if-inherits-from` | -| `@admin.register` and other admin decorators | Covered by `@admin.*` in `ignore-names-if-decorated-with` | -| Forms and `ModelForm` fields and validation methods | Covered by `forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` | -| Class-based views and HTTP-method handlers | Covered by `View` in `ignore-bodies-if-inherits-from` | -| Middleware classes and hooks | Covered by `*Middleware` in `ignore-definitions` and `MiddlewareMixin` in `ignore-bodies-if-inherits-from` | -| `@receiver` signal handlers | Covered by `@receiver` in `ignore-names-if-decorated-with` | -| `@register.simple_tag` and `@register.filter` | Covered by `@register.*` in `ignore-names-if-decorated-with` | -| Management commands | Covered by `Command` in `ignore-definitions` and `BaseCommand` in `ignore-bodies-if-inherits-from` | -| `AppConfig` subclasses | Covered by `*Config` in `ignore-definitions` and `AppConfig` in `ignore-bodies-if-inherits-from` | -| Django test lifecycle names such as `setUpTestData` and `_pre_setup` | `None` in the base block; ignored by default in test files | -| Context processors and other string-referenced callables | `None` in the base block; use `# noqa: DC02` or add the name to `ignore-names` | -| `@login_required` and the other security decorators | Covered by the decorator entries in `ignore-names-if-decorated-with` | - -A context processor is named only as a string in the `TEMPLATES` setting, so deadcode cannot see the load. Mark the definition directly: +| Django part | Configuration entry | +|-------------|---------------------| +| Models and migrations | `Model` and `Migration` in `ignore-definitions-if-inherits-from`, plus `*/settings.py` and `*/urls.py` in `ignore-names-in-files` | +| URLs and settings | `ignore-names-in-files = ["*/settings.py", "*/urls.py"]` | +| Function and class-based views | `@*require_*`, `@*login_required`, `@*permission_required`, `@*user_passes_test`, and `@*method_decorator`; `*View` for every generic view | +| Middleware | `ignore-names = ["*Middleware", "*middleware"]` and `MiddlewareMixin` in the inheritance list | +| Templates and template tags | `@*register` and `@*register.*` | +| Forms and model forms | `Form` and `ModelForm` in the inheritance list | +| Admin classes and actions | `@*admin.*`, `@*register`, `@*display`, and `ModelAdmin` in the inheritance list | +| App configurations | `AppConfig` in the inheritance list | +| Management commands | `BaseCommand` in the inheritance list | +| Signals | `@*receiver` | +| Tests | No entry; the Django test lifecycle names are ignored by default in test files | +| Django REST Framework | `*ViewSet`, `*Serializer`, `*Permission`, `*Authentication`, `*Pagination`, `*FilterBackend`, `*Throttle`, `@*api_view`, `@*action`, `@*_classes`, and `@*schema` | +| Auth, security, i18n, caching, GIS, and the remaining core tools | The same four keys; their ordinary function and class references need no extra entry | + +## Versions + +The example targets Django 5.2 LTS, latest patch release 5.2.19, and Django REST Framework 3.18.3. Django 5.2 is the newest LTS release that runs on +the project's minimum Python 3.10 and is supported until April 2028; Django REST Framework 3.18.3 requires Django 5.2 or newer. The example uses APIs that +are present in Django 5.2 through 6.1, so a project on any of those releases can copy the configuration. Django 6.1 requires Python 3.12 or newer, while +deadcode itself supports Python 3.10 and later, which is why the example pins the LTS line. + +The fixture is parsed as text and never executed. Django and Django REST Framework are not dependencies of deadcode, and the tests do not import either +framework. The version statement describes the APIs in the snippets, not a runtime requirement of the analyser. + +## Model layer + +Models are the largest source of Django false positives. A field and a nested `Meta` option are class-level assignments with no Python name load, and +Django calls model methods and properties at runtime. `Model` in `ignore-definitions-if-inherits-from` removes the class name and every finding in the +body in one entry. ```python -def context_processor(request): # noqa: DC02 - return {'site_name': 'Example'} +from django.db import models + +from .managers import ArticleManager + + +class Category(models.Model): + name = models.CharField(max_length=100) + + class Meta: + verbose_name_plural = 'categories' + + def __str__(self): + return self.name + + +class Article(models.Model): + title = models.CharField(max_length=100) + category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='articles') + published = models.BooleanField(default=False) + + objects = ArticleManager() + + class Meta: + ordering = ['title'] + + @property + def display_name(self): + return self.title + + +class UnusedModel(models.Model): + value = models.IntegerField() ``` -## Model layer +Without configuration the nested `Meta` classes are `DC03`, `verbose_name_plural`, `ordering`, and `category` are `DC06`, and `display_name` and +`published_titles` are `DC08`. With the recommended configuration none of those appear. The same `Model` entry also suppresses the `UnusedModel` class +name, which is a deliberate trade-off: the definition option hides a genuinely unused model as well as the framework fields. Use +`ignore-bodies-if-inherits-from = ["Model"]` instead when unused model names must stay visible, or mark +the definition with `# noqa: DC03`. -Models are the largest source of Django false positives. A model field or nested `Meta` option is a class-level assignment with no Python -name load, and Django calls model methods and properties at runtime. `models.Model` in `ignore-bodies-if-inherits-from` removes all findings -from the body of a model class while the class name itself is still reported when nothing uses it. - -| Mechanism | Finding without configuration | Remedy | -|-----------|-------------------------------|--------| -| Model fields such as `CharField`, `ForeignKey`, and `BooleanField` | `DC06` for each field name | `models.Model` in `ignore-bodies-if-inherits-from` | -| Nested `class Meta` and its options such as `ordering` and `verbose_name_plural` | `DC03` for `Meta`, `DC06` for the options | `models.Model` in `ignore-bodies-if-inherits-from` | -| Model methods such as `__str__`, and `@property` methods called by templates | `DC04` for methods, `DC08` for properties | `models.Model` in `ignore-bodies-if-inherits-from` | -| Relations and related managers such as `category.articles` | `DC06` when the relation attribute is assigned only | `models.Model` in `ignore-bodies-if-inherits-from` | -| Custom managers and `QuerySet` methods | `DC04` for methods that only other code calls | Add the manager or queryset base class to `ignore-bodies-if-inherits-from`, or mark each method with `# noqa` | -| Migration attributes and operations | `DC06` for `initial`, `dependencies`, and `operations` | `Migration` in `ignore-bodies-if-inherits-from` | -| Migration module helpers and imports | `DC01`, `DC06`, `DC07`, or `DC12` depending on the code | `ignore-names-in-files = ["*/migrations/*"]` | - -A custom manager or `QuerySet` is not covered by `models.Model`. Add its base class to `ignore-bodies-if-inherits-from` when its methods are called -only by other code, or mark the individual method with `# noqa: DC04`. The same rule applies to a model mixin that is not itself a `models.Model` -subclass. - -Migrations need both options. `Migration` in `ignore-bodies-if-inherits-from` removes the body attributes, and the file pattern for -`*/migrations/*` removes module-level findings and keeps generated migration code out of the report. Raw SQL through `connection.cursor()`, -transactions through `atomic()` and `transaction.on_commit()`, and aggregation through `annotate()` and `aggregate()` are ordinary function and -method calls, so they need no additional configuration beyond the base block. +### Both import forms -## View layer +An inheritance expression matches the dotted base and its final component. Research with the analyser shows that `models.Model` contributes both the +dotted name and `Model`, so the plain `Model` entry covers both forms without a second, overlapping entry: + +```python +from django.db import models +from django.db.models import Model + + +class QualifiedModel(models.Model): + title = models.CharField(max_length=100) + + +class ImportedModel(Model): + title = models.CharField(max_length=100) +``` + +An alias is not resolved. `import django.db.models as m` followed by `class Article(m.Model)` and +`from django.db.models import Model as M` followed by `class Article(M)` do not match the `Model` entry. Use the unaliased import or add a pattern for the +alias. + +Custom managers and `QuerySet` subclasses are not `Model` subclasses. `ArticleManager` and `ArticleQuerySet` are ordinary classes; a method that only +other code calls is still reported. Add the manager base to the inheritance list when its whole body should be ignored, or mark the individual method with +`# noqa: DC04`. Migrations are covered by `Migration` in the inheritance list, so `initial`, `dependencies`, and `operations` disappear without a +separate `*/migrations/*` file pattern. + +## URLs and settings + +Settings and URLconf modules are configuration data. `SECRET_KEY`, `DEBUG`, `INSTALLED_APPS`, `MIDDLEWARE`, `TEMPLATES`, `DATABASES`, +`ROOT_URLCONF`, `app_name`, `urlpatterns`, and route name constants are module-level names that nothing in the project loads. The file patterns remove +them: + +```python +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views +from .api import ArticleViewSet + +app_name = 'django_example' + +router = DefaultRouter() +router.register(r'articles', ArticleViewSet, basename='article') + +urlpatterns = [ + path('', views.index, name='index'), + path('articles/', views.article_list, name='article-list'), + path('api/', include(router.urls)), +] + +UNUSED_URL_NAME = 'unused' +``` + +`ignore-names-in-files` matches the reported path with `fnmatch`, where `*` also spans directory separators. `*/settings.py` and `*/urls.py` therefore +match relative paths such as `project/settings.py` and absolute paths such as `/home/me/project/settings.py`. A project that splits settings into +`settings/base.py` needs a broader pattern such as `*/settings/*.py`; a URL module with another name needs its own entry. + +`ROOT_URLCONF`, `WSGI_APPLICATION`, and `ASGI_APPLICATION` are strings, so the modules they name are not loaded by the analyser. A custom WSGI or ASGI +module that assigns `application` needs the file pattern or `# noqa: DC01`. Context processors named in the `TEMPLATES` setting need the same treatment. -A URLconf names views as attributes and strings, so deadcode sees only part of the wiring. `urls.py` uses `path()`, `re_path()`, and `include()`, and -the module-level `urlpatterns` list is assigned without a name load. `ignore-names-in-files = ["*/urls.py"]` removes the URLconf variables; a project -that keeps URLs outside a file named `urls.py` needs its own pattern. Views referenced through `include()` or a dotted string keep their class and -function definitions reachable only by name, so a view class that nothing imports is reported as `DC03`. +## View layer -| Mechanism | Finding without configuration | Remedy | -|-----------|-------------------------------|--------| -| URLconf variables such as `app_name`, `urlpatterns`, and route name constants | `DC01` for each variable | `ignore-names-in-files = ["*/urls.py"]` | -| Function views and decorators such as `@login_required` and `@require_POST` | `DC02` for the decorated function, `DC04` when only the framework calls it | `@login_required`, `@permission_required`, `@require_http_methods`, `@require_POST`, and `@method_decorator` in `ignore-names-if-decorated-with` | -| Class-based views referenced only by a string or `include()` | `DC03` for the class | A name load, or `# noqa: DC03` when the class is genuinely used | -| HTTP-method handlers such as `get`, `post`, and `dispatch` | `DC04` for each handler | `View` in `ignore-bodies-if-inherits-from` | -| Middleware classes named only in `MIDDLEWARE` | `DC03` for the class, `DC04` for hooks such as `process_request` | `*Middleware` in `ignore-definitions` and `MiddlewareMixin` in `ignore-bodies-if-inherits-from` | +A URLconf loads function views through attributes such as `views.article_list`, so those functions are not reported. A view that is registered only +through a string, a dynamic import, or code outside the analysed tree looks unused. Decorator wildcards cover the definition and its body: -Function views are ordinary functions. Deadcode follows their name loads, so `path('', views.index)` keeps `index` alive and a decorator that it does not -know would otherwise make the function look unused. The decorator entries in the recommended configuration cover the Django decorators that wrap a -view at definition time. +| Decorator family | Entry | Notes | +|------------------|-------|-------| +| `@require_GET`, `@require_POST`, `@require_safe`, `@require_http_methods` | `@*require_*` | Django has no `require_PUT`; use `@require_http_methods(['PUT'])` for the other methods | +| `@login_required`, `@permission_required`, `@user_passes_test` | `@*login_required`, `@*permission_required`, `@*user_passes_test` | The auth decorators in bare and qualified form | +| `@method_decorator` | `@*method_decorator` | Applies a decorator to a class-based view method | +| `@admin.register`, `@register`, `@register.simple_tag`, `@register.filter` | `@*admin.*`, `@*register`, `@*register.*` | The leading `@*` covers a dotted and a directly imported name | -Class-based views inherit their handlers from `View`, `TemplateView`, `DetailView`, `ListView`, `FormView`, or another generic base class. Django calls -`dispatch()` and then the matching `get()` or `post()` method by name, and no Python code loads them. `View` in `ignore-bodies-if-inherits-from` -removes those findings from every class-based view while the class name itself is still reported when nothing references it: +Class-based views are covered by inheritance. `*View` matches `View`, `TemplateView`, `RedirectView`, `DetailView`, `ListView`, `FormView`, `CreateView`, +`UpdateView`, `DeleteView`, the archive views, and DRF `APIView` descendants. `View` alone does not match `ListView` or `DetailView`, which is why the +family pattern is used: ```python +from django.http import HttpResponse from django.views import View -from django.views.generic import ListView +from django.views.decorators.http import require_GET, require_POST, require_http_methods +from django.views.generic import DetailView, ListView -from .models import Article +@require_GET +def article_list(request): + return HttpResponse('articles') -class ArticleView(View): + +@require_POST +def article_create(request): + return HttpResponse('created') + + +@require_http_methods(['PUT']) +def article_update(request): + return HttpResponse('updated') + + +class ArticleListView(ListView): def get(self, request): return HttpResponse('articles') -class ArticleListView(ListView): - model = Article - template_name = 'blog/article_list.html' +class ArticleDetailView(DetailView): + def get(self, request): + return HttpResponse('article') + + +class ArticleView(View): + def get(self, request): + return HttpResponse('article') ``` -`TemplateResponse`, `render()`, `redirect()`, and the request and response objects are ordinary calls and attributes, so they need no extra -configuration. File uploads through `request.FILES`, `FileField`, and a storage backend are likewise ordinary attribute loads; the model-field rule -covers the field and the storage class needs a name load or `# noqa` when it is configured only as a string. +Without configuration the handlers are `DC04`, `ArticleListView` and `ArticleDetailView` are `DC03`, and a decorated function that no URLconf loads is +`DC02`. With the configuration every one of those findings disappears. The `View`/`ListView`/`DetailView` class name is suppressed too, because the +definition option hides the whole definition. + +`TemplateResponse`, `render()`, `redirect()`, `get_object_or_404()`, and the request and response objects are ordinary calls and attributes, so they need +no extra configuration. File uploads through `request.FILES`, `FileField`, and a storage backend are ordinary attribute loads; a storage class named only +as a string needs a name load or `# noqa`. -The same rule applies to middleware. `MIDDLEWARE` lists classes as dotted strings, and Django calls `process_request`, `process_response`, and the other -hooks at runtime. `*Middleware` in `ignore-definitions` removes the class-name finding, and `MiddlewareMixin` in `ignore-bodies-if-inherits-from` removes -the hook and option findings: +## Middleware + +Django has two current middleware forms. The function factory receives `get_response` and returns a nested callable: + +```python +def simple_middleware(get_response): + def middleware(request): + return get_response(request) + + return middleware +``` + +The class form stores `get_response` in `__init__` and calls it in `__call__`: + +```python +class CurrentMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) +``` + +Both are current in Django 5.2 through 6.1. `__init__` and `__call__` are dunder methods, which the analyser ignores already. The two middleware +names have no framework base class, which is why `ignore-names = ["*Middleware", "*middleware"]` removes `simple_middleware` and +`CurrentMiddleware`. The function name itself is the settings string, so the name pattern is the only way to keep it while `MIDDLEWARE` names it as text. + +`MiddlewareMixin` from `django.utils.deprecation` is the pre-Django-1.10 compatibility form. Django still supports it and does not warn about it, so it +is not deprecated, but new middleware should use one of the two forms above. When an existing project uses it, the inheritance entry covers the +`process_request` and `process_response` hooks: ```python from django.utils.deprecation import MiddlewareMixin @@ -184,18 +347,16 @@ class SimpleMiddleware(MiddlewareMixin): return None ``` -A middleware class that does not inherit `MiddlewareMixin` needs its hooks covered by a base class in `ignore-bodies-if-inherits-from` or by -`# noqa: DC04`. A view or middleware helper that is genuinely dead is still reported when the configuration is applied to a different base class. +`MiddlewareMixin` in `ignore-definitions-if-inherits-from` removes the class name and `process_request` together. Without configuration the example +reports `simple_middleware`, `CurrentMiddleware`, `SimpleMiddleware`, and `process_request`; with the configuration none of them appear. ## Template layer -The template language is data, not Python: deadcode does not parse templates, so a context variable, a `{% url %}` reference, or a filter used only in a -`.html` file is invisible. The `TEMPLATES` setting in `settings.py` is covered by the settings file pattern, and the template backend is named as a -string. Built-in tags and filters such as `{% for %}`, `{% if %}`, `{{ value|date }}`, and `{{ value|default }}` need no configuration because -deadcode never sees the template. +The template language is data, not Python. deadcode does not parse templates, so a context variable, a `{% url %}` reference, or a filter used only in an +HTML file is invisible. Built-in tags such as `{% for %}`, `{% if %}`, and `{{ value|date }}` need no configuration because deadcode never sees them. +The `TEMPLATES` setting and the backend class are covered by the settings file pattern. -The template API in Python is ordinary code. `loader.get_template()`, `render_to_string()`, `Template`, `Context`, and `TemplateResponse` are normal -function and class calls. The interesting findings come from custom tags and filters, which are registered at import time: +Custom tags and filters are registered at import time and are covered by the `@*register` and `@*register.*` entries: ```python from django import template @@ -217,185 +378,267 @@ def unused_tag(value): return value ``` -`@register.simple_tag`, `@register.filter`, `@register.inclusion_tag`, and `@register.tag` are all covered by `@register.*` in -`ignore-names-if-decorated-with`, so `greeting` and `shout` are not reported. The undecorated `unused_tag` is genuinely unused and still reported as -`DC02`. A tag registered with a different library decorator needs its own pattern or a name load. +`@register.simple_tag`, `@register.filter`, `@register.inclusion_tag`, and `@register.tag` are all covered, so `greeting` and `shout` are not reported. +The undecorated `unused_tag` is genuinely unused and remains `DC02`. A tag registered through a differently named library variable needs its own pattern, +for example `@*library.*` for `library = template.Library()`. -A context processor is named only as a string in the `TEMPLATES` setting, so deadcode cannot see the call: +A context processor is named only as a string in the `TEMPLATES` setting. Mark its definition with `# noqa: DC02` or add its name to `ignore-names`; +a project-wide name entry is broader than one `# noqa` comment when several context processors are genuinely reachable. + +## Forms + +Django forms declare fields as class attributes, and a `ModelForm` reads its model and field list from a nested `Meta` class. Validation hooks are called +by name after the form is bound. `Form` and `ModelForm` in `ignore-definitions-if-inherits-from` remove the class name, the nested `Meta`, the field +names, and the hooks in one entry: ```python -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'project.context_processors.site_name', - ], - }, - }, -] +from django import forms + +from .models import Article + + +class ArticleForm(forms.ModelForm): + class Meta: + model = Article + fields = ['title', 'category'] + + def clean_title(self): + return self.cleaned_data['title'].strip() + + +class UnusedForm(forms.Form): + name = forms.CharField(max_length=100) ``` -Mark the definition directly with `# noqa: DC02` or add its name to `ignore-names`. The `# noqa` form keeps the suppression next to the function, which -is clearer than a project-wide name entry when a project has several context processors: +Without configuration the example reports `Meta`, `model`, `fields`, and `clean_title`, and `UnusedForm` is `DC03`; with the configuration none of them +appear. The same `Model` and `Form` matching rules apply to the dotted and directly imported base, so `forms.Form` and a bare `Form` both match. + +Formsets are created with `formset_factory()`, and their `clean()` method is an ordinary method on the form. Custom validators are ordinary functions; +a validator named only as a string in a field definition needs a name load, `ignore-names`, or `# noqa`. + +## Admin + +The admin site discovers `ModelAdmin` subclasses when it imports a project's `admin` module. `@admin.register`, `@register`, and `@admin.display` are +covered by the decorator entries; the options and action methods are covered by `ModelAdmin` in the inheritance list: ```python -def site_name(request): # noqa: DC02 - return {'site_name': 'Example'} +from django.contrib import admin + +from .forms import ArticleForm +from .models import Article + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + form = ArticleForm + list_display = ('title', 'published') + search_fields = ('title',) + list_filter = ('published',) + actions = ('mark_featured',) + + def mark_featured(self, request, queryset): + queryset.update(featured=True) + + +class UnusedAdmin(admin.ModelAdmin): + list_display = ('title',) ``` -A template backend configured through a dotted path is a string reference too. The built-in Django backend needs no configuration because deadcode does -not analyse Django itself; a custom backend class in the project needs a name load or `# noqa`. +The `actions` tuple contains the action names as strings, so deadcode does not analyse `'mark_featured'`; the method is covered by the +`ModelAdmin` body rule instead. Without configuration the example reports `ArticleAdmin`, `form`, `list_display`, `search_fields`, `list_filter`, +`actions`, `mark_featured`, and `UnusedAdmin` plus its `list_display`; with the configuration none of them appear. Inline classes inherit +`InlineModelAdmin` rather than `ModelAdmin`, so `*Inline` covers `StackedInline` and `TabularInline` and their options. -## Forms +## App configs and management commands -Django forms declare fields as class attributes, and a `ModelForm` reads its model and field list from a nested `Meta` class. Deadcode reports each -field as `DC06`, the nested `Meta` class as `DC03`, and validation hooks such as `clean_title()` and `clean()` as `DC04` because Django calls them by -name after the form is bound. +`AppConfig` subclasses are discovered through the dotted string in `INSTALLED_APPS`. `BaseCommand` subclasses are imported by convention from a +`management/commands` package. `AppConfig` and `BaseCommand` in the inheritance list cover the class name, `default_auto_field`, `name`, `ready`, +`help`, `add_arguments()`, and `handle()` in one entry each: -| Mechanism | Finding without configuration | Remedy | -|-----------|-------------------------------|--------| -| Form fields and widgets such as `CharField` and `Select` | `DC06` for each field name | `forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` | -| Nested `class Meta` and its `model` and `fields` options | `DC03` for `Meta`, `DC06` for the options | `forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` | -| Validation hooks such as `clean_title()` and `clean()` | `DC04` for each method | `forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` | -| Formsets, `media` definitions, and custom validators | None when the code is called directly | Add a name load, or `# noqa` for a callable referenced only by a string | +```python +from django.apps import AppConfig -`forms.Form` and `forms.ModelForm` in `ignore-bodies-if-inherits-from` remove every finding from the form body, while the class name itself is still -reported as `DC03` when nothing references it. A form that inherits a project mixin instead of `forms.Form` needs that mixin's base class added to the -same list, or each validation method marked with `# noqa: DC04`. A form set is created with `formset_factory()`, and its `clean()` method is an ordinary -method on the form; neither needs extra configuration when the call site loads the class. Custom validators are ordinary functions, so a validator named -only as a string in a field definition needs a name load, `ignore-names`, or `# noqa`. -## Admin +class BlogConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'project' + + def ready(self): + from . import signals # noqa: F401, PLC0415 +``` + +```python +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'Import article data from a file.' + + def add_arguments(self, parser): + parser.add_argument('path') + + def handle(self, *args, **options: object): + pass +``` + +No `*Config`, `*Middleware`, or `Command` name pattern is needed. The inheritance entries already hide the class name and body, so the earlier set's +`ignore-definitions = ["*Config", "*Middleware", "Command"]` was redundant and has been removed. A helper command class with another base class is still +reported unless it has a name load or its own inheritance entry. + +`manage.py` and `django-admin` are ordinary scripts. The settings module and application entry points they name are strings, so deadcode never sees those +loads; the settings file pattern covers the settings module. Deployment modules that assign `application` need the file pattern or `# noqa: DC01`. + +## Signals + +Django signals have two registration forms. The decorator form calls `receiver` at import time; the direct form passes the handler to `connect()`: + +```python +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import Article + + +@receiver(post_save, sender=Article) +def article_saved(sender, instance, **kwargs: object): + pass + + +def article_saved_direct(sender, instance, **kwargs: object): + pass + + +post_save.connect(article_saved_direct, sender=Article) + + +def unused_signal_handler(sender, **kwargs: object): + pass +``` -The admin site discovers `ModelAdmin` subclasses when it imports a project's `admin` module. `@admin.register` registers the class and, because it is a -decorator, is covered by `@admin.*` in `ignore-names-if-decorated-with`. A class registered without the decorator is referenced only by the admin site -and is reported as `DC03` unless it has a name load. - -| Mechanism | Finding without configuration | Remedy | -|-----------|-------------------------------|--------| -| `@admin.register` and other admin decorators | `DC03` for the decorated class | `@admin.*` in `ignore-names-if-decorated-with` | -| Options such as `list_display`, `search_fields`, `list_filter`, and `actions` | `DC06` for each option | `ModelAdmin` in `ignore-bodies-if-inherits-from` | -| Admin action methods named in `actions` | `DC04` for each action method | `ModelAdmin` in `ignore-bodies-if-inherits-from` | -| Inline classes such as `StackedInline` and `TabularInline` | `DC03` for the class, `DC06` for its options | Add the inline base class to `ignore-bodies-if-inherits-from`, or use `# noqa` | - -The options and the action method are attributes of the `ModelAdmin` body, so `ModelAdmin` in `ignore-bodies-if-inherits-from` removes them together. The -`actions` tuple contains the action names as strings, such as `'mark_featured'`, and deadcode does not analyse string contents; the method itself -is covered by the base-class rule, not by a name load. An inline class inherits `InlineModelAdmin` rather than `ModelAdmin` in a typical project, so check -its base class and add the missing entry when its body is reported. The admin site's documentation generator is an ordinary command and needs no -configuration beyond the base block. - -## Development process - -A Django settings module is a sequence of module-level assignments that nothing in the project imports by name. The settings file pattern in the -recommended configuration removes those findings. Projects that split settings into a package, such as `settings/base.py` and `settings/production.py`, -need a broader pattern such as `*/settings/*.py`; a differently named environment module needs its own entry. `manage.py` and `django-admin` are ordinary -scripts, and the settings module and application entry points they name are strings, so deadcode never sees those loads. - -`AppConfig` subclasses are discovered through `INSTALLED_APPS`, which names them as dotted strings. `*Config` in `ignore-definitions` covers the class -name, and `AppConfig` in `ignore-bodies-if-inherits-from` covers `default_auto_field`, `name`, and the `ready()` hook. A project with a differently named -application configuration class needs an additional definition pattern or a name load. - -Custom management commands are classes named `Command` in a `management/commands` package. The `Command` entry in `ignore-definitions` covers the class -name, and `BaseCommand` in `ignore-bodies-if-inherits-from` covers `help`, `add_arguments()`, and `handle()`. `django-admin` imports the module and -constructs the command by convention, so there is no name load to follow. A helper command class with another name is still reported unless it has a name -load; the example application keeps one intentionally unused command to show that difference. - -Django's `TestCase` lifecycle methods are called by the framework, not by project code. deadcode ignores `setUpTestData`, `_pre_setup`, `_post_teardown`, -`_fixture_setup`, and `_fixture_teardown` by default in test files, so the recommended configuration needs no entry for them. A genuine helper that is -not a lifecycle name and is not called is still reported. - -Deployment modules have the same string-reference problem as settings. A WSGI or ASGI module assigns `application` without loading it, so add the module -pattern to `ignore-names-in-files` or mark the assignment with `# noqa: DC01`. Static files are collected through the `staticfiles` application and -settings, and error reporting is configured through `ADMINS`, `SERVER_EMAIL`, and logging settings; all of those are string or module assignments -covered by the settings pattern. A custom handler class named only as a string needs a name load, `# noqa`, or an entry in `ignore-names`. - -## Security - -Most Django security configuration is settings: `SECRET_KEY`, `ALLOWED_HOSTS`, `SECURE_*`, `CSRF_*`, `SESSION_COOKIE_*`, and the password hashers are -module-level assignments covered by the settings file pattern. CSRF tokens, clickjacking protection, and cryptographic signing are ordinary template -tags, middleware, and function calls such as `signing.dumps()` and `signing.loads()`, so they need no additional configuration. - -The security decorators on views and helpers are covered by `ignore-names-if-decorated-with`. The recommended entries include `@csrf_exempt`, -`@login_required`, `@permission_required`, `@require_http_methods`, `@require_POST`, and `@method_decorator`. An undecorated function that is genuinely -unused is still reported, and a custom security decorator needs its own pattern. - -## Internationalization and localization - -`gettext`, `gettext_lazy`, `ngettext`, and their aliases are ordinary function calls, so a wrapped literal and a translated name are normal name loads. -The `LANGUAGES`, `LOCALE_PATHS`, and `USE_I18N` settings are covered by the settings file pattern, and locale directories contain data files rather -than Python modules. Per-site and per-user time zone settings are also settings assignments; code that calls `timezone.localtime()` or -`activate()` needs no extra configuration beyond the base block. - -## Performance and optimization - -Caching through `cache.get()`, `cache.set()`, `cache_page`, and a configured cache backend is ordinary code and settings. `select_related()` and -`prefetch_related()` are ordinary queryset method calls, and `django-debug-toolbar`-style tooling is a third-party package that deadcode parses like -any other dependency. No special configuration is needed beyond the base block; a custom cache backend named only through a string setting needs a name -load, `ignore-names`, or `# noqa`. - -## Geographic framework - -GeoDjango models are still `models.Model` subclasses and spatial fields are still `Field` subclasses, so the model base rule applies and the class name -is reported only when nothing references it. `GeoManager` and `GeoQuerySet` follow the manager rule: add the base class to -`ignore-bodies-if-inherits-from` when their methods are called only by the framework. GDAL and GEOS are optional and are imported by Django at runtime; -deadcode never imports the project, so a missing native library does not affect the report. - -## Common web application tools - -| Tool | Configuration needed | -|------|----------------------| -| Authentication and passwords | `None`; ordinary views, models, and settings | -| Caching | `None` beyond the base block; string backend settings use the settings pattern | -| Logging | `None`; `LOGGING` is a settings assignment covered by the settings pattern | -| Email | `None`; message classes and settings are ordinary code | -| Syndication feeds | `None`; feed classes are ordinary classes and views | -| Pagination | `None`; `Paginator` and `Page` are ordinary classes | -| Messages | `None`; the messages framework is middleware and ordinary calls | -| Serialization | `None`; serializers are ordinary functions and classes | -| Sessions | `None`; session backends are configured through settings strings | -| Sitemaps | `None`; sitemap classes are ordinary classes | -| Static files | `None` beyond the base block; `STATICFILES_*` settings use the settings pattern | -| Data validation | `None`; validators are ordinary callables | - -## Other core functionality - -Conditional content processing through `{% if %}`, content types and generic relations through `ContentType` and `GenericForeignKey`, flatpages, -redirects, system checks, and the sites framework are ordinary models, functions, and settings. Their class-level attributes follow the model rule, -and their configuration strings follow the settings pattern. - -Signals have their own rule. A handler decorated with `@receiver` is covered by `@receiver` in `ignore-names-if-decorated-with`. A handler connected -with `post_save.connect(article_saved)` has an ordinary name load and is not reported. A handler that has no decorator and no call is reported as -`DC02` and needs an explicit `ignore-names` entry or `# noqa`; the example application keeps one such handler to show the intentional finding. Unicode -handling and the remaining utility helpers are ordinary Python code with no Django-specific configuration. +Without configuration the decorated `article_saved` is `DC02` because a decorator does not create a name load. The direct form needs no configuration: +`article_saved_direct` is an ordinary name load in the `post_save.connect(...)` call. The unbound `unused_signal_handler` is `DC02` and remains after the +configuration. `@*receiver` covers the decorated handler and its body, and because it starts with `@*` it matches both `@receiver` and +`@django.dispatch.receiver`. The database signals `pre_save`, `post_save`, `pre_delete`, and `post_delete` all use the same rule. + +## Tests + +Django's `TestCase` lifecycle methods are called by the framework, not by project code. deadcode ignores `setUpTestData`, `_pre_setup`, +`_post_teardown`, `_fixture_setup`, and `_fixture_teardown` by default in test files, so the recommended configuration needs no entry for them. A genuine +helper whose name is not a lifecycle name is still reported: + +```python +from django.test import TestCase + +from .models import Article + + +class ArticleTests(TestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.article = Article.objects.create(title='Example') + + def _pre_setup(self): + pass + + def test_article(self): + assert self.article.title == 'Example' + + def unused_test_helper(self): + return None +``` + +`setUpTestData`, `_pre_setup`, and the other lifecycle hooks produce no finding. `unused_test_helper` is `DC04` and remains after the configuration. +Django test helpers that the test calls itself, such as `assertContains`, `assertNumQueries`, `settings`, and `modify_settings`, are ordinary name loads and +need no configuration. `pytest-django` fixtures and `unittest` lifecycle methods follow the same rule as ordinary Python code. + +## Django REST Framework + +Django REST Framework needs the class families and the policy decorators. `*ViewSet` covers `ViewSet`, `GenericViewSet`, `ModelViewSet`, and +`ReadOnlyModelViewSet`; `*Serializer` covers `Serializer`, `ModelSerializer`, and `HyperlinkedModelSerializer`; `*Permission`, `*Authentication`, +`*Pagination`, `*FilterBackend`, and `*Throttle` cover the extension classes. `@*api_view`, `@*action`, `@*_classes` (policy decorators such as +`@permission_classes` and `@authentication_classes`), and `@*schema` cover the decorators: + +```python +from rest_framework import serializers, viewsets +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .models import Article + + +class ArticleSerializer(serializers.ModelSerializer): + class Meta: + model = Article + fields = ['id', 'title', 'published'] + + +class ArticleViewSet(viewsets.ModelViewSet): + queryset = Article.objects.all() + serializer_class = ArticleSerializer + + @action(detail=True, methods=['post']) + def publish(self, request, pk=None): + return Response({'status': 'published'}) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def article_stats(request): + return Response({'articles': Article.objects.count()}) + + +def unused_api_helper(request): + return Response({}) +``` + +Routers call `list`, `create`, `retrieve`, `update`, `partial_update`, and `destroy` by name, and no Python code loads those methods in a typical +project; `*ViewSet` removes the body findings. `serializer_class`, the nested serializer `Meta`, `model`, and `fields` disappear with `*Serializer` and +`*ViewSet`. `@*action` covers `publish`, `@*api_view` covers `article_stats`, and `@*_classes` covers `@permission_classes`. `unused_api_helper` is +genuinely unused and remains `DC02`. + +The `IsAuthenticated` class is loaded by the decorator argument, so permissions do not need a name entry. A custom permission, authentication, +pagination, filter backend, or throttle class is covered by the matching family pattern when its name ends with the family suffix; a differently named +base class needs its own inheritance entry. A serializer used only through a string or a dynamic lookup needs a name load or `# noqa`. + +## Remaining framework areas + +The same four keys cover the rest of Django. Settings for auth and security (`SECRET_KEY`, `ALLOWED_HOSTS`, `SECURE_*`, `CSRF_*`, `SESSION_COOKIE_*`), +i18n (`LANGUAGES`, `LOCALE_PATHS`, `USE_I18N`), caching (`CACHES`, `CACHE_*`), and GIS (`GDAL_LIBRARY_PATH`, `GEOS_LIBRARY_PATH`) are settings +assignments covered by `*/settings.py`. The auth decorators `@login_required`, `@permission_required`, and `@user_passes_test` are covered by their +entries. `gettext`, `gettext_lazy`, `ngettext`, `cache.get`, `select_related`, `prefetch_related`, `signing.dumps`, and `timezone.localtime` are +ordinary calls and name loads. + +GeoDjango models are still `Model` subclasses and spatial fields are still fields, so the model entry applies. A custom `GeoManager` or `GeoQuerySet` +follows the manager rule. A custom cache backend, storage class, template backend, or database engine named only through a string needs a name load, +`ignore-names`, or `# noqa`. Built-in Django classes and functions are never analysed because deadcode only reads the project's source tree. ## Example application -The acceptance fixture in `tests/files/django` is a small multi-module Django project. It uses `tests/files/django/settings.py`, -`tests/files/django/urls.py`, `tests/files/django/views.py`, `tests/files/django/middleware.py`, `tests/files/django/models.py`, -`tests/files/django/managers.py`, `tests/files/django/migrations/0001_initial.py`, `tests/files/django/templatetags/blog_tags.py`, -`tests/files/django/forms.py`, `tests/files/django/admin.py`, `tests/files/django/apps.py`, `tests/files/django/signals.py`, -`tests/files/django/management/commands/import_data.py`, and `tests/files/django/tests.py`. - -`settings.py` defines the usual module-level settings plus one intentionally unused setting, and it names the application configuration in -`INSTALLED_APPS`. `urls.py` defines `app_name`, `urlpatterns`, and one intentionally unused URLconf variable. `views.py` defines a function view loaded -by the URLconf, a class-based view loaded through `.as_view()`, one intentionally unused setting, one intentionally unused function view, and one -intentionally unused class-based view. `middleware.py` defines a `MiddlewareMixin` subclass referenced only by the `MIDDLEWARE` string. `models.py` -defines a `Category`/`Article` pair with fields, nested `Meta` classes, and properties, plus one intentionally unused model. `managers.py` defines a -custom `QuerySet` and manager with one intentionally unused manager method. `migrations/0001_initial.py` is a generated-style migration. -`templatetags/blog_tags.py` registers a simple tag and a filter and leaves one intentionally unused function. `forms.py` defines a `ModelForm` with a -nested `Meta` and a validation hook, plus one intentionally unused form. `admin.py` registers an `ArticleAdmin` with options and an action method, -plus one intentionally unused admin class. `apps.py` defines a `BlogConfig` with `default_auto_field` and a `ready()` hook, and `signals.py` defines a -`@receiver` handler plus one intentionally unused signal handler. `management/commands/import_data.py` defines a `Command` with `help`, -`add_arguments()`, and `handle()`, plus one intentionally unused command. `tests.py` defines a `TestCase` with Django's five lifecycle hooks, a real -test, and one intentionally unused helper. - -Without configuration, the settings and URLconf variables, the middleware and its hook, the class-based view handlers, the registered template tags, -the form fields and validation hooks, the admin options and action method, the application configuration, the management command members, and the -decorated signal handler are reported together with the intentionally unused code. The test lifecycle hooks are not reported, because deadcode ignores -them by default in test files: +The acceptance fixture in `tests/files/django` is a small multi-module Django application. It uses `settings.py`, `urls.py`, `views.py`, +`middleware.py`, `models.py`, `managers.py`, `migrations/0001_initial.py`, `templatetags/blog_tags.py`, `forms.py`, `import_forms.py`, +`admin.py`, `apps.py`, `signals.py`, `management/commands/import_data.py`, `tests.py`, and the DRF module `api.py`. + +`settings.py` defines the usual module-level settings, lists `rest_framework` and `project.apps.BlogConfig`, and names all three middleware forms in +`MIDDLEWARE`, plus one intentionally unused setting. `urls.py` defines `app_name`, `urlpatterns`, a DRF router registration, and one intentionally unused +URLconf variable. `views.py` defines function views, the three `@require_*` decorators, a decorated helper that no URLconf loads, `ListView`, +`DetailView`, and `View` subclasses, one intentionally unused setting, one intentionally unused function, and one intentionally unused class. `middleware.py` +contains the function factory, the current class form, and the `MiddlewareMixin` form. `models.py` defines a `Category`/`Article` pair with fields, nested +`Meta` classes, and properties, plus one intentionally unused model. `managers.py` defines a custom `QuerySet` and manager with one intentionally unused +manager method. `migrations/0001_initial.py` is a generated-style migration. `templatetags/blog_tags.py` registers a simple tag and a filter and leaves one +intentionally unused function. `forms.py` defines a `ModelForm` with a nested `Meta` and a validation hook, plus one intentionally unused form. +`import_forms.py` shows the dotted and directly imported model bases and the qualified and bare admin and receiver decorators. `admin.py` registers an +`ArticleAdmin` with options and an action method, plus one intentionally unused admin class. `apps.py` defines a `BlogConfig` with `default_auto_field` +and a `ready()` hook. `signals.py` defines a decorated handler, a directly connected handler, and one intentionally unused handler. +`management/commands/import_data.py` defines a `Command` with `help`, `add_arguments()`, and `handle()`, plus one intentionally unused command. +`tests.py` defines a `TestCase` with Django's five lifecycle hooks, a real test, and one intentionally unused helper. `api.py` defines a +`ModelSerializer`, a `ModelViewSet` with an `@action` method and the router-called methods, an `@api_view` function with `@permission_classes`, and one +intentionally unused helper. + +Without configuration, the settings and URLconf variables, the three middleware forms and the legacy hook, the class-based view handlers, the registered +template tags, the form fields and validation hooks, the admin options and action method, the application configuration, the management command members, +the decorated signal handlers, the import forms, and the DRF serializer, action, and function view are reported together with the intentionally unused +code. The test lifecycle hooks are not reported, because deadcode ignores them by default in test files: ```text project/admin.py:8:0: DC03 Class `ArticleAdmin` is never used @@ -407,6 +650,15 @@ project/admin.py:13:4: DC06 Name `actions` is never used project/admin.py:15:4: DC04 Method `mark_featured` is never used project/admin.py:19:0: DC03 Class `UnusedAdmin` is never used project/admin.py:20:4: DC06 Name `list_display` is never used +project/api.py:10:4: DC03 Class `Meta` is never used +project/api.py:11:8: DC06 Name `model` is never used +project/api.py:12:8: DC06 Name `fields` is never used +project/api.py:17:4: DC06 Name `serializer_class` is never used +project/api.py:20:4: DC04 Method `publish` is never used +project/api.py:29:4: DC04 Method `retrieve` is never used +project/api.py:35:4: DC04 Method `destroy` is never used +project/api.py:41:0: DC02 Function `article_stats` is never used +project/api.py:45:0: DC02 Function `unused_api_helper` is never used project/apps.py:4:0: DC03 Class `BlogConfig` is never used project/apps.py:5:4: DC06 Name `default_auto_field` is never used project/apps.py:8:4: DC04 Method `ready` is never used @@ -415,6 +667,11 @@ project/forms.py:8:8: DC06 Name `model` is never used project/forms.py:9:8: DC06 Name `fields` is never used project/forms.py:11:4: DC04 Method `clean_title` is never used project/forms.py:15:0: DC03 Class `UnusedForm` is never used +project/import_forms.py:14:0: DC03 Class `QualifiedModel` is never used +project/import_forms.py:18:0: DC03 Class `ImportedForm` is never used +project/import_forms.py:23:0: DC03 Class `QualifiedAdmin` is never used +project/import_forms.py:28:0: DC03 Class `BareAdmin` is never used +project/import_forms.py:33:0: DC02 Function `imported_receiver` is never used project/management/commands/import_data.py:4:0: DC03 Class `Command` is never used project/management/commands/import_data.py:5:4: DC06 Name `help` is never used project/management/commands/import_data.py:7:4: DC04 Method `add_arguments` is never used @@ -422,8 +679,10 @@ project/management/commands/import_data.py:10:4: DC04 Method `handle` is never u project/management/commands/import_data.py:14:0: DC03 Class `UnusedCommand` is never used project/management/commands/import_data.py:15:4: DC04 Method `handle` is never used project/managers.py:13:4: DC04 Method `unused_manager_method` is never used -project/middleware.py:4:0: DC03 Class `SimpleMiddleware` is never used -project/middleware.py:5:4: DC04 Method `process_request` is never used +project/middleware.py:4:0: DC02 Function `simple_middleware` is never used +project/middleware.py:11:0: DC03 Class `CurrentMiddleware` is never used +project/middleware.py:19:0: DC03 Class `SimpleMiddleware` is never used +project/middleware.py:20:4: DC04 Method `process_request` is never used project/migrations/0001_initial.py:5:4: DC06 Name `initial` is never used project/migrations/0001_initial.py:7:4: DC06 Name `dependencies` is never used project/migrations/0001_initial.py:9:4: DC06 Name `operations` is never used @@ -438,46 +697,50 @@ project/models.py:35:0: DC03 Class `UnusedModel` is never used project/settings.py:1:0: DC01 Variable `SECRET_KEY` is never used project/settings.py:2:0: DC01 Variable `DEBUG` is never used project/settings.py:4:0: DC01 Variable `INSTALLED_APPS` is never used -project/settings.py:10:0: DC01 Variable `MIDDLEWARE` is never used -project/settings.py:15:0: DC01 Variable `TEMPLATES` is never used -project/settings.py:27:0: DC01 Variable `DATABASES` is never used -project/settings.py:33:0: DC01 Variable `ROOT_URLCONF` is never used -project/settings.py:35:0: DC01 Variable `UNUSED_SETTING` is never used +project/settings.py:11:0: DC01 Variable `MIDDLEWARE` is never used +project/settings.py:18:0: DC01 Variable `TEMPLATES` is never used +project/settings.py:30:0: DC01 Variable `DATABASES` is never used +project/settings.py:36:0: DC01 Variable `ROOT_URLCONF` is never used +project/settings.py:38:0: DC01 Variable `UNUSED_SETTING` is never used project/signals.py:8:0: DC02 Function `article_saved` is never used -project/signals.py:12:0: DC02 Function `unused_signal_handler` is never used +project/signals.py:19:0: DC02 Function `unused_signal_handler` is never used project/templatetags/blog_tags.py:7:0: DC02 Function `greeting` is never used project/templatetags/blog_tags.py:12:0: DC02 Function `shout` is never used project/templatetags/blog_tags.py:16:0: DC02 Function `unused_tag` is never used project/tests.py:26:4: DC04 Method `unused_test_helper` is never used -project/urls.py:5:0: DC01 Variable `app_name` is never used -project/urls.py:7:0: DC01 Variable `urlpatterns` is never used -project/urls.py:12:0: DC01 Variable `UNUSED_URL_NAME` is never used -project/views.py:4:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used -project/views.py:11:0: DC02 Function `unused_view` is never used -project/views.py:16:4: DC04 Method `get` is never used -project/views.py:20:0: DC03 Class `UnusedView` is never used -project/views.py:21:4: DC04 Method `get` is never used +project/urls.py:7:0: DC01 Variable `app_name` is never used +project/urls.py:12:0: DC01 Variable `urlpatterns` is never used +project/urls.py:21:0: DC01 Variable `UNUSED_URL_NAME` is never used +project/views.py:6:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used +project/views.py:13:0: DC02 Function `unused_view` is never used +project/views.py:33:0: DC02 Function `unreferenced_require_view` is never used +project/views.py:37:0: DC03 Class `ArticleListView` is never used +project/views.py:38:4: DC04 Method `get` is never used +project/views.py:42:0: DC03 Class `ArticleDetailView` is never used +project/views.py:43:4: DC04 Method `get` is never used +project/views.py:48:4: DC04 Method `get` is never used +project/views.py:52:0: DC03 Class `UnusedView` is never used +project/views.py:53:4: DC04 Method `get` is never used ``` -With the recommended configuration, only the intentionally unused code in `views.py`, `models.py`, `managers.py`, `forms.py`, `admin.py`, -`templatetags/blog_tags.py`, `signals.py`, `management/commands/import_data.py`, and `tests.py` remains. The settings, URLconf, model-field, -nested-`Meta`, property, migration, middleware-hook, `@register.*`, form, admin, application-configuration, management-command, and receiver findings -disappear because the base-class, decorator, definition, and file options cover them. The final report is: +With the recommended configuration, only deliberately unused code remains. The settings and URLconf variables, the middleware names, class-based view +names and handlers, template tags, form and admin definitions, app configuration, management command, decorated signals, import forms, and DRF +definitions disappear. The final report is: ```text -project/admin.py:19:0: DC03 Class `UnusedAdmin` is never used -project/forms.py:15:0: DC03 Class `UnusedForm` is never used -project/management/commands/import_data.py:14:0: DC03 Class `UnusedCommand` is never used +project/api.py:45:0: DC02 Function `unused_api_helper` is never used project/managers.py:13:4: DC04 Method `unused_manager_method` is never used -project/models.py:35:0: DC03 Class `UnusedModel` is never used -project/signals.py:12:0: DC02 Function `unused_signal_handler` is never used +project/signals.py:19:0: DC02 Function `unused_signal_handler` is never used project/templatetags/blog_tags.py:16:0: DC02 Function `unused_tag` is never used project/tests.py:26:4: DC04 Method `unused_test_helper` is never used -project/views.py:4:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used -project/views.py:11:0: DC02 Function `unused_view` is never used -project/views.py:20:0: DC03 Class `UnusedView` is never used +project/views.py:6:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used +project/views.py:13:0: DC02 Function `unused_view` is never used ``` +The intentionally unused classes `UnusedModel`, `UnusedAdmin`, `UnusedForm`, `UnusedCommand`, and `UnusedView` are not in the second report. That is the +documented behaviour of the definition options: `ignore-definitions-if-inherits-from` hides a matching class name as well as its body. The remaining +seven findings are definitions that do not inherit from a framework base and are not decorated, so the four keys do not cover them. + ## Verifying the recommended configuration Copy the configuration into `pyproject.toml`, then run: @@ -486,18 +749,27 @@ Copy the configuration into `pyproject.toml`, then run: deadcode . --no-color ``` -The command exits with status `0` when there are no findings, `1` when findings remain, and `2` for argument errors. See {doc}`usage` for the exit -status details and the other command-line options. If a framework entry point is still reported, compare its decorator, base class, or file path -with the patterns above and add the missing pattern. +The command exits with status `1` while real dead code remains and `0` once the application has none. If a framework entry point is still reported, +compare its decorator, base class, or file path with the patterns above. Use the category order in this page to find the missing entry, add the +narrowest matching pattern, and run the command again. To reproduce the example, copy `tests/files/django` to a temporary `project/` directory, run +`deadcode project --no-color` without a `pyproject.toml`, then write the recommended block and run the same command again; the two reports must match the +blocks in this page byte for byte. ## Limitations -- deadcode does not detect Django automatically. Configuration is explicit and applies to any framework that uses decorators, base classes, and - file conventions. -- String references are invisible. Settings such as `MIDDLEWARE`, `TEMPLATES`, and `ROOT_URLCONF` name callables and modules as strings, so the - referenced code needs a name load, a file pattern, or `# noqa`. -- Base-class overrides are not resolved. A method that Django calls but that no Python code loads needs its base class in - `ignore-bodies-if-inherits-from` or an explicit `# noqa`. +- deadcode does not detect Django automatically. Configuration is explicit and applies to any framework that uses decorators, base classes, and file + conventions. +- Alias imports are not resolved. `import django.db.models as m`, `from django.db.models import Model as M`, and an aliased decorator such as + `from django.dispatch import receiver as r` do not match the unaliased patterns until the alias or a dedicated pattern is added. +- Definition options hide names. `ignore-definitions-if-inherits-from` and `ignore-definitions-if-decorated-with` suppress the class or function name + together with its body, so a genuinely unused Django subclass is not reported. Use the narrower `ignore-bodies-if-inherits-from` or + `ignore-names-if-decorated-with` when the names must remain visible, or mark the definition with `# noqa`. +- String references are invisible. `MIDDLEWARE`, `TEMPLATES`, `ROOT_URLCONF`, `INSTALLED_APPS`, context processors, storage backends, cache backends, + and WSGI or ASGI application paths are strings, so the referenced code needs a name load, a file pattern, or `# noqa`. - Dynamic registration through `getattr`, `importlib`, string paths, or code generation cannot be seen by a static analyser. -- A different decorator or base-class spelling needs its own pattern. For example, a custom admin decorator or an aliased `ModelAdmin` import does - not match the entries above until the pattern or the imported name is added. +- A different decorator or base-class spelling needs its own pattern. A custom admin decorator, a custom permission base, or a project-specific generic + view that does not end in `View`, `ViewSet`, or `Serializer` is not covered until the pattern or the name is added. +- The comma is the only command-line list separator. A pipe such as `@admin.*|@receiver|@register.*` is one literal pattern and matches nothing useful; + use separate flags or a comma-separated value instead. +- The recommended set intentionally stays small rather than complete. Add an entry only for the finding in front of you, and prefer the narrowest + decorator or base-class pattern that covers it. diff --git a/tests/documented_examples/test_django.py b/tests/documented_examples/test_django.py index 3f1632e..4e94b99 100644 --- a/tests/documented_examples/test_django.py +++ b/tests/documented_examples/test_django.py @@ -7,135 +7,162 @@ FIXTURE_DIRECTORY = Path(__file__).resolve().parents[1] / 'files' / 'django' -CONFIGURATION = """[tool.deadcode] -ignore-names-if-decorated-with = [ - "@admin.*", - "@receiver", - "@register.*", - "@login_required", - "@permission_required", - "@require_http_methods", - "@require_POST", - "@method_decorator", +DECORATOR_PATTERNS = [ + '@*admin.*', + '@*register', + '@*display', + '@*action', + '@*receiver', + '@*register.*', + '@*require_*', + '@*login_required', + '@*permission_required', + '@*user_passes_test', + '@*method_decorator', + '@*api_view', + '@*_classes', + '@*schema', ] -ignore-bodies-if-inherits-from = [ - "models.Model", - "ModelAdmin", - "forms.Form", - "forms.ModelForm", - "View", - "BaseCommand", - "Migration", - "AppConfig", - "MiddlewareMixin", + +INHERITANCE_PATTERNS = [ + 'Model', + 'ModelAdmin', + 'Form', + 'ModelForm', + '*Inline', + 'BaseCommand', + 'Migration', + 'AppConfig', + 'MiddlewareMixin', + '*View', + '*ViewSet', + '*Serializer', + '*Permission', + '*Authentication', + '*Pagination', + '*FilterBackend', + '*Throttle', ] -ignore-names-in-files = ["*/settings.py", "*/urls.py", "*/migrations/*"] -ignore-definitions = ["*Config", "*Middleware", "Command"] -""" + +NAME_PATTERNS = ['*Middleware', '*middleware'] + +FILE_PATTERNS = ['*/settings.py', '*/urls.py'] + +CONFIGURATION = ( + '[tool.deadcode]\n' + 'ignore-definitions-if-decorated-with = [\n' + + ''.join(f' "{pattern}",\n' for pattern in DECORATOR_PATTERNS) + + ']\n' + 'ignore-definitions-if-inherits-from = [\n' + + ''.join(f' "{pattern}",\n' for pattern in INHERITANCE_PATTERNS) + + ']\n' + 'ignore-names = ["*Middleware", "*middleware"]\n' + 'ignore-names-in-files = ["*/settings.py", "*/urls.py"]\n' +) COMMAND_LINE_OPTIONS = [ - '--ignore-names-if-decorated-with=@admin.*', - '--ignore-names-if-decorated-with=@receiver', - '--ignore-names-if-decorated-with=@register.*', - '--ignore-names-if-decorated-with=@login_required', - '--ignore-names-if-decorated-with=@permission_required', - '--ignore-names-if-decorated-with=@require_http_methods', - '--ignore-names-if-decorated-with=@require_POST', - '--ignore-names-if-decorated-with=@method_decorator', - '--ignore-bodies-if-inherits-from=models.Model', - '--ignore-bodies-if-inherits-from=ModelAdmin', - '--ignore-bodies-if-inherits-from=forms.Form', - '--ignore-bodies-if-inherits-from=forms.ModelForm', - '--ignore-bodies-if-inherits-from=View', - '--ignore-bodies-if-inherits-from=BaseCommand', - '--ignore-bodies-if-inherits-from=Migration', - '--ignore-bodies-if-inherits-from=AppConfig', - '--ignore-bodies-if-inherits-from=MiddlewareMixin', - '--ignore-names-in-files=*/settings.py', - '--ignore-names-in-files=*/urls.py', - '--ignore-names-in-files=*/migrations/*', - '--ignore-definitions=*Config', - '--ignore-definitions=*Middleware', - '--ignore-definitions=Command', + *(f'--ignore-definitions-if-decorated-with={pattern}' for pattern in DECORATOR_PATTERNS), + *(f'--ignore-definitions-if-inherits-from={pattern}' for pattern in INHERITANCE_PATTERNS), + *(f'--ignore-names={pattern}' for pattern in NAME_PATTERNS), + *(f'--ignore-names-in-files={pattern}' for pattern in FILE_PATTERNS), +] + +COMMA_SEPARATED_COMMAND_LINE_OPTIONS = [ + f'--ignore-definitions-if-decorated-with={",".join(DECORATOR_PATTERNS)}', + f'--ignore-definitions-if-inherits-from={",".join(INHERITANCE_PATTERNS)}', + f'--ignore-names={",".join(NAME_PATTERNS)}', + f'--ignore-names-in-files={",".join(FILE_PATTERNS)}', ] FINDING_PATTERN = re.compile(r'^(?P.+):\d+:\d+: (?PDC\d+) \w+ `(?P[^`]+)` is never used$') BASELINE_FINDINGS = { - ('project/settings.py', 'DC01', 'SECRET_KEY'), - ('project/settings.py', 'DC01', 'DEBUG'), - ('project/settings.py', 'DC01', 'INSTALLED_APPS'), - ('project/settings.py', 'DC01', 'MIDDLEWARE'), - ('project/settings.py', 'DC01', 'TEMPLATES'), - ('project/settings.py', 'DC01', 'DATABASES'), - ('project/settings.py', 'DC01', 'ROOT_URLCONF'), - ('project/settings.py', 'DC01', 'UNUSED_SETTING'), - ('project/urls.py', 'DC01', 'app_name'), - ('project/urls.py', 'DC01', 'urlpatterns'), - ('project/urls.py', 'DC01', 'UNUSED_URL_NAME'), + ('project/admin.py', 'DC03', 'ArticleAdmin'), + ('project/admin.py', 'DC06', 'form'), + ('project/admin.py', 'DC06', 'list_display'), + ('project/admin.py', 'DC06', 'search_fields'), + ('project/admin.py', 'DC06', 'list_filter'), + ('project/admin.py', 'DC06', 'actions'), + ('project/admin.py', 'DC04', 'mark_featured'), + ('project/admin.py', 'DC03', 'UnusedAdmin'), + ('project/api.py', 'DC03', 'Meta'), + ('project/api.py', 'DC06', 'model'), + ('project/api.py', 'DC06', 'fields'), + ('project/api.py', 'DC06', 'serializer_class'), + ('project/api.py', 'DC04', 'publish'), + ('project/api.py', 'DC04', 'retrieve'), + ('project/api.py', 'DC04', 'destroy'), + ('project/api.py', 'DC02', 'article_stats'), + ('project/api.py', 'DC02', 'unused_api_helper'), ('project/apps.py', 'DC03', 'BlogConfig'), ('project/apps.py', 'DC06', 'default_auto_field'), ('project/apps.py', 'DC04', 'ready'), + ('project/forms.py', 'DC03', 'Meta'), + ('project/forms.py', 'DC06', 'model'), + ('project/forms.py', 'DC06', 'fields'), + ('project/forms.py', 'DC04', 'clean_title'), + ('project/forms.py', 'DC03', 'UnusedForm'), + ('project/import_forms.py', 'DC03', 'QualifiedModel'), + ('project/import_forms.py', 'DC03', 'ImportedForm'), + ('project/import_forms.py', 'DC03', 'QualifiedAdmin'), + ('project/import_forms.py', 'DC03', 'BareAdmin'), + ('project/import_forms.py', 'DC02', 'imported_receiver'), ('project/management/commands/import_data.py', 'DC03', 'Command'), ('project/management/commands/import_data.py', 'DC06', 'help'), ('project/management/commands/import_data.py', 'DC04', 'add_arguments'), ('project/management/commands/import_data.py', 'DC04', 'handle'), ('project/management/commands/import_data.py', 'DC03', 'UnusedCommand'), - ('project/management/commands/import_data.py', 'DC04', 'handle'), - ('project/signals.py', 'DC02', 'article_saved'), - ('project/signals.py', 'DC02', 'unused_signal_handler'), - ('project/tests.py', 'DC04', 'unused_test_helper'), - ('project/admin.py', 'DC03', 'ArticleAdmin'), - ('project/admin.py', 'DC03', 'UnusedAdmin'), - ('project/admin.py', 'DC04', 'mark_featured'), - ('project/admin.py', 'DC06', 'actions'), - ('project/admin.py', 'DC06', 'form'), - ('project/admin.py', 'DC06', 'list_display'), - ('project/admin.py', 'DC06', 'list_filter'), - ('project/admin.py', 'DC06', 'search_fields'), - ('project/forms.py', 'DC03', 'Meta'), - ('project/forms.py', 'DC03', 'UnusedForm'), - ('project/forms.py', 'DC04', 'clean_title'), - ('project/forms.py', 'DC06', 'fields'), - ('project/forms.py', 'DC06', 'model'), - ('project/views.py', 'DC01', 'UNUSED_VIEW_SETTING'), - ('project/views.py', 'DC02', 'unused_view'), - ('project/views.py', 'DC03', 'UnusedView'), - ('project/views.py', 'DC04', 'get'), + ('project/managers.py', 'DC04', 'unused_manager_method'), + ('project/middleware.py', 'DC02', 'simple_middleware'), + ('project/middleware.py', 'DC03', 'CurrentMiddleware'), ('project/middleware.py', 'DC03', 'SimpleMiddleware'), ('project/middleware.py', 'DC04', 'process_request'), - ('project/templatetags/blog_tags.py', 'DC02', 'greeting'), - ('project/templatetags/blog_tags.py', 'DC02', 'shout'), - ('project/templatetags/blog_tags.py', 'DC02', 'unused_tag'), - ('project/managers.py', 'DC04', 'unused_manager_method'), + ('project/migrations/0001_initial.py', 'DC06', 'initial'), + ('project/migrations/0001_initial.py', 'DC06', 'dependencies'), + ('project/migrations/0001_initial.py', 'DC06', 'operations'), ('project/models.py', 'DC03', 'Meta'), - ('project/models.py', 'DC03', 'UnusedModel'), + ('project/models.py', 'DC06', 'verbose_name_plural'), ('project/models.py', 'DC06', 'category'), ('project/models.py', 'DC06', 'ordering'), - ('project/models.py', 'DC06', 'verbose_name_plural'), ('project/models.py', 'DC08', 'display_name'), ('project/models.py', 'DC08', 'published_titles'), - ('project/migrations/0001_initial.py', 'DC06', 'dependencies'), - ('project/migrations/0001_initial.py', 'DC06', 'initial'), - ('project/migrations/0001_initial.py', 'DC06', 'operations'), -} - -CONFIGURED_FINDINGS = { - ('project/admin.py', 'DC03', 'UnusedAdmin'), - ('project/forms.py', 'DC03', 'UnusedForm'), + ('project/models.py', 'DC03', 'UnusedModel'), + ('project/settings.py', 'DC01', 'SECRET_KEY'), + ('project/settings.py', 'DC01', 'DEBUG'), + ('project/settings.py', 'DC01', 'INSTALLED_APPS'), + ('project/settings.py', 'DC01', 'MIDDLEWARE'), + ('project/settings.py', 'DC01', 'TEMPLATES'), + ('project/settings.py', 'DC01', 'DATABASES'), + ('project/settings.py', 'DC01', 'ROOT_URLCONF'), + ('project/settings.py', 'DC01', 'UNUSED_SETTING'), + ('project/signals.py', 'DC02', 'article_saved'), + ('project/signals.py', 'DC02', 'unused_signal_handler'), + ('project/templatetags/blog_tags.py', 'DC02', 'greeting'), + ('project/templatetags/blog_tags.py', 'DC02', 'shout'), + ('project/templatetags/blog_tags.py', 'DC02', 'unused_tag'), + ('project/tests.py', 'DC04', 'unused_test_helper'), + ('project/urls.py', 'DC01', 'app_name'), + ('project/urls.py', 'DC01', 'urlpatterns'), + ('project/urls.py', 'DC01', 'UNUSED_URL_NAME'), ('project/views.py', 'DC01', 'UNUSED_VIEW_SETTING'), ('project/views.py', 'DC02', 'unused_view'), + ('project/views.py', 'DC02', 'unreferenced_require_view'), + ('project/views.py', 'DC03', 'ArticleListView'), + ('project/views.py', 'DC04', 'get'), + ('project/views.py', 'DC03', 'ArticleDetailView'), ('project/views.py', 'DC03', 'UnusedView'), - ('project/templatetags/blog_tags.py', 'DC02', 'unused_tag'), - ('project/models.py', 'DC03', 'UnusedModel'), +} + +CONFIGURED_FINDINGS = { + ('project/api.py', 'DC02', 'unused_api_helper'), ('project/managers.py', 'DC04', 'unused_manager_method'), - ('project/management/commands/import_data.py', 'DC03', 'UnusedCommand'), ('project/signals.py', 'DC02', 'unused_signal_handler'), + ('project/templatetags/blog_tags.py', 'DC02', 'unused_tag'), ('project/tests.py', 'DC04', 'unused_test_helper'), + ('project/views.py', 'DC01', 'UNUSED_VIEW_SETTING'), + ('project/views.py', 'DC02', 'unused_view'), } -MODEL_LAYER_FINDINGS = CONFIGURED_FINDINGS - def _copy_fixture(tmp_path: Path) -> None: shutil.copytree(FIXTURE_DIRECTORY, tmp_path / 'project') @@ -156,6 +183,13 @@ def _assert_no_parse_warning(output: str | None) -> None: assert 'could not parse' not in output +def _configured_findings(tmp_path: Path) -> set[tuple[str, str, str]]: + (tmp_path / 'pyproject.toml').write_text(CONFIGURATION) + output = main(['project', '--no-color']) + _assert_no_parse_warning(output) + return _findings(output) + + def test_django_fixture_without_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) @@ -172,14 +206,12 @@ def test_django_fixture_without_configuration(project_directory: Path) -> None: def test_django_fixture_with_documented_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) - (project_directory / 'pyproject.toml').write_text(CONFIGURATION) # When - output = main(['project', '--no-color']) + findings = _configured_findings(project_directory) # Then - _assert_no_parse_warning(output) - assert _findings(output) == CONFIGURED_FINDINGS + assert findings == CONFIGURED_FINDINGS assert print_main(['project', '--no-color']) == 1 @@ -196,10 +228,21 @@ def test_django_fixture_with_documented_command_line_options(project_directory: assert print_main(['project', '--no-color', *COMMAND_LINE_OPTIONS]) == 1 -def test_model_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: +def test_django_fixture_with_comma_separated_command_line_options(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color', *COMMA_SEPARATED_COMMAND_LINE_OPTIONS]) + + # Then + _assert_no_parse_warning(output) + assert _findings(output) == CONFIGURED_FINDINGS + + +def test_middleware_forms_without_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) - (project_directory / 'pyproject.toml').write_text(CONFIGURATION) # When output = main(['project', '--no-color']) @@ -207,16 +250,26 @@ def test_model_layer_false_positives_with_documented_configuration(project_direc # Then _assert_no_parse_warning(output) findings = _findings(output) - assert findings == MODEL_LAYER_FINDINGS - assert ('project/models.py', 'DC03', 'UnusedModel') in findings - assert ('project/managers.py', 'DC04', 'unused_manager_method') in findings - assert not any(path == 'project/models.py' and code == 'DC06' for path, code, _ in findings) - assert not any(name == 'Meta' for _, _, name in findings) - assert not any(name == 'display_name' for _, _, name in findings) - assert not any(path.startswith('project/migrations/') for path, _, _ in findings) + assert { + ('project/middleware.py', 'DC02', 'simple_middleware'), + ('project/middleware.py', 'DC03', 'CurrentMiddleware'), + ('project/middleware.py', 'DC03', 'SimpleMiddleware'), + ('project/middleware.py', 'DC04', 'process_request'), + } <= findings -def test_model_layer_false_positives_without_configuration(project_directory: Path) -> None: +def test_middleware_forms_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then + assert not any(path == 'project/middleware.py' for path, _, _ in findings) + + +def test_class_based_view_families_without_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) @@ -226,16 +279,33 @@ def test_model_layer_false_positives_without_configuration(project_directory: Pa # Then _assert_no_parse_warning(output) findings = _findings(output) - assert any(name == 'Meta' for _, _, name in findings) - assert any(name == 'category' for _, _, name in findings) - assert any(name == 'display_name' for _, _, name in findings) - assert any(path.startswith('project/migrations/') for path, _, _ in findings) + assert { + ('project/views.py', 'DC03', 'ArticleListView'), + ('project/views.py', 'DC03', 'ArticleDetailView'), + ('project/views.py', 'DC03', 'UnusedView'), + ('project/views.py', 'DC04', 'get'), + } <= findings + assert any(path == 'project/views.py' and code == 'DC04' and name == 'get' for path, code, name in findings) + +def test_class_based_view_families_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) -def test_view_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Then + view_findings = {finding for finding in findings if finding[0] == 'project/views.py'} + assert view_findings == { + ('project/views.py', 'DC01', 'UNUSED_VIEW_SETTING'), + ('project/views.py', 'DC02', 'unused_view'), + } + + +def test_decorator_wildcards_without_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) - (project_directory / 'pyproject.toml').write_text(CONFIGURATION) # When output = main(['project', '--no-color']) @@ -243,21 +313,46 @@ def test_view_layer_false_positives_with_documented_configuration(project_direct # Then _assert_no_parse_warning(output) findings = _findings(output) + assert { + ('project/views.py', 'DC02', 'unreferenced_require_view'), + ('project/import_forms.py', 'DC02', 'imported_receiver'), + ('project/import_forms.py', 'DC03', 'QualifiedAdmin'), + ('project/import_forms.py', 'DC03', 'BareAdmin'), + ('project/api.py', 'DC02', 'article_stats'), + ('project/api.py', 'DC04', 'publish'), + } <= findings + + +def test_decorator_wildcards_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then names = {name for _, _, name in findings} - assert 'ArticleListView' not in names - assert 'get' not in names - assert 'process_request' not in names - assert 'SimpleMiddleware' not in names - assert ('project/views.py', 'DC03', 'UnusedView') in findings - assert not any( - code == 'DC04' and path in {'project/views.py', 'project/middleware.py'} for path, code, _ in findings - ) + assert 'unreferenced_require_view' not in names + assert 'imported_receiver' not in names + assert 'QualifiedAdmin' not in names + assert 'BareAdmin' not in names + assert 'article_stats' not in names + assert 'publish' not in names + assert 'article_update' not in names -def test_template_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: +def test_require_put_uses_require_http_methods(project_directory: Path) -> None: + # Having + fixture_sources = {path.name: path.read_text() for path in FIXTURE_DIRECTORY.glob('*.py')} + + # Then + assert 'require_PUT' not in ''.join(fixture_sources.values()) + assert "@require_http_methods(['PUT'])" in fixture_sources['views.py'] + + +def test_import_forms_without_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) - (project_directory / 'pyproject.toml').write_text(CONFIGURATION) # When output = main(['project', '--no-color']) @@ -265,8 +360,132 @@ def test_template_layer_false_positives_with_documented_configuration(project_di # Then _assert_no_parse_warning(output) findings = _findings(output) - assert not any(name == 'greeting' for _, _, name in findings) - assert not any(name == 'shout' for _, _, name in findings) + assert { + ('project/import_forms.py', 'DC03', 'QualifiedModel'), + ('project/import_forms.py', 'DC03', 'ImportedForm'), + ('project/import_forms.py', 'DC03', 'QualifiedAdmin'), + ('project/import_forms.py', 'DC03', 'BareAdmin'), + ('project/import_forms.py', 'DC02', 'imported_receiver'), + } <= findings + assert ('project/import_forms.py', 'DC03', 'ImportedModel') not in findings + + +def test_import_forms_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then + assert not any(path == 'project/import_forms.py' for path, _, _ in findings) + + +def test_signal_forms_without_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + assert { + ('project/signals.py', 'DC02', 'article_saved'), + ('project/signals.py', 'DC02', 'unused_signal_handler'), + ('project/import_forms.py', 'DC02', 'imported_receiver'), + } <= findings + assert ('project/signals.py', 'DC02', 'article_saved_direct') not in findings + + +def test_signal_forms_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then + signal_findings = [finding for finding in findings if finding[0] == 'project/signals.py'] + assert signal_findings == [('project/signals.py', 'DC02', 'unused_signal_handler')] + + +def test_django_rest_framework_without_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + assert { + ('project/api.py', 'DC03', 'Meta'), + ('project/api.py', 'DC06', 'serializer_class'), + ('project/api.py', 'DC04', 'publish'), + ('project/api.py', 'DC02', 'article_stats'), + ('project/api.py', 'DC02', 'unused_api_helper'), + } <= findings + assert ('project/api.py', 'DC03', 'ArticleViewSet') not in findings + assert ('project/api.py', 'DC03', 'ArticleSerializer') not in findings + + +def test_django_rest_framework_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then + api_findings = [finding for finding in findings if finding[0] == 'project/api.py'] + assert api_findings == [('project/api.py', 'DC02', 'unused_api_helper')] + + +def test_model_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then + assert not any(path == 'project/models.py' for path, _, _ in findings) + assert not any(path == 'project/managers.py' and code != 'DC04' for path, code, _ in findings) + assert not any(name == 'Meta' for _, _, name in findings) + assert not any(name == 'display_name' for _, _, name in findings) + assert not any(path.startswith('project/migrations/') for path, _, _ in findings) + + +def test_model_layer_false_positives_without_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + assert any(name == 'Meta' for _, _, name in findings) + assert any(name == 'category' for _, _, name in findings) + assert any(name == 'display_name' for _, _, name in findings) + assert any(path.startswith('project/migrations/') for path, _, _ in findings) + + +def test_template_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then + names = {name for _, _, name in findings} + assert 'greeting' not in names + assert 'shout' not in names tag_findings = [finding for finding in findings if finding[0] == 'project/templatetags/blog_tags.py'] assert tag_findings == [('project/templatetags/blog_tags.py', 'DC02', 'unused_tag')] @@ -289,42 +508,34 @@ def test_view_and_template_layers_without_configuration(project_directory: Path) def test_forms_false_positives_with_documented_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) - (project_directory / 'pyproject.toml').write_text(CONFIGURATION) # When - output = main(['project', '--no-color']) + findings = _configured_findings(project_directory) # Then - _assert_no_parse_warning(output) - findings = _findings(output) names = {name for _, _, name in findings} assert 'Meta' not in names assert 'model' not in names assert 'fields' not in names assert 'clean_title' not in names - assert 'ArticleForm' not in names - assert ('project/forms.py', 'DC03', 'UnusedForm') in findings + assert not any(path == 'project/forms.py' for path, _, _ in findings) def test_admin_false_positives_with_documented_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) - (project_directory / 'pyproject.toml').write_text(CONFIGURATION) # When - output = main(['project', '--no-color']) + findings = _configured_findings(project_directory) # Then - _assert_no_parse_warning(output) - findings = _findings(output) names = {name for _, _, name in findings} assert 'list_display' not in names assert 'search_fields' not in names assert 'list_filter' not in names assert 'actions' not in names assert 'mark_featured' not in names - assert 'ArticleAdmin' not in names - assert ('project/admin.py', 'DC03', 'UnusedAdmin') in findings + assert not any(path == 'project/admin.py' for path, _, _ in findings) def test_forms_and_admin_without_configuration(project_directory: Path) -> None: @@ -347,15 +558,13 @@ def test_forms_and_admin_without_configuration(project_directory: Path) -> None: def test_development_process_false_positives_with_documented_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) - (project_directory / 'pyproject.toml').write_text(CONFIGURATION) # When - output = main(['project', '--no-color']) + findings = _configured_findings(project_directory) # Then - _assert_no_parse_warning(output) - findings = _findings(output) assert not any(path == 'project/apps.py' for path, _, _ in findings) + assert not any(path == 'project/management/commands/import_data.py' for path, _, _ in findings) assert not any(name in {'Command', 'help', 'add_arguments', 'handle', 'article_saved'} for _, _, name in findings) assert not any( path == 'project/tests.py' @@ -363,7 +572,6 @@ def test_development_process_false_positives_with_documented_configuration(proje for path, _, name in findings ) assert ('project/signals.py', 'DC02', 'unused_signal_handler') in findings - assert ('project/management/commands/import_data.py', 'DC03', 'UnusedCommand') in findings assert ('project/tests.py', 'DC04', 'unused_test_helper') in findings diff --git a/tests/files/django/api.py b/tests/files/django/api.py new file mode 100644 index 0000000..b8fc139 --- /dev/null +++ b/tests/files/django/api.py @@ -0,0 +1,46 @@ +from rest_framework import serializers, viewsets +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .models import Article + + +class ArticleSerializer(serializers.ModelSerializer): + class Meta: + model = Article + fields = ['id', 'title', 'published'] + + +class ArticleViewSet(viewsets.ModelViewSet): + queryset = Article.objects.all() + serializer_class = ArticleSerializer + + @action(detail=True, methods=['post']) + def publish(self, request, pk=None): + return Response({'status': 'published'}) + + def list(self, request): + return Response([]) + + def create(self, request): + return Response({}) + + def retrieve(self, request): + return Response({}) + + def update(self, request): + return Response({}) + + def destroy(self, request): + return Response({}) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def article_stats(request): + return Response({'articles': Article.objects.count()}) + + +def unused_api_helper(request): + return Response({}) diff --git a/tests/files/django/import_forms.py b/tests/files/django/import_forms.py new file mode 100644 index 0000000..2ef932d --- /dev/null +++ b/tests/files/django/import_forms.py @@ -0,0 +1,34 @@ +from django import forms +from django.contrib import admin +from django.contrib.admin import register +from django.db import models +from django.db.models import Model +from django.db.models.signals import post_save +from django.dispatch import receiver + + +class ImportedModel(Model): # noqa: DJ008 + title = models.CharField(max_length=100) + + +class QualifiedModel(models.Model): # noqa: DJ008 + title = models.CharField(max_length=100) + + +class ImportedForm(forms.Form): + title = forms.CharField(max_length=100) + + +@admin.register(ImportedModel) +class QualifiedAdmin(admin.ModelAdmin): + pass + + +@register(ImportedModel) +class BareAdmin(admin.ModelAdmin): + pass + + +@receiver(post_save) +def imported_receiver(sender, **kwargs: object): + pass diff --git a/tests/files/django/middleware.py b/tests/files/django/middleware.py index f815880..91e61c4 100644 --- a/tests/files/django/middleware.py +++ b/tests/files/django/middleware.py @@ -1,6 +1,21 @@ from django.utils.deprecation import MiddlewareMixin +def simple_middleware(get_response): + def middleware(request): + return get_response(request) + + return middleware + + +class CurrentMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) + + class SimpleMiddleware(MiddlewareMixin): def process_request(self, request): return None diff --git a/tests/files/django/settings.py b/tests/files/django/settings.py index c4540f0..1c7ce89 100644 --- a/tests/files/django/settings.py +++ b/tests/files/django/settings.py @@ -5,12 +5,15 @@ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', + 'rest_framework', 'project.apps.BlogConfig', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'project.middleware.SimpleMiddleware', + 'project.middleware.CurrentMiddleware', + 'project.middleware.simple_middleware', ] TEMPLATES = [ { diff --git a/tests/files/django/signals.py b/tests/files/django/signals.py index f85a66d..37e9cc6 100644 --- a/tests/files/django/signals.py +++ b/tests/files/django/signals.py @@ -9,5 +9,12 @@ def article_saved(sender, instance, **kwargs: object): pass +def article_saved_direct(sender, instance, **kwargs: object): + pass + + +post_save.connect(article_saved_direct, sender=Article) + + def unused_signal_handler(sender, **kwargs: object): pass diff --git a/tests/files/django/urls.py b/tests/files/django/urls.py index 9718e48..3ae7549 100644 --- a/tests/files/django/urls.py +++ b/tests/files/django/urls.py @@ -1,12 +1,21 @@ -from django.urls import path +from django.urls import include, path +from rest_framework.routers import DefaultRouter from . import views +from .api import ArticleViewSet app_name = 'django_example' +router = DefaultRouter() +router.register(r'articles', ArticleViewSet, basename='article') + urlpatterns = [ path('', views.index, name='index'), - path('articles/', views.ArticleListView.as_view(), name='articles'), + path('articles/', views.article_list, name='article-list'), + path('articles/create/', views.article_create, name='article-create'), + path('articles//update/', views.article_update, name='article-update'), + path('articles/view/', views.ArticleView.as_view(), name='article-view'), + path('api/', include(router.urls)), ] UNUSED_URL_NAME = 'unused' diff --git a/tests/files/django/views.py b/tests/files/django/views.py index 27bdf48..36da1b1 100644 --- a/tests/files/django/views.py +++ b/tests/files/django/views.py @@ -1,5 +1,7 @@ from django.http import HttpResponse from django.views import View +from django.views.decorators.http import require_GET, require_POST, require_http_methods +from django.views.generic import DetailView, ListView UNUSED_VIEW_SETTING = 'unused' @@ -12,11 +14,41 @@ def unused_view(request): return HttpResponse('unused') -class ArticleListView(View): +@require_GET +def article_list(request): + return HttpResponse('articles') + + +@require_POST +def article_create(request): + return HttpResponse('created') + + +@require_http_methods(['PUT']) +def article_update(request): + return HttpResponse('updated') + + +@require_GET +def unreferenced_require_view(request): + return HttpResponse('helper') + + +class ArticleListView(ListView): def get(self, request): return HttpResponse('articles') +class ArticleDetailView(DetailView): + def get(self, request): + return HttpResponse('article') + + +class ArticleView(View): + def get(self, request): + return HttpResponse('article') + + class UnusedView(View): def get(self, request): return HttpResponse('unused') From 4117856cba555332e369227c3d9882671e1cce43 Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 07:59:28 +0200 Subject: [PATCH 06/12] Resolve absolute-import names and registered middleware --- deadcode/visitor/dead_code_visitor.py | 66 +++++++- tests/documented_examples/test_django.py | 79 +++++----- tests/files/django/middleware.py | 8 + .../test_ignore_interface_decorators.py | 4 +- tests/visitor/test_middleware_registration.py | 122 +++++++++++++++ .../visitor/test_qualified_name_resolution.py | 148 ++++++++++++++++++ 6 files changed, 377 insertions(+), 50 deletions(-) create mode 100644 tests/visitor/test_middleware_registration.py create mode 100644 tests/visitor/test_qualified_name_resolution.py diff --git a/deadcode/visitor/dead_code_visitor.py b/deadcode/visitor/dead_code_visitor.py index 1dc2e02..ecfb703 100644 --- a/deadcode/visitor/dead_code_visitor.py +++ b/deadcode/visitor/dead_code_visitor.py @@ -76,6 +76,7 @@ def __init__(self, filenames: list[str], args: Args) -> None: self.bare_used_names: dict[str, set[str]] = {} self.wildcard_imports: dict[str, set[str]] = {} self.explicit_imports: dict[str, set[str]] = {} + self.qualified_imports: dict[str, dict[str, str]] = {} self.filename = Path() self.code: list[str] = [] @@ -149,6 +150,7 @@ def visit_abstract_syntax_trees(self) -> None: filename = os.path.basename(file_path) module_name = os.path.splitext(filename)[0] self.module_name = module_name + self.qualified_imports[self.module_name] = {} self.scope_parts = [module_name] file_content = f.read() @@ -242,6 +244,7 @@ def _add_aliases(self, node: ast.Import | ast.ImportFrom) -> None: # We can't easily detect when "os.path" is used. name = name_and_alias.name.partition('.')[0] alias = name_and_alias.asname + self._record_qualified_import(node, name_and_alias) self._define( self.defined_imports, alias or name, @@ -251,6 +254,32 @@ def _add_aliases(self, node: ast.Import | ast.ImportFrom) -> None: if alias is not None: self.add_used_name(name_and_alias.name) + def _record_qualified_import(self, node: ast.Import | ast.ImportFrom, name_and_alias: ast.alias) -> None: + """Record absolute imports so qualified names can be resolved.""" + qualified_imports = self.qualified_imports.setdefault(self.module_name, {}) + if isinstance(node, ast.Import): + if name_and_alias.asname: + qualified_imports[name_and_alias.asname] = name_and_alias.name + else: + top_level = name_and_alias.name.partition('.')[0] + qualified_imports[top_level] = top_level + return + + if node.level > 0 or node.module is None or node.module == '__future__': + return + if name_and_alias.name == '*': + return + local_name = name_and_alias.asname or name_and_alias.name + qualified_imports[local_name] = f'{node.module}.{name_and_alias.name}' + + def _resolve_qualified_expression(self, expression: str) -> str | None: + """Resolve the first component of a dotted expression through absolute imports.""" + first_component, separator, remaining = expression.partition('.') + target = self.qualified_imports.get(self.module_name, {}).get(first_component) + if target is None: + return None + return f'{target}{separator}{remaining}' if separator else target + def _collect_commented_out_code(self, file_content: bytes) -> None: """Collect comments whose text parses as Python code.""" try: @@ -543,8 +572,15 @@ def _is_locals_call(node: ast.AST) -> bool: ) def _get_decorator_names(self, node: ast.AST) -> list[str]: - """Returns decorator names of the given node.""" - return [utils.get_decorator_name(decorator) for decorator in getattr(node, 'decorator_list', ())] + """Returns decorator names of the given node, including resolved absolute imports.""" + decorator_names = [] + for decorator in getattr(node, 'decorator_list', ()): + name = utils.get_decorator_name(decorator) + decorator_names.append(name) + resolved = self._resolve_qualified_expression(name[1:]) + if resolved is not None and '@' + resolved != name: + decorator_names.append('@' + resolved) + return decorator_names def get_inherits_from(self, node: ast.AST) -> list[str] | None: """Returns a set of base classes from any level in inheritance tree.""" @@ -552,7 +588,14 @@ def get_inherits_from(self, node: ast.AST) -> list[str] | None: if not (bases_attr := getattr(node, 'bases', None)): return None - bases = [name for base in bases_attr for name in utils.get_base_class_names(base)] + bases = [] + for base in bases_attr: + bases.extend(utils.get_base_class_names(base)) + if isinstance(base, (ast.Name, ast.Attribute)): + source = ast.unparse(base) + resolved = self._resolve_qualified_expression(source) + if resolved is not None and resolved != source: + bases.append(resolved) inherits_from = bases[:] for base in bases: @@ -563,7 +606,7 @@ def get_inherits_from(self, node: ast.AST) -> list[str] | None: return list(dict.fromkeys(inherits_from)) def visit_ClassDef(self, node: ast.ClassDef) -> None: - decorator_names = [utils.get_decorator_name(decorator) for decorator in node.decorator_list] + decorator_names = self._get_decorator_names(node) for decorator in node.decorator_list: if _match(utils.get_decorator_name(decorator), self.ignore_decorators): self._log(f'Ignoring class "{node.name}" (decorator whitelisted)') @@ -579,7 +622,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: self._record_annotation_names(node.returns) - decorator_names = [utils.get_decorator_name(decorator) for decorator in node.decorator_list] + decorator_names = self._get_decorator_names(node) first_arg = node.args.args[0].arg if node.args.args else None @@ -633,6 +676,19 @@ def visit_Assign(self, node: ast.Assign) -> None: if isinstance(elt, ast.Constant) and isinstance(elt.value, str): self.add_used_name(elt.value) + if ( + len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == 'MIDDLEWARE' + and len(self.scope_parts) == 1 + and isinstance(node.value, (ast.List, ast.Tuple)) + ): + for elt in node.value.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + final_component = elt.value.rpartition('.')[2] + if final_component: + self.add_used_name(final_component) + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: self._record_annotation_names(node.annotation) diff --git a/tests/documented_examples/test_django.py b/tests/documented_examples/test_django.py index 4e94b99..bbea91a 100644 --- a/tests/documented_examples/test_django.py +++ b/tests/documented_examples/test_django.py @@ -8,44 +8,34 @@ FIXTURE_DIRECTORY = Path(__file__).resolve().parents[1] / 'files' / 'django' DECORATOR_PATTERNS = [ - '@*admin.*', - '@*register', - '@*display', - '@*action', - '@*receiver', + '@django.contrib.admin.*', + '@django.contrib.auth.*', + '@django.dispatch.receiver', + '@django.utils.decorators.method_decorator', + '@django.views.decorators.http.require_*', + '@rest_framework.*', '@*register.*', - '@*require_*', - '@*login_required', - '@*permission_required', - '@*user_passes_test', - '@*method_decorator', - '@*api_view', - '@*_classes', - '@*schema', ] INHERITANCE_PATTERNS = [ - 'Model', - 'ModelAdmin', - 'Form', - 'ModelForm', - '*Inline', - 'BaseCommand', - 'Migration', - 'AppConfig', - 'MiddlewareMixin', - '*View', - '*ViewSet', - '*Serializer', - '*Permission', - '*Authentication', - '*Pagination', - '*FilterBackend', - '*Throttle', + 'django.apps.AppConfig', + 'django.contrib.admin.*', + 'django.core.management.base.BaseCommand', + 'django.db.migrations.Migration', + 'django.db.models.Model', + 'django.forms.*Form', + 'django.utils.deprecation.MiddlewareMixin', + 'django.views.*View', + 'rest_framework.*View', + 'rest_framework.*ViewSet', + 'rest_framework.*Serializer', + 'rest_framework.*Permission', + 'rest_framework.*Authentication', + 'rest_framework.*Pagination', + 'rest_framework.*FilterBackend', + 'rest_framework.*Throttle', ] -NAME_PATTERNS = ['*Middleware', '*middleware'] - FILE_PATTERNS = ['*/settings.py', '*/urls.py'] CONFIGURATION = ( @@ -56,21 +46,18 @@ 'ignore-definitions-if-inherits-from = [\n' + ''.join(f' "{pattern}",\n' for pattern in INHERITANCE_PATTERNS) + ']\n' - 'ignore-names = ["*Middleware", "*middleware"]\n' 'ignore-names-in-files = ["*/settings.py", "*/urls.py"]\n' ) COMMAND_LINE_OPTIONS = [ *(f'--ignore-definitions-if-decorated-with={pattern}' for pattern in DECORATOR_PATTERNS), *(f'--ignore-definitions-if-inherits-from={pattern}' for pattern in INHERITANCE_PATTERNS), - *(f'--ignore-names={pattern}' for pattern in NAME_PATTERNS), *(f'--ignore-names-in-files={pattern}' for pattern in FILE_PATTERNS), ] COMMA_SEPARATED_COMMAND_LINE_OPTIONS = [ f'--ignore-definitions-if-decorated-with={",".join(DECORATOR_PATTERNS)}', f'--ignore-definitions-if-inherits-from={",".join(INHERITANCE_PATTERNS)}', - f'--ignore-names={",".join(NAME_PATTERNS)}', f'--ignore-names-in-files={",".join(FILE_PATTERNS)}', ] @@ -113,10 +100,8 @@ ('project/management/commands/import_data.py', 'DC04', 'handle'), ('project/management/commands/import_data.py', 'DC03', 'UnusedCommand'), ('project/managers.py', 'DC04', 'unused_manager_method'), - ('project/middleware.py', 'DC02', 'simple_middleware'), - ('project/middleware.py', 'DC03', 'CurrentMiddleware'), - ('project/middleware.py', 'DC03', 'SimpleMiddleware'), ('project/middleware.py', 'DC04', 'process_request'), + ('project/middleware.py', 'DC03', 'UnusedMiddleware'), ('project/migrations/0001_initial.py', 'DC06', 'initial'), ('project/migrations/0001_initial.py', 'DC06', 'dependencies'), ('project/migrations/0001_initial.py', 'DC06', 'operations'), @@ -155,6 +140,7 @@ CONFIGURED_FINDINGS = { ('project/api.py', 'DC02', 'unused_api_helper'), + ('project/middleware.py', 'DC03', 'UnusedMiddleware'), ('project/managers.py', 'DC04', 'unused_manager_method'), ('project/signals.py', 'DC02', 'unused_signal_handler'), ('project/templatetags/blog_tags.py', 'DC02', 'unused_tag'), @@ -251,11 +237,17 @@ def test_middleware_forms_without_configuration(project_directory: Path) -> None _assert_no_parse_warning(output) findings = _findings(output) assert { - ('project/middleware.py', 'DC02', 'simple_middleware'), - ('project/middleware.py', 'DC03', 'CurrentMiddleware'), - ('project/middleware.py', 'DC03', 'SimpleMiddleware'), ('project/middleware.py', 'DC04', 'process_request'), + ('project/middleware.py', 'DC03', 'UnusedMiddleware'), } <= findings + assert ( + not { + ('project/middleware.py', 'DC02', 'simple_middleware'), + ('project/middleware.py', 'DC03', 'CurrentMiddleware'), + ('project/middleware.py', 'DC03', 'SimpleMiddleware'), + } + & findings + ) def test_middleware_forms_with_documented_configuration(project_directory: Path) -> None: @@ -266,7 +258,8 @@ def test_middleware_forms_with_documented_configuration(project_directory: Path) findings = _configured_findings(project_directory) # Then - assert not any(path == 'project/middleware.py' for path, _, _ in findings) + middleware_findings = {finding for finding in findings if finding[0] == 'project/middleware.py'} + assert middleware_findings == {('project/middleware.py', 'DC03', 'UnusedMiddleware')} def test_class_based_view_families_without_configuration(project_directory: Path) -> None: @@ -501,7 +494,7 @@ def test_view_and_template_layers_without_configuration(project_directory: Path) _assert_no_parse_warning(output) findings = _findings(output) names = {name for _, _, name in findings} - assert {'SimpleMiddleware', 'process_request', 'get', 'greeting', 'shout', 'unused_tag'} <= names + assert {'process_request', 'UnusedMiddleware', 'get', 'greeting', 'shout', 'unused_tag'} <= names assert ('project/views.py', 'DC03', 'UnusedView') in findings diff --git a/tests/files/django/middleware.py b/tests/files/django/middleware.py index 91e61c4..ed3a330 100644 --- a/tests/files/django/middleware.py +++ b/tests/files/django/middleware.py @@ -19,3 +19,11 @@ def __call__(self, request): class SimpleMiddleware(MiddlewareMixin): def process_request(self, request): return None + + +class UnusedMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) diff --git a/tests/visitor/test_ignore_interface_decorators.py b/tests/visitor/test_ignore_interface_decorators.py index 20cfc1e..3747f70 100644 --- a/tests/visitor/test_ignore_interface_decorators.py +++ b/tests/visitor/test_ignore_interface_decorators.py @@ -117,7 +117,7 @@ def value(self): assert unused_names is None -def test_aliased_import_is_still_reported(virtual_file_system): +def test_aliased_import_is_resolved_for_interface_decorators(virtual_file_system): # Having virtual_file_system.files = { 'foo.py': b""" @@ -138,7 +138,7 @@ def compute(self): unused_names = main(['foo.py', '--no-color']) # Then - assert unused_names == 'foo.py:6:4: DC04 Method `compute` is never used' + assert unused_names is None def test_unused_method_next_to_ignored_method_is_reported(virtual_file_system): diff --git a/tests/visitor/test_middleware_registration.py b/tests/visitor/test_middleware_registration.py new file mode 100644 index 0000000..38d7e9f --- /dev/null +++ b/tests/visitor/test_middleware_registration.py @@ -0,0 +1,122 @@ +from deadcode.cli import main + + +def test_middleware_list_marks_registered_class_and_function_as_used(virtual_file_system): + # Having + virtual_file_system.files = { + 'project/settings.py': b""" + MIDDLEWARE = [ + 'project.middleware.ListedMiddleware', + 'project.middleware.listed_middleware', + ] + """, + 'project/middleware.py': b""" + class ListedMiddleware: + pass + + + def listed_middleware(get_response): + return get_response + + + class UnregisteredMiddleware: + pass + """, + } + + # When + unused_names = main(['project', '--no-color', '--ignore-names-in-files=*/settings.py']) + + # Then + assert unused_names == 'project/middleware.py:9:0: DC03 Class `UnregisteredMiddleware` is never used' + + +def test_middleware_tuple_marks_registered_class_as_used(virtual_file_system): + # Having + virtual_file_system.files = { + 'project/settings.py': b"MIDDLEWARE = ('project.middleware.ListedMiddleware',)", + 'project/middleware.py': b""" + class ListedMiddleware: + pass + + + class UnregisteredMiddleware: + pass + """, + } + + # When + unused_names = main(['project', '--no-color', '--ignore-names-in-files=*/settings.py']) + + # Then + assert unused_names == 'project/middleware.py:5:0: DC03 Class `UnregisteredMiddleware` is never used' + + +def test_non_registration_shapes_keep_definitions_reported(virtual_file_system): + # Having + virtual_file_system.files = { + 'project/settings.py': b""" + MIDDLEWARE = 'project.middleware.NotRegistered' + OTHER = ['project.middleware.NotRegisteredEither'] + MIDDLEWARE = [ + 'project.middleware.ListedWithEmptyPath', + 1, + None, + '', + 'project.middleware.multi.part.ListedWithManyDots', + ] + + + def configure(): + MIDDLEWARE = ['project.middleware.NestedMiddleware'] + """, + 'project/middleware.py': b""" + class NotRegistered: + pass + + + class NotRegisteredEither: + pass + + + class ListedWithEmptyPath: + pass + + + class ListedWithManyDots: + pass + + + class NestedMiddleware: + pass + """, + } + + # When + unused_names = main(['project', '--no-color', '--ignore-names-in-files=*/settings.py']) + + # Then + assert unused_names == ( + 'project/middleware.py:1:0: DC03 Class `NotRegistered` is never used\n' + 'project/middleware.py:5:0: DC03 Class `NotRegisteredEither` is never used\n' + 'project/middleware.py:17:0: DC03 Class `NestedMiddleware` is never used' + ) + + +def test_multiple_assignment_targets_do_not_mark_middleware_as_used(virtual_file_system): + # Having + virtual_file_system.files = { + 'project/settings.py': b""" + MIDDLEWARE = OTHER = ['project.middleware.MultiTargetMiddleware'] + """, + 'project/middleware.py': b""" + class MultiTargetMiddleware: + pass + """, + } + + # When + unused_names = main(['project', '--no-color', '--ignore-names-in-files=*/settings.py']) + + # Then + assert unused_names == 'project/middleware.py:1:0: DC03 Class `MultiTargetMiddleware` is never used' diff --git a/tests/visitor/test_qualified_name_resolution.py b/tests/visitor/test_qualified_name_resolution.py new file mode 100644 index 0000000..035fed5 --- /dev/null +++ b/tests/visitor/test_qualified_name_resolution.py @@ -0,0 +1,148 @@ +from deadcode.cli import main + + +def test_absolute_import_forms_resolve_inherited_models(virtual_file_system): + # Having + virtual_file_system.files = { + 'foo.py': b""" + from django.db import models + from django.db.models import Model + import django.db.models as m + import django.db.models + from django.db.models import Model as M + from django.db.models import * + + + class A(models.Model): + pass + + + class B(Model): + pass + + + class C(m.Model): + pass + + + class D(M): + pass + + + class E(django.db.models.Model): + pass + + + class W(Model): + pass + + + def unused_sentinel(): + pass + """ + } + + # When + unused_names = main(['foo.py', '--no-color', '--ignore-definitions-if-inherits-from=django.db.models.Model']) + + # Then + assert unused_names == 'foo.py:33:0: DC02 Function `unused_sentinel` is never used' + + +def test_absolute_import_forms_resolve_decorators(virtual_file_system): + # Having + virtual_file_system.files = { + 'foo.py': b""" + from django.contrib import admin + from django.contrib.admin import register + from django.dispatch import receiver + + + @admin.register + class AdminA: + pass + + + @register + class AdminB: + pass + + + @receiver + def receiver_handler(sender, **kwargs): + pass + + + def unused_sentinel(): + pass + """ + } + + # When + unused_names = main( + [ + 'foo.py', + '--no-color', + '--ignore-definitions-if-decorated-with=@django.contrib.admin.register,@django.dispatch.receiver', + ] + ) + + # Then + assert unused_names == 'foo.py:21:0: DC02 Function `unused_sentinel` is never used' + + +def test_relative_import_keeps_bare_source_candidate(virtual_file_system): + # Having + virtual_file_system.files = { + 'foo.py': b""" + from .models import Model + from . import Model as RelativeModel + + + class LocalModel(Model): + pass + + + class AnotherLocalModel(RelativeModel): + pass + + + def unused_sentinel(): + pass + """ + } + + # When + unused_names = main(['foo.py', '--no-color', '--ignore-definitions-if-inherits-from=Model']) + + # Then + assert unused_names == ( + 'foo.py:9:0: DC03 Class `AnotherLocalModel` is never used\n' + 'foo.py:13:0: DC02 Function `unused_sentinel` is never used' + ) + + +def test_relative_import_is_not_resolved_to_a_qualified_name(virtual_file_system): + # Having + virtual_file_system.files = { + 'foo.py': b""" + from .models import Model + + + class LocalModel(Model): + pass + + + def unused_sentinel(): + pass + """ + } + + # When + unused_names = main(['foo.py', '--no-color', '--ignore-definitions-if-inherits-from=django.db.models.Model']) + + # Then + assert unused_names == ( + 'foo.py:4:0: DC03 Class `LocalModel` is never used\n' + 'foo.py:8:0: DC02 Function `unused_sentinel` is never used' + ) From ed248e40436fd2d564bceddbb832d016c5a80534 Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 07:59:28 +0200 Subject: [PATCH 07/12] Document the Django guide with the three-key configuration --- README.md | 61 +++-- docs/django.md | 588 +++++++++++++++++++------------------------------ 2 files changed, 254 insertions(+), 395 deletions(-) diff --git a/README.md b/README.md index 58c23f8..c579431 100644 --- a/README.md +++ b/README.md @@ -83,48 +83,39 @@ framework entry points look unused to a static analyser. Configure deadcode like ```toml [tool.deadcode] ignore-definitions-if-decorated-with = [ - "@*admin.*", - "@*register", - "@*display", - "@*action", - "@*receiver", + "@django.contrib.admin.*", + "@django.contrib.auth.*", + "@django.dispatch.receiver", + "@django.utils.decorators.method_decorator", + "@django.views.decorators.http.require_*", + "@rest_framework.*", "@*register.*", - "@*require_*", - "@*login_required", - "@*permission_required", - "@*user_passes_test", - "@*method_decorator", - "@*api_view", - "@*_classes", - "@*schema", ] ignore-definitions-if-inherits-from = [ - "Model", - "ModelAdmin", - "Form", - "ModelForm", - "*Inline", - "BaseCommand", - "Migration", - "AppConfig", - "MiddlewareMixin", - "*View", - "*ViewSet", - "*Serializer", - "*Permission", - "*Authentication", - "*Pagination", - "*FilterBackend", - "*Throttle", + "django.apps.AppConfig", + "django.contrib.admin.*", + "django.core.management.base.BaseCommand", + "django.db.migrations.Migration", + "django.db.models.Model", + "django.forms.*Form", + "django.utils.deprecation.MiddlewareMixin", + "django.views.*View", + "rest_framework.*View", + "rest_framework.*ViewSet", + "rest_framework.*Serializer", + "rest_framework.*Permission", + "rest_framework.*Authentication", + "rest_framework.*Pagination", + "rest_framework.*FilterBackend", + "rest_framework.*Throttle", ] -ignore-names = ["*Middleware", "*middleware"] ignore-names-in-files = ["*/settings.py", "*/urls.py"] ``` -The example targets Django 5.2 LTS (5.2.19) and Django REST Framework 3.18.3 and uses APIs present in Django 5.2 through 6.1. The four keys replace the +The example targets Django 5.2 LTS (5.2.19) and Django REST Framework 3.18.3 and uses APIs present in Django 5.2 through 6.1. The three keys replace the older overlapping name, body, and definition options: one inheritance entry hides a framework subclass name and body, and one decorator entry hides a -decorated definition name and body. Command-line flags accept the same values repeatedly, space-separated, or as one comma-separated string; a pipe is -not a separator. +decorated definition name and body. deadcode parses `MIDDLEWARE` registrations and resolves absolute-import qualified names, so a registered middleware +name and a `models.Model` base need no separate name pattern. Read the [Django guide](https://deadcode.readthedocs.io/en/latest/django.html) for the common use cases, every framework area, the current and legacy middleware forms, both import forms, database signals, Django REST Framework, the complete example application, and the expected output with no wrong @@ -354,7 +345,7 @@ A checked entry is implemented on `main` and is included in the next release; pu - Features: - Audit runtime and development dependencies for known vulnerabilities in CI on every pull request and weekly. - Declare the package development status as Beta. - - Document how to run deadcode on a Django project, covering models, admin, views, forms and more. + - Add a Django usage guide covering models, admin, views, forms, middleware, signals and more, plus Django `MIDDLEWARE` registration parsing and Django-aware qualified matching through absolute imports. - Ignore Django test lifecycle methods in test files. - **v2.6.0** `2026-11-08`: - Features: diff --git a/docs/django.md b/docs/django.md index a949bdb..4a830cb 100644 --- a/docs/django.md +++ b/docs/django.md @@ -1,239 +1,138 @@ # Django -deadcode is framework-agnostic: it parses source code and never imports it. Django registers models, views, admin classes, middleware, and signal -handlers at runtime, so much of a Django project's code is reachable only through a base class, a decorator, a settings string, or a URLconf. A static -analyser that only follows name loads sees those framework entry points as unused. This page shows one small configuration for Django and Django REST -Framework, documents the current middleware forms and both import forms, and gives a complete example application whose reports you can reproduce. +deadcode parses source text and never imports the project, so it sees only Python name loads. Django reaches code through base classes, decorators, +`MIDDLEWARE` strings, settings modules, and URLconfs, and those framework entry points look unused to a static analyser. This page shows one small +configuration for Django and Django REST Framework, explains `MIDDLEWARE` registrations and both qualified import forms, and gives a complete example +application whose two reports you can reproduce. ## Recommended configuration -Add these four option keys to `pyproject.toml` in the directory where you run deadcode: +Add these three option keys to `pyproject.toml` in the directory where you run deadcode: ```toml [tool.deadcode] ignore-definitions-if-decorated-with = [ - "@*admin.*", - "@*register", - "@*display", - "@*action", - "@*receiver", + "@django.contrib.admin.*", + "@django.contrib.auth.*", + "@django.dispatch.receiver", + "@django.utils.decorators.method_decorator", + "@django.views.decorators.http.require_*", + "@rest_framework.*", "@*register.*", - "@*require_*", - "@*login_required", - "@*permission_required", - "@*user_passes_test", - "@*method_decorator", - "@*api_view", - "@*_classes", - "@*schema", ] ignore-definitions-if-inherits-from = [ - "Model", - "ModelAdmin", - "Form", - "ModelForm", - "*Inline", - "BaseCommand", - "Migration", - "AppConfig", - "MiddlewareMixin", - "*View", - "*ViewSet", - "*Serializer", - "*Permission", - "*Authentication", - "*Pagination", - "*FilterBackend", - "*Throttle", + "django.apps.AppConfig", + "django.contrib.admin.*", + "django.core.management.base.BaseCommand", + "django.db.migrations.Migration", + "django.db.models.Model", + "django.forms.*Form", + "django.utils.deprecation.MiddlewareMixin", + "django.views.*View", + "rest_framework.*View", + "rest_framework.*ViewSet", + "rest_framework.*Serializer", + "rest_framework.*Permission", + "rest_framework.*Authentication", + "rest_framework.*Pagination", + "rest_framework.*FilterBackend", + "rest_framework.*Throttle", ] -ignore-names = ["*Middleware", "*middleware"] ignore-names-in-files = ["*/settings.py", "*/urls.py"] ``` -Every entry has one job. `ignore-definitions-if-inherits-from` suppresses the name and the whole body of a framework subclass, so one `Model` entry -replaces a model-name pattern, a field pattern, and a nested-`Meta` pattern. `ignore-definitions-if-decorated-with` does the same for a decorated -definition, so one `@*receiver` entry covers the handler name and its body. `ignore-names` covers the two middleware forms that have no framework base -class. `ignore-names-in-files` covers settings and URLconf modules, whose module-level names are configuration data rather than ordinary code. - -The same options are available as command-line flags, with one occurrence per value: - -```shell -deadcode . \ - --ignore-definitions-if-decorated-with=@*admin.* \ - --ignore-definitions-if-decorated-with=@*register \ - --ignore-definitions-if-decorated-with=@*display \ - --ignore-definitions-if-decorated-with=@*action \ - --ignore-definitions-if-decorated-with=@*receiver \ - --ignore-definitions-if-decorated-with=@*register.* \ - --ignore-definitions-if-decorated-with=@*require_* \ - --ignore-definitions-if-decorated-with=@*login_required \ - --ignore-definitions-if-decorated-with=@*permission_required \ - --ignore-definitions-if-decorated-with=@*user_passes_test \ - --ignore-definitions-if-decorated-with=@*method_decorator \ - --ignore-definitions-if-decorated-with=@*api_view \ - --ignore-definitions-if-decorated-with=@*_classes \ - --ignore-definitions-if-decorated-with=@*schema \ - --ignore-definitions-if-inherits-from=Model \ - --ignore-definitions-if-inherits-from=ModelAdmin \ - --ignore-definitions-if-inherits-from=Form \ - --ignore-definitions-if-inherits-from=ModelForm \ - --ignore-definitions-if-inherits-from='*Inline' \ - --ignore-definitions-if-inherits-from=BaseCommand \ - --ignore-definitions-if-inherits-from=Migration \ - --ignore-definitions-if-inherits-from=AppConfig \ - --ignore-definitions-if-inherits-from=MiddlewareMixin \ - --ignore-definitions-if-inherits-from='*View' \ - --ignore-definitions-if-inherits-from='*ViewSet' \ - --ignore-definitions-if-inherits-from='*Serializer' \ - --ignore-definitions-if-inherits-from='*Permission' \ - --ignore-definitions-if-inherits-from='*Authentication' \ - --ignore-definitions-if-inherits-from='*Pagination' \ - --ignore-definitions-if-inherits-from='*FilterBackend' \ - --ignore-definitions-if-inherits-from='*Throttle' \ - --ignore-names='*Middleware' \ - --ignore-names='*middleware' \ - --ignore-names-in-files='*/settings.py' \ - --ignore-names-in-files='*/urls.py' -``` - -A list flag accepts space-separated values, repeated flags, and one comma-separated value. The comma form is shorter to type: - -```shell -deadcode . \ - --ignore-definitions-if-decorated-with='@*admin.*,@*register,@*display,@*action,@*receiver,@*register.*,@*require_*,@*login_required,@*permission_required,@*user_passes_test,@*method_decorator,@*api_view,@*_classes,@*schema' \ - --ignore-definitions-if-inherits-from='Model,ModelAdmin,Form,ModelForm,*Inline,BaseCommand,Migration,AppConfig,MiddlewareMixin,*View,*ViewSet,*Serializer,*Permission,*Authentication,*Pagination,*FilterBackend,*Throttle' \ - --ignore-names='*Middleware,*middleware' \ - --ignore-names-in-files='*/settings.py,*/urls.py' -``` - -The comma is the only separator. `|` is not a separator. A TOML array is not comma-split either: write one quoted value per line, because -`["Model,Form"]` is the single literal pattern `Model,Form`. deadcode reads `./pyproject.toml` in the current working directory; it does not search parent -directories and there is no `--config` option. See {doc}`usage` for the full option semantics and {doc}`rules` for the rule codes. +Each entry covers one family, and no two entries match the same qualified base or decorator. One inheritance entry hides a framework subclass name and +its whole body; one decorator entry hides a decorated definition name and its body; the file patterns remove settings and URLconf configuration names. +`MIDDLEWARE` registrations need no entry because deadcode parses the setting itself. See {doc}`usage` for the command-line form of every key and +{doc}`rules` for the rule codes. ## Common use cases | Django part | Configuration entry | |-------------|---------------------| -| Models and migrations | `Model` and `Migration` in `ignore-definitions-if-inherits-from`, plus `*/settings.py` and `*/urls.py` in `ignore-names-in-files` | -| URLs and settings | `ignore-names-in-files = ["*/settings.py", "*/urls.py"]` | -| Function and class-based views | `@*require_*`, `@*login_required`, `@*permission_required`, `@*user_passes_test`, and `@*method_decorator`; `*View` for every generic view | -| Middleware | `ignore-names = ["*Middleware", "*middleware"]` and `MiddlewareMixin` in the inheritance list | -| Templates and template tags | `@*register` and `@*register.*` | -| Forms and model forms | `Form` and `ModelForm` in the inheritance list | -| Admin classes and actions | `@*admin.*`, `@*register`, `@*display`, and `ModelAdmin` in the inheritance list | -| App configurations | `AppConfig` in the inheritance list | -| Management commands | `BaseCommand` in the inheritance list | -| Signals | `@*receiver` | +| Models | `django.db.models.Model` in `ignore-definitions-if-inherits-from` | +| Migrations | `django.db.migrations.Migration` in `ignore-definitions-if-inherits-from` | +| URLs | `*/urls.py` in `ignore-names-in-files` | +| Settings | `*/settings.py` in `ignore-names-in-files` | +| Function views | `@django.views.decorators.http.require_*`, `@django.contrib.auth.*`, and `@django.utils.decorators.method_decorator` | +| Class-based views | `django.views.*View` in `ignore-definitions-if-inherits-from` | +| Middleware | No entry; names listed in `MIDDLEWARE` are parsed as used, while an unregistered middleware class is still reported | +| Templates and template tags | `@*register.*` in `ignore-definitions-if-decorated-with` | +| Forms and model forms | `django.forms.*Form` in `ignore-definitions-if-inherits-from` | +| Admin classes and actions | `django.contrib.admin.*` in `ignore-definitions-if-inherits-from` and `@django.contrib.admin.*` in the decorator list | +| App configurations | `django.apps.AppConfig` in `ignore-definitions-if-inherits-from` | +| Management commands | `django.core.management.base.BaseCommand` in `ignore-definitions-if-inherits-from` | +| Signals | `@django.dispatch.receiver` in `ignore-definitions-if-decorated-with` | | Tests | No entry; the Django test lifecycle names are ignored by default in test files | -| Django REST Framework | `*ViewSet`, `*Serializer`, `*Permission`, `*Authentication`, `*Pagination`, `*FilterBackend`, `*Throttle`, `@*api_view`, `@*action`, `@*_classes`, and `@*schema` | -| Auth, security, i18n, caching, GIS, and the remaining core tools | The same four keys; their ordinary function and class references need no extra entry | +| Auth and security | `@django.contrib.auth.*` for the auth decorators and `*/settings.py` for the settings | +| i18n, caching, and GIS | `*/settings.py` for the settings; ordinary calls and name loads need no entry | +| Django REST Framework | `rest_framework.*View`, `rest_framework.*ViewSet`, `rest_framework.*Serializer`, and the permission, authentication, pagination, filter, and throttle families, plus `@rest_framework.*` | ## Versions -The example targets Django 5.2 LTS, latest patch release 5.2.19, and Django REST Framework 3.18.3. Django 5.2 is the newest LTS release that runs on -the project's minimum Python 3.10 and is supported until April 2028; Django REST Framework 3.18.3 requires Django 5.2 or newer. The example uses APIs that -are present in Django 5.2 through 6.1, so a project on any of those releases can copy the configuration. Django 6.1 requires Python 3.12 or newer, while -deadcode itself supports Python 3.10 and later, which is why the example pins the LTS line. +The example targets Django 5.2 LTS, latest patch release 5.2.19, and Django REST Framework 3.18.3. Django 5.2 is the newest LTS release that runs on the +project's minimum Python 3.10 and is supported until April 2028; Django REST Framework 3.18.3 requires Django 5.2 or newer. -The fixture is parsed as text and never executed. Django and Django REST Framework are not dependencies of deadcode, and the tests do not import either -framework. The version statement describes the APIs in the snippets, not a runtime requirement of the analyser. +The snippets use APIs present in Django 5.2 through 6.1, so a project on any of those releases can copy the configuration. Django 6.1 requires Python 3.12 +or newer, while deadcode itself supports Python 3.10 and later. The fixture is parsed as text and never executed: Django and Django REST Framework are +not dependencies of deadcode, and the tests do not run a project or a server. ## Model layer -Models are the largest source of Django false positives. A field and a nested `Meta` option are class-level assignments with no Python name load, and -Django calls model methods and properties at runtime. `Model` in `ignore-definitions-if-inherits-from` removes the class name and every finding in the -body in one entry. +A model field and a nested `Meta` option are class-level assignments, and Django calls model methods and properties at runtime. The single +`django.db.models.Model` entry removes the class name, the fields, the nested `Meta`, and the properties in one definition rule. -```python -from django.db import models - -from .managers import ArticleManager - - -class Category(models.Model): - name = models.CharField(max_length=100) +### Both import forms - class Meta: - verbose_name_plural = 'categories' +deadcode records absolute `import` and `from ... import` statements per module and adds the resolved qualified spelling to every class base and +decorator, while keeping the source spelling. These four forms therefore all match the one `django.db.models.Model` entry: - def __str__(self): - return self.name +```python +from django.db import models +from django.db.models import Model +import django.db.models as m +from django.db.models import Model as M -class Article(models.Model): +class A(models.Model): title = models.CharField(max_length=100) - category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='articles') - published = models.BooleanField(default=False) - - objects = ArticleManager() - - class Meta: - ordering = ['title'] - - @property - def display_name(self): - return self.title - - -class UnusedModel(models.Model): - value = models.IntegerField() -``` - -Without configuration the nested `Meta` classes are `DC03`, `verbose_name_plural`, `ordering`, and `category` are `DC06`, and `display_name` and -`published_titles` are `DC08`. With the recommended configuration none of those appear. The same `Model` entry also suppresses the `UnusedModel` class -name, which is a deliberate trade-off: the definition option hides a genuinely unused model as well as the framework fields. Use -`ignore-bodies-if-inherits-from = ["Model"]` instead when unused model names must stay visible, or mark -the definition with `# noqa: DC03`. -### Both import forms - -An inheritance expression matches the dotted base and its final component. Research with the analyser shows that `models.Model` contributes both the -dotted name and `Model`, so the plain `Model` entry covers both forms without a second, overlapping entry: -```python -from django.db import models -from django.db.models import Model +class B(Model): + title = models.CharField(max_length=100) -class QualifiedModel(models.Model): +class C(m.Model): title = models.CharField(max_length=100) -class ImportedModel(Model): +class D(M): title = models.CharField(max_length=100) ``` -An alias is not resolved. `import django.db.models as m` followed by `class Article(m.Model)` and -`from django.db.models import Model as M` followed by `class Article(M)` do not match the `Model` entry. Use the unaliased import or add a pattern for the -alias. +Relative imports and dynamic imports are not resolved. `from .models import Model` with `class Article(Model)` keeps working through the source +candidate and a bare or wildcard expression such as `Model` or `*Model`, but it does not match `django.db.models.Model`. An import performed inside a +function is also outside the module-level map. -Custom managers and `QuerySet` subclasses are not `Model` subclasses. `ArticleManager` and `ArticleQuerySet` are ordinary classes; a method that only -other code calls is still reported. Add the manager base to the inheritance list when its whole body should be ignored, or mark the individual method with -`# noqa: DC04`. Migrations are covered by `Migration` in the inheritance list, so `initial`, `dependencies`, and `operations` disappear without a -separate `*/migrations/*` file pattern. +Custom managers and `QuerySet` subclasses are not `Model` subclasses. A method that only other code calls is still reported; add the manager base to the +inheritance list when its whole body should be ignored, or mark the method with `# noqa: DC04`. Migrations are covered by +`django.db.migrations.Migration`, so `initial`, `dependencies`, and `operations` disappear without a separate `*/migrations/*` file pattern. ## URLs and settings Settings and URLconf modules are configuration data. `SECRET_KEY`, `DEBUG`, `INSTALLED_APPS`, `MIDDLEWARE`, `TEMPLATES`, `DATABASES`, -`ROOT_URLCONF`, `app_name`, `urlpatterns`, and route name constants are module-level names that nothing in the project loads. The file patterns remove -them: +`ROOT_URLCONF`, `app_name`, `urlpatterns`, and route name constants are module-level names that no project code loads. The file patterns remove them: ```python from django.urls import include, path -from rest_framework.routers import DefaultRouter from . import views -from .api import ArticleViewSet app_name = 'django_example' -router = DefaultRouter() -router.register(r'articles', ArticleViewSet, basename='article') - urlpatterns = [ path('', views.index, name='index'), - path('articles/', views.article_list, name='article-list'), - path('api/', include(router.urls)), + path('articles/', include('project.articles.urls')), ] UNUSED_URL_NAME = 'unused' @@ -249,59 +148,22 @@ module that assigns `application` needs the file pattern or `# noqa: DC01`. Cont ## View layer A URLconf loads function views through attributes such as `views.article_list`, so those functions are not reported. A view that is registered only -through a string, a dynamic import, or code outside the analysed tree looks unused. Decorator wildcards cover the definition and its body: +through a string, a dynamic import, or code outside the analysed tree looks unused. The function decorators are covered by their family entries: -| Decorator family | Entry | Notes | -|------------------|-------|-------| -| `@require_GET`, `@require_POST`, `@require_safe`, `@require_http_methods` | `@*require_*` | Django has no `require_PUT`; use `@require_http_methods(['PUT'])` for the other methods | -| `@login_required`, `@permission_required`, `@user_passes_test` | `@*login_required`, `@*permission_required`, `@*user_passes_test` | The auth decorators in bare and qualified form | -| `@method_decorator` | `@*method_decorator` | Applies a decorator to a class-based view method | -| `@admin.register`, `@register`, `@register.simple_tag`, `@register.filter` | `@*admin.*`, `@*register`, `@*register.*` | The leading `@*` covers a dotted and a directly imported name | +| Decorator family | Entry | +|------------------|-------| +| `@require_GET`, `@require_POST`, `@require_safe`, `@require_http_methods` | `@django.views.decorators.http.require_*` | +| `@login_required`, `@permission_required`, `@user_passes_test` | `@django.contrib.auth.*` | +| `@method_decorator` | `@django.utils.decorators.method_decorator` | +| `@admin.register`, `@admin.display`, `@admin.action` | `@django.contrib.admin.*` | -Class-based views are covered by inheritance. `*View` matches `View`, `TemplateView`, `RedirectView`, `DetailView`, `ListView`, `FormView`, `CreateView`, -`UpdateView`, `DeleteView`, the archive views, and DRF `APIView` descendants. `View` alone does not match `ListView` or `DetailView`, which is why the -family pattern is used: - -```python -from django.http import HttpResponse -from django.views import View -from django.views.decorators.http import require_GET, require_POST, require_http_methods -from django.views.generic import DetailView, ListView +Class-based views are covered by inheritance. `django.views.*View` matches `View`, `TemplateView`, `RedirectView`, `DetailView`, `ListView`, `FormView`, +`CreateView`, `UpdateView`, `DeleteView`, and the archive views. `rest_framework.*View` matches `APIView` and the generic API views, and +`rest_framework.*ViewSet` matches the viewset family. - -@require_GET -def article_list(request): - return HttpResponse('articles') - - -@require_POST -def article_create(request): - return HttpResponse('created') - - -@require_http_methods(['PUT']) -def article_update(request): - return HttpResponse('updated') - - -class ArticleListView(ListView): - def get(self, request): - return HttpResponse('articles') - - -class ArticleDetailView(DetailView): - def get(self, request): - return HttpResponse('article') - - -class ArticleView(View): - def get(self, request): - return HttpResponse('article') -``` - -Without configuration the handlers are `DC04`, `ArticleListView` and `ArticleDetailView` are `DC03`, and a decorated function that no URLconf loads is -`DC02`. With the configuration every one of those findings disappears. The `View`/`ListView`/`DetailView` class name is suppressed too, because the -definition option hides the whole definition. +A project subclass such as `ArticleListView(django.views.generic.ListView)` is covered through its inherited framework base, so the class name and the +whole body are suppressed. Router-called methods (`list`, `create`, `retrieve`, `update`, `partial_update`, `destroy`) are suppressed with the class body +because no Python code loads them in a typical project. `TemplateResponse`, `render()`, `redirect()`, `get_object_or_404()`, and the request and response objects are ordinary calls and attributes, so they need no extra configuration. File uploads through `request.FILES`, `FileField`, and a storage backend are ordinary attribute loads; a storage class named only @@ -330,13 +192,8 @@ class CurrentMiddleware: return self.get_response(request) ``` -Both are current in Django 5.2 through 6.1. `__init__` and `__call__` are dunder methods, which the analyser ignores already. The two middleware -names have no framework base class, which is why `ignore-names = ["*Middleware", "*middleware"]` removes `simple_middleware` and -`CurrentMiddleware`. The function name itself is the settings string, so the name pattern is the only way to keep it while `MIDDLEWARE` names it as text. - -`MiddlewareMixin` from `django.utils.deprecation` is the pre-Django-1.10 compatibility form. Django still supports it and does not warn about it, so it -is not deprecated, but new middleware should use one of the two forms above. When an existing project uses it, the inheritance entry covers the -`process_request` and `process_response` hooks: +`MiddlewareMixin` from `django.utils.deprecation` is the pre-Django-1.10 compatibility form. Django still supports it and does not label it deprecated, +but new middleware should use one of the two forms above: ```python from django.utils.deprecation import MiddlewareMixin @@ -347,16 +204,17 @@ class SimpleMiddleware(MiddlewareMixin): return None ``` -`MiddlewareMixin` in `ignore-definitions-if-inherits-from` removes the class name and `process_request` together. Without configuration the example -reports `simple_middleware`, `CurrentMiddleware`, `SimpleMiddleware`, and `process_request`; with the configuration none of them appear. +deadcode parses a module-level `MIDDLEWARE` list or tuple and marks the final dotted component of every string as used. A name listed in `MIDDLEWARE` is +therefore not reported, while a middleware class that is absent from the setting is still reported. A `MiddlewareMixin` subclass's hooks are suppressed +by its inheritance entry. The example fixture contains all three forms and an unregistered `UnusedMiddleware`; that class remains in both reports. ## Template layer The template language is data, not Python. deadcode does not parse templates, so a context variable, a `{% url %}` reference, or a filter used only in an -HTML file is invisible. Built-in tags such as `{% for %}`, `{% if %}`, and `{{ value|date }}` need no configuration because deadcode never sees them. -The `TEMPLATES` setting and the backend class are covered by the settings file pattern. +HTML file is invisible. Built-in tags such as `{% for %}`, `{% if %}`, and `{{ value|date }}` need no configuration. The `TEMPLATES` setting and the +backend class are covered by the settings file pattern. -Custom tags and filters are registered at import time and are covered by the `@*register` and `@*register.*` entries: +Custom tags and filters are registered at import time and are covered by `@*register.*`: ```python from django import template @@ -382,14 +240,13 @@ def unused_tag(value): The undecorated `unused_tag` is genuinely unused and remains `DC02`. A tag registered through a differently named library variable needs its own pattern, for example `@*library.*` for `library = template.Library()`. -A context processor is named only as a string in the `TEMPLATES` setting. Mark its definition with `# noqa: DC02` or add its name to `ignore-names`; -a project-wide name entry is broader than one `# noqa` comment when several context processors are genuinely reachable. +A context processor is named only as a string in the `TEMPLATES` setting. Mark its definition with `# noqa: DC02` or add its name to `ignore-names`; a +project-wide name entry is broader than one `# noqa` comment when several context processors are genuinely reachable. ## Forms Django forms declare fields as class attributes, and a `ModelForm` reads its model and field list from a nested `Meta` class. Validation hooks are called -by name after the form is bound. `Form` and `ModelForm` in `ignore-definitions-if-inherits-from` remove the class name, the nested `Meta`, the field -names, and the hooks in one entry: +by name after the form is bound. `django.forms.*Form` removes the class name, the nested `Meta`, the field names, and the hooks in one entry: ```python from django import forms @@ -404,22 +261,16 @@ class ArticleForm(forms.ModelForm): def clean_title(self): return self.cleaned_data['title'].strip() - - -class UnusedForm(forms.Form): - name = forms.CharField(max_length=100) ``` -Without configuration the example reports `Meta`, `model`, `fields`, and `clean_title`, and `UnusedForm` is `DC03`; with the configuration none of them -appear. The same `Model` and `Form` matching rules apply to the dotted and directly imported base, so `forms.Form` and a bare `Form` both match. - -Formsets are created with `formset_factory()`, and their `clean()` method is an ordinary method on the form. Custom validators are ordinary functions; -a validator named only as a string in a field definition needs a name load, `ignore-names`, or `# noqa`. +The `django.forms.*Form` pattern covers `Form`, `ModelForm`, and every form subclass whose base name ends with `Form`. Formsets are created with +`formset_factory()`, and their `clean()` method is an ordinary method on the form. Custom validators are ordinary functions; a validator named only as a +string in a field definition needs a name load, `ignore-names`, or `# noqa`. ## Admin -The admin site discovers `ModelAdmin` subclasses when it imports a project's `admin` module. `@admin.register`, `@register`, and `@admin.display` are -covered by the decorator entries; the options and action methods are covered by `ModelAdmin` in the inheritance list: +The admin site discovers `ModelAdmin` subclasses when it imports a project's `admin` module. `@admin.register`, `@admin.display`, and `@admin.action` are +covered by `@django.contrib.admin.*`; the options and action methods are covered by `django.contrib.admin.*` in the inheritance list: ```python from django.contrib import admin @@ -432,28 +283,21 @@ from .models import Article class ArticleAdmin(admin.ModelAdmin): form = ArticleForm list_display = ('title', 'published') - search_fields = ('title',) - list_filter = ('published',) actions = ('mark_featured',) + @admin.action(description='Mark selected articles as featured') def mark_featured(self, request, queryset): queryset.update(featured=True) - - -class UnusedAdmin(admin.ModelAdmin): - list_display = ('title',) ``` -The `actions` tuple contains the action names as strings, so deadcode does not analyse `'mark_featured'`; the method is covered by the -`ModelAdmin` body rule instead. Without configuration the example reports `ArticleAdmin`, `form`, `list_display`, `search_fields`, `list_filter`, -`actions`, `mark_featured`, and `UnusedAdmin` plus its `list_display`; with the configuration none of them appear. Inline classes inherit -`InlineModelAdmin` rather than `ModelAdmin`, so `*Inline` covers `StackedInline` and `TabularInline` and their options. +The `actions` tuple contains the action names as strings, so deadcode does not analyse `'mark_featured'`; the method is covered by the `ModelAdmin` body +rule instead. Inline classes inherit `InlineModelAdmin` rather than `ModelAdmin`, and `django.contrib.admin.*` covers `StackedInline`, `TabularInline`, +and their options. ## App configs and management commands `AppConfig` subclasses are discovered through the dotted string in `INSTALLED_APPS`. `BaseCommand` subclasses are imported by convention from a -`management/commands` package. `AppConfig` and `BaseCommand` in the inheritance list cover the class name, `default_auto_field`, `name`, `ready`, -`help`, `add_arguments()`, and `handle()` in one entry each: +`management/commands` package. The two inheritance entries cover the class name and the whole body in one rule each: ```python from django.apps import AppConfig @@ -481,12 +325,9 @@ class Command(BaseCommand): pass ``` -No `*Config`, `*Middleware`, or `Command` name pattern is needed. The inheritance entries already hide the class name and body, so the earlier set's -`ignore-definitions = ["*Config", "*Middleware", "Command"]` was redundant and has been removed. A helper command class with another base class is still -reported unless it has a name load or its own inheritance entry. - -`manage.py` and `django-admin` are ordinary scripts. The settings module and application entry points they name are strings, so deadcode never sees those -loads; the settings file pattern covers the settings module. Deployment modules that assign `application` need the file pattern or `# noqa: DC01`. +`django.apps.AppConfig` hides `default_auto_field`, `name`, and `ready`; `django.core.management.base.BaseCommand` hides `help`, `add_arguments()`, and +`handle()`. `manage.py` and `django-admin` are ordinary scripts whose settings module is a string, so the settings file pattern covers that module. +Deployment modules that assign `application` need the file pattern or `# noqa: DC01`. ## Signals @@ -515,16 +356,15 @@ def unused_signal_handler(sender, **kwargs: object): pass ``` -Without configuration the decorated `article_saved` is `DC02` because a decorator does not create a name load. The direct form needs no configuration: -`article_saved_direct` is an ordinary name load in the `post_save.connect(...)` call. The unbound `unused_signal_handler` is `DC02` and remains after the -configuration. `@*receiver` covers the decorated handler and its body, and because it starts with `@*` it matches both `@receiver` and -`@django.dispatch.receiver`. The database signals `pre_save`, `post_save`, `pre_delete`, and `post_delete` all use the same rule. +The decorator form needs `@django.dispatch.receiver`; without it, `article_saved` is `DC02` because a decorator does not create a name load. The direct +`connect()` form is an ordinary name load, so `article_saved_direct` needs no entry. The unconnected `unused_signal_handler` remains `DC02` in both +reports. The database signals `pre_save`, `post_save`, `pre_delete`, and `post_delete` all use the same rule. ## Tests -Django's `TestCase` lifecycle methods are called by the framework, not by project code. deadcode ignores `setUpTestData`, `_pre_setup`, -`_post_teardown`, `_fixture_setup`, and `_fixture_teardown` by default in test files, so the recommended configuration needs no entry for them. A genuine -helper whose name is not a lifecycle name is still reported: +Django's `TestCase` lifecycle methods are called by the framework, not by project code. deadcode ignores `setUpTestData`, `_pre_setup`, `_post_teardown`, +`_fixture_setup`, and `_fixture_teardown` by default in test files, so the recommended configuration needs no entry for them. A genuine helper whose name +is not a lifecycle name is still reported: ```python from django.test import TestCase @@ -537,9 +377,6 @@ class ArticleTests(TestCase): def setUpTestData(cls) -> None: cls.article = Article.objects.create(title='Example') - def _pre_setup(self): - pass - def test_article(self): assert self.article.title == 'Example' @@ -547,16 +384,16 @@ class ArticleTests(TestCase): return None ``` -`setUpTestData`, `_pre_setup`, and the other lifecycle hooks produce no finding. `unused_test_helper` is `DC04` and remains after the configuration. -Django test helpers that the test calls itself, such as `assertContains`, `assertNumQueries`, `settings`, and `modify_settings`, are ordinary name loads and -need no configuration. `pytest-django` fixtures and `unittest` lifecycle methods follow the same rule as ordinary Python code. +`setUpTestData` produces no finding, while `unused_test_helper` is `DC04` and remains after the configuration. Django test helpers that the test calls +itself, such as `assertContains`, `assertNumQueries`, `settings`, and `modify_settings`, are ordinary name loads and need no configuration. +`pytest-django` fixtures and `unittest` lifecycle methods follow the same rule as ordinary Python code. ## Django REST Framework -Django REST Framework needs the class families and the policy decorators. `*ViewSet` covers `ViewSet`, `GenericViewSet`, `ModelViewSet`, and -`ReadOnlyModelViewSet`; `*Serializer` covers `Serializer`, `ModelSerializer`, and `HyperlinkedModelSerializer`; `*Permission`, `*Authentication`, -`*Pagination`, `*FilterBackend`, and `*Throttle` cover the extension classes. `@*api_view`, `@*action`, `@*_classes` (policy decorators such as -`@permission_classes` and `@authentication_classes`), and `@*schema` cover the decorators: +Django REST Framework needs the class families and the policy decorators. `rest_framework.*Serializer` covers `Serializer`, `ModelSerializer`, and +`HyperlinkedModelSerializer`; `rest_framework.*Permission`, `rest_framework.*Authentication`, `rest_framework.*Pagination`, `rest_framework.*FilterBackend`, +and `rest_framework.*Throttle` cover the extension classes. `@rest_framework.*` covers `@api_view`, `@action`, `@schema`, and the `*_classes` policy +decorators such as `@permission_classes` and `@authentication_classes`: ```python from rest_framework import serializers, viewsets @@ -586,59 +423,58 @@ class ArticleViewSet(viewsets.ModelViewSet): @permission_classes([IsAuthenticated]) def article_stats(request): return Response({'articles': Article.objects.count()}) - - -def unused_api_helper(request): - return Response({}) ``` -Routers call `list`, `create`, `retrieve`, `update`, `partial_update`, and `destroy` by name, and no Python code loads those methods in a typical -project; `*ViewSet` removes the body findings. `serializer_class`, the nested serializer `Meta`, `model`, and `fields` disappear with `*Serializer` and -`*ViewSet`. `@*action` covers `publish`, `@*api_view` covers `article_stats`, and `@*_classes` covers `@permission_classes`. `unused_api_helper` is -genuinely unused and remains `DC02`. - -The `IsAuthenticated` class is loaded by the decorator argument, so permissions do not need a name entry. A custom permission, authentication, -pagination, filter backend, or throttle class is covered by the matching family pattern when its name ends with the family suffix; a differently named -base class needs its own inheritance entry. A serializer used only through a string or a dynamic lookup needs a name load or `# noqa`. +Routers call `list`, `create`, `retrieve`, `update`, `partial_update`, and `destroy` by name, so `rest_framework.*ViewSet` removes the body findings. +`serializer_class` and the nested serializer `Meta` disappear with the serializer and viewset entries, `@rest_framework.*` covers `publish`, +`article_stats`, and `@permission_classes`, and `rest_framework.*Permission` covers a custom permission class. An authentication, pagination, filter +backend, or throttle class is covered by the matching family pattern; a differently named base class needs its own inheritance entry. A serializer used +only through a string or a dynamic lookup needs a name load or `# noqa`. ## Remaining framework areas -The same four keys cover the rest of Django. Settings for auth and security (`SECRET_KEY`, `ALLOWED_HOSTS`, `SECURE_*`, `CSRF_*`, `SESSION_COOKIE_*`), -i18n (`LANGUAGES`, `LOCALE_PATHS`, `USE_I18N`), caching (`CACHES`, `CACHE_*`), and GIS (`GDAL_LIBRARY_PATH`, `GEOS_LIBRARY_PATH`) are settings -assignments covered by `*/settings.py`. The auth decorators `@login_required`, `@permission_required`, and `@user_passes_test` are covered by their -entries. `gettext`, `gettext_lazy`, `ngettext`, `cache.get`, `select_related`, `prefetch_related`, `signing.dumps`, and `timezone.localtime` are -ordinary calls and name loads. - -GeoDjango models are still `Model` subclasses and spatial fields are still fields, so the model entry applies. A custom `GeoManager` or `GeoQuerySet` -follows the manager rule. A custom cache backend, storage class, template backend, or database engine named only through a string needs a name load, -`ignore-names`, or `# noqa`. Built-in Django classes and functions are never analysed because deadcode only reads the project's source tree. +The same three keys cover the rest of Django: + +- **Auth and security.** `@django.contrib.auth.*` covers `login_required`, `permission_required`, `user_passes_test`, and the other auth decorators. + `SECRET_KEY`, `ALLOWED_HOSTS`, `SECURE_*`, `CSRF_*`, and `SESSION_COOKIE_*` are settings assignments covered by `*/settings.py`. +- **i18n.** `LANGUAGES`, `LOCALE_PATHS`, and `USE_I18N` are settings; `gettext`, `gettext_lazy`, and `ngettext` are ordinary calls and name loads. +- **Caching.** `CACHES` and the `CACHE_*` settings are covered by the settings file pattern; `cache.get`, `cache.set`, and `cache.delete` are ordinary + attribute loads. A custom cache backend named only through a string needs a name load, `ignore-names`, or `# noqa`. +- **GIS.** GeoDjango models are still `django.db.models.Model` subclasses and spatial fields are still fields, so the model entry applies. A custom + `GeoManager` or `GeoQuerySet` follows the manager rule. `GDAL_LIBRARY_PATH` and `GEOS_LIBRARY_PATH` are settings. +- **contenttypes, sessions, and messages.** Their models inherit from `django.db.models.Model`, and their framework calls such as + `ContentType.objects.get_for_model`, `request.session.get`, and `messages.success` are ordinary calls. +- **Static files.** `STATIC_URL`, `STATIC_ROOT`, `STATICFILES_DIRS`, and `STORAGES` are settings; `static()` and the storage classes are ordinary calls + and name loads. +- **Email and logging.** `EMAIL_*`, `LOGGING`, and the handler classes named as strings need the settings file pattern or `# noqa`. `send_mail`, + `EmailMessage`, and `logging.getLogger` are ordinary calls. +- **Checks, async, and deployment.** `@register` covers a custom check, `async def` views follow the same decorator and base-class rules as synchronous + ones, and `WSGI_APPLICATION` or `ASGI_APPLICATION` is a string covered by the settings file pattern. ## Example application -The acceptance fixture in `tests/files/django` is a small multi-module Django application. It uses `settings.py`, `urls.py`, `views.py`, -`middleware.py`, `models.py`, `managers.py`, `migrations/0001_initial.py`, `templatetags/blog_tags.py`, `forms.py`, `import_forms.py`, -`admin.py`, `apps.py`, `signals.py`, `management/commands/import_data.py`, `tests.py`, and the DRF module `api.py`. - -`settings.py` defines the usual module-level settings, lists `rest_framework` and `project.apps.BlogConfig`, and names all three middleware forms in -`MIDDLEWARE`, plus one intentionally unused setting. `urls.py` defines `app_name`, `urlpatterns`, a DRF router registration, and one intentionally unused -URLconf variable. `views.py` defines function views, the three `@require_*` decorators, a decorated helper that no URLconf loads, `ListView`, -`DetailView`, and `View` subclasses, one intentionally unused setting, one intentionally unused function, and one intentionally unused class. `middleware.py` -contains the function factory, the current class form, and the `MiddlewareMixin` form. `models.py` defines a `Category`/`Article` pair with fields, nested -`Meta` classes, and properties, plus one intentionally unused model. `managers.py` defines a custom `QuerySet` and manager with one intentionally unused -manager method. `migrations/0001_initial.py` is a generated-style migration. `templatetags/blog_tags.py` registers a simple tag and a filter and leaves one -intentionally unused function. `forms.py` defines a `ModelForm` with a nested `Meta` and a validation hook, plus one intentionally unused form. +The acceptance fixture in `tests/files/django/` is a small multi-module Django application. It contains `settings.py`, `urls.py`, `views.py`, +`middleware.py`, `models.py`, `managers.py`, `migrations/0001_initial.py`, `templatetags/blog_tags.py`, `forms.py`, `import_forms.py`, `admin.py`, +`apps.py`, `signals.py`, `management/commands/import_data.py`, `tests.py`, and the DRF module `api.py`. + +`settings.py` lists all three middleware forms in `MIDDLEWARE` and keeps one intentionally unused setting. `urls.py` defines `app_name`, `urlpatterns`, +a DRF router registration, and one intentionally unused URLconf variable. `views.py` defines function views, the three `@require_*` decorators, a +decorated helper that no URLconf loads, `ListView`, `DetailView`, and `View` subclasses, and intentionally unused definitions. `middleware.py` contains +the function factory, the current class form, the `MiddlewareMixin` form, and `UnusedMiddleware`. `models.py` defines a `Category`/`Article` pair with +fields, nested `Meta` classes, and properties. `managers.py` defines a custom `QuerySet` and manager. `migrations/0001_initial.py` is a generated-style +migration. `templatetags/blog_tags.py` registers a simple tag and a filter. `forms.py` defines a `ModelForm` with a nested `Meta` and a validation hook. `import_forms.py` shows the dotted and directly imported model bases and the qualified and bare admin and receiver decorators. `admin.py` registers an -`ArticleAdmin` with options and an action method, plus one intentionally unused admin class. `apps.py` defines a `BlogConfig` with `default_auto_field` -and a `ready()` hook. `signals.py` defines a decorated handler, a directly connected handler, and one intentionally unused handler. -`management/commands/import_data.py` defines a `Command` with `help`, `add_arguments()`, and `handle()`, plus one intentionally unused command. -`tests.py` defines a `TestCase` with Django's five lifecycle hooks, a real test, and one intentionally unused helper. `api.py` defines a -`ModelSerializer`, a `ModelViewSet` with an `@action` method and the router-called methods, an `@api_view` function with `@permission_classes`, and one -intentionally unused helper. - -Without configuration, the settings and URLconf variables, the three middleware forms and the legacy hook, the class-based view handlers, the registered -template tags, the form fields and validation hooks, the admin options and action method, the application configuration, the management command members, -the decorated signal handlers, the import forms, and the DRF serializer, action, and function view are reported together with the intentionally unused -code. The test lifecycle hooks are not reported, because deadcode ignores them by default in test files: +`ArticleAdmin` with options and an action method. `apps.py` defines a `BlogConfig` with `default_auto_field` and a `ready()` hook. `signals.py` defines a +decorated handler, a directly connected handler, and an unconnected handler. `management/commands/import_data.py` defines a `Command` with `help`, +`add_arguments()`, and `handle()`. `tests.py` defines a `TestCase` with Django's five lifecycle hooks and one unused helper. `api.py` defines a +`ModelSerializer`, a `ModelViewSet` with an `@action` method and the router-called methods, an `@api_view` function with `@permission_classes`, and +intentionally unused code. + +Without configuration, the settings and URLconf variables, the legacy middleware hook, the unregistered middleware class, the class-based view handlers, +the registered template tags, the form fields and validation hooks, the admin options and action method, the application configuration, the management +command members, the decorated signal handler, the import forms, and the DRF serializer, action, and function view are reported together with the +intentionally unused code. The registered middleware names are not reported, because deadcode parses `MIDDLEWARE`. The test lifecycle hooks are also +not reported, because deadcode ignores them by default in test files: ```text project/admin.py:8:0: DC03 Class `ArticleAdmin` is never used @@ -679,10 +515,8 @@ project/management/commands/import_data.py:10:4: DC04 Method `handle` is never u project/management/commands/import_data.py:14:0: DC03 Class `UnusedCommand` is never used project/management/commands/import_data.py:15:4: DC04 Method `handle` is never used project/managers.py:13:4: DC04 Method `unused_manager_method` is never used -project/middleware.py:4:0: DC02 Function `simple_middleware` is never used -project/middleware.py:11:0: DC03 Class `CurrentMiddleware` is never used -project/middleware.py:19:0: DC03 Class `SimpleMiddleware` is never used project/middleware.py:20:4: DC04 Method `process_request` is never used +project/middleware.py:24:0: DC03 Class `UnusedMiddleware` is never used project/migrations/0001_initial.py:5:4: DC06 Name `initial` is never used project/migrations/0001_initial.py:7:4: DC06 Name `dependencies` is never used project/migrations/0001_initial.py:9:4: DC06 Name `operations` is never used @@ -723,13 +557,12 @@ project/views.py:52:0: DC03 Class `UnusedView` is never used project/views.py:53:4: DC04 Method `get` is never used ``` -With the recommended configuration, only deliberately unused code remains. The settings and URLconf variables, the middleware names, class-based view -names and handlers, template tags, form and admin definitions, app configuration, management command, decorated signals, import forms, and DRF -definitions disappear. The final report is: +With the recommended configuration, only deliberately unused code and the unregistered middleware remain: ```text project/api.py:45:0: DC02 Function `unused_api_helper` is never used project/managers.py:13:4: DC04 Method `unused_manager_method` is never used +project/middleware.py:24:0: DC03 Class `UnusedMiddleware` is never used project/signals.py:19:0: DC02 Function `unused_signal_handler` is never used project/templatetags/blog_tags.py:16:0: DC02 Function `unused_tag` is never used project/tests.py:26:4: DC04 Method `unused_test_helper` is never used @@ -737,39 +570,74 @@ project/views.py:6:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used project/views.py:13:0: DC02 Function `unused_view` is never used ``` -The intentionally unused classes `UnusedModel`, `UnusedAdmin`, `UnusedForm`, `UnusedCommand`, and `UnusedView` are not in the second report. That is the -documented behaviour of the definition options: `ignore-definitions-if-inherits-from` hides a matching class name as well as its body. The remaining -seven findings are definitions that do not inherit from a framework base and are not decorated, so the four keys do not cover them. +The intentionally unused classes `UnusedModel`, `UnusedAdmin`, `UnusedForm`, `UnusedCommand`, and `UnusedView` are not in the second report, because the +definition options hide a matching class name together with its body. `UnusedMiddleware` remains because it is not registered in `MIDDLEWARE`. The +registered middleware names and the class-based view handlers disappear, as do the settings and URLconf variables, the template tags, the form and admin +definitions, the app configuration, the management command, the decorated signal handler, the import forms, and the DRF definitions. ## Verifying the recommended configuration -Copy the configuration into `pyproject.toml`, then run: +To reproduce the reports, copy the fixture, write the recommended block, and run deadcode: ```shell -deadcode . --no-color +rm -rf /tmp/deadcode-django-example +mkdir -p /tmp/deadcode-django-example +cp -r tests/files/django /tmp/deadcode-django-example/project +cat > /tmp/deadcode-django-example/pyproject.toml <<'EOF' +[tool.deadcode] +ignore-definitions-if-decorated-with = [ + "@django.contrib.admin.*", + "@django.contrib.auth.*", + "@django.dispatch.receiver", + "@django.utils.decorators.method_decorator", + "@django.views.decorators.http.require_*", + "@rest_framework.*", + "@*register.*", +] +ignore-definitions-if-inherits-from = [ + "django.apps.AppConfig", + "django.contrib.admin.*", + "django.core.management.base.BaseCommand", + "django.db.migrations.Migration", + "django.db.models.Model", + "django.forms.*Form", + "django.utils.deprecation.MiddlewareMixin", + "django.views.*View", + "rest_framework.*View", + "rest_framework.*ViewSet", + "rest_framework.*Serializer", + "rest_framework.*Permission", + "rest_framework.*Authentication", + "rest_framework.*Pagination", + "rest_framework.*FilterBackend", + "rest_framework.*Throttle", +] +ignore-names-in-files = ["*/settings.py", "*/urls.py"] +EOF +cd /tmp/deadcode-django-example +deadcode project --no-color ``` -The command exits with status `1` while real dead code remains and `0` once the application has none. If a framework entry point is still reported, -compare its decorator, base class, or file path with the patterns above. Use the category order in this page to find the missing entry, add the -narrowest matching pattern, and run the command again. To reproduce the example, copy `tests/files/django` to a temporary `project/` directory, run -`deadcode project --no-color` without a `pyproject.toml`, then write the recommended block and run the same command again; the two reports must match the -blocks in this page byte for byte. +The unconfigured run reports the first block and exits with status `1`. Removing `pyproject.toml` restores that output. With the file in place, the +command reports only the second block and still exits with status `1`, because real dead code remains in the example. A project with no findings exits +with status `0`. + +If a framework entry point is still reported, compare its decorator, base class, or file path with the patterns above. Use the table in this page to find +the missing family, add the narrowest matching pattern, and run the command again. ## Limitations -- deadcode does not detect Django automatically. Configuration is explicit and applies to any framework that uses decorators, base classes, and file - conventions. -- Alias imports are not resolved. `import django.db.models as m`, `from django.db.models import Model as M`, and an aliased decorator such as - `from django.dispatch import receiver as r` do not match the unaliased patterns until the alias or a dedicated pattern is added. +- Relative and dynamic imports are not resolved. `from .models import Model` and `from . import models` keep working through a bare or wildcard + expression, but they do not match a module-qualified pattern such as `django.db.models.Model`. An import inside a function is outside the module-level + map, and `importlib`, `getattr`, string paths, and code generation cannot be seen by a static analyser. +- `UnusedMiddleware` is intentionally reported. It is absent from `MIDDLEWARE`, so registration cannot mark it as used; the example keeps it to show that + an unregistered middleware class is still detected. +- The fixture contains deliberately unused definitions: `unused_api_helper`, `unused_manager_method`, `unused_signal_handler`, `unused_tag`, + `unused_test_helper`, `UNUSED_VIEW_SETTING`, and `unused_view`. They are the real dead code that keeps the configured exit status at `1`. - Definition options hide names. `ignore-definitions-if-inherits-from` and `ignore-definitions-if-decorated-with` suppress the class or function name together with its body, so a genuinely unused Django subclass is not reported. Use the narrower `ignore-bodies-if-inherits-from` or `ignore-names-if-decorated-with` when the names must remain visible, or mark the definition with `# noqa`. -- String references are invisible. `MIDDLEWARE`, `TEMPLATES`, `ROOT_URLCONF`, `INSTALLED_APPS`, context processors, storage backends, cache backends, - and WSGI or ASGI application paths are strings, so the referenced code needs a name load, a file pattern, or `# noqa`. -- Dynamic registration through `getattr`, `importlib`, string paths, or code generation cannot be seen by a static analyser. +- String references other than `MIDDLEWARE` are invisible. `TEMPLATES`, `ROOT_URLCONF`, `INSTALLED_APPS`, context processors, storage backends, cache + backends, and WSGI or ASGI application paths are strings, so the referenced code needs a name load, a file pattern, or `# noqa`. - A different decorator or base-class spelling needs its own pattern. A custom admin decorator, a custom permission base, or a project-specific generic - view that does not end in `View`, `ViewSet`, or `Serializer` is not covered until the pattern or the name is added. -- The comma is the only command-line list separator. A pipe such as `@admin.*|@receiver|@register.*` is one literal pattern and matches nothing useful; - use separate flags or a comma-separated value instead. -- The recommended set intentionally stays small rather than complete. Add an entry only for the finding in front of you, and prefer the narrowest - decorator or base-class pattern that covers it. + view that does not end in `View`, `ViewSet`, or `Serializer` is not covered until a pattern or a name is added. From a95fdbe6da3ccad5ca9324b6d4250556590d207c Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 07:59:28 +0200 Subject: [PATCH 08/12] record release notes --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c579431..d13f872 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ A checked entry is implemented on `main` and is included in the next release; pu - Add a Django usage guide covering models, admin, views, forms, middleware, signals and more, plus Django `MIDDLEWARE` registration parsing and Django-aware qualified matching through absolute imports. - Ignore Django test lifecycle methods in test files. - **v2.6.0** `2026-11-08`: + - Django `MIDDLEWARE` registrations are treated as used and absolute-import qualified expressions resolve to their modules. - Features: - Add support for Python 3.13, 3.14 and 3.15. - Documentation is now published on Read the Docs at [deadcode.readthedocs.io](https://deadcode.readthedocs.io/). From 59e1242a487bc3b7ad40d8b6388373a48c06886f Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 08:00:36 +0200 Subject: [PATCH 09/12] Fix release notes placement for Django guide --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d13f872..c7e796d 100644 --- a/README.md +++ b/README.md @@ -347,8 +347,8 @@ A checked entry is implemented on `main` and is included in the next release; pu - Declare the package development status as Beta. - Add a Django usage guide covering models, admin, views, forms, middleware, signals and more, plus Django `MIDDLEWARE` registration parsing and Django-aware qualified matching through absolute imports. - Ignore Django test lifecycle methods in test files. -- **v2.6.0** `2026-11-08`: - Django `MIDDLEWARE` registrations are treated as used and absolute-import qualified expressions resolve to their modules. +- **v2.6.0** `2026-11-08`: - Features: - Add support for Python 3.13, 3.14 and 3.15. - Documentation is now published on Read the Docs at [deadcode.readthedocs.io](https://deadcode.readthedocs.io/). From 8b3bd06e9b60a53bc2ac4b3f8bfac828aab9b880 Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 08:02:28 +0200 Subject: [PATCH 10/12] Format the qualified name resolution test --- tests/visitor/test_qualified_name_resolution.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/visitor/test_qualified_name_resolution.py b/tests/visitor/test_qualified_name_resolution.py index 035fed5..30e5df3 100644 --- a/tests/visitor/test_qualified_name_resolution.py +++ b/tests/visitor/test_qualified_name_resolution.py @@ -143,6 +143,5 @@ def unused_sentinel(): # Then assert unused_names == ( - 'foo.py:4:0: DC03 Class `LocalModel` is never used\n' - 'foo.py:8:0: DC02 Function `unused_sentinel` is never used' + 'foo.py:4:0: DC03 Class `LocalModel` is never used\nfoo.py:8:0: DC02 Function `unused_sentinel` is never used' ) From 8142f0d79f4b8ae21749aeb0683dcf59b72c6479 Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 09:56:54 +0200 Subject: [PATCH 11/12] Move Django REST Framework patterns to their own section --- README.md | 17 +--- tests/documented_examples/test_django.py | 101 +++++++++++++++++------ 2 files changed, 82 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index c7e796d..2b2c2db 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,6 @@ ignore-definitions-if-decorated-with = [ "@django.dispatch.receiver", "@django.utils.decorators.method_decorator", "@django.views.decorators.http.require_*", - "@rest_framework.*", "@*register.*", ] ignore-definitions-if-inherits-from = [ @@ -100,26 +99,18 @@ ignore-definitions-if-inherits-from = [ "django.forms.*Form", "django.utils.deprecation.MiddlewareMixin", "django.views.*View", - "rest_framework.*View", - "rest_framework.*ViewSet", - "rest_framework.*Serializer", - "rest_framework.*Permission", - "rest_framework.*Authentication", - "rest_framework.*Pagination", - "rest_framework.*FilterBackend", - "rest_framework.*Throttle", ] ignore-names-in-files = ["*/settings.py", "*/urls.py"] ``` -The example targets Django 5.2 LTS (5.2.19) and Django REST Framework 3.18.3 and uses APIs present in Django 5.2 through 6.1. The three keys replace the +The example targets Django 5.2 LTS (5.2.19). The Django REST Framework 3.18.3 entries are additions to the same two keys; see the +[Django REST Framework section of the Django guide](https://deadcode.readthedocs.io/en/latest/django.html#django-rest-framework). The three keys replace the older overlapping name, body, and definition options: one inheritance entry hides a framework subclass name and body, and one decorator entry hides a decorated definition name and body. deadcode parses `MIDDLEWARE` registrations and resolves absolute-import qualified names, so a registered middleware name and a `models.Model` base need no separate name pattern. -Read the [Django guide](https://deadcode.readthedocs.io/en/latest/django.html) for the common use cases, every framework area, the current and legacy -middleware forms, both import forms, database signals, Django REST Framework, the complete example application, and the expected output with no wrong -detections. +Read the [Django guide](https://deadcode.readthedocs.io/en/latest/django.html) for the framework areas, the current and legacy +middleware forms, both import forms, database signals, the Django REST Framework entries, and the expected behaviour. ## Pre-commit hook Create a `.pre-commit-config.yaml` file in the root of your project directory, if it doesn't exist, and add the following to the file: diff --git a/tests/documented_examples/test_django.py b/tests/documented_examples/test_django.py index bbea91a..3d70d23 100644 --- a/tests/documented_examples/test_django.py +++ b/tests/documented_examples/test_django.py @@ -7,17 +7,16 @@ FIXTURE_DIRECTORY = Path(__file__).resolve().parents[1] / 'files' / 'django' -DECORATOR_PATTERNS = [ +DJANGO_DECORATOR_PATTERNS = [ '@django.contrib.admin.*', '@django.contrib.auth.*', '@django.dispatch.receiver', '@django.utils.decorators.method_decorator', '@django.views.decorators.http.require_*', - '@rest_framework.*', '@*register.*', ] -INHERITANCE_PATTERNS = [ +DJANGO_INHERITANCE_PATTERNS = [ 'django.apps.AppConfig', 'django.contrib.admin.*', 'django.core.management.base.BaseCommand', @@ -26,6 +25,11 @@ 'django.forms.*Form', 'django.utils.deprecation.MiddlewareMixin', 'django.views.*View', +] + +DJANGO_REST_FRAMEWORK_DECORATOR_PATTERNS = ['@rest_framework.*'] + +DJANGO_REST_FRAMEWORK_INHERITANCE_PATTERNS = [ 'rest_framework.*View', 'rest_framework.*ViewSet', 'rest_framework.*Serializer', @@ -36,28 +40,39 @@ 'rest_framework.*Throttle', ] +FULL_DECORATOR_PATTERNS = DJANGO_DECORATOR_PATTERNS + DJANGO_REST_FRAMEWORK_DECORATOR_PATTERNS + +FULL_INHERITANCE_PATTERNS = DJANGO_INHERITANCE_PATTERNS + DJANGO_REST_FRAMEWORK_INHERITANCE_PATTERNS + FILE_PATTERNS = ['*/settings.py', '*/urls.py'] -CONFIGURATION = ( - '[tool.deadcode]\n' - 'ignore-definitions-if-decorated-with = [\n' - + ''.join(f' "{pattern}",\n' for pattern in DECORATOR_PATTERNS) - + ']\n' - 'ignore-definitions-if-inherits-from = [\n' - + ''.join(f' "{pattern}",\n' for pattern in INHERITANCE_PATTERNS) - + ']\n' - 'ignore-names-in-files = ["*/settings.py", "*/urls.py"]\n' -) + +def _configuration(decorator_patterns: list[str], inheritance_patterns: list[str]) -> str: + return ( + '[tool.deadcode]\n' + 'ignore-definitions-if-decorated-with = [\n' + + ''.join(f' "{pattern}",\n' for pattern in decorator_patterns) + + ']\n' + 'ignore-definitions-if-inherits-from = [\n' + + ''.join(f' "{pattern}",\n' for pattern in inheritance_patterns) + + ']\n' + 'ignore-names-in-files = ["*/settings.py", "*/urls.py"]\n' + ) + + +DJANGO_CONFIGURATION = _configuration(DJANGO_DECORATOR_PATTERNS, DJANGO_INHERITANCE_PATTERNS) + +DJANGO_REST_FRAMEWORK_CONFIGURATION = _configuration(FULL_DECORATOR_PATTERNS, FULL_INHERITANCE_PATTERNS) COMMAND_LINE_OPTIONS = [ - *(f'--ignore-definitions-if-decorated-with={pattern}' for pattern in DECORATOR_PATTERNS), - *(f'--ignore-definitions-if-inherits-from={pattern}' for pattern in INHERITANCE_PATTERNS), + *(f'--ignore-definitions-if-decorated-with={pattern}' for pattern in FULL_DECORATOR_PATTERNS), + *(f'--ignore-definitions-if-inherits-from={pattern}' for pattern in FULL_INHERITANCE_PATTERNS), *(f'--ignore-names-in-files={pattern}' for pattern in FILE_PATTERNS), ] COMMA_SEPARATED_COMMAND_LINE_OPTIONS = [ - f'--ignore-definitions-if-decorated-with={",".join(DECORATOR_PATTERNS)}', - f'--ignore-definitions-if-inherits-from={",".join(INHERITANCE_PATTERNS)}', + f'--ignore-definitions-if-decorated-with={",".join(FULL_DECORATOR_PATTERNS)}', + f'--ignore-definitions-if-inherits-from={",".join(FULL_INHERITANCE_PATTERNS)}', f'--ignore-names-in-files={",".join(FILE_PATTERNS)}', ] @@ -149,6 +164,17 @@ ('project/views.py', 'DC02', 'unused_view'), } +DJANGO_CONFIGURED_FINDINGS = CONFIGURED_FINDINGS | { + ('project/api.py', 'DC03', 'Meta'), + ('project/api.py', 'DC06', 'model'), + ('project/api.py', 'DC06', 'fields'), + ('project/api.py', 'DC06', 'serializer_class'), + ('project/api.py', 'DC04', 'publish'), + ('project/api.py', 'DC04', 'retrieve'), + ('project/api.py', 'DC04', 'destroy'), + ('project/api.py', 'DC02', 'article_stats'), +} + def _copy_fixture(tmp_path: Path) -> None: shutil.copytree(FIXTURE_DIRECTORY, tmp_path / 'project') @@ -170,7 +196,14 @@ def _assert_no_parse_warning(output: str | None) -> None: def _configured_findings(tmp_path: Path) -> set[tuple[str, str, str]]: - (tmp_path / 'pyproject.toml').write_text(CONFIGURATION) + (tmp_path / 'pyproject.toml').write_text(DJANGO_CONFIGURATION) + output = main(['project', '--no-color']) + _assert_no_parse_warning(output) + return _findings(output) + + +def _rest_framework_configured_findings(tmp_path: Path) -> set[tuple[str, str, str]]: + (tmp_path / 'pyproject.toml').write_text(DJANGO_REST_FRAMEWORK_CONFIGURATION) output = main(['project', '--no-color']) _assert_no_parse_warning(output) return _findings(output) @@ -197,7 +230,7 @@ def test_django_fixture_with_documented_configuration(project_directory: Path) - findings = _configured_findings(project_directory) # Then - assert findings == CONFIGURED_FINDINGS + assert findings == DJANGO_CONFIGURED_FINDINGS assert print_main(['project', '--no-color']) == 1 @@ -321,7 +354,7 @@ def test_decorator_wildcards_with_documented_configuration(project_directory: Pa _copy_fixture(project_directory) # When - findings = _configured_findings(project_directory) + findings = _rest_framework_configured_findings(project_directory) # Then names = {name for _, _, name in findings} @@ -430,13 +463,35 @@ def test_django_rest_framework_with_documented_configuration(project_directory: _copy_fixture(project_directory) # When - findings = _configured_findings(project_directory) + findings = _rest_framework_configured_findings(project_directory) # Then api_findings = [finding for finding in findings if finding[0] == 'project/api.py'] assert api_findings == [('project/api.py', 'DC02', 'unused_api_helper')] +def test_django_rest_framework_reported_with_pure_django_configuration(project_directory: Path) -> None: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then + api_findings = {finding for finding in findings if finding[0] == 'project/api.py'} + assert api_findings == { + ('project/api.py', 'DC03', 'Meta'), + ('project/api.py', 'DC06', 'model'), + ('project/api.py', 'DC06', 'fields'), + ('project/api.py', 'DC06', 'serializer_class'), + ('project/api.py', 'DC04', 'publish'), + ('project/api.py', 'DC04', 'retrieve'), + ('project/api.py', 'DC04', 'destroy'), + ('project/api.py', 'DC02', 'article_stats'), + ('project/api.py', 'DC02', 'unused_api_helper'), + } + + def test_model_layer_false_positives_with_documented_configuration(project_directory: Path) -> None: # Having _copy_fixture(project_directory) @@ -447,7 +502,7 @@ def test_model_layer_false_positives_with_documented_configuration(project_direc # Then assert not any(path == 'project/models.py' for path, _, _ in findings) assert not any(path == 'project/managers.py' and code != 'DC04' for path, code, _ in findings) - assert not any(name == 'Meta' for _, _, name in findings) + assert not any(name == 'Meta' for path, _, name in findings if path != 'project/api.py') assert not any(name == 'display_name' for _, _, name in findings) assert not any(path.startswith('project/migrations/') for path, _, _ in findings) @@ -506,7 +561,7 @@ def test_forms_false_positives_with_documented_configuration(project_directory: findings = _configured_findings(project_directory) # Then - names = {name for _, _, name in findings} + names = {name for path, _, name in findings if path != 'project/api.py'} assert 'Meta' not in names assert 'model' not in names assert 'fields' not in names From ad4bcc6c37cafe26fa23797de52af3841b57d694 Mon Sep 17 00:00:00 2001 From: Albertas Gimbutas Date: Tue, 10 Nov 2026 09:56:55 +0200 Subject: [PATCH 12/12] Reorganize Django guide sections and remove example walkthrough --- docs/django.md | 231 +++++++------------------------------------------ docs/index.md | 4 +- 2 files changed, 35 insertions(+), 200 deletions(-) diff --git a/docs/django.md b/docs/django.md index 4a830cb..293bc0d 100644 --- a/docs/django.md +++ b/docs/django.md @@ -1,9 +1,8 @@ # Django deadcode parses source text and never imports the project, so it sees only Python name loads. Django reaches code through base classes, decorators, -`MIDDLEWARE` strings, settings modules, and URLconfs, and those framework entry points look unused to a static analyser. This page shows one small -configuration for Django and Django REST Framework, explains `MIDDLEWARE` registrations and both qualified import forms, and gives a complete example -application whose two reports you can reproduce. +`MIDDLEWARE` strings, settings modules, and URLconfs, and those framework entry points look unused to a static analyser. This page shows a short Django +configuration, explains `MIDDLEWARE` registrations and both qualified import forms, and lists the entries to append for Django REST Framework. ## Recommended configuration @@ -17,7 +16,6 @@ ignore-definitions-if-decorated-with = [ "@django.dispatch.receiver", "@django.utils.decorators.method_decorator", "@django.views.decorators.http.require_*", - "@rest_framework.*", "@*register.*", ] ignore-definitions-if-inherits-from = [ @@ -29,22 +27,14 @@ ignore-definitions-if-inherits-from = [ "django.forms.*Form", "django.utils.deprecation.MiddlewareMixin", "django.views.*View", - "rest_framework.*View", - "rest_framework.*ViewSet", - "rest_framework.*Serializer", - "rest_framework.*Permission", - "rest_framework.*Authentication", - "rest_framework.*Pagination", - "rest_framework.*FilterBackend", - "rest_framework.*Throttle", ] ignore-names-in-files = ["*/settings.py", "*/urls.py"] ``` -Each entry covers one family, and no two entries match the same qualified base or decorator. One inheritance entry hides a framework subclass name and -its whole body; one decorator entry hides a decorated definition name and its body; the file patterns remove settings and URLconf configuration names. -`MIDDLEWARE` registrations need no entry because deadcode parses the setting itself. See {doc}`usage` for the command-line form of every key and -{doc}`rules` for the rule codes. +Each Django entry covers one family, and no two entries match the same qualified base or decorator. One inheritance entry hides a framework subclass name +and its whole body; one decorator entry hides a decorated definition name and its body; the file patterns remove settings and URLconf configuration names. +`MIDDLEWARE` registrations need no entry because deadcode parses the setting itself. Django REST Framework needs the entries in the +[Django REST Framework](#django-rest-framework) section. See {doc}`usage` for the command-line form of every key and {doc}`rules` for the rule codes. ## Common use cases @@ -66,7 +56,7 @@ its whole body; one decorator entry hides a decorated definition name and its bo | Tests | No entry; the Django test lifecycle names are ignored by default in test files | | Auth and security | `@django.contrib.auth.*` for the auth decorators and `*/settings.py` for the settings | | i18n, caching, and GIS | `*/settings.py` for the settings; ordinary calls and name loads need no entry | -| Django REST Framework | `rest_framework.*View`, `rest_framework.*ViewSet`, `rest_framework.*Serializer`, and the permission, authentication, pagination, filter, and throttle families, plus `@rest_framework.*` | +| Django REST Framework | See [Django REST Framework](#django-rest-framework) for the entries to append. | ## Versions @@ -111,8 +101,7 @@ class D(M): ``` Relative imports and dynamic imports are not resolved. `from .models import Model` with `class Article(Model)` keeps working through the source -candidate and a bare or wildcard expression such as `Model` or `*Model`, but it does not match `django.db.models.Model`. An import performed inside a -function is also outside the module-level map. +candidate and a bare or wildcard expression such as `Model` or `*Model`, but it does not match `django.db.models.Model`. Custom managers and `QuerySet` subclasses are not `Model` subclasses. A method that only other code calls is still reported; add the manager base to the inheritance list when its whole body should be ignored, or mark the method with `# noqa: DC04`. Migrations are covered by @@ -158,8 +147,8 @@ through a string, a dynamic import, or code outside the analysed tree looks unus | `@admin.register`, `@admin.display`, `@admin.action` | `@django.contrib.admin.*` | Class-based views are covered by inheritance. `django.views.*View` matches `View`, `TemplateView`, `RedirectView`, `DetailView`, `ListView`, `FormView`, -`CreateView`, `UpdateView`, `DeleteView`, and the archive views. `rest_framework.*View` matches `APIView` and the generic API views, and -`rest_framework.*ViewSet` matches the viewset family. +`CreateView`, `UpdateView`, `DeleteView`, and the archive views. Django REST Framework views and viewsets need the entries in +[Django REST Framework](#django-rest-framework). A project subclass such as `ArticleListView(django.views.generic.ListView)` is covered through its inherited framework base, so the class name and the whole body are suppressed. Router-called methods (`list`, `create`, `retrieve`, `update`, `partial_update`, `destroy`) are suppressed with the class body @@ -393,7 +382,28 @@ itself, such as `assertContains`, `assertNumQueries`, `settings`, and `modify_se Django REST Framework needs the class families and the policy decorators. `rest_framework.*Serializer` covers `Serializer`, `ModelSerializer`, and `HyperlinkedModelSerializer`; `rest_framework.*Permission`, `rest_framework.*Authentication`, `rest_framework.*Pagination`, `rest_framework.*FilterBackend`, and `rest_framework.*Throttle` cover the extension classes. `@rest_framework.*` covers `@api_view`, `@action`, `@schema`, and the `*_classes` policy -decorators such as `@permission_classes` and `@authentication_classes`: +decorators such as `@permission_classes` and `@authentication_classes`. + +These entries are appended to the two arrays in `## Recommended configuration`; the fragment intentionally has no `[tool.deadcode]` header and no +`ignore-names-in-files` key: + +```toml +ignore-definitions-if-decorated-with = [ + "@rest_framework.*", +] +ignore-definitions-if-inherits-from = [ + "rest_framework.*View", + "rest_framework.*ViewSet", + "rest_framework.*Serializer", + "rest_framework.*Permission", + "rest_framework.*Authentication", + "rest_framework.*Pagination", + "rest_framework.*FilterBackend", + "rest_framework.*Throttle", +] +``` + +The snippet below uses these entries: ```python from rest_framework import serializers, viewsets @@ -451,185 +461,10 @@ The same three keys cover the rest of Django: - **Checks, async, and deployment.** `@register` covers a custom check, `async def` views follow the same decorator and base-class rules as synchronous ones, and `WSGI_APPLICATION` or `ASGI_APPLICATION` is a string covered by the settings file pattern. -## Example application - -The acceptance fixture in `tests/files/django/` is a small multi-module Django application. It contains `settings.py`, `urls.py`, `views.py`, -`middleware.py`, `models.py`, `managers.py`, `migrations/0001_initial.py`, `templatetags/blog_tags.py`, `forms.py`, `import_forms.py`, `admin.py`, -`apps.py`, `signals.py`, `management/commands/import_data.py`, `tests.py`, and the DRF module `api.py`. - -`settings.py` lists all three middleware forms in `MIDDLEWARE` and keeps one intentionally unused setting. `urls.py` defines `app_name`, `urlpatterns`, -a DRF router registration, and one intentionally unused URLconf variable. `views.py` defines function views, the three `@require_*` decorators, a -decorated helper that no URLconf loads, `ListView`, `DetailView`, and `View` subclasses, and intentionally unused definitions. `middleware.py` contains -the function factory, the current class form, the `MiddlewareMixin` form, and `UnusedMiddleware`. `models.py` defines a `Category`/`Article` pair with -fields, nested `Meta` classes, and properties. `managers.py` defines a custom `QuerySet` and manager. `migrations/0001_initial.py` is a generated-style -migration. `templatetags/blog_tags.py` registers a simple tag and a filter. `forms.py` defines a `ModelForm` with a nested `Meta` and a validation hook. -`import_forms.py` shows the dotted and directly imported model bases and the qualified and bare admin and receiver decorators. `admin.py` registers an -`ArticleAdmin` with options and an action method. `apps.py` defines a `BlogConfig` with `default_auto_field` and a `ready()` hook. `signals.py` defines a -decorated handler, a directly connected handler, and an unconnected handler. `management/commands/import_data.py` defines a `Command` with `help`, -`add_arguments()`, and `handle()`. `tests.py` defines a `TestCase` with Django's five lifecycle hooks and one unused helper. `api.py` defines a -`ModelSerializer`, a `ModelViewSet` with an `@action` method and the router-called methods, an `@api_view` function with `@permission_classes`, and -intentionally unused code. - -Without configuration, the settings and URLconf variables, the legacy middleware hook, the unregistered middleware class, the class-based view handlers, -the registered template tags, the form fields and validation hooks, the admin options and action method, the application configuration, the management -command members, the decorated signal handler, the import forms, and the DRF serializer, action, and function view are reported together with the -intentionally unused code. The registered middleware names are not reported, because deadcode parses `MIDDLEWARE`. The test lifecycle hooks are also -not reported, because deadcode ignores them by default in test files: - -```text -project/admin.py:8:0: DC03 Class `ArticleAdmin` is never used -project/admin.py:9:4: DC06 Name `form` is never used -project/admin.py:10:4: DC06 Name `list_display` is never used -project/admin.py:11:4: DC06 Name `search_fields` is never used -project/admin.py:12:4: DC06 Name `list_filter` is never used -project/admin.py:13:4: DC06 Name `actions` is never used -project/admin.py:15:4: DC04 Method `mark_featured` is never used -project/admin.py:19:0: DC03 Class `UnusedAdmin` is never used -project/admin.py:20:4: DC06 Name `list_display` is never used -project/api.py:10:4: DC03 Class `Meta` is never used -project/api.py:11:8: DC06 Name `model` is never used -project/api.py:12:8: DC06 Name `fields` is never used -project/api.py:17:4: DC06 Name `serializer_class` is never used -project/api.py:20:4: DC04 Method `publish` is never used -project/api.py:29:4: DC04 Method `retrieve` is never used -project/api.py:35:4: DC04 Method `destroy` is never used -project/api.py:41:0: DC02 Function `article_stats` is never used -project/api.py:45:0: DC02 Function `unused_api_helper` is never used -project/apps.py:4:0: DC03 Class `BlogConfig` is never used -project/apps.py:5:4: DC06 Name `default_auto_field` is never used -project/apps.py:8:4: DC04 Method `ready` is never used -project/forms.py:7:4: DC03 Class `Meta` is never used -project/forms.py:8:8: DC06 Name `model` is never used -project/forms.py:9:8: DC06 Name `fields` is never used -project/forms.py:11:4: DC04 Method `clean_title` is never used -project/forms.py:15:0: DC03 Class `UnusedForm` is never used -project/import_forms.py:14:0: DC03 Class `QualifiedModel` is never used -project/import_forms.py:18:0: DC03 Class `ImportedForm` is never used -project/import_forms.py:23:0: DC03 Class `QualifiedAdmin` is never used -project/import_forms.py:28:0: DC03 Class `BareAdmin` is never used -project/import_forms.py:33:0: DC02 Function `imported_receiver` is never used -project/management/commands/import_data.py:4:0: DC03 Class `Command` is never used -project/management/commands/import_data.py:5:4: DC06 Name `help` is never used -project/management/commands/import_data.py:7:4: DC04 Method `add_arguments` is never used -project/management/commands/import_data.py:10:4: DC04 Method `handle` is never used -project/management/commands/import_data.py:14:0: DC03 Class `UnusedCommand` is never used -project/management/commands/import_data.py:15:4: DC04 Method `handle` is never used -project/managers.py:13:4: DC04 Method `unused_manager_method` is never used -project/middleware.py:20:4: DC04 Method `process_request` is never used -project/middleware.py:24:0: DC03 Class `UnusedMiddleware` is never used -project/migrations/0001_initial.py:5:4: DC06 Name `initial` is never used -project/migrations/0001_initial.py:7:4: DC06 Name `dependencies` is never used -project/migrations/0001_initial.py:9:4: DC06 Name `operations` is never used -project/models.py:9:4: DC03 Class `Meta` is never used -project/models.py:10:8: DC06 Name `verbose_name_plural` is never used -project/models.py:18:4: DC06 Name `category` is never used -project/models.py:23:4: DC03 Class `Meta` is never used -project/models.py:24:8: DC06 Name `ordering` is never used -project/models.py:27:4: DC08 Property `display_name` is never used -project/models.py:31:4: DC08 Property `published_titles` is never used -project/models.py:35:0: DC03 Class `UnusedModel` is never used -project/settings.py:1:0: DC01 Variable `SECRET_KEY` is never used -project/settings.py:2:0: DC01 Variable `DEBUG` is never used -project/settings.py:4:0: DC01 Variable `INSTALLED_APPS` is never used -project/settings.py:11:0: DC01 Variable `MIDDLEWARE` is never used -project/settings.py:18:0: DC01 Variable `TEMPLATES` is never used -project/settings.py:30:0: DC01 Variable `DATABASES` is never used -project/settings.py:36:0: DC01 Variable `ROOT_URLCONF` is never used -project/settings.py:38:0: DC01 Variable `UNUSED_SETTING` is never used -project/signals.py:8:0: DC02 Function `article_saved` is never used -project/signals.py:19:0: DC02 Function `unused_signal_handler` is never used -project/templatetags/blog_tags.py:7:0: DC02 Function `greeting` is never used -project/templatetags/blog_tags.py:12:0: DC02 Function `shout` is never used -project/templatetags/blog_tags.py:16:0: DC02 Function `unused_tag` is never used -project/tests.py:26:4: DC04 Method `unused_test_helper` is never used -project/urls.py:7:0: DC01 Variable `app_name` is never used -project/urls.py:12:0: DC01 Variable `urlpatterns` is never used -project/urls.py:21:0: DC01 Variable `UNUSED_URL_NAME` is never used -project/views.py:6:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used -project/views.py:13:0: DC02 Function `unused_view` is never used -project/views.py:33:0: DC02 Function `unreferenced_require_view` is never used -project/views.py:37:0: DC03 Class `ArticleListView` is never used -project/views.py:38:4: DC04 Method `get` is never used -project/views.py:42:0: DC03 Class `ArticleDetailView` is never used -project/views.py:43:4: DC04 Method `get` is never used -project/views.py:48:4: DC04 Method `get` is never used -project/views.py:52:0: DC03 Class `UnusedView` is never used -project/views.py:53:4: DC04 Method `get` is never used -``` - -With the recommended configuration, only deliberately unused code and the unregistered middleware remain: - -```text -project/api.py:45:0: DC02 Function `unused_api_helper` is never used -project/managers.py:13:4: DC04 Method `unused_manager_method` is never used -project/middleware.py:24:0: DC03 Class `UnusedMiddleware` is never used -project/signals.py:19:0: DC02 Function `unused_signal_handler` is never used -project/templatetags/blog_tags.py:16:0: DC02 Function `unused_tag` is never used -project/tests.py:26:4: DC04 Method `unused_test_helper` is never used -project/views.py:6:0: DC01 Variable `UNUSED_VIEW_SETTING` is never used -project/views.py:13:0: DC02 Function `unused_view` is never used -``` - -The intentionally unused classes `UnusedModel`, `UnusedAdmin`, `UnusedForm`, `UnusedCommand`, and `UnusedView` are not in the second report, because the -definition options hide a matching class name together with its body. `UnusedMiddleware` remains because it is not registered in `MIDDLEWARE`. The -registered middleware names and the class-based view handlers disappear, as do the settings and URLconf variables, the template tags, the form and admin -definitions, the app configuration, the management command, the decorated signal handler, the import forms, and the DRF definitions. - -## Verifying the recommended configuration - -To reproduce the reports, copy the fixture, write the recommended block, and run deadcode: - -```shell -rm -rf /tmp/deadcode-django-example -mkdir -p /tmp/deadcode-django-example -cp -r tests/files/django /tmp/deadcode-django-example/project -cat > /tmp/deadcode-django-example/pyproject.toml <<'EOF' -[tool.deadcode] -ignore-definitions-if-decorated-with = [ - "@django.contrib.admin.*", - "@django.contrib.auth.*", - "@django.dispatch.receiver", - "@django.utils.decorators.method_decorator", - "@django.views.decorators.http.require_*", - "@rest_framework.*", - "@*register.*", -] -ignore-definitions-if-inherits-from = [ - "django.apps.AppConfig", - "django.contrib.admin.*", - "django.core.management.base.BaseCommand", - "django.db.migrations.Migration", - "django.db.models.Model", - "django.forms.*Form", - "django.utils.deprecation.MiddlewareMixin", - "django.views.*View", - "rest_framework.*View", - "rest_framework.*ViewSet", - "rest_framework.*Serializer", - "rest_framework.*Permission", - "rest_framework.*Authentication", - "rest_framework.*Pagination", - "rest_framework.*FilterBackend", - "rest_framework.*Throttle", -] -ignore-names-in-files = ["*/settings.py", "*/urls.py"] -EOF -cd /tmp/deadcode-django-example -deadcode project --no-color -``` - -The unconfigured run reports the first block and exits with status `1`. Removing `pyproject.toml` restores that output. With the file in place, the -command reports only the second block and still exits with status `1`, because real dead code remains in the example. A project with no findings exits -with status `0`. - -If a framework entry point is still reported, compare its decorator, base class, or file path with the patterns above. Use the table in this page to find -the missing family, add the narrowest matching pattern, and run the command again. - ## Limitations - Relative and dynamic imports are not resolved. `from .models import Model` and `from . import models` keep working through a bare or wildcard - expression, but they do not match a module-qualified pattern such as `django.db.models.Model`. An import inside a function is outside the module-level - map, and `importlib`, `getattr`, string paths, and code generation cannot be seen by a static analyser. + expression, but they do not match a module-qualified pattern such as `django.db.models.Model`. `importlib`, `getattr`, string paths, and code generation cannot be seen by a static analyser. - `UnusedMiddleware` is intentionally reported. It is absent from `MIDDLEWARE`, so registration cannot mark it as used; the example keeps it to show that an unregistered middleware class is still detected. - The fixture contains deliberately unused definitions: `unused_api_helper`, `unused_manager_method`, `unused_signal_handler`, `unused_tag`, diff --git a/docs/index.md b/docs/index.md index 5fe3192..a5ae79e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,8 +20,8 @@ Read the {doc}`rules` page for the implemented rules and for ignoring findings w Read the {doc}`fastapi` page for running deadcode on a FastAPI project, including common use cases, dependency injection, and a complete example application. -Read the {doc}`django` page for running deadcode on a Django project, including settings, models, views, templates, forms, the admin, and a -complete example application. +Read the {doc}`django` page for running deadcode on a Django project, including the recommended configuration, settings, models, views, templates, +forms, the admin, middleware, signals, and Django REST Framework. Read the {doc}`pre_commit` page for setting up and using the pre-commit hook.