diff --git a/README.md b/README.md index bb71950..2b2c2db 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,43 @@ 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, 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-definitions-if-decorated-with = [ + "@django.contrib.admin.*", + "@django.contrib.auth.*", + "@django.dispatch.receiver", + "@django.utils.decorators.method_decorator", + "@django.views.decorators.http.require_*", + "@*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", +] +ignore-names-in-files = ["*/settings.py", "*/urls.py"] +``` + +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 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: @@ -299,7 +336,9 @@ 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. + - 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. + - 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. 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/docs/django.md b/docs/django.md new file mode 100644 index 0000000..293bc0d --- /dev/null +++ b/docs/django.md @@ -0,0 +1,478 @@ +# 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 a short Django +configuration, explains `MIDDLEWARE` registrations and both qualified import forms, and lists the entries to append for Django REST Framework. + +## Recommended configuration + +Add these three option keys to `pyproject.toml` in the directory where you run deadcode: + +```toml +[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_*", + "@*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", +] +ignore-names-in-files = ["*/settings.py", "*/urls.py"] +``` + +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 + +| Django part | Configuration entry | +|-------------|---------------------| +| 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 | +| 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 | See [Django REST Framework](#django-rest-framework) for the entries to append. | + +## 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 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 + +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. + +### Both import forms + +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: + +```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 A(models.Model): + title = models.CharField(max_length=100) + + +class B(Model): + title = models.CharField(max_length=100) + + +class C(m.Model): + title = models.CharField(max_length=100) + + +class D(M): + title = models.CharField(max_length=100) +``` + +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`. + +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 no project code loads. The file patterns remove them: + +```python +from django.urls import include, path + +from . import views + +app_name = 'django_example' + +urlpatterns = [ + path('', views.index, name='index'), + path('articles/', include('project.articles.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. + +## 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. The function decorators are covered by their family entries: + +| 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. `django.views.*View` matches `View`, `TemplateView`, `RedirectView`, `DetailView`, `ListView`, `FormView`, +`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 +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 +as a string needs a name load or `# noqa`. + +## 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) +``` + +`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 + + +class SimpleMiddleware(MiddlewareMixin): + def process_request(self, request): + return None +``` + +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. 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 `@*register.*`: + +```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, 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. 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. `django.forms.*Form` removes the class name, the nested `Meta`, the field names, and the hooks in one entry: + +```python +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() +``` + +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`, `@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 + +from .forms import ArticleForm +from .models import Article + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + form = ArticleForm + list_display = ('title', 'published') + actions = ('mark_featured',) + + @admin.action(description='Mark selected articles as featured') + def mark_featured(self, request, queryset): + queryset.update(featured=True) +``` + +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. The two inheritance entries cover the class name and the whole body in one rule each: + +```python +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 +``` + +```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 +``` + +`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 + +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 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: + +```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 test_article(self): + assert self.article.title == 'Example' + + def unused_test_helper(self): + return None +``` + +`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. `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`. + +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 +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()}) +``` + +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 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. + +## 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`. `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 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 a pattern or a name is added. diff --git a/docs/features.md b/docs/features.md index 7740911..5719288 100644 --- a/docs/features.md +++ b/docs/features.md @@ -45,6 +45,7 @@ for every row. | Unittest lifecycle methods | `## Ignoring unittest lifecycle methods` | `deadcode/visitor/ignore.py` | `tests/visitor/test_unittest_lifecycle_methods.py` | 3 | | Django test lifecycle methods | `## Ignoring Django test lifecycle methods` | `deadcode/visitor/ignore.py` | `tests/visitor/test_django_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..a5ae79e 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 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. 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..3d70d23 --- /dev/null +++ b/tests/documented_examples/test_django.py @@ -0,0 +1,640 @@ +import re +import shutil + +from pathlib import Path + +from deadcode.cli import main, print_main + +FIXTURE_DIRECTORY = Path(__file__).resolve().parents[1] / 'files' / 'django' + +DJANGO_DECORATOR_PATTERNS = [ + '@django.contrib.admin.*', + '@django.contrib.auth.*', + '@django.dispatch.receiver', + '@django.utils.decorators.method_decorator', + '@django.views.decorators.http.require_*', + '@*register.*', +] + +DJANGO_INHERITANCE_PATTERNS = [ + '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', +] + +DJANGO_REST_FRAMEWORK_DECORATOR_PATTERNS = ['@rest_framework.*'] + +DJANGO_REST_FRAMEWORK_INHERITANCE_PATTERNS = [ + 'rest_framework.*View', + 'rest_framework.*ViewSet', + 'rest_framework.*Serializer', + 'rest_framework.*Permission', + 'rest_framework.*Authentication', + 'rest_framework.*Pagination', + 'rest_framework.*FilterBackend', + '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'] + + +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 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(FULL_DECORATOR_PATTERNS)}', + f'--ignore-definitions-if-inherits-from={",".join(FULL_INHERITANCE_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/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/managers.py', 'DC04', 'unused_manager_method'), + ('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'), + ('project/models.py', 'DC03', 'Meta'), + ('project/models.py', 'DC06', 'verbose_name_plural'), + ('project/models.py', 'DC06', 'category'), + ('project/models.py', 'DC06', 'ordering'), + ('project/models.py', 'DC08', 'display_name'), + ('project/models.py', 'DC08', 'published_titles'), + ('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'), +} + +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'), + ('project/tests.py', 'DC04', 'unused_test_helper'), + ('project/views.py', 'DC01', 'UNUSED_VIEW_SETTING'), + ('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') + + +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 _configured_findings(tmp_path: Path) -> set[tuple[str, str, str]]: + (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) + + +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) + + # When + findings = _configured_findings(project_directory) + + # Then + assert findings == DJANGO_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_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) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + assert { + ('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: + # Having + _copy_fixture(project_directory) + + # When + findings = _configured_findings(project_directory) + + # Then + 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: + # Having + _copy_fixture(project_directory) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + 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) + + # 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) + + # When + output = main(['project', '--no-color']) + + # 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 = _rest_framework_configured_findings(project_directory) + + # Then + names = {name for _, _, name 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_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) + + # When + output = main(['project', '--no-color']) + + # Then + _assert_no_parse_warning(output) + findings = _findings(output) + 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 = _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) + + # 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 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) + + +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')] + + +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 {'process_request', 'UnusedMiddleware', '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) + + # When + findings = _configured_findings(project_directory) + + # Then + 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 + assert 'clean_title' not in names + 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) + + # When + findings = _configured_findings(project_directory) + + # Then + 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 not any(path == 'project/admin.py' for path, _, _ 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) + + # When + findings = _configured_findings(project_directory) + + # Then + 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' + 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/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 not {'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 1310020..a7ca55b 100644 --- a/tests/documented_examples/test_documented_features.py +++ b/tests/documented_examples/test_documented_features.py @@ -41,6 +41,7 @@ 'Unittest lifecycle methods', 'Django test lifecycle methods', 'FastAPI guide', + 'Django guide', 'Pre-commit hook', 'Wildcard imports and __all__', 'Syntax-error files', 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/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/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/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/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..ed3a330 --- /dev/null +++ b/tests/files/django/middleware.py @@ -0,0 +1,29 @@ +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 + + +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/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/settings.py b/tests/files/django/settings.py new file mode 100644 index 0000000..1c7ce89 --- /dev/null +++ b/tests/files/django/settings.py @@ -0,0 +1,38 @@ +SECRET_KEY = 'django-insecure-example' # noqa: S105 +DEBUG = True + +INSTALLED_APPS = [ + '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 = [ + { + '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/signals.py b/tests/files/django/signals.py new file mode 100644 index 0000000..37e9cc6 --- /dev/null +++ b/tests/files/django/signals.py @@ -0,0 +1,20 @@ +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 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 diff --git a/tests/files/django/urls.py b/tests/files/django/urls.py new file mode 100644 index 0000000..3ae7549 --- /dev/null +++ b/tests/files/django/urls.py @@ -0,0 +1,21 @@ +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('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 new file mode 100644 index 0000000..36da1b1 --- /dev/null +++ b/tests/files/django/views.py @@ -0,0 +1,54 @@ +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' + + +def index(request): + return HttpResponse('ok') + + +def unused_view(request): + return HttpResponse('unused') + + +@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') 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..30e5df3 --- /dev/null +++ b/tests/visitor/test_qualified_name_resolution.py @@ -0,0 +1,147 @@ +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\nfoo.py:8:0: DC02 Function `unused_sentinel` is never used' + )