From a3f8e27eb61f39d98157c2dd24f71eefc06abd88 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Fri, 13 Sep 2024 08:10:03 +0200 Subject: [PATCH 01/18] Add link to Vulture GitHub Action. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7275f6cd..a230ed93 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Vulture will automatically look for a `pyproject.toml` in the current working di To use a `pyproject.toml` in another directory, you can use the `--config path/to/pyproject.toml` flag. -## Version control integration +## Integrations You can use a [pre-commit](https://pre-commit.com/#install) hook to run Vulture before each commit. For this, install pre-commit and add the @@ -207,6 +207,8 @@ Then run `pre-commit install`. Finally, create a `pyproject.toml` file in your repository and specify all files that Vulture should check under `[tool.vulture] --> paths` (see above). +There's also a [GitHub Action for Vulture](https://github.com/gtkacz/vulture-action). + ## How does it work? Vulture uses the `ast` module to build abstract syntax trees for all From 4df68f8e0cea556f202217be23b572fd5071868d Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Tue, 17 Sep 2024 18:55:33 +0200 Subject: [PATCH 02/18] Update changelog. --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c4a9f7..8556c512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,8 @@ -# next (unreleased) +# 2.12 (2024-09-17) -* Use `ruff` for linting (Anh Trinh, #347). -* Use `ruff` for formatting (Anh Trinh, #349). +* Use `ruff` for linting and formatting (Anh Trinh, #347, #349). * Replace `tox` by `pre-commit` for linting and formatting (Anh Trinh, #349). -* Add `--config` flag to specify path to pyproject.toml configuration file (Glen Robertson #352). +* Add `--config` flag to specify path to pyproject.toml configuration file (Glen Robertson, #352). # 2.11 (2024-01-06) From 224dc09997d6816230cac34dd2ba284b927dbe50 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Tue, 17 Sep 2024 18:55:56 +0200 Subject: [PATCH 03/18] Update version number to 2.12 for release. --- vulture/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vulture/version.py b/vulture/version.py index 3e58dd32..36b665ad 100644 --- a/vulture/version.py +++ b/vulture/version.py @@ -1 +1 @@ -__version__ = "2.11" +__version__ = "2.12" From 6d9883458da9065112d21c876c252fd97e9c1ba0 Mon Sep 17 00:00:00 2001 From: Trevor James Smith <10819524+Zeitsperre@users.noreply.github.com> Date: Fri, 20 Sep 2024 08:05:09 -0400 Subject: [PATCH 04/18] Add PyPI and conda-forge badges (#356) --- CHANGELOG.md | 4 ++++ README.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8556c512..5cec65db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.13 (unreleased) + +* Add PyPI and conda-forge badges to README file (Trevor James Smith, #356). + # 2.12 (2024-09-17) * Use `ruff` for linting and formatting (Anh Trinh, #347, #349). diff --git a/README.md b/README.md index a230ed93..45bb3c44 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Vulture - Find dead code +[![PyPI Version](https://img.shields.io/pypi/v/vulture.svg)](https://pypi.python.org/pypi/vulture) +[![Conda Version](https://img.shields.io/conda/vn/conda-forge/vulture.svg)](https://anaconda.org/conda-forge/vulture) ![CI:Test](https://github.com/jendrikseipp/vulture/workflows/CI/badge.svg) [![Codecov Badge](https://codecov.io/gh/jendrikseipp/vulture/branch/main/graphs/badge.svg)](https://codecov.io/gh/jendrikseipp/vulture?branch=main) From 26ce978a2ecbb5195df9c84808e3a70cd7b2711f Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Fri, 27 Sep 2024 12:14:01 +0100 Subject: [PATCH 05/18] Include tests/**/*.toml in sdist (#368) Otherwise `test_toml_config_custom_path` fails when run from the sdist. --- CHANGELOG.md | 1 + MANIFEST.in | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cec65db..03509bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 2.13 (unreleased) * Add PyPI and conda-forge badges to README file (Trevor James Smith, #356). +* Include `tests/**/*.toml` in sdist (Colin Watson). # 2.12 (2024-09-17) diff --git a/MANIFEST.in b/MANIFEST.in index df6df808..22e0ead7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include *.md include *.txt include tests/*.py +include tests/**/*.toml include tox.ini include vulture/whitelists/*.py From b7ae5b3e08c6a3cc92c761f9e678ffacdd7dc93a Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Tue, 1 Oct 2024 20:55:35 +0200 Subject: [PATCH 06/18] Add support for Python 3.13. (#369) --- .github/workflows/main.yml | 3 ++- CHANGELOG.md | 1 + setup.py | 1 + tests/__init__.py | 5 ++++- tox.ini | 10 +++++----- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d2a1d119..812f811a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] steps: - name: Checkout code @@ -26,6 +26,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 03509bd7..d62b1398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 2.13 (unreleased) +* Add support for Python 3.13 (Jendrik Seipp, #369). * Add PyPI and conda-forge badges to README file (Trevor James Smith, #356). * Include `tests/**/*.toml` in sdist (Colin Watson). diff --git a/setup.py b/setup.py index d9187eac..e9a453f8 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ def find_version(*parts): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Quality Assurance", diff --git a/tests/__init__.py b/tests/__init__.py index 58e1da85..c54b3877 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -8,7 +8,10 @@ REPO = pathlib.Path(__file__).resolve().parents[1] WHITELISTS = [ - str(path) for path in (REPO / "vulture" / "whitelists").glob("*.py") + str(path) + for path in (REPO / "vulture" / "whitelists").glob("*.py") + # Pint is incompatible with Python 3.13 (https://github.com/hgrecco/pint/issues/1969). + if sys.version_info < (3, 13) or path.name != "pint_whitelist.py" ] diff --git a/tox.ini b/tox.ini index 4ca649a9..3d363561 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,20 @@ [tox] -envlist = cleanup, py{38,310,311,312}, style # Skip py39 since it chokes on distutils. +envlist = cleanup, py{38,310,311,312,313}, style # Skip py39 since it chokes on distutils. skip_missing_interpreters = true # Erase old coverage results, then accumulate them during this tox run. [testenv:cleanup] deps = - coverage==7.0.5 + coverage commands = coverage erase [testenv] deps = - coverage==7.0.5 + coverage pint # Use latest version to catch API changes. - pytest==7.4.2 - pytest-cov==4.0.0 + pytest + pytest-cov commands = pytest {posargs} # Install package as wheel in all envs (https://hynek.me/articles/turbo-charge-tox/). From ea66db28999ee141b71bf7021299498a96a66130 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Wed, 2 Oct 2024 14:10:11 +0200 Subject: [PATCH 07/18] Add release date. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d62b1398..1a944eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 2.13 (unreleased) +# 2.13 (2024-10-02) * Add support for Python 3.13 (Jendrik Seipp, #369). * Add PyPI and conda-forge badges to README file (Trevor James Smith, #356). From d21ad35d53bfa32ec8f090de6bb9f4af80076365 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Wed, 2 Oct 2024 14:10:27 +0200 Subject: [PATCH 08/18] Update version number to 2.13 for release. --- vulture/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vulture/version.py b/vulture/version.py index 36b665ad..a7e46e76 100644 --- a/vulture/version.py +++ b/vulture/version.py @@ -1 +1 @@ -__version__ = "2.12" +__version__ = "2.13" From 798809a43b4f79997be397bb7aa3a2ad27ed5e66 Mon Sep 17 00:00:00 2001 From: John Doknjas <32089502+johndoknjas@users.noreply.github.com> Date: Wed, 9 Oct 2024 01:50:50 -0700 Subject: [PATCH 09/18] Add type hints for the `get_unused_code` function and the fields of `Item`. (#361) * Test code with pytype. --- CHANGELOG.md | 4 ++++ README.md | 10 ++++++++++ tests/test_pytype.py | 11 +++++++++++ tox.ini | 1 + vulture/core.py | 21 +++++++++++++-------- 5 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 tests/test_pytype.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a944eb1..eef612b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# next (unreleased) + +* Add typehints for `get_unused_code` and the fields of the `Item` class (John Doknjas, #361). + # 2.13 (2024-10-02) * Add support for Python 3.13 (Jendrik Seipp, #369). diff --git a/README.md b/README.md index 45bb3c44..9746153b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,16 @@ Vulture will automatically look for a `pyproject.toml` in the current working di To use a `pyproject.toml` in another directory, you can use the `--config path/to/pyproject.toml` flag. +It's also possible to use vulture programatically. For example: + +``` python +import vulture + +v = vulture.Vulture() +v.scavenge(['.']) +unused_code = v.get_unused_code() # returns a list of `Item` objects +``` + ## Integrations You can use a [pre-commit](https://pre-commit.com/#install) hook to run diff --git a/tests/test_pytype.py b/tests/test_pytype.py new file mode 100644 index 00000000..8ee01c0b --- /dev/null +++ b/tests/test_pytype.py @@ -0,0 +1,11 @@ +import subprocess +import sys + +import pytest + + +@pytest.mark.skipif( + sys.version_info >= (3, 13), reason="needs Python < 3.13 for pytype" +) +def test_pytype(): + assert subprocess.run(["pytype", "vulture/core.py"]).returncode == 0 diff --git a/tox.ini b/tox.ini index 3d363561..357c410b 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ deps = pint # Use latest version to catch API changes. pytest pytest-cov + pytype ; python_version < '3.13' commands = pytest {posargs} # Install package as wheel in all envs (https://hynek.me/articles/turbo-charge-tox/). diff --git a/vulture/core.py b/vulture/core.py index b3c845cd..2cadabb6 100644 --- a/vulture/core.py +++ b/vulture/core.py @@ -5,6 +5,7 @@ import re import string import sys +from typing import List from vulture import lines from vulture import noqa @@ -139,13 +140,13 @@ def __init__( message="", confidence=DEFAULT_CONFIDENCE, ): - self.name = name - self.typ = typ - self.filename = filename - self.first_lineno = first_lineno - self.last_lineno = last_lineno - self.message = message or f"unused {typ} '{name}'" - self.confidence = confidence + self.name: str = name + self.typ: str = typ + self.filename: Path = filename + self.first_lineno: int = first_lineno + self.last_lineno: int = last_lineno + self.message: str = message or f"unused {typ} '{name}'" + self.confidence: int = confidence @property def size(self): @@ -219,6 +220,7 @@ def get_list(typ): self.filename = Path() self.code = [] self.exit_code = ExitCode.NoDeadCode + self.noqa_lines = {} def scan(self, code, filename=""): filename = Path(filename) @@ -300,10 +302,13 @@ def exclude_path(path): except OSError: # Most imported modules don't have a whitelist. continue + assert module_data is not None module_string = module_data.decode("utf-8") self.scan(module_string, filename=path) - def get_unused_code(self, min_confidence=0, sort_by_size=False): + def get_unused_code( + self, min_confidence=0, sort_by_size=False + ) -> List[Item]: """ Return ordered list of unused Item objects. """ From f0ab7baa19e7571a0374aa17caa6e198f1423800 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Wed, 9 Oct 2024 10:56:33 +0200 Subject: [PATCH 10/18] Cosmetics. --- CHANGELOG.md | 2 +- README.md | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eef612b7..a1d999d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # next (unreleased) -* Add typehints for `get_unused_code` and the fields of the `Item` class (John Doknjas, #361). +* Add type hints for `get_unused_code` and the fields of the `Item` class (John Doknjas, #361). # 2.13 (2024-10-02) diff --git a/README.md b/README.md index 9746153b..b46c46d5 100644 --- a/README.md +++ b/README.md @@ -191,16 +191,6 @@ Vulture will automatically look for a `pyproject.toml` in the current working di To use a `pyproject.toml` in another directory, you can use the `--config path/to/pyproject.toml` flag. -It's also possible to use vulture programatically. For example: - -``` python -import vulture - -v = vulture.Vulture() -v.scavenge(['.']) -unused_code = v.get_unused_code() # returns a list of `Item` objects -``` - ## Integrations You can use a [pre-commit](https://pre-commit.com/#install) hook to run @@ -219,7 +209,16 @@ Then run `pre-commit install`. Finally, create a `pyproject.toml` file in your repository and specify all files that Vulture should check under `[tool.vulture] --> paths` (see above). -There's also a [GitHub Action for Vulture](https://github.com/gtkacz/vulture-action). +There's also a [GitHub Action for Vulture](https://github.com/gtkacz/vulture-action) +and you can use Vulture programatically. For example: + +``` python +import vulture + +v = vulture.Vulture() +v.scavenge(['.']) +unused_code = v.get_unused_code() # returns a list of `Item` objects +``` ## How does it work? From 9f44d4c2d2d1299757db1a3817123c6b70fa2c07 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Wed, 9 Oct 2024 10:59:22 +0200 Subject: [PATCH 11/18] Drop obsolete 'style' tox environment. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 357c410b..3f42a90b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = cleanup, py{38,310,311,312,313}, style # Skip py39 since it chokes on distutils. +envlist = cleanup, py{38,310,311,312,313} # Skip py39 since it chokes on distutils. skip_missing_interpreters = true # Erase old coverage results, then accumulate them during this tox run. From 609f5f2193cf963bc13ef867441b3ec44b643ec5 Mon Sep 17 00:00:00 2001 From: Jannic Beck Date: Sun, 24 Nov 2024 20:37:09 +0100 Subject: [PATCH 12/18] Improve unreachable code analysis (#302) Fixes #270 and catches additional unreachable code. --------- Co-authored-by: Jendrik Seipp --- CHANGELOG.md | 1 + tests/__init__.py | 8 + tests/test_conditions.py | 145 -------- tests/test_reachability.py | 744 +++++++++++++++++++++++++++++++++++++ tests/test_unreachable.py | 337 ----------------- vulture/core.py | 94 +---- vulture/reachability.py | 193 ++++++++++ 7 files changed, 964 insertions(+), 558 deletions(-) create mode 100644 tests/test_reachability.py delete mode 100644 tests/test_unreachable.py create mode 100644 vulture/reachability.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1d999d2..8dcf8fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # next (unreleased) +* Improve reachability analysis (kreathon, #270, #302). * Add type hints for `get_unused_code` and the fields of the `Item` class (John Doknjas, #361). # 2.13 (2024-10-02) diff --git a/tests/__init__.py b/tests/__init__.py index c54b3877..4167a4dc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -39,6 +39,14 @@ def check_unreachable(v, lineno, size, name): assert item.name == name +def check_multiple_unreachable(v, checks): + assert len(v.unreachable_code) == len(checks) + for item, (lineno, size, name) in zip(v.unreachable_code, checks): + assert item.first_lineno == lineno + assert item.size == size + assert item.name == name + + @pytest.fixture def v(): return core.Vulture(verbose=True) diff --git a/tests/test_conditions.py b/tests/test_conditions.py index 3799d442..c401b226 100644 --- a/tests/test_conditions.py +++ b/tests/test_conditions.py @@ -2,7 +2,6 @@ from vulture import utils -from . import check_unreachable from . import v assert v # Silence pyflakes @@ -73,147 +72,3 @@ def test_errors(): condition = ast.parse(condition, mode="eval").body assert not utils.condition_is_always_false(condition) assert not utils.condition_is_always_true(condition) - - -def test_while(v): - v.scan( - """\ -while False: - pass -""" - ) - check_unreachable(v, 1, 2, "while") - - -def test_while_nested(v): - v.scan( - """\ -while True: - while False: - pass -""" - ) - check_unreachable(v, 2, 2, "while") - - -def test_if_false(v): - v.scan( - """\ -if False: - pass -""" - ) - check_unreachable(v, 1, 2, "if") - - -def test_elif_false(v): - v.scan( - """\ -if bar(): - pass -elif False: - print("Unreachable") -""" - ) - check_unreachable(v, 3, 2, "if") - - -def test_nested_if_statements_false(v): - v.scan( - """\ -if foo(): - if bar(): - pass - elif False: - print("Unreachable") - pass - elif something(): - print("Reachable") - else: - pass -else: - pass -""" - ) - check_unreachable(v, 4, 3, "if") - - -def test_if_false_same_line(v): - v.scan( - """\ -if False: a = 1 -else: c = 3 -""" - ) - check_unreachable(v, 1, 1, "if") - - -def test_if_true(v): - v.scan( - """\ -if True: - a = 1 - b = 2 -else: - c = 3 - d = 3 -""" - ) - # For simplicity, we don't report the "else" line as dead code. - check_unreachable(v, 5, 2, "else") - - -def test_if_true_same_line(v): - v.scan( - """\ -if True: - a = 1 - b = 2 -else: c = 3 -d = 3 -""" - ) - check_unreachable(v, 4, 1, "else") - - -def test_nested_if_statements_true(v): - v.scan( - """\ -if foo(): - if bar(): - pass - elif True: - if something(): - pass - else: - pass - elif something_else(): - print("foo") - else: - print("bar") -else: - pass -""" - ) - check_unreachable(v, 9, 4, "else") - - -def test_redundant_if(v): - v.scan( - """\ -if [5]: - pass -""" - ) - print(v.unreachable_code[0].size) - check_unreachable(v, 1, 2, "if") - - -def test_if_exp_true(v): - v.scan("foo if True else bar") - check_unreachable(v, 1, 1, "ternary") - - -def test_if_exp_false(v): - v.scan("foo if False else bar") - check_unreachable(v, 1, 1, "ternary") diff --git a/tests/test_reachability.py b/tests/test_reachability.py new file mode 100644 index 00000000..a7eb24a3 --- /dev/null +++ b/tests/test_reachability.py @@ -0,0 +1,744 @@ +from . import check_multiple_unreachable, check_unreachable +from . import v + +assert v # Silence pyflakes + + +def test_return_assignment(v): + v.scan( + """\ +def foo(): + print("Hello World") + return + a = 1 +""" + ) + check_unreachable(v, 4, 1, "return") + + +def test_return_multiline_return_statements(v): + v.scan( + """\ +def foo(): + print("Something") + return (something, + that, + spans, + over, + multiple, + lines) + print("Hello World") +""" + ) + check_unreachable(v, 9, 1, "return") + + +def test_return_multiple_return_statements(v): + v.scan( + """\ +def foo(): + return something + return None + return (some, statement) +""" + ) + check_unreachable(v, 3, 2, "return") + + +def test_return_pass(v): + v.scan( + """\ +def foo(): + return + pass + return something +""" + ) + check_unreachable(v, 3, 2, "return") + + +def test_return_multiline_return(v): + v.scan( + """ +def foo(): + return \ + "Hello" + print("Unreachable code") +""" + ) + check_unreachable(v, 4, 1, "return") + + +def test_return_recursive_functions(v): + v.scan( + """\ +def foo(a): + if a == 1: + return 1 + else: + return foo(a - 1) + print("This line is never executed") +""" + ) + check_unreachable(v, 6, 1, "return") + + +def test_return_semicolon(v): + v.scan( + """\ +def foo(): + return; a = 1 +""" + ) + check_unreachable(v, 2, 1, "return") + + +def test_return_list(v): + v.scan( + """\ +def foo(a): + return + a[1:2] +""" + ) + check_unreachable(v, 3, 1, "return") + + +def test_return_continue(v): + v.scan( + """\ +def foo(): + if foo(): + return True + continue + else: + return False +""" + ) + check_unreachable(v, 4, 1, "return") + + +def test_return_function_definition(v): + v.scan( + """\ +def foo(): + return True + def bar(): + return False +""" + ) + check_unreachable(v, 3, 2, "return") + + +def test_raise_global(v): + v.scan( + """\ +raise ValueError +a = 1 +""" + ) + check_unreachable(v, 2, 1, "raise") + + +def test_raise_assignment(v): + v.scan( + """\ +def foo(): + raise ValueError + li = [] +""" + ) + check_unreachable(v, 3, 1, "raise") + + +def test_multiple_raise_statements(v): + v.scan( + """\ +def foo(): + a = 1 + raise + raise KeyError + # a comment + b = 2 + raise CustomDefinedError +""" + ) + check_unreachable(v, 4, 4, "raise") + + +def test_return_with_raise(v): + v.scan( + """\ +def foo(): + a = 1 + return + raise ValueError + return +""" + ) + check_unreachable(v, 4, 2, "return") + + +def test_return_comment_and_code(v): + v.scan( + """\ +def foo(): + return + # This is a comment + print("Hello World") +""" + ) + check_unreachable(v, 4, 1, "return") + + +def test_raise_with_return(v): + v.scan( + """\ +def foo(): + a = 1 + raise + return a +""" + ) + check_unreachable(v, 4, 1, "raise") + + +def test_raise_error_message(v): + v.scan( + """\ +def foo(): + raise SomeError("There is a problem") + print("I am unreachable") +""" + ) + check_unreachable(v, 3, 1, "raise") + + +def test_raise_try_except(v): + v.scan( + """\ +def foo(): + try: + a = 1 + raise + except IOError as e: + print("We have some problem.") + raise + print(":-(") +""" + ) + check_unreachable(v, 8, 1, "raise") + + +def test_raise_with_comment_and_code(v): + v.scan( + """\ +def foo(): + raise + # This is a comment + print("Something") + return None +""" + ) + check_unreachable(v, 4, 2, "raise") + + +def test_continue_basic(v): + v.scan( + """\ +def foo(): + if bar(): + a = 1 + else: + continue + a = 2 +""" + ) + check_unreachable(v, 6, 1, "continue") + + +def test_continue_one_liner(v): + v.scan( + """\ +def foo(): + for i in range(1, 10): + if i == 5: continue + print(1 / i) +""" + ) + assert v.unreachable_code == [] + + +def test_continue_nested_loops(v): + v.scan( + """\ +def foo(): + a = 0 + if something(): + foo() + if bar(): + a = 2 + continue + # This is unreachable + a = 1 + elif a == 1: + pass + else: + a = 3 + continue + else: + continue +""" + ) + check_unreachable(v, 9, 1, "continue") + + +def test_continue_with_comment_and_code(v): + v.scan( + """\ +def foo(): + if bar1(): + bar2() + else: + a = 1 + continue + # Just a comment + raise ValueError +""" + ) + check_unreachable(v, 8, 1, "continue") + + +def test_break_basic(v): + v.scan( + """\ +def foo(): + for i in range(123): + break + # A comment + return + dead = 1 +""" + ) + check_unreachable(v, 5, 2, "break") + + +def test_break_one_liner(v): + v.scan( + """\ +def foo(): + for i in range(10): + if i == 3: break + print(i) +""" + ) + assert v.unreachable_code == [] + + +def test_break_with_comment_and_code(v): + v.scan( + """\ +while True: + break + # some comment + print("Hello") +""" + ) + check_unreachable(v, 4, 1, "break") + + +def test_if_false(v): + v.scan( + """\ +if False: + pass +""" + ) + check_unreachable(v, 1, 2, "if") + + +def test_elif_false(v): + v.scan( + """\ +if bar(): + pass +elif False: + print("Unreachable") +""" + ) + check_unreachable(v, 3, 2, "if") + + +def test_nested_if_statements_false(v): + v.scan( + """\ +if foo(): + if bar(): + pass + elif False: + print("Unreachable") + pass + elif something(): + print("Reachable") + else: + pass +else: + pass +""" + ) + check_unreachable(v, 4, 3, "if") + + +def test_if_false_same_line(v): + v.scan( + """\ +if False: a = 1 +else: c = 3 +""" + ) + check_unreachable(v, 1, 1, "if") + + +def test_if_true(v): + v.scan( + """\ +if True: + a = 1 + b = 2 +else: + c = 3 + d = 3 +""" + ) + # For simplicity, we don't report the "else" line as dead code. + check_unreachable(v, 5, 2, "else") + + +def test_if_true_same_line(v): + v.scan( + """\ +if True: + a = 1 + b = 2 +else: c = 3 +d = 3 +""" + ) + check_unreachable(v, 4, 1, "else") + + +def test_nested_if_statements_true(v): + v.scan( + """\ +if foo(): + if bar(): + pass + elif True: + if something(): + pass + else: + pass + elif something_else(): + print("foo") + else: + print("bar") +else: + pass +""" + ) + check_unreachable(v, 9, 4, "else") + + +def test_redundant_if(v): + v.scan( + """\ +if [5]: + pass +""" + ) + print(v.unreachable_code[0].size) + check_unreachable(v, 1, 2, "if") + + +def test_if_exp_true(v): + v.scan("foo if True else bar") + check_unreachable(v, 1, 1, "ternary") + + +def test_if_exp_false(v): + v.scan("foo if False else bar") + check_unreachable(v, 1, 1, "ternary") + + +def test_if_true_return(v): + v.scan( + """\ +def foo(a): + if True: + return 0 + print(":-(") +""" + ) + check_multiple_unreachable(v, [(2, 2, "if"), (4, 1, "if")]) + + +def test_if_true_return_else(v): + v.scan( + """\ +def foo(a): + if True: + return 0 + else: + return 1 + print(":-(") +""" + ) + check_multiple_unreachable(v, [(5, 1, "else"), (6, 1, "if")]) + + +def test_if_some_branches_return(v): + v.scan( + """\ +def foo(a): + if a == 0: + return 0 + elif a == 1: + pass + else: + return 2 + print(":-(") +""" + ) + assert v.unreachable_code == [] + + +def test_if_all_branches_return(v): + v.scan( + """\ +def foo(a): + if a == 0: + return 0 + elif a == 1: + return 1 + else: + return 2 + print(":-(") +""" + ) + check_unreachable(v, 8, 1, "if") + + +def test_if_all_branches_return_nested(v): + v.scan( + """\ +def foo(a, b): + if a: + if b: + return 1 + return 2 + else: + return 3 + print(":-(") +""" + ) + check_unreachable(v, 8, 1, "if") + + +def test_if_all_branches_return_or_raise(v): + v.scan( + """\ +def foo(a): + if a == 0: + return 0 + else: + raise Exception() + print(":-(") +""" + ) + check_unreachable(v, 6, 1, "if") + + +def test_try_fall_through(v): + v.scan( + """\ +def foo(): + try: + pass + except IndexError as e: + raise e + print(":-(") +""" + ) + assert v.unreachable_code == [] + + +def test_try_some_branches_raise(v): + v.scan( + """\ +def foo(e): + try: + raise e + except IndexError as e: + pass + except Exception as e: + raise e + print(":-(") +""" + ) + assert v.unreachable_code == [] + + +def test_try_all_branches_return_or_raise(v): + v.scan( + """\ +def foo(): + try: + return 2 + except IndexError as e: + raise e + except Exception as e: + raise e + print(":-(") +""" + ) + check_unreachable(v, 8, 1, "try") + + +def test_try_nested_no_fall_through(v): + v.scan( + """\ +def foo(a): + try: + raise a + except: + try: + return + except Exception as e: + raise e + print(":-(") +""" + ) + check_unreachable(v, 9, 1, "try") + + +def test_try_reachable_else(v): + v.scan( + """\ +def foo(): + try: + print(":-)") + except: + return 1 + else: + print(":-(") +""" + ) + assert v.unreachable_code == [] + + +def test_try_unreachable_else(v): + v.scan( + """\ +def foo(): + try: + raise Exception() + except Exception as e: + return 1 + else: + print(":-(") +""" + ) + check_unreachable(v, 7, 1, "else") + + +def test_with_fall_through(v): + v.scan( + """\ +def foo(a): + with a(): + raise Exception() + print(":-(") +""" + ) + assert v.unreachable_code == [] + + +def test_async_with_fall_through(v): + v.scan( + """\ +async def foo(a): + async with a(): + raise Exception() + print(":-(") +""" + ) + assert v.unreachable_code == [] + + +def test_for_fall_through(v): + v.scan( + """\ +def foo(a): + for i in a: + raise Exception() + print(":-(") +""" + ) + assert v.unreachable_code == [] + + +def test_async_for_fall_through(v): + v.scan( + """\ +async def foo(a): + async for i in a: + raise Exception() + print(":-(") +""" + ) + assert v.unreachable_code == [] + + +def test_while_false(v): + v.scan( + """\ +while False: + pass +""" + ) + check_unreachable(v, 1, 2, "while") + + +def test_while_nested(v): + v.scan( + """\ +while True: + while False: + pass +""" + ) + check_unreachable(v, 2, 2, "while") + + +def test_while_true_else(v): + v.scan( + """\ +while True: + print("I won't stop") +else: + print("I won't run") +""" + ) + check_unreachable(v, 4, 1, "else") + + +def test_while_fall_through(v): + v.scan( + """\ +def foo(a): + while a > 0: + return 1 + print(":-(") +""" + ) + assert v.unreachable_code == [] diff --git a/tests/test_unreachable.py b/tests/test_unreachable.py deleted file mode 100644 index 11e81256..00000000 --- a/tests/test_unreachable.py +++ /dev/null @@ -1,337 +0,0 @@ -from . import check_unreachable -from . import v - -assert v # Silence pyflakes - - -def test_return_assignment(v): - v.scan( - """\ -def foo(): - print("Hello World") - return - a = 1 -""" - ) - check_unreachable(v, 4, 1, "return") - - -def test_return_multiline_return_statements(v): - v.scan( - """\ -def foo(): - print("Something") - return (something, - that, - spans, - over, - multiple, - lines) - print("Hello World") -""" - ) - check_unreachable(v, 9, 1, "return") - - -def test_return_multiple_return_statements(v): - v.scan( - """\ -def foo(): - return something - return None - return (some, statement) -""" - ) - check_unreachable(v, 3, 2, "return") - - -def test_return_pass(v): - v.scan( - """\ -def foo(): - return - pass - return something -""" - ) - check_unreachable(v, 3, 2, "return") - - -def test_return_multiline_return(v): - v.scan( - """ -def foo(): - return \ - "Hello" - print("Unreachable code") -""" - ) - check_unreachable(v, 4, 1, "return") - - -def test_return_recursive_functions(v): - v.scan( - """\ -def foo(a): - if a == 1: - return 1 - else: - return foo(a - 1) - print("This line is never executed") -""" - ) - check_unreachable(v, 6, 1, "return") - - -def test_return_semicolon(v): - v.scan( - """\ -def foo(): - return; a = 1 -""" - ) - check_unreachable(v, 2, 1, "return") - - -def test_return_list(v): - v.scan( - """\ -def foo(a): - return - a[1:2] -""" - ) - check_unreachable(v, 3, 1, "return") - - -def test_return_continue(v): - v.scan( - """\ -def foo(): - if foo(): - return True - continue - else: - return False -""" - ) - check_unreachable(v, 4, 1, "return") - - -def test_raise_assignment(v): - v.scan( - """\ -def foo(): - raise ValueError - li = [] -""" - ) - check_unreachable(v, 3, 1, "raise") - - -def test_multiple_raise_statements(v): - v.scan( - """\ -def foo(): - a = 1 - raise - raise KeyError - # a comment - b = 2 - raise CustomDefinedError -""" - ) - check_unreachable(v, 4, 4, "raise") - - -def test_return_with_raise(v): - v.scan( - """\ -def foo(): - a = 1 - return - raise ValueError - return -""" - ) - check_unreachable(v, 4, 2, "return") - - -def test_return_comment_and_code(v): - v.scan( - """\ -def foo(): - return - # This is a comment - print("Hello World") -""" - ) - check_unreachable(v, 4, 1, "return") - - -def test_raise_with_return(v): - v.scan( - """\ -def foo(): - a = 1 - raise - return a -""" - ) - check_unreachable(v, 4, 1, "raise") - - -def test_raise_error_message(v): - v.scan( - """\ -def foo(): - raise SomeError("There is a problem") - print("I am unreachable") -""" - ) - check_unreachable(v, 3, 1, "raise") - - -def test_raise_try_except(v): - v.scan( - """\ -def foo(): - try: - a = 1 - raise - except IOError as e: - print("We have some problem.") - raise - print(":-(") -""" - ) - check_unreachable(v, 8, 1, "raise") - - -def test_raise_with_comment_and_code(v): - v.scan( - """\ -def foo(): - raise - # This is a comment - print("Something") - return None -""" - ) - check_unreachable(v, 4, 2, "raise") - - -def test_continue_basic(v): - v.scan( - """\ -def foo(): - if bar(): - a = 1 - else: - continue - a = 2 -""" - ) - check_unreachable(v, 6, 1, "continue") - - -def test_continue_one_liner(v): - v.scan( - """\ -def foo(): - for i in range(1, 10): - if i == 5: continue - print(1 / i) -""" - ) - assert v.unreachable_code == [] - - -def test_continue_nested_loops(v): - v.scan( - """\ -def foo(): - a = 0 - if something(): - foo() - if bar(): - a = 2 - continue - # This is unreachable - a = 1 - elif a == 1: - pass - else: - a = 3 - continue - else: - continue -""" - ) - check_unreachable(v, 9, 1, "continue") - - -def test_continue_with_comment_and_code(v): - v.scan( - """\ -def foo(): - if bar1(): - bar2() - else: - a = 1 - continue - # Just a comment - raise ValueError -""" - ) - check_unreachable(v, 8, 1, "continue") - - -def test_break_basic(v): - v.scan( - """\ -def foo(): - for i in range(123): - break - # A comment - return - dead = 1 -""" - ) - check_unreachable(v, 5, 2, "break") - - -def test_break_one_liner(v): - v.scan( - """\ -def foo(): - for i in range(10): - if i == 3: break - print(i) -""" - ) - assert v.unreachable_code == [] - - -def test_break_with_comment_and_code(v): - v.scan( - """\ -while True: - break - # some comment - print("Hello") -""" - ) - check_unreachable(v, 4, 1, "break") - - -def test_while_true_else(v): - v.scan( - """\ -while True: - print("I won't stop") -else: - print("I won't run") -""" - ) - check_unreachable(v, 4, 1, "else") diff --git a/vulture/core.py b/vulture/core.py index 2cadabb6..5b7db43a 100644 --- a/vulture/core.py +++ b/vulture/core.py @@ -1,5 +1,6 @@ import ast from fnmatch import fnmatch, fnmatchcase +from functools import partial from pathlib import Path import pkgutil import re @@ -11,6 +12,7 @@ from vulture import noqa from vulture import utils from vulture.config import InputError, make_config +from vulture.reachability import Reachability from vulture.utils import ExitCode @@ -222,6 +224,13 @@ def get_list(typ): self.exit_code = ExitCode.NoDeadCode self.noqa_lines = {} + report = partial( + self._define, + collection=self.unreachable_code, + confidence=100, + ) + self.reachability = Reachability(report=report) + def scan(self, code, filename=""): filename = Path(filename) self.code = code.splitlines() @@ -258,6 +267,10 @@ def handle_syntax_error(e): except SyntaxError as err: handle_syntax_error(err) + # Reset the reachability internals for every module to reduce memory + # usage. + self.reachability.reset() + def scavenge(self, paths, exclude=None): def prepare_pattern(pattern): if not any(char in pattern for char in "*?["): @@ -417,47 +430,6 @@ def _add_aliases(self, node): if alias is not None: self.used_names.add(name_and_alias.name) - def _handle_conditional_node(self, node, name): - if utils.condition_is_always_false(node.test): - self._define( - self.unreachable_code, - name, - node, - last_node=node.body - if isinstance(node, ast.IfExp) - else node.body[-1], - message=f"unsatisfiable '{name}' condition", - confidence=100, - ) - elif utils.condition_is_always_true(node.test): - else_body = node.orelse - if name == "ternary": - self._define( - self.unreachable_code, - name, - else_body, - message="unreachable 'else' expression", - confidence=100, - ) - elif else_body: - self._define( - self.unreachable_code, - "else", - else_body[0], - last_node=else_body[-1], - message="unreachable 'else' block", - confidence=100, - ) - elif name == "if": - # Redundant if-condition without else block. - self._define( - self.unreachable_code, - name, - node, - message="redundant if-condition", - confidence=100, - ) - def _define( self, collection, @@ -630,12 +602,6 @@ def visit_FunctionDef(self, node): self.defined_funcs, node.name, node, ignore=_ignore_function ) - def visit_If(self, node): - self._handle_conditional_node(node, "if") - - def visit_IfExp(self, node): - self._handle_conditional_node(node, "ternary") - def visit_Import(self, node): self._add_aliases(node) @@ -659,14 +625,16 @@ def visit_Assign(self, node): if utils.is_ast_string(elt): self.used_names.add(elt.value) - def visit_While(self, node): - self._handle_conditional_node(node, "while") - def visit_MatchClass(self, node): for kwd_attr in node.kwd_attrs: self.used_names.add(kwd_attr) def visit(self, node): + # Visit children nodes first to allow recursive reachability analysis. + self.generic_visit(node) + + self.reachability.visit(node) + method = "visit_" + node.__class__.__name__ visitor = getattr(self, method, None) if self.verbose: @@ -689,36 +657,10 @@ def visit(self, node): ast.parse(type_comment, filename="", mode=mode) ) - return self.generic_visit(node) - - def _handle_ast_list(self, ast_list): - """ - Find unreachable nodes in the given sequence of ast nodes. - """ - for index, node in enumerate(ast_list): - if isinstance( - node, (ast.Break, ast.Continue, ast.Raise, ast.Return) - ): - try: - first_unreachable_node = ast_list[index + 1] - except IndexError: - continue - class_name = node.__class__.__name__.lower() - self._define( - self.unreachable_code, - class_name, - first_unreachable_node, - last_node=ast_list[-1], - message=f"unreachable code after '{class_name}'", - confidence=100, - ) - return - def generic_visit(self, node): """Called if no explicit visitor function exists for a node.""" for _, value in ast.iter_fields(node): if isinstance(value, list): - self._handle_ast_list(value) for item in value: if isinstance(item, ast.AST): self.visit(item) diff --git a/vulture/reachability.py b/vulture/reachability.py new file mode 100644 index 00000000..fc043828 --- /dev/null +++ b/vulture/reachability.py @@ -0,0 +1,193 @@ +import ast + +from vulture import utils + + +class Reachability: + def __init__(self, report): + self._report = report + self._no_fall_through_nodes = set() + + def visit(self, node): + """When called, all children of this node have already been visited.""" + if isinstance(node, (ast.Break, ast.Continue, ast.Return, ast.Raise)): + self._mark_as_no_fall_through(node) + elif isinstance( + node, + ( + ast.Module, + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.For, + ast.AsyncFor, + ast.With, + ast.AsyncWith, + ), + ): + self._can_fall_through_statements_analysis(node.body) + elif isinstance(node, ast.While): + self._handle_reachability_while(node) + elif isinstance(node, ast.If): + self._handle_reachability_if(node) + elif isinstance(node, ast.IfExp): + self._handle_reachability_if_expr(node) + elif isinstance(node, ast.Try): + self._handle_reachability_try(node) + + def reset(self): + self._no_fall_through_nodes = set() + + def _can_fall_through(self, node): + return node not in self._no_fall_through_nodes + + def _mark_as_no_fall_through(self, node): + self._no_fall_through_nodes.add(node) + + def _can_fall_through_statements_analysis(self, statements): + """Report unreachable statements. + Return True if we can execute the full list of statements. + """ + for idx, statement in enumerate(statements): + if not self._can_fall_through(statement): + try: + next_sibling = statements[idx + 1] + except IndexError: + next_sibling = None + if next_sibling is not None: + class_name = statement.__class__.__name__.lower() + self._report( + name=class_name, + first_node=next_sibling, + last_node=statements[-1], + message=f"unreachable code after '{class_name}'", + ) + return False + return True + + def _handle_reachability_if(self, node): + has_else = bool(node.orelse) + + if utils.condition_is_always_false(node.test): + self._report( + name="if", + first_node=node, + last_node=node.body + if isinstance(node, ast.IfExp) + else node.body[-1], + message="unsatisfiable 'if' condition", + ) + if_can_fall_through = True + else_can_fall_through = self._can_else_fall_through( + node.orelse, condition_always_true=False + ) + + elif utils.condition_is_always_true(node.test): + if_can_fall_through = self._can_fall_through_statements_analysis( + node.body + ) + else_can_fall_through = self._can_else_fall_through( + node.orelse, condition_always_true=True + ) + + if has_else: + self._report( + name="else", + first_node=node.orelse[0], + last_node=node.orelse[-1], + message="unreachable 'else' block", + ) + else: + # Redundant if-condition without else block. + self._report( + name="if", + first_node=node, + message="redundant if-condition", + ) + else: + if_can_fall_through = self._can_fall_through_statements_analysis( + node.body + ) + else_can_fall_through = self._can_else_fall_through( + node.orelse, condition_always_true=False + ) + + statement_can_fall_through = ( + if_can_fall_through or else_can_fall_through + ) + + if not statement_can_fall_through: + self._mark_as_no_fall_through(node) + + def _can_else_fall_through(self, orelse, condition_always_true): + if not orelse: + return not condition_always_true + return self._can_fall_through_statements_analysis(orelse) + + def _handle_reachability_if_expr(self, node): + if utils.condition_is_always_false(node.test): + self._report( + name="ternary", + first_node=node, + last_node=node.body + if isinstance(node, ast.IfExp) + else node.body[-1], + message="unsatisfiable 'ternary' condition", + ) + elif utils.condition_is_always_true(node.test): + else_body = node.orelse + self._report( + name="ternary", + first_node=else_body, + message="unreachable 'else' expression", + ) + + def _handle_reachability_while(self, node): + if utils.condition_is_always_false(node.test): + self._report( + name="while", + first_node=node, + last_node=node.body + if isinstance(node, ast.IfExp) + else node.body[-1], + message="unsatisfiable 'while' condition", + ) + + elif utils.condition_is_always_true(node.test): + else_body = node.orelse + if else_body: + self._report( + name="else", + first_node=else_body[0], + last_node=else_body[-1], + message="unreachable 'else' block", + ) + + self._can_fall_through_statements_analysis(node.body) + + def _handle_reachability_try(self, node): + try_can_fall_through = self._can_fall_through_statements_analysis( + node.body + ) + + has_else = bool(node.orelse) + + if not try_can_fall_through and has_else: + else_body = node.orelse + self._report( + name="else", + first_node=else_body[0], + last_node=else_body[-1], + message="unreachable 'else' block", + ) + + any_except_can_fall_through = any( + self._can_fall_through_statements_analysis(handler.body) + for handler in node.handlers + ) + + statement_can_fall_through = ( + try_can_fall_through or any_except_can_fall_through + ) + + if not statement_can_fall_through: + self._mark_as_no_fall_through(node) From 84d32ca39508112738a39623f10950c72e0d9d6e Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Sun, 24 Nov 2024 20:50:41 +0100 Subject: [PATCH 13/18] Enable more style checks. --- dev/make-release-notes.py | 2 +- pyproject.toml | 9 ++++++--- tests/test_config.py | 4 ++-- tests/test_encoding.py | 3 ++- tests/test_errors.py | 3 ++- tests/test_noqa.py | 1 + tests/test_reachability.py | 3 +-- tests/test_scavenging.py | 3 ++- tests/test_script.py | 3 ++- vulture/core.py | 25 ++++++++++-------------- vulture/noqa.py | 2 +- vulture/utils.py | 2 +- vulture/whitelists/ctypes_whitelist.py | 3 +-- vulture/whitelists/unittest_whitelist.py | 2 +- 14 files changed, 33 insertions(+), 32 deletions(-) diff --git a/dev/make-release-notes.py b/dev/make-release-notes.py index 0c9882c2..0bfc0612 100755 --- a/dev/make-release-notes.py +++ b/dev/make-release-notes.py @@ -31,7 +31,7 @@ def check(name, text): print("*" * 60) print(text) print("*" * 60) - response = input("Accept this %s (Y/n)? " % name).strip().lower() + response = input(f"Accept this {name} (Y/n)? ").strip().lower() if response and response != "y": sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index 117c7fc8..66b9e52a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,16 +22,19 @@ indent-width = 4 target-version = "py38" [tool.ruff.lint] -# ruff enables Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# ruff enables Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. select = [ - "F", # pyflakes - "E4", "E7", "E9", # pycodestyle "B", # flake8-bugbear "C4", # comprehensions + "E", # pycodestyle + "F", # pyflakes + "I001", # isort + "SIM", # flake8-simplify "UP", # pyupgrade ] ignore = [ "C408", # unnecessary dict call + "SIM115", # Use context handler for opening files ] # Allow fix for all enabled rules (when `--fix`) is provided. diff --git a/tests/test_config.py b/tests/test_config.py index 2d55612b..6209fa11 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,19 +2,19 @@ Unit tests for config file and CLI argument parsing. """ -from io import BytesIO import pathlib +from io import BytesIO from textwrap import dedent import pytest from vulture.config import ( DEFAULTS, + InputError, _check_input_config, _parse_args, _parse_toml, make_config, - InputError, ) diff --git a/tests/test_encoding.py b/tests/test_encoding.py index 560a6e5a..88b4c6f6 100644 --- a/tests/test_encoding.py +++ b/tests/test_encoding.py @@ -1,8 +1,9 @@ import codecs -from . import v from vulture.utils import ExitCode +from . import v + assert v # Silence pyflakes. diff --git a/tests/test_errors.py b/tests/test_errors.py index 87feff43..17bbffe3 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,8 +1,9 @@ import pytest -from . import v, call_vulture from vulture.utils import ExitCode +from . import call_vulture, v + assert v # Silence pyflakes. diff --git a/tests/test_noqa.py b/tests/test_noqa.py index 7b364ad8..5ce96724 100644 --- a/tests/test_noqa.py +++ b/tests/test_noqa.py @@ -2,6 +2,7 @@ from vulture.core import ERROR_CODES from vulture.noqa import NOQA_CODE_MAP, NOQA_REGEXP, _parse_error_codes + from . import check, v assert v # Silence pyflakes. diff --git a/tests/test_reachability.py b/tests/test_reachability.py index a7eb24a3..e0f8f359 100644 --- a/tests/test_reachability.py +++ b/tests/test_reachability.py @@ -1,5 +1,4 @@ -from . import check_multiple_unreachable, check_unreachable -from . import v +from . import check_multiple_unreachable, check_unreachable, v assert v # Silence pyflakes diff --git a/tests/test_scavenging.py b/tests/test_scavenging.py index 71d762f1..3095978e 100644 --- a/tests/test_scavenging.py +++ b/tests/test_scavenging.py @@ -2,9 +2,10 @@ import pytest -from . import check, v from vulture.utils import ExitCode +from . import check, v + assert v # Silence pyflakes. diff --git a/tests/test_script.py b/tests/test_script.py index 5723addb..6b417434 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -3,9 +3,10 @@ import subprocess import sys -from . import call_vulture, REPO, WHITELISTS from vulture.utils import ExitCode +from . import REPO, WHITELISTS, call_vulture + def test_module_with_explicit_whitelists(): assert call_vulture(["vulture/"] + WHITELISTS) == ExitCode.NoDeadCode diff --git a/vulture/core.py b/vulture/core.py index 5b7db43a..cc301b71 100644 --- a/vulture/core.py +++ b/vulture/core.py @@ -1,21 +1,18 @@ import ast -from fnmatch import fnmatch, fnmatchcase -from functools import partial -from pathlib import Path import pkgutil import re import string import sys +from fnmatch import fnmatch, fnmatchcase +from functools import partial +from pathlib import Path from typing import List -from vulture import lines -from vulture import noqa -from vulture import utils +from vulture import lines, noqa, utils from vulture.config import InputError, make_config from vulture.reachability import Reachability from vulture.utils import ExitCode - DEFAULT_CONFIDENCE = 60 IGNORED_VARIABLE_NAMES = {"object", "self"} @@ -161,12 +158,9 @@ def get_report(self, add_size=False): size_report = f", {self.size:d} {line_format}" else: size_report = "" - return "{}:{:d}: {} ({}% confidence{})".format( - utils.format_path(self.filename), - self.first_lineno, - self.message, - self.confidence, - size_report, + return ( + f"{utils.format_path(self.filename)}:{self.first_lineno:d}: " + f"{self.message} ({self.confidence}% confidence{size_report})" ) def get_whitelist_string(self): @@ -177,8 +171,9 @@ def get_whitelist_string(self): prefix = "" if self.typ in ["attribute", "method", "property"]: prefix = "_." - return "{}{} # unused {} ({}:{:d})".format( - prefix, self.name, self.typ, filename, self.first_lineno + return ( + f"{prefix}{self.name} # unused {self.typ} " + f"({filename}:{self.first_lineno:d})" ) def _tuple(self): diff --git a/vulture/noqa.py b/vulture/noqa.py index 4fb3057a..471f0f95 100644 --- a/vulture/noqa.py +++ b/vulture/noqa.py @@ -1,5 +1,5 @@ -from collections import defaultdict import re +from collections import defaultdict NOQA_REGEXP = re.compile( # Use the same regex as flake8 does. diff --git a/vulture/utils.py b/vulture/utils.py index 3382dca3..a3a40fd8 100644 --- a/vulture/utils.py +++ b/vulture/utils.py @@ -1,8 +1,8 @@ import ast -from enum import IntEnum import pathlib import sys import tokenize +from enum import IntEnum class VultureInputException(Exception): diff --git a/vulture/whitelists/ctypes_whitelist.py b/vulture/whitelists/ctypes_whitelist.py index 42fed2d7..294bf710 100644 --- a/vulture/whitelists/ctypes_whitelist.py +++ b/vulture/whitelists/ctypes_whitelist.py @@ -1,5 +1,4 @@ -from ctypes import _CFuncPtr -from ctypes import _Pointer +from ctypes import _CFuncPtr, _Pointer _CFuncPtr.argtypes _CFuncPtr.errcheck diff --git a/vulture/whitelists/unittest_whitelist.py b/vulture/whitelists/unittest_whitelist.py index d68e0b55..121fc4a0 100644 --- a/vulture/whitelists/unittest_whitelist.py +++ b/vulture/whitelists/unittest_whitelist.py @@ -1,4 +1,4 @@ -from unittest import mock, TestCase +from unittest import TestCase, mock TestCase.setUp TestCase.tearDown From 73df02f0d7c6634e0777a71315a71694cbec1263 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Mon, 25 Nov 2024 08:19:17 +0100 Subject: [PATCH 14/18] Add comment regarding pass_filenames. --- .pre-commit-hooks.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 8d2df77e..fc796f59 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -4,6 +4,9 @@ entry: vulture description: Find unused Python code. types: [python] + # Vulture needs access to all files for a complete analysis, so we + # prevent pre-commit from passing only the changed files. Instead, + # please create a `pyproject.toml` file in your repository and specify + # all files that Vulture should check under `[tool.vulture] --> paths`. pass_filenames: false require_serial: true - From f7f3f747fad0a9da4b15a8e050ec89c8e8f88462 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Sun, 8 Dec 2024 18:39:17 +0100 Subject: [PATCH 15/18] Add release date. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dcf8fbc..fe844ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# next (unreleased) +# 2.14 (2024-12-08) * Improve reachability analysis (kreathon, #270, #302). * Add type hints for `get_unused_code` and the fields of the `Item` class (John Doknjas, #361). From e454d2ef39fc23e72549ff23a1a14e31c3a75605 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Sun, 8 Dec 2024 18:39:39 +0100 Subject: [PATCH 16/18] Update version number to 2.14 for release. --- vulture/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vulture/version.py b/vulture/version.py index a7e46e76..53099cc0 100644 --- a/vulture/version.py +++ b/vulture/version.py @@ -1 +1 @@ -__version__ = "2.13" +__version__ = "2.14" From 2d147f5bc243b3046d50e5a05b11091a6cc91da0 Mon Sep 17 00:00:00 2001 From: Jannic Beck Date: Thu, 12 Dec 2024 07:18:05 +0100 Subject: [PATCH 17/18] Handle while True loops without break statements (#378) --- CHANGELOG.md | 1 + tests/test_reachability.py | 66 +++++++++++++++++++++++++++++++++++++- vulture/reachability.py | 18 +++++++++-- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe844ac7..423ab498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 2.14 (2024-12-08) +* Handle `while True` loops without `break` statements (kreathon). * Improve reachability analysis (kreathon, #270, #302). * Add type hints for `get_unused_code` and the fields of the `Item` class (John Doknjas, #361). diff --git a/tests/test_reachability.py b/tests/test_reachability.py index e0f8f359..fdcbd4c0 100644 --- a/tests/test_reachability.py +++ b/tests/test_reachability.py @@ -731,13 +731,77 @@ def test_while_true_else(v): check_unreachable(v, 4, 1, "else") +def test_while_true_no_fall_through(v): + v.scan( + """\ +while True: + raise Exception() +print(":-(") +""" + ) + check_unreachable(v, 3, 1, "while") + + +def test_while_true_no_fall_through_nested(v): + v.scan( + """\ +while True: + if a > 3: + raise Exception() + else: + pass +print(":-(") +""" + ) + check_unreachable(v, 6, 1, "while") + + +def test_while_true_no_fall_through_nested_loops(v): + v.scan( + """\ +while True: + for _ in range(3): + break + while False: + break +print(":-(") +""" + ) + check_multiple_unreachable(v, [(4, 2, "while"), (6, 1, "while")]) + + +def test_while_true_fall_through(v): + v.scan( + """\ +while True: + break +print(":-)") +""" + ) + assert v.unreachable_code == [] + + +def test_while_true_fall_through_nested(v): + v.scan( + """\ +while True: + if a > 3: + raise Exception() + else: + break +print(":-(") +""" + ) + assert v.unreachable_code == [] + + def test_while_fall_through(v): v.scan( """\ def foo(a): while a > 0: return 1 - print(":-(") + print(":-)") """ ) assert v.unreachable_code == [] diff --git a/vulture/reachability.py b/vulture/reachability.py index fc043828..df13f176 100644 --- a/vulture/reachability.py +++ b/vulture/reachability.py @@ -8,18 +8,25 @@ def __init__(self, report): self._report = report self._no_fall_through_nodes = set() + # Since we visit the children nodes first, we need to maintain a flag + # that indicates if a break statement was seen. When visiting the + # parent (While, For, or AsyncFor), the value is checked and reset. + # Assumes code is valid (break statements only in loops). + self._current_loop_has_break_statement = False + def visit(self, node): """When called, all children of this node have already been visited.""" if isinstance(node, (ast.Break, ast.Continue, ast.Return, ast.Raise)): self._mark_as_no_fall_through(node) + if isinstance(node, ast.Break): + self._current_loop_has_break_statement = True + elif isinstance( node, ( ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, - ast.For, - ast.AsyncFor, ast.With, ast.AsyncWith, ), @@ -27,6 +34,10 @@ def visit(self, node): self._can_fall_through_statements_analysis(node.body) elif isinstance(node, ast.While): self._handle_reachability_while(node) + self._current_loop_has_break_statement = False + elif isinstance(node, (ast.For, ast.AsyncFor)): + self._can_fall_through_statements_analysis(node.body) + self._current_loop_has_break_statement = False elif isinstance(node, ast.If): self._handle_reachability_if(node) elif isinstance(node, ast.IfExp): @@ -162,6 +173,9 @@ def _handle_reachability_while(self, node): message="unreachable 'else' block", ) + if not self._current_loop_has_break_statement: + self._mark_as_no_fall_through(node) + self._can_fall_through_statements_analysis(node.body) def _handle_reachability_try(self, node): From 4ecc14923a307b265e912ea6520b139a0448c726 Mon Sep 17 00:00:00 2001 From: Jendrik Seipp Date: Thu, 12 Dec 2024 07:21:29 +0100 Subject: [PATCH 18/18] Update changelog and revise comment. --- CHANGELOG.md | 5 ++++- vulture/reachability.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 423ab498..a61f5577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ -# 2.14 (2024-12-08) +# next (unreleased) * Handle `while True` loops without `break` statements (kreathon). + +# 2.14 (2024-12-08) + * Improve reachability analysis (kreathon, #270, #302). * Add type hints for `get_unused_code` and the fields of the `Item` class (John Doknjas, #361). diff --git a/vulture/reachability.py b/vulture/reachability.py index df13f176..d207f7fd 100644 --- a/vulture/reachability.py +++ b/vulture/reachability.py @@ -10,8 +10,8 @@ def __init__(self, report): # Since we visit the children nodes first, we need to maintain a flag # that indicates if a break statement was seen. When visiting the - # parent (While, For, or AsyncFor), the value is checked and reset. - # Assumes code is valid (break statements only in loops). + # parent (While, For or AsyncFor), the value is checked (for While) + # and reset. Assumes code is valid (break statements only in loops). self._current_loop_has_break_statement = False def visit(self, node):