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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ A comment on the first decorator line is still accepted.
`unittest` lifecycle methods (`setUp`, `tearDown`, `setUpClass`, `tearDownClass`, `asyncSetUp`, `asyncTearDown`, `setUpModule`, `tearDownModule`) declared in test files are ignored by default, alongside the existing pytest hooks.
Declarations with those names outside test files are still reported as unused code.

## Ignoring Django test lifecycle methods
Django test lifecycle methods (`setUpTestData`, the `django.test.TestCase` class method invoked by the framework, and the internal hooks `_pre_setup`, `_post_teardown`, `_fixture_setup`, `_fixture_teardown`) declared in test files are ignored by default.
Declarations with those names outside test files are still reported as unused code.
Django helpers that your test code invokes itself are not suppressed, so an unreferenced `assertContains`, `assertNumQueries`, `settings`, `modify_settings`, or `captureOnCommitCallbacks` is still reported.

## Contributing

Create a feature branch from `main`, open a pull request targeting `main`, and
Expand Down Expand Up @@ -294,6 +299,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.
- Ignore Django test lifecycle methods in test files.
- **v2.6.0** `2026-11-08`:
- Features:
- Add support for Python 3.13, 3.14 and 3.15.
Expand Down
16 changes: 15 additions & 1 deletion deadcode/visitor/ignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
'setUpModule',
'tearDownModule',
}
DJANGO_METHOD_NAMES = {
'setUpTestData',
'_pre_setup',
'_post_teardown',
'_fixture_setup',
'_fixture_teardown',
}
# Built-in interface markers: abstract and overriding definitions are never reported, no option needed.
IGNORED_INTERFACE_DECORATOR_NAMES: set[str] = {
'@abstractmethod',
Expand Down Expand Up @@ -111,17 +118,24 @@ def _ignore_import(filename: Path, import_name: str) -> bool:
def _ignore_function(filename: Path, function_name: str) -> bool:
# Class-level lifecycle methods such as `def setUpClass(cls)` have no decorator and a first argument other
# than `self`, so `visit_FunctionDef` classifies them as functions and they must be suppressed here as well.
# Django lifecycle methods are declared the same way.
return (
function_name in PYTEST_FUNCTION_NAMES
or function_name in UNITTEST_METHOD_NAMES
or function_name in UNITTEST_FUNCTION_NAMES
or function_name in DJANGO_METHOD_NAMES
or function_name.startswith('test_')
) and _is_test_file(filename)


def _ignore_method(filename: Path, method_name: str) -> bool:
return _is_special_name(method_name) or (
(method_name in PYTEST_METHOD_NAMES or method_name in UNITTEST_METHOD_NAMES or method_name.startswith('test_'))
(
method_name in PYTEST_METHOD_NAMES
or method_name in UNITTEST_METHOD_NAMES
or method_name in DJANGO_METHOD_NAMES
or method_name.startswith('test_')
)
and _is_test_file(filename)
)

Expand Down
1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ for every row.
| Per-rule fixing | `### Rules` | `deadcode/actions/remove_file_parts_from_content.py` | `tests/fix/test_unused_imports.py`, `tests/fix/test_assign.py` | 3 |
| `# 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 |
| 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 |
| 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 |
Expand Down
1 change: 1 addition & 0 deletions tests/documented_examples/test_documented_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
'Per-rule fixing',
'# noqa comments',
'Unittest lifecycle methods',
'Django test lifecycle methods',
'FastAPI guide',
'Pre-commit hook',
'Wildcard imports and __all__',
Expand Down
207 changes: 207 additions & 0 deletions tests/visitor/test_django_lifecycle_methods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
from pathlib import Path

import pytest

from deadcode.cli import main, print_main

SOURCE = b"""
from unittest import TestCase


class MyTests(TestCase):
def setUpTestData(cls):
pass

def _pre_setup(self):
pass

def _post_teardown(self):
pass

def _fixture_setup(self):
pass

def _fixture_teardown(self):
pass

def test_something(self):
pass
"""

SOURCE_WITH_UNUSED_HELPER = (
SOURCE
+ b"""
def unused_helper(self):
pass
"""
)

SOURCE_WITH_DECORATED_SET_UP_TEST_DATA = b"""
from django.test import TestCase


class MyTests(TestCase):
@classmethod
def setUpTestData(cls):
pass

def test_something(self):
pass
"""

SOURCE_WITHOUT_TEST_CASE_BASE = b"""
class TestMySuite:
def setUpTestData(cls):
pass

def _pre_setup(self):
pass
"""

SOURCE_WITH_USER_INVOKED_HELPERS = b"""
class MyTests:
def assertContains(self):
pass

def assertNumQueries(self):
pass

def settings(self):
pass

def modify_settings(self):
pass

def captureOnCommitCallbacks(self):
pass
"""


def test_django_lifecycle_methods_in_test_file_are_not_reported(virtual_file_system):
# Having
virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE}

# When
result = main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert result is None

# When
result = print_main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert result == 0


def test_django_lifecycle_declared_without_classmethod_is_not_reported(virtual_file_system):
# Having
virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE_WITHOUT_TEST_CASE_BASE}

# When
result = main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert result is None

# When
result = print_main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert result == 0


def test_django_test_case_class_method_set_up_test_data_is_not_reported(virtual_file_system):
# Having
virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE_WITH_DECORATED_SET_UP_TEST_DATA}

# When
result = main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert result is None

# When
result = print_main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert result == 0


def test_django_lifecycle_methods_outside_test_file_are_still_reported(virtual_file_system):
# Having
virtual_file_system.files = {'/project/sample.py': SOURCE}

# When
output = main(['/project/sample.py', '--no-color'])

# Then
assert output is not None
assert 'DC02 Function `setUpTestData` is never used' in output
assert 'DC04 Method `_pre_setup` is never used' in output
assert 'DC04 Method `_post_teardown` is never used' in output
assert 'DC04 Method `_fixture_setup` is never used' in output
assert 'DC04 Method `_fixture_teardown` is never used' in output

# When
result = print_main(['/project/sample.py', '--no-color'])

# Then
assert result == 1


def test_unrelated_unused_method_in_test_file_is_still_reported(virtual_file_system):
# Having
virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE_WITH_UNUSED_HELPER}

# When
output = main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert output is not None
assert len(output.splitlines()) == 1
assert output.endswith('DC04 Method `unused_helper` is never used')


def test_user_invoked_django_helpers_in_test_file_are_still_reported(virtual_file_system):
# Having
virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE_WITH_USER_INVOKED_HELPERS}

# When
output = main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert output is not None
assert 'DC04 Method `assertContains` is never used' in output
assert 'DC04 Method `assertNumQueries` is never used' in output
assert 'DC04 Method `settings` is never used' in output
assert 'DC04 Method `modify_settings` is never used' in output
assert 'DC04 Method `captureOnCommitCallbacks` is never used' in output

# When
result = print_main(['/project/tests/test_sample.py', '--no-color'])

# Then
assert result == 1


def test_django_lifecycle_in_nested_test_directory_returns_no_findings(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Having
test_directory = tmp_path / 'app' / 'tests'
test_directory.mkdir(parents=True)
(test_directory / 'test_models.py').write_bytes(SOURCE)
monkeypatch.chdir(tmp_path)

# When
result = main(['app', '--no-color'])

# Then
assert result is None

# When
result = print_main(['app', '--no-color'])

# Then
assert result == 0
Loading