Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
66 changes: 61 additions & 5 deletions deadcode/visitor/dead_code_visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -543,16 +572,30 @@ 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."""

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:
Expand All @@ -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)')
Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading