├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── main.yml │ └── pre-commit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── TODO.md ├── dev ├── make-release-notes.py └── release.sh ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── test_conditions.py ├── test_confidence.py ├── test_config.py ├── test_encoding.py ├── test_errors.py ├── test_format_strings.py ├── test_ignore.py ├── test_imports.py ├── test_item.py ├── test_make_whitelist.py ├── test_noqa.py ├── test_pytype.py ├── test_reachability.py ├── test_report.py ├── test_scavenging.py ├── test_script.py ├── test_size.py ├── test_sorting.py ├── test_utils.py └── toml │ └── mock_pyproject.toml ├── tox.ini └── vulture ├── __init__.py ├── __main__.py ├── config.py ├── core.py ├── lines.py ├── noqa.py ├── reachability.py ├── utils.py ├── version.py └── whitelists ├── argparse_whitelist.py ├── ast_whitelist.py ├── collections_whitelist.py ├── ctypes_whitelist.py ├── enum_whitelist.py ├── logging_whitelist.py ├── pint_whitelist.py ├── socketserver_whitelist.py ├── string_whitelist.py ├── sys_whitelist.py ├── threading_whitelist.py ├── unittest_whitelist.py └── whitelist_utils.py /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Related Issue 7 | 8 | 9 | 10 | ## Checklist: 11 | 12 | 13 | - [ ] I have updated the documentation in the README.md file or my changes don't require an update. 14 | - [ ] I have added an entry in CHANGELOG.md. 15 | - [ ] I have added or adapted tests to cover my changes. 16 | - [ ] I have run `pre-commit run --all-files` to format and lint my code. 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | # Run daily (* needs quotes). 10 | - cron: '0 4 * * *' 11 | 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [macos-latest, ubuntu-latest, windows-latest] 19 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | allow-prereleases: true 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | python -m pip install --upgrade build coveralls setuptools tox wheel 35 | python -m pip install -r requirements.txt 36 | 37 | - name: Build Vulture wheel 38 | run: python -m build 39 | 40 | - name: Install Vulture wheel 41 | run: "python -m pip install --only-binary=:all: --ignore-installed --find-links=dist/ vulture" 42 | 43 | - name: Run Vulture 44 | run: | 45 | vulture vulture/ tests/ 46 | python -m vulture vulture/ tests/ 47 | 48 | - name: Run tests 49 | run: python -m tox -e py 50 | 51 | - name: Report coverage to Codecov 52 | uses: codecov/codecov-action@v3 53 | with: 54 | files: ./coverage.xml 55 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4.1.1 13 | - uses: pre-commit/action@v3.0.0 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | MANIFEST 3 | build/ 4 | coverage.xml 5 | dist/ 6 | htmlcov/ 7 | vulture.egg-info/ 8 | .cache/ 9 | .coverage 10 | .pytest_cache/ 11 | .tox/ 12 | .venv/ 13 | .vscode/ 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.1.13 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: vulture 2 | name: vulture 3 | language: python 4 | entry: vulture 5 | description: Find unused Python code. 6 | types: [python] 7 | # Vulture needs access to all files for a complete analysis, so we 8 | # prevent pre-commit from passing only the changed files. Instead, 9 | # please create a `pyproject.toml` file in your repository and specify 10 | # all files that Vulture should check under `[tool.vulture] --> paths`. 11 | pass_filenames: false 12 | require_serial: true 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # next (unreleased) 2 | 3 | * Handle `while True` loops without `break` statements (kreathon). 4 | 5 | # 2.14 (2024-12-08) 6 | 7 | * Improve reachability analysis (kreathon, #270, #302). 8 | * Add type hints for `get_unused_code` and the fields of the `Item` class (John Doknjas, #361). 9 | 10 | # 2.13 (2024-10-02) 11 | 12 | * Add support for Python 3.13 (Jendrik Seipp, #369). 13 | * Add PyPI and conda-forge badges to README file (Trevor James Smith, #356). 14 | * Include `tests/**/*.toml` in sdist (Colin Watson). 15 | 16 | # 2.12 (2024-09-17) 17 | 18 | * Use `ruff` for linting and formatting (Anh Trinh, #347, #349). 19 | * Replace `tox` by `pre-commit` for linting and formatting (Anh Trinh, #349). 20 | * Add `--config` flag to specify path to pyproject.toml configuration file (Glen Robertson, #352). 21 | 22 | # 2.11 (2024-01-06) 23 | 24 | * Switch to tomllib/tomli to support heterogeneous arrays (Sebastian Csar, #340). 25 | * Bump flake8, flake8-comprehensions and flake8-bugbear (Sebastian Csar, #341). 26 | * Provide whitelist parity for `MagicMock` and `Mock` (maxrake, #342). 27 | 28 | # 2.10 (2023-10-06) 29 | 30 | * Drop support for Python 3.7 (Jendrik Seipp, #323). 31 | * Add support for Python 3.12 (Jendrik Seipp, #332). 32 | * Use `end_lineno` AST attribute to obtain more accurate line counts (Jendrik Seipp). 33 | 34 | # 2.9.1 (2023-08-21) 35 | 36 | * Use exit code 0 for `--help` and `--version` again (Jendrik Seipp, #321). 37 | 38 | # 2.9 (2023-08-20) 39 | 40 | * Use exit code 3 when dead code is found (whosayn, #319). 41 | * Treat non-supported decorator names as "@" instead of crashing (Llandy3d and Jendrik Seipp, #284). 42 | * Drop support for Python 3.6 (Jendrik Seipp). 43 | 44 | # 2.8 (2023-08-10) 45 | 46 | * Add `UnicodeEncodeError` exception handling to `core.py` (milanbalazs, #299). 47 | * Add whitelist for `Enum` attributes `_name_` and `_value_` (Eugene Toder, #305). 48 | * Run tests and add PyPI trove for Python 3.11 (Jendrik Seipp). 49 | 50 | # 2.7 (2023-01-08) 51 | 52 | * Ignore `setup_module()`, `teardown_module()`, etc. in pytest `test_*.py` files (Jendrik Seipp). 53 | * Add whitelist for `socketserver.TCPServer.allow_reuse_address` (Ben Elliston). 54 | * Clarify that `--exclude` patterns are matched against absolute paths (Jendrik Seipp, #260). 55 | * Fix example in README file (Jendrik Seipp, #272). 56 | 57 | # 2.6 (2022-09-19) 58 | 59 | * Add basic `match` statement support (kreathon, #276, #291). 60 | 61 | # 2.5 (2022-07-03) 62 | 63 | * Mark imports in `__all__` as used (kreathon, #172, #282). 64 | * Add whitelist for `pint.UnitRegistry.default_formatter` (Ben Elliston, #258). 65 | 66 | # 2.4 (2022-05-19) 67 | 68 | * Print absolute filepaths as relative again (as in version 2.1 and before) 69 | if they are below the current directory (The-Compiler, #246). 70 | * Run tests and add PyPI trove for Python 3.10 (chayim, #266). 71 | * Allow using the `del` keyword to mark unused variables (sshishov, #279). 72 | 73 | # 2.3 (2021-01-16) 74 | 75 | * Add [pre-commit](https://pre-commit.com) hook (Clément Robert, #244). 76 | 77 | # 2.2 (2021-01-15) 78 | 79 | * Only parse format strings when being used with `locals()` (jingw, #225). 80 | * Don't override paths in pyproject.toml with empty CLI paths (bcbnz, #228). 81 | * Run continuous integration tests for Python 3.9 (ju-sh, #232). 82 | * Use pathlib internally (ju-sh, #226). 83 | 84 | # 2.1 (2020-08-19) 85 | 86 | * Treat `getattr/hasattr(obj, "constant_string", ...)` as a reference to 87 | `obj.constant_string` (jingw, #219). 88 | * Fix false positives when assigning to `x.some_name` but reading via 89 | `some_name`, at the cost of potential false negatives (jingw, #221). 90 | * Allow reading options from `pyproject.toml` (Michel Albert, #164, #215). 91 | 92 | # 2.0 (2020-08-11) 93 | 94 | * Parse `# type: ...` comments if on Python 3.8+ (jingw, #220). 95 | * Bump minimum Python version to 3.6 (Jendrik Seipp, #218). The last 96 | Vulture release that supports Python 2.7 and Python 3.5 is version 1.6. 97 | * Consider all files under `test` or `tests` directories test files 98 | (Jendrik Seipp). 99 | * Ignore `logging.Logger.propagate` attribute (Jendrik Seipp). 100 | 101 | # 1.6 (2020-07-28) 102 | 103 | * Differentiate between functions and methods (Jendrik Seipp, #112, #209). 104 | * Move from Travis to GitHub actions (RJ722, #211). 105 | 106 | # 1.5 (2020-05-24) 107 | 108 | * Support flake8 "noqa" error codes F401 (unused import) and F841 (unused 109 | local variable) (RJ722, #195). 110 | * Detect unreachable code in conditional expressions 111 | (Agathiyan Bragadeesh, #178). 112 | 113 | # 1.4 (2020-03-30) 114 | 115 | * Ignore unused import statements in `__init__.py` (RJ722, #192). 116 | * Report first decorator's line number for unused decorated objects on 117 | Python 3.8+ (RJ722, #200). 118 | * Check code with black and pyupgrade. 119 | 120 | # 1.3 (2020-02-03) 121 | 122 | * Detect redundant 'if' conditions without 'else' blocks. 123 | * Add whitelist for `string.Formatter` (Joseph Bylund, #183). 124 | 125 | # 1.2 (2019-11-22) 126 | 127 | * Fix tests for Python 3.8 (#166). 128 | * Use new `Constant` AST node under Python 3.8+ (#175). 129 | * Add test for f-strings (#177). 130 | * Add whitelist for `logging` module. 131 | 132 | # 1.1 (2019-09-23) 133 | 134 | * Add `sys.excepthook` to `sys` whitelist. 135 | * Add whitelist for `ctypes` module. 136 | * Check that type annotations are parsed and type comments are ignored 137 | (thanks @kx-chen). 138 | * Support checking files with BOM under Python 2.7 (#170). 139 | 140 | # 1.0 (2018-10-23) 141 | 142 | * Add `--ignore-decorators` flag (thanks @RJ722). 143 | * Add whitelist for `threading` module (thanks @andrewhalle). 144 | 145 | # 0.29 (2018-07-31) 146 | 147 | * Add `--ignore-names` flag for ignoring names matching the given glob 148 | patterns (thanks @RJ722). 149 | 150 | # 0.28 (2018-07-05) 151 | 152 | * Add `--make-whitelist` flag for reporting output in whitelist format 153 | (thanks @RJ722). 154 | * Ignore case of `--exclude` arguments on Windows. 155 | * Add `*-test.py` to recognized test file patterns. 156 | * Add `failureException`, `longMessage` and `maxDiff` to `unittest` 157 | whitelist. 158 | * Refer to actual objects rather than their mocks in default 159 | whitelists (thanks @RJ722). 160 | * Don't import any Vulture modules in setup.py (thanks @RJ722). 161 | 162 | # 0.27 (2018-06-05) 163 | 164 | * Report `while (True): ... else: ...` as unreachable (thanks @RJ722). 165 | * Use `argparse` instead of `optparse`. 166 | * Whitelist Mock.return\_value and Mock.side\_effect in unittest.mock 167 | module. 168 | * Drop support for Python 2.6 and 3.3. 169 | * Improve documentation and test coverage (thanks @RJ722). 170 | 171 | # 0.26 (2017-08-28) 172 | 173 | * Detect `async` function definitions (thanks @RJ722). 174 | * Add `Item.get_report()` method (thanks @RJ722). 175 | * Move method for finding Python modules out of Vulture class. 176 | 177 | # 0.25 (2017-08-15) 178 | 179 | * Detect unsatisfiable statements containing `and`, `or` and `not`. 180 | * Use filenames and line numbers as tie-breakers when sorting by size. 181 | * Store first and last line numbers in Item objects. 182 | * Pass relevant options directly to `scavenge()` and `report()`. 183 | 184 | # 0.24 (2017-08-14) 185 | 186 | * Detect unsatisfiable `while`-conditions (thanks @RJ722). 187 | * Detect unsatisfiable `if`- and `else`-conditions (thanks @RJ722). 188 | * Handle null bytes in source code. 189 | 190 | # 0.23 (2017-08-10) 191 | 192 | * Add `--min-confidence` flag (thanks @RJ722). 193 | 194 | # 0.22 (2017-08-04) 195 | 196 | * Detect unreachable code after `return`, `break`, `continue` and 197 | `raise` (thanks @RJ722). 198 | * Parse all variable and attribute names in new format strings. 199 | * Extend ast whitelist. 200 | 201 | # 0.21 (2017-07-26) 202 | 203 | * If an unused item is defined multiple times, report it multiple 204 | times. 205 | * Make size estimates for function calls more accurate. 206 | * Create wheel files for Vulture (thanks @RJ722). 207 | 208 | # 0.20 (2017-07-26) 209 | 210 | * Report unused tuple assignments as dead code. 211 | * Report attribute names that have the same names as variables as dead 212 | code. 213 | * Let Item class inherit from `object` (thanks @RJ722). 214 | * Handle names imported as aliases like all other used variable names. 215 | * Rename Vulture.used\_vars to Vulture.used\_names. 216 | * Use function for determining which imports to ignore. 217 | * Only try to import each whitelist file once. 218 | * Store used names and used attributes in sets instead of lists. 219 | * Fix estimating the size of code containing ellipses (...). 220 | * Refactor and simplify code. 221 | 222 | # 0.19 (2017-07-20) 223 | 224 | * Don't ignore \_\_foo variable names. 225 | * Use separate methods for determining whether to ignore classes and 226 | functions. 227 | * Only try to find a whitelist for each defined import once (thanks 228 | @roivanov). 229 | * Fix finding the last child for many types of AST nodes. 230 | 231 | # 0.18 (2017-07-17) 232 | 233 | * Make --sort-by-size faster and more 234 | accurate (thanks @RJ722). 235 | 236 | # 0.17 (2017-07-17) 237 | 238 | * Add get\_unused\_code() method. 239 | * Return with exit code 1 when syntax errors are found or files can't 240 | be read. 241 | 242 | # 0.16 (2017-07-12) 243 | 244 | * Differentiate between unused classes and functions (thanks @RJ722). 245 | * Add --sort-by-size option (thanks @jackric and @RJ722). 246 | * Count imports as used if they are accessed as module attributes. 247 | 248 | # 0.15 (2017-07-04) 249 | 250 | * Automatically include whitelists based on imported modules (thanks 251 | @RJ722). 252 | * Add --version parameter (thanks @RJ722). 253 | * Add appveyor tests for testing on Windows (thanks @RJ722). 254 | 255 | # 0.14 (2017-04-06) 256 | 257 | * Add stub whitelist file for Python standard library (thanks @RJ722) 258 | * Ignore class names starting with "Test" in "test\_" files (thanks 259 | @thisch). 260 | * Ignore "test\_" functions only in "test\_" files. 261 | 262 | # 0.13 (2017-03-06) 263 | 264 | * Ignore star-imported names since we cannot detect whether they are 265 | used. 266 | * Move repository to GitHub. 267 | 268 | # 0.12 (2017-01-05) 269 | 270 | * Detect unused imports. 271 | * Use tokenize.open() on Python \>= 3.2 for reading input files, 272 | assume UTF-8 encoding on older Python versions. 273 | 274 | # 0.11 (2016-11-27) 275 | 276 | * Use the system's default encoding when reading files. 277 | * Report syntax errors instead of aborting. 278 | 279 | # 0.10 (2016-07-14) 280 | 281 | * Detect unused function and method arguments (issue #15). 282 | * Detect unused \*args and \*\*kwargs parameters. 283 | * Change license from GPL to MIT. 284 | 285 | # 0.9 (2016-06-29) 286 | 287 | * Don't flag attributes as unused if they are used as global variables 288 | in another module (thanks Florian Bruhin). 289 | * Don't consider "True" and "False" variable names. 290 | * Abort with error message when invoked on .pyc files. 291 | 292 | # 0.8.1 (2015-09-28) 293 | 294 | * Fix code for Python 3. 295 | 296 | # 0.8 (2015-09-28) 297 | 298 | * Do not flag names imported with "import as" as dead code (thanks Tom 299 | Terrace). 300 | 301 | # 0.7 (2015-09-26) 302 | 303 | * Exit with exitcode 1 if path on commandline can't be found. 304 | * Test vulture with vulture using a whitelist module for false 305 | positives. 306 | * Add tests that run vulture as a script. 307 | * Add "python setup.py test" command for running tests. 308 | * Add support for tox. 309 | * Raise test coverage to 100%. 310 | * Remove ez\_setup.py. 311 | 312 | # 0.6 (2014-09-06) 313 | 314 | * Ignore function names starting with "test\_". 315 | * Parse variable names in new format strings (e.g. "This is 316 | {x}".format(x="nice")). 317 | * Only parse alphanumeric variable names in format strings and ignore 318 | types. 319 | * Abort with exit code 1 on syntax errors. 320 | * Support installation under Windows by using setuptools (thanks 321 | Reuben Fletcher-Costin). 322 | 323 | # 0.5 (2014-05-09) 324 | 325 | * If dead code is found, exit with 1. 326 | 327 | # 0.4.1 (2013-09-17) 328 | 329 | * Only warn if a path given on the command line cannot be found. 330 | 331 | # 0.4 (2013-06-23) 332 | 333 | * Ignore unused variables starting with an underscore. 334 | * Show warning for syntax errors instead of aborting directly. 335 | * Print warning if a file cannot be found. 336 | 337 | # 0.3 (2012-03-19) 338 | 339 | * Add support for python3 340 | * Report unused attributes 341 | * Find tuple assignments in comprehensions 342 | * Scan files given on the command line even if they don't end with .py 343 | 344 | # 0.2 (2012-03-18) 345 | 346 | * Only format nodes in verbose mode (gives 4x speedup). 347 | 348 | # 0.1 (2012-03-17) 349 | 350 | * First release. 351 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jendrikseipp@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Vulture 2 | 3 | ## Creating and cloning a fork 4 | 5 | Fork the Vulture repository on GitHub by clicking the "fork" button on the 6 | top right. Then clone your fork to your local machine: 7 | 8 | $ git clone https://github.com/USERNAME/vulture.git # Use your GitHub username. 9 | $ cd vulture 10 | 11 | ## Installation 12 | 13 | We recommend using a Python virtual environment to isolate the 14 | installation of vulture. 15 | 16 | ### Setting up the virtual environment 17 | 18 | You can read more about `virtualenv` in the [virtualenv 19 | documentation](http://virtualenv.readthedocs.org). 20 | 21 | To install the `virtualenv` package using `pip`, run: 22 | 23 | $ python3 -m pip install virtualenv 24 | 25 | Now you can create your own environment (named `vulture_dev`): 26 | 27 | $ virtualenv vulture_dev 28 | 29 | Now, whenever you work on the project, activate the corresponding 30 | environment. 31 | 32 | - On **Unix-based** systems, this can be done with: 33 | 34 | $ source vulture_dev/bin/activate 35 | 36 | - And on **Windows** this is done with: 37 | 38 | $ vulture_dev\scripts\activate 39 | 40 | To leave the virtual environment use: 41 | 42 | (vulture_dev)$ deactivate 43 | 44 | ### Installing vulture 45 | 46 | Navigate to your cloned `vulture` directory, and run the following to 47 | install in development mode: 48 | 49 | $ pip install --editable . 50 | 51 | ### Installing test tools 52 | 53 | Vulture uses tox for testing. You can read more about it in the [tox 54 | documentation](https://tox.readthedocs.io). 55 | 56 | To install `tox`, run: 57 | 58 | $ pip install tox 59 | 60 | It's also recommended that you use `pre-commit` to catch style errors 61 | early: 62 | 63 | $ pip install pre-commit 64 | $ pre-commit install 65 | 66 | ## Coding standards 67 | 68 | ### Creating a new branch 69 | 70 | To start working on a pull request, create a new branch to work on. You 71 | should never develop on your main branch because your main branch 72 | should always be synchronized with the main repo’s main branch, which 73 | is challenging if it has new commits. Create a branch using: 74 | 75 | $ git checkout -b your-new-branch 76 | 77 | #### Naming branches 78 | 79 | Branch names should describe the feature/issue that you want to work on, 80 | but at the same time be short. 81 | 82 | ### Commits 83 | 84 | Each commit should be atomic and its message should adequately describe 85 | the change in a clear manner. Use imperative, e.g., "Fix issue12." instead 86 | of "Fixed issue12.". Please make sure that you only fix the issue at hand 87 | or implement the desired new feature instead of making "drive-by" changes 88 | like adding type hints. 89 | 90 | ### Formating and linting 91 | 92 | Run `pre-commit` using: 93 | 94 | $ pre-commit run --all-files 95 | 96 | ## Testing 97 | 98 | Run `tox` using: 99 | 100 | $ tox 101 | 102 | ## Pull requests 103 | 104 | ### How to send a pull request? 105 | 106 | Push your changes to your fork with: 107 | 108 | $ git push --set-upstream origin BRANCHNAME 109 | 110 | Then visit your fork on GitHub, change the branch to the one you committed 111 | to, and click the `New Pull Request` button. 112 | 113 | ### Follow-up 114 | 115 | In case your PR needs to be updated (tests fail or reviewer requests some 116 | changes), update it by committing on top of your branch. It is not 117 | necessary to amend your previous commit, since we will usually squash all 118 | commits when merging anyway. 119 | 120 | ### Feedback 121 | 122 | Take reviewer feedback positively. It's unlikely for a PR to be merged on 123 | the first attempt, but don’t worry that’s just how it works. It helps to 124 | keep the code clean. 125 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012-2020 Jendrik Seipp (jendrikseipp@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include *.txt 3 | include tests/*.py 4 | include tests/**/*.toml 5 | include tox.ini 6 | include vulture/whitelists/*.py 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vulture - Find dead code 2 | 3 | [![PyPI Version](https://img.shields.io/pypi/v/vulture.svg)](https://pypi.python.org/pypi/vulture) 4 | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/vulture.svg)](https://anaconda.org/conda-forge/vulture) 5 | ![CI:Test](https://github.com/jendrikseipp/vulture/workflows/CI/badge.svg) 6 | [![Codecov Badge](https://codecov.io/gh/jendrikseipp/vulture/branch/main/graphs/badge.svg)](https://codecov.io/gh/jendrikseipp/vulture?branch=main) 7 | 8 | Vulture finds unused code in Python programs. This is useful for 9 | cleaning up and finding errors in large code bases. If you run Vulture 10 | on both your library and test suite you can find untested code. 11 | 12 | Due to Python's dynamic nature, static code analyzers like Vulture are 13 | likely to miss some dead code. Also, code that is only called implicitly 14 | may be reported as unused. Nonetheless, Vulture can be a very helpful 15 | tool for higher code quality. 16 | 17 | ## Features 18 | 19 | * fast: uses static code analysis 20 | * tested: tests itself and has complete test coverage 21 | * complements pyflakes and has the same output syntax 22 | * sorts unused classes and functions by size with `--sort-by-size` 23 | 24 | ## Installation 25 | 26 | $ pip install vulture 27 | 28 | ## Usage 29 | 30 | $ vulture myscript.py # or 31 | $ python3 -m vulture myscript.py 32 | $ vulture myscript.py mypackage/ 33 | $ vulture myscript.py --min-confidence 100 # Only report 100% dead code. 34 | 35 | The provided arguments may be Python files or directories. For each 36 | directory Vulture analyzes all contained 37 | \*.py files. 38 | 39 | After you have found and deleted dead code, run Vulture again, because 40 | it may discover more dead code. 41 | 42 | ## Types of unused code 43 | 44 | In addition to finding unused functions, classes, etc., Vulture can detect 45 | unreachable code. Each chunk of dead code is assigned a *confidence value* 46 | between 60% and 100%, where a value of 100% signals that it is certain that the 47 | code won't be executed. Values below 100% are *very rough* estimates (based on 48 | the type of code chunk) for how likely it is that the code is unused. 49 | 50 | | Code type | Confidence value | 51 | | ------------------- | -- | 52 | | function/method/class argument, unreachable code | 100% | 53 | | import | 90% | 54 | | attribute, class, function, method, property, variable | 60% | 55 | 56 | You can use the `--min-confidence` flag to set the minimum confidence 57 | for code to be reported as unused. Use `--min-confidence 100` to only 58 | report code that is guaranteed to be unused within the analyzed files. 59 | 60 | ## Handling false positives 61 | 62 | When Vulture incorrectly reports chunks of code as unused, you have 63 | several options for suppressing the false positives. If fixing your false 64 | positives could benefit other users as well, please file an issue report. 65 | 66 | #### Whitelists 67 | 68 | The recommended option is to add used code that is reported as unused to a 69 | Python module and add it to the list of scanned paths. To obtain such a 70 | whitelist automatically, pass `--make-whitelist` to Vulture: 71 | 72 | $ vulture mydir --make-whitelist > whitelist.py 73 | $ vulture mydir whitelist.py 74 | 75 | Note that the resulting `whitelist.py` file will contain valid Python 76 | syntax, but for Python to be able to *run* it, you will usually have to 77 | make some modifications. 78 | 79 | We collect whitelists for common Python modules and packages in 80 | `vulture/whitelists/` (pull requests are welcome). 81 | 82 | #### Ignoring files 83 | 84 | If you want to ignore a whole file or directory, use the `--exclude` parameter 85 | (e.g., `--exclude "*settings.py,*/docs/*.py,*/test_*.py,*/.venv/*.py"`). The 86 | exclude patterns are matched against absolute paths. 87 | 88 | #### Flake8 noqa comments 89 | 90 | 99 | 100 | For compatibility with [flake8](https://flake8.pycqa.org/), Vulture 101 | supports the [F401 and 102 | F841](https://flake8.pycqa.org/en/latest/user/error-codes.html) error 103 | codes for ignoring unused imports (`# noqa: F401`) and unused local 104 | variables (`# noqa: F841`). However, we recommend using whitelists instead 105 | of `noqa` comments, since `noqa` comments add visual noise to the code and 106 | make it harder to read. 107 | 108 | #### Ignoring names 109 | 110 | You can use `--ignore-names foo*,ba[rz]` to let Vulture ignore all names 111 | starting with `foo` and the names `bar` and `baz`. Additionally, the 112 | `--ignore-decorators` option can be used to ignore the names of functions 113 | decorated with the given decorator (but not their arguments or function body). 114 | This is helpful for example in Flask 115 | projects, where you can use `--ignore-decorators "@app.route"` to ignore all 116 | function names with the `@app.route` decorator. Note that Vulture simplifies 117 | decorators it cannot parse: `@foo.bar(x, y)` becomes "@foo.bar" and 118 | `@foo.bar(x, y).baz` becomes "@" internally. 119 | 120 | We recommend using whitelists instead of `--ignore-names` or 121 | `--ignore-decorators` whenever possible, since whitelists are 122 | automatically checked for syntactic correctness when passed to Vulture 123 | and often you can even pass them to your Python interpreter and let it 124 | check that all whitelisted code actually still exists in your project. 125 | 126 | #### Marking unused variables 127 | 128 | There are situations where you can't just remove unused variables, e.g., 129 | in function signatures. The recommended solution is to use the `del` 130 | keyword as described in the 131 | [PyLint manual](http://pylint-messages.wikidot.com/messages:w0613) and on 132 | [StackOverflow](https://stackoverflow.com/a/14836005): 133 | 134 | ```python 135 | def foo(x, y): 136 | del y 137 | return x + 3 138 | ``` 139 | 140 | Vulture will also ignore all variables that start with an underscore, so 141 | you can use `_x, y = get_pos()` to mark unused tuple assignments or 142 | function arguments, e.g., `def foo(x, _y)`. 143 | 144 | #### Minimum confidence 145 | 146 | Raise the minimum [confidence value](#types-of-unused-code) with the `--min-confidence` flag. 147 | 148 | #### Unreachable code 149 | 150 | If Vulture complains about code like `if False:`, you can use a Boolean 151 | flag `debug = False` and write `if debug:` instead. This makes the code 152 | more readable and silences Vulture. 153 | 154 | #### Forward references for type annotations 155 | 156 | See [#216](https://github.com/jendrikseipp/vulture/issues/216). For 157 | example, instead of `def foo(arg: "Sequence"): ...`, we recommend using 158 | 159 | ``` python 160 | from __future__ import annotations 161 | 162 | def foo(arg: Sequence): 163 | ... 164 | ``` 165 | 166 | 167 | ## Configuration 168 | 169 | You can also store command line arguments in `pyproject.toml` under the 170 | `tool.vulture` section. Simply remove leading dashes and replace all 171 | remaining dashes with underscores. 172 | 173 | Options given on the command line have precedence over options in 174 | `pyproject.toml`. 175 | 176 | Example Config: 177 | 178 | ``` toml 179 | [tool.vulture] 180 | exclude = ["*file*.py", "dir/"] 181 | ignore_decorators = ["@app.route", "@require_*"] 182 | ignore_names = ["visit_*", "do_*"] 183 | make_whitelist = true 184 | min_confidence = 80 185 | paths = ["myscript.py", "mydir", "whitelist.py"] 186 | sort_by_size = true 187 | verbose = true 188 | ``` 189 | 190 | Vulture will automatically look for a `pyproject.toml` in the current working directory. 191 | 192 | To use a `pyproject.toml` in another directory, you can use the `--config path/to/pyproject.toml` flag. 193 | 194 | ## Integrations 195 | 196 | You can use a [pre-commit](https://pre-commit.com/#install) hook to run 197 | Vulture before each commit. For this, install pre-commit and add the 198 | following to the `.pre-commit-config.yaml` file in your repository: 199 | 200 | ```yaml 201 | repos: 202 | - repo: https://github.com/jendrikseipp/vulture 203 | rev: 'v2.3' # or any later Vulture version 204 | hooks: 205 | - id: vulture 206 | ``` 207 | 208 | Then run `pre-commit install`. Finally, create a `pyproject.toml` file 209 | in your repository and specify all files that Vulture should check under 210 | `[tool.vulture] --> paths` (see above). 211 | 212 | There's also a [GitHub Action for Vulture](https://github.com/gtkacz/vulture-action), 213 | a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=sebastienfi.dead-code-finder) 214 | and you can use Vulture programatically. For example: 215 | 216 | ``` python 217 | import vulture 218 | 219 | v = vulture.Vulture() 220 | v.scavenge(['.']) 221 | unused_code = v.get_unused_code() # returns a list of `Item` objects 222 | ``` 223 | 224 | ## How does it work? 225 | 226 | Vulture uses the `ast` module to build abstract syntax trees for all 227 | given files. While traversing all syntax trees it records the names of 228 | defined and used objects. Afterwards, it reports the objects which have 229 | been defined, but not used. This analysis ignores scopes and only takes 230 | object names into account. 231 | 232 | Vulture also detects unreachable code by looking for code after 233 | `return`, `break`, `continue` and `raise` statements, and by searching 234 | for unsatisfiable `if`- and `while`-conditions. 235 | 236 | ## Sort by size 237 | 238 | When using the `--sort-by-size` option, Vulture sorts unused code by its 239 | number of lines. This helps developers prioritize where to look for dead 240 | code first. 241 | 242 | ## Examples 243 | 244 | Consider the following Python script (`dead_code.py`): 245 | 246 | ``` python 247 | import os 248 | 249 | class Greeter: 250 | def greet(self): 251 | print("Hi") 252 | 253 | def hello_world(): 254 | message = "Hello, world!" 255 | greeter = Greeter() 256 | func_name = "greet" 257 | greet_func = getattr(greeter, func_name) 258 | greet_func() 259 | 260 | if __name__ == "__main__": 261 | hello_world() 262 | ``` 263 | 264 | Calling : 265 | 266 | $ vulture dead_code.py 267 | 268 | results in the following output: 269 | 270 | dead_code.py:1: unused import 'os' (90% confidence) 271 | dead_code.py:4: unused function 'greet' (60% confidence) 272 | dead_code.py:8: unused variable 'message' (60% confidence) 273 | 274 | Vulture correctly reports `os` and `message` as unused but it fails to 275 | detect that `greet` is actually used. The recommended method to deal 276 | with false positives like this is to create a whitelist Python file. 277 | 278 | **Preparing whitelists** 279 | 280 | In a whitelist we simulate the usage of variables, attributes, etc. For 281 | the program above, a whitelist could look as follows: 282 | 283 | ``` python 284 | # whitelist_dead_code.py 285 | from dead_code import Greeter 286 | Greeter.greet 287 | ``` 288 | 289 | Alternatively, you can pass `--make-whitelist` to Vulture and obtain an 290 | automatically generated whitelist. 291 | 292 | Passing both the original program and the whitelist to Vulture 293 | 294 | $ vulture dead_code.py whitelist_dead_code.py 295 | 296 | makes Vulture ignore the `greet` method: 297 | 298 | dead_code.py:1: unused import 'os' (90% confidence) 299 | dead_code.py:8: unused variable 'message' (60% confidence) 300 | 301 | 331 | 332 | ## Exit codes 333 | 334 | | Exit code | Description | 335 | | --------- | ------------------------------------------------------------- | 336 | | 0 | No dead code found | 337 | | 1 | Invalid input (file missing, syntax error, wrong encoding) | 338 | | 2 | Invalid command line arguments | 339 | | 3 | Dead code found | 340 | 341 | ## Similar programs 342 | 343 | - [pyflakes](https://pypi.org/project/pyflakes/) finds unused imports 344 | and unused local variables (in addition to many other programmatic 345 | errors). 346 | - [coverage](https://pypi.org/project/coverage/) finds unused code 347 | more reliably than Vulture, but requires all branches of the code to 348 | actually be run. 349 | - [uncalled](https://pypi.org/project/uncalled/) finds dead code by 350 | using the abstract syntax tree (like Vulture), regular expressions, 351 | or both. 352 | - [dead](https://pypi.org/project/dead/) finds dead code by using the 353 | abstract syntax tree (like Vulture). 354 | 355 | ## Participate 356 | 357 | Please visit to report any 358 | issues or to make pull requests. 359 | 360 | - Contributing guide: 361 | [CONTRIBUTING.md](https://github.com/jendrikseipp/vulture/blob/main/CONTRIBUTING.md) 362 | - Release notes: 363 | [CHANGELOG.md](https://github.com/jendrikseipp/vulture/blob/main/CHANGELOG.md) 364 | - Roadmap: 365 | [TODO.md](https://github.com/jendrikseipp/vulture/blob/main/TODO.md) 366 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODOs 2 | 3 | * Add --ignore-attributes-for-classes option. When visiting such a class, 4 | mark all its attributes as used. Fixes (partly): 5 | * https://github.com/jendrikseipp/vulture/issues/309 6 | * https://github.com/jendrikseipp/vulture/issues/264 7 | * https://github.com/jendrikseipp/vulture/issues/249 8 | * https://github.com/jendrikseipp/vulture/issues/315 9 | Use these as test cases. 10 | * Honor (speaking) pylint error codes (e.g., # pylint: 11 | disable=unused-import): unused-import, unused-variable, unused-argument, 12 | possibly-unused-variable and unreachable-code. See 13 | https://github.com/janjur/readable-pylint-messages#unused-import. 14 | 15 | # Non-TODOs 16 | 17 | * Ignore hidden files and directories (might be unexpected, use 18 | --exclude instead). 19 | * Use Assign instead of Name AST nodes for estimating the size of 20 | assignments (KISS). 21 | * Only count lines for unused code by storing a function `get_size` in 22 | Item for computing the size on demand. This is 1.5 times as slow as 23 | computing no sizes. 24 | * Compute sizes on demand. Storing nodes increases memory usage from 25 | ~120 MiB to ~580 MiB for tensorflow's Python code. 26 | * Detect unreachable code for `ast.Assert` (`assert False` is common 27 | idiom for aborting rogue code). 28 | * Detect superfluous expressions like `a <= b`, `42`, `foo and bar` 29 | occurring outside of a statement (hard to detect if code is 30 | unneeded). 31 | * Detect that body of `if foo:` is unreachable if foo is only assigned 32 | "false" values (complicated: e.g., foo = \[\]; foo.append(1); if 33 | foo: ...). 34 | * Use coverage.py to detect false-positives (\#109). Workflow too 35 | complicated. 36 | * Ignore some decorators by default: @app.route, @cli.command. 37 | * Ignore functions in conftest.py files that start with "pytest_". 38 | -------------------------------------------------------------------------------- /dev/make-release-notes.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import re 4 | import sys 5 | 6 | _, VERSION, CHANGELOG, LIST = sys.argv 7 | HEADER_REGEX = rf"# {VERSION} \(\d\d\d\d-\d\d-\d\d\)\n" 8 | 9 | notes_list = [] 10 | 11 | 12 | with open(CHANGELOG) as f: 13 | first_line = next(f) 14 | if not re.match(HEADER_REGEX, first_line): 15 | sys.exit( 16 | f'First changelog line "{first_line.rstrip()}" must ' 17 | f'start with "{HEADER_REGEX.rstrip()}"' 18 | ) 19 | notes_list.extend([first_line[2:], "\n"]) 20 | line = next(f) # Skip empty line. 21 | if line.strip(): 22 | sys.exit("There must be an empty line after each header") 23 | for line in f: 24 | if not line.strip(): 25 | break 26 | else: 27 | notes_list.append(line) 28 | 29 | 30 | def check(name, text): 31 | print("*" * 60) 32 | print(text) 33 | print("*" * 60) 34 | response = input(f"Accept this {name} (Y/n)? ").strip().lower() 35 | if response and response != "y": 36 | sys.exit(1) 37 | 38 | 39 | check("changelog", "".join(notes_list)) 40 | 41 | with open(LIST, "w") as f: 42 | f.writelines(notes_list) 43 | -------------------------------------------------------------------------------- /dev/release.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -exuo pipefail 4 | 5 | VERSION="$1" 6 | CHANGES="/tmp/vulture-$VERSION-changes" 7 | 8 | cd "$(dirname ${0})/../" 9 | 10 | # Check dependencies. 11 | hub --version > /dev/null 12 | tox --version > /dev/null 13 | twine -h > /dev/null 14 | 15 | # Check for uncommitted changes. 16 | set +e 17 | git diff --quiet && git diff --cached --quiet 18 | retcode=$? 19 | set -e 20 | if [[ $retcode != 0 ]]; then 21 | echo "There are uncommitted changes:" 22 | git status 23 | exit 1 24 | fi 25 | 26 | git pull 27 | 28 | tox 29 | 30 | # Bump version. 31 | sed -i -e "s/__version__ = \".*\"/__version__ = \"$VERSION\"/" vulture/version.py 32 | git commit -am "Update version number to ${VERSION} for release." 33 | git tag -a "v$VERSION" -m "v$VERSION" HEAD 34 | 35 | python3 setup.py sdist bdist_wheel --universal 36 | twine upload dist/vulture-${VERSION}.tar.gz dist/vulture-${VERSION}-py2.py3-none-any.whl 37 | 38 | git push 39 | git push --tags 40 | 41 | # Add changelog to Github release. 42 | ./dev/make-release-notes.py "$VERSION" CHANGELOG.md "$CHANGES" 43 | hub release create v"$VERSION" --file="$CHANGES" 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | exclude = [ 3 | ".eggs", 4 | ".git", 5 | "_build", 6 | "build", 7 | "dist", 8 | "htmlcov", 9 | "vulture.egg-info", 10 | ".cache", 11 | ".coverage", 12 | ".pytest_cache", 13 | ".tox", 14 | ".venv", 15 | ".vscode", 16 | ] 17 | 18 | # Same as Black. 19 | line-length = 79 20 | indent-width = 4 21 | 22 | target-version = "py38" 23 | 24 | [tool.ruff.lint] 25 | # ruff enables Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 26 | select = [ 27 | "B", # flake8-bugbear 28 | "C4", # comprehensions 29 | "E", # pycodestyle 30 | "F", # pyflakes 31 | "I001", # isort 32 | "SIM", # flake8-simplify 33 | "UP", # pyupgrade 34 | ] 35 | ignore = [ 36 | "C408", # unnecessary dict call 37 | "SIM115", # Use context handler for opening files 38 | ] 39 | 40 | # Allow fix for all enabled rules (when `--fix`) is provided. 41 | fixable = ["ALL"] 42 | unfixable = [] 43 | 44 | # Allow unused variables when underscore-prefixed. 45 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 46 | 47 | [tool.ruff.lint.per-file-ignores] 48 | "vulture/whitelists/*.py" = ["B018"] 49 | 50 | [tool.ruff.format] 51 | # Like Black, use double quotes for strings. 52 | quote-style = "double" 53 | 54 | # Like Black, indent with spaces, rather than tabs. 55 | indent-style = "space" 56 | 57 | # Like Black, respect magic trailing commas. 58 | skip-magic-trailing-comma = false 59 | 60 | # Like Black, automatically detect the appropriate line ending. 61 | line-ending = "auto" 62 | 63 | # Enable auto-formatting of code examples in docstrings. Markdown, 64 | # reStructuredText code/literal blocks and doctests are all supported. 65 | # 66 | # This is currently disabled by default, but it is planned for this 67 | # to be opt-out in the future. 68 | docstring-code-format = false 69 | 70 | # Set the line length limit used when formatting code snippets in 71 | # docstrings. 72 | # 73 | # This only has an effect when the `docstring-code-format` setting is 74 | # enabled. 75 | docstring-code-line-length = "dynamic" 76 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tomli >= 1.1.0; python_version < '3.11' 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | omit = 3 | setup.py 4 | .tox/* 5 | parallel = true 6 | 7 | [tool:pytest] 8 | addopts = --cov vulture --cov-report=html --cov-report=term --cov-report=xml --cov-append 9 | 10 | [bdist_wheel] 11 | universal = 1 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import pathlib 4 | import re 5 | 6 | import setuptools 7 | 8 | 9 | def find_version(*parts): 10 | here = pathlib.Path(__file__).parent 11 | version_file = here.joinpath(*parts).read_text() 12 | version_match = re.search( 13 | r"^__version__ = ['\"]([^'\"]*)['\"]$", version_file, re.M 14 | ) 15 | if version_match: 16 | return version_match.group(1) 17 | raise RuntimeError("Unable to find version string.") 18 | 19 | 20 | with open("README.md") as f1, open("CHANGELOG.md") as f2: 21 | long_description = f1.read() + "\n\n" + f2.read() 22 | 23 | with open("requirements.txt") as f: 24 | install_requires = f.read().splitlines() 25 | 26 | setuptools.setup( 27 | name="vulture", 28 | version=find_version("vulture", "version.py"), 29 | description="Find dead code", 30 | long_description=long_description, 31 | long_description_content_type="text/markdown", 32 | keywords="dead-code-removal", 33 | author="Jendrik Seipp", 34 | author_email="jendrikseipp@gmail.com", 35 | url="https://github.com/jendrikseipp/vulture", 36 | license="MIT", 37 | classifiers=[ 38 | "Development Status :: 5 - Production/Stable", 39 | "Environment :: Console", 40 | "Intended Audience :: Developers", 41 | "License :: OSI Approved :: MIT License", 42 | "Programming Language :: Python", 43 | "Programming Language :: Python :: 3", 44 | "Programming Language :: Python :: 3.8", 45 | "Programming Language :: Python :: 3.9", 46 | "Programming Language :: Python :: 3.10", 47 | "Programming Language :: Python :: 3.11", 48 | "Programming Language :: Python :: 3.12", 49 | "Programming Language :: Python :: 3.13", 50 | "Programming Language :: Python :: Implementation :: CPython", 51 | "Programming Language :: Python :: Implementation :: PyPy", 52 | "Topic :: Software Development :: Quality Assurance", 53 | ], 54 | install_requires=install_requires, 55 | entry_points={"console_scripts": ["vulture = vulture.core:main"]}, 56 | python_requires=">=3.8", 57 | packages=setuptools.find_packages(exclude=["tests"]), 58 | package_data={"vulture": ["whitelists/*.py"]}, 59 | ) 60 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import subprocess 3 | import sys 4 | 5 | import pytest 6 | 7 | from vulture import core 8 | 9 | REPO = pathlib.Path(__file__).resolve().parents[1] 10 | WHITELISTS = [ 11 | str(path) 12 | for path in (REPO / "vulture" / "whitelists").glob("*.py") 13 | # Pint is incompatible with Python 3.13 (https://github.com/hgrecco/pint/issues/1969). 14 | if sys.version_info < (3, 13) or path.name != "pint_whitelist.py" 15 | ] 16 | 17 | 18 | def call_vulture(args, **kwargs): 19 | return subprocess.call( 20 | [sys.executable, "-m", "vulture"] + args, cwd=REPO, **kwargs 21 | ) 22 | 23 | 24 | def check(items_or_names, expected_names): 25 | """items_or_names must be a collection of Items or a set of strings.""" 26 | try: 27 | assert sorted(item.name for item in items_or_names) == sorted( 28 | expected_names 29 | ) 30 | except AttributeError: 31 | assert items_or_names == set(expected_names) 32 | 33 | 34 | def check_unreachable(v, lineno, size, name): 35 | assert len(v.unreachable_code) == 1 36 | item = v.unreachable_code[0] 37 | assert item.first_lineno == lineno 38 | assert item.size == size 39 | assert item.name == name 40 | 41 | 42 | def check_multiple_unreachable(v, checks): 43 | assert len(v.unreachable_code) == len(checks) 44 | for item, (lineno, size, name) in zip(v.unreachable_code, checks): 45 | assert item.first_lineno == lineno 46 | assert item.size == size 47 | assert item.name == name 48 | 49 | 50 | @pytest.fixture 51 | def v(): 52 | return core.Vulture(verbose=True) 53 | -------------------------------------------------------------------------------- /tests/test_conditions.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from vulture import utils 4 | 5 | from . import v 6 | 7 | assert v # Silence pyflakes 8 | 9 | 10 | def check_condition(code, result): 11 | condition = ast.parse(code, mode="eval").body 12 | if result: 13 | assert utils.condition_is_always_true(condition) 14 | else: 15 | assert utils.condition_is_always_false(condition) 16 | 17 | 18 | def test_false(): 19 | check_condition("False", False) 20 | check_condition("None", False) 21 | check_condition("0", False) 22 | 23 | 24 | def test_empty(): 25 | check_condition("''", False) 26 | check_condition("[]", False) 27 | check_condition("{}", False) 28 | 29 | 30 | def test_true(): 31 | check_condition("True", True) 32 | check_condition("2", True) 33 | check_condition("'s'", True) 34 | check_condition("['foo', 'bar']", True) 35 | check_condition("{'a': 1, 'b': 2}", True) 36 | 37 | 38 | def test_complex_conditions(): 39 | conditions = [ 40 | ("foo and False", True, False), 41 | ("foo or False", False, False), 42 | ("foo and True", False, False), 43 | ("foo or True", False, True), 44 | ("False and foo", True, False), 45 | ("False and 1", True, False), 46 | ("not False", False, True), 47 | ("not True", True, False), 48 | ("not foo", False, False), 49 | ("foo and (False or [])", True, False), 50 | ('(foo and bar) or {"a": 1}', False, True), 51 | ] 52 | for condition, always_false, always_true in conditions: 53 | condition = ast.parse(condition, mode="eval").body 54 | assert not (always_false and always_true) 55 | assert utils.condition_is_always_false(condition) == always_false 56 | assert utils.condition_is_always_true(condition) == always_true 57 | 58 | 59 | def test_errors(): 60 | conditions = [ 61 | "foo", 62 | '__name__ == "__main__"', 63 | "chr(-1)", 64 | 'getattr(True, "foo")', 65 | 'hasattr(str, "foo")', 66 | "isinstance(True, True)", 67 | "globals()", 68 | "locals()", 69 | "().__class__", 70 | ] 71 | for condition in conditions: 72 | condition = ast.parse(condition, mode="eval").body 73 | assert not utils.condition_is_always_false(condition) 74 | assert not utils.condition_is_always_true(condition) 75 | -------------------------------------------------------------------------------- /tests/test_confidence.py: -------------------------------------------------------------------------------- 1 | from vulture import core 2 | 3 | dc = core.DEFAULT_CONFIDENCE 4 | 5 | 6 | def check_min_confidence(code, min_confidence, expected): 7 | v = core.Vulture(verbose=True) 8 | v.scan(code) 9 | detected = { 10 | item.name: item.confidence 11 | for item in v.get_unused_code(min_confidence=min_confidence) 12 | } 13 | assert detected == expected 14 | 15 | 16 | def test_confidence_import(): 17 | code = """\ 18 | import foo 19 | """ 20 | check_min_confidence(code, 50, {"foo": 90}) 21 | check_min_confidence(code, 100, {}) 22 | 23 | 24 | def test_confidence_unreachable(): 25 | code = """\ 26 | def foo(): 27 | return 28 | bar() 29 | 30 | foo() 31 | """ 32 | check_min_confidence(code, 50, {"return": 100}) 33 | check_min_confidence(code, 100, {"return": 100}) 34 | 35 | 36 | def test_function_arg(): 37 | code = """\ 38 | def foo(a): 39 | b = 3 40 | 41 | foo(5) 42 | """ 43 | check_min_confidence(code, 50, {"a": 100, "b": dc}) 44 | check_min_confidence(code, dc, {"a": 100, "b": dc}) 45 | check_min_confidence(code, 100, {"a": 100}) 46 | 47 | 48 | def test_confidence_class(): 49 | code = """\ 50 | class Foo: 51 | pass 52 | """ 53 | check_min_confidence(code, 50, {"Foo": dc}) 54 | check_min_confidence(code, 100, {}) 55 | 56 | 57 | def test_confidence_attr(): 58 | code = "A.b = 'something'" 59 | check_min_confidence(code, 50, {"b": dc}) 60 | check_min_confidence(code, 100, {}) 61 | 62 | 63 | def test_confidence_props(): 64 | code = """\ 65 | class Foo: 66 | @property 67 | def some_prop(): 68 | pass 69 | 70 | Foo() 71 | """ 72 | check_min_confidence(code, 50, {"some_prop": dc}) 73 | check_min_confidence(code, 100, {}) 74 | 75 | 76 | def test_confidence_async_def(): 77 | code = """\ 78 | async def foo(): 79 | if bar(): 80 | pass 81 | else: 82 | print("Else") 83 | """ 84 | check_min_confidence(code, 50, {"foo": dc}) 85 | check_min_confidence(code, 75, {}) 86 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for config file and CLI argument parsing. 3 | """ 4 | 5 | import pathlib 6 | from io import BytesIO 7 | from textwrap import dedent 8 | 9 | import pytest 10 | 11 | from vulture.config import ( 12 | DEFAULTS, 13 | InputError, 14 | _check_input_config, 15 | _parse_args, 16 | _parse_toml, 17 | make_config, 18 | ) 19 | 20 | 21 | def get_toml_bytes(toml_str: str) -> BytesIO: 22 | """ 23 | Wrap a string in BytesIO to play the role of the incoming config stream. 24 | """ 25 | return BytesIO(bytes(toml_str, "utf-8")) 26 | 27 | 28 | def test_cli_args(): 29 | """ 30 | Ensure that CLI arguments are converted to a config object. 31 | """ 32 | expected = dict( 33 | paths=["path1", "path2"], 34 | exclude=["file*.py", "dir/"], 35 | ignore_decorators=["deco1", "deco2"], 36 | ignore_names=["name1", "name2"], 37 | config="pyproject.toml", 38 | make_whitelist=True, 39 | min_confidence=10, 40 | sort_by_size=True, 41 | verbose=True, 42 | ) 43 | result = _parse_args( 44 | [ 45 | "--exclude=file*.py,dir/", 46 | "--ignore-decorators=deco1,deco2", 47 | "--ignore-names=name1,name2", 48 | "--make-whitelist", 49 | "--min-confidence=10", 50 | "--sort-by-size", 51 | "--verbose", 52 | "path1", 53 | "path2", 54 | ] 55 | ) 56 | assert isinstance(result, dict) 57 | assert result == expected 58 | 59 | 60 | def test_toml_config(): 61 | """ 62 | Ensure parsing of TOML files results in a valid config object. 63 | """ 64 | expected = dict( 65 | paths=["path1", "path2"], 66 | exclude=["file*.py", "dir/"], 67 | ignore_decorators=["deco1", "deco2"], 68 | ignore_names=["name1", "name2"], 69 | make_whitelist=True, 70 | min_confidence=10, 71 | sort_by_size=True, 72 | verbose=True, 73 | ) 74 | data = get_toml_bytes( 75 | dedent( 76 | """\ 77 | [tool.vulture] 78 | exclude = ["file*.py", "dir/"] 79 | ignore_decorators = ["deco1", "deco2"] 80 | ignore_names = ["name1", "name2"] 81 | make_whitelist = true 82 | min_confidence = 10 83 | sort_by_size = true 84 | verbose = true 85 | paths = ["path1", "path2"] 86 | """ 87 | ) 88 | ) 89 | result = _parse_toml(data) 90 | assert isinstance(result, dict) 91 | assert result == expected 92 | 93 | 94 | def test_toml_config_with_heterogenous_array(): 95 | """ 96 | Ensure parsing of TOML files results in a valid config object, even if some 97 | other part of the file contains an array of mixed types. 98 | """ 99 | expected = dict( 100 | paths=["path1", "path2"], 101 | exclude=["file*.py", "dir/"], 102 | ignore_decorators=["deco1", "deco2"], 103 | ignore_names=["name1", "name2"], 104 | make_whitelist=True, 105 | min_confidence=10, 106 | sort_by_size=True, 107 | verbose=True, 108 | ) 109 | data = get_toml_bytes( 110 | dedent( 111 | """\ 112 | [tool.foo] 113 | # comment for good measure 114 | problem_array = [{a = 1}, [2,3,4], "foo"] 115 | 116 | [tool.vulture] 117 | exclude = ["file*.py", "dir/"] 118 | ignore_decorators = ["deco1", "deco2"] 119 | ignore_names = ["name1", "name2"] 120 | make_whitelist = true 121 | min_confidence = 10 122 | sort_by_size = true 123 | verbose = true 124 | paths = ["path1", "path2"] 125 | """ 126 | ) 127 | ) 128 | result = _parse_toml(data) 129 | assert isinstance(result, dict) 130 | assert result == expected 131 | 132 | 133 | def test_config_merging(): 134 | """ 135 | If we have both CLI args and a ``pyproject.toml`` file, the CLI args should 136 | have precedence. 137 | """ 138 | toml = get_toml_bytes( 139 | dedent( 140 | """\ 141 | [tool.vulture] 142 | exclude = ["toml_exclude"] 143 | ignore_decorators = ["toml_deco"] 144 | ignore_names = ["toml_name"] 145 | make_whitelist = false 146 | min_confidence = 10 147 | sort_by_size = false 148 | verbose = false 149 | paths = ["toml_path"] 150 | """ 151 | ) 152 | ) 153 | cliargs = [ 154 | "--exclude=cli_exclude", 155 | "--ignore-decorators=cli_deco", 156 | "--ignore-names=cli_name", 157 | "--make-whitelist", 158 | "--min-confidence=20", 159 | "--sort-by-size", 160 | "--verbose", 161 | "cli_path", 162 | ] 163 | result = make_config(cliargs, toml) 164 | expected = dict( 165 | paths=["cli_path"], 166 | exclude=["cli_exclude"], 167 | ignore_decorators=["cli_deco"], 168 | ignore_names=["cli_name"], 169 | config="pyproject.toml", 170 | make_whitelist=True, 171 | min_confidence=20, 172 | sort_by_size=True, 173 | verbose=True, 174 | ) 175 | assert result == expected 176 | 177 | 178 | def test_toml_config_custom_path(): 179 | """ 180 | Ensure that TOML pyproject.toml files can be read from a custom path, 181 | other than the current working directory. 182 | 183 | Test file is in tests/toml/mock_pyproject.toml 184 | """ 185 | here = pathlib.Path(__file__).parent 186 | tomlfile_path = here.joinpath("toml", "mock_pyproject.toml") 187 | cliargs = [ 188 | f"--config={tomlfile_path}", 189 | "cli_path", 190 | ] 191 | result = make_config(cliargs) 192 | assert result["ignore_names"] == ["name_from_toml_file"] 193 | 194 | 195 | def test_config_merging_missing(): 196 | """ 197 | If we have set a boolean value in the TOML file, but not on the CLI, we 198 | want the TOML value to be taken. 199 | """ 200 | toml = get_toml_bytes( 201 | dedent( 202 | """\ 203 | [tool.vulture] 204 | verbose = true 205 | ignore_names = ["name1"] 206 | """ 207 | ) 208 | ) 209 | cliargs = [ 210 | "cli_path", 211 | ] 212 | result = make_config(cliargs, toml) 213 | assert result["verbose"] is True 214 | assert result["ignore_names"] == ["name1"] 215 | 216 | 217 | def test_config_merging_toml_paths_only(): 218 | """ 219 | If we have paths in the TOML but not on the CLI, the TOML paths should be 220 | used. 221 | """ 222 | toml = get_toml_bytes( 223 | dedent( 224 | """\ 225 | [tool.vulture] 226 | paths = ["path1", "path2"] 227 | """ 228 | ) 229 | ) 230 | cliargs = [ 231 | "--exclude=test_*.py", 232 | ] 233 | result = make_config(cliargs, toml) 234 | assert result["paths"] == ["path1", "path2"] 235 | assert result["exclude"] == ["test_*.py"] 236 | 237 | 238 | def test_invalid_config_options_output(): 239 | """ 240 | If the config file contains unknown options we want to abort. 241 | """ 242 | 243 | with pytest.raises(InputError): 244 | _check_input_config({"unknown_key_1": 1}) 245 | 246 | 247 | @pytest.mark.parametrize("key, value", list(DEFAULTS.items())) 248 | def test_incompatible_option_type(key, value): 249 | """ 250 | If a config value has a different type from the default value we abort. 251 | """ 252 | wrong_types = {int, str, list, bool} - {type(value)} 253 | for wrong_type in wrong_types: 254 | test_value = wrong_type() 255 | with pytest.raises(InputError): 256 | _check_input_config({key: test_value}) 257 | 258 | 259 | def test_missing_paths(): 260 | """ 261 | If the script is run without any paths, we want to abort. 262 | """ 263 | with pytest.raises(InputError): 264 | make_config([]) 265 | -------------------------------------------------------------------------------- /tests/test_encoding.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | 3 | from vulture.utils import ExitCode 4 | 5 | from . import v 6 | 7 | assert v # Silence pyflakes. 8 | 9 | 10 | def test_encoding1(v): 11 | v.scan( 12 | """\ 13 | # -*- coding: utf-8 -*- 14 | pass 15 | """ 16 | ) 17 | assert v.exit_code == ExitCode.NoDeadCode 18 | 19 | 20 | def test_encoding2(v): 21 | v.scan( 22 | """\ 23 | #! /usr/bin/env python 24 | # -*- coding: utf-8 -*- 25 | pass 26 | """ 27 | ) 28 | assert v.exit_code == ExitCode.NoDeadCode 29 | 30 | 31 | def test_non_utf8_encoding(v, tmp_path): 32 | code = "" 33 | name = "non_utf8" 34 | non_utf_8_file = tmp_path / (name + ".py") 35 | with open(non_utf_8_file, mode="wb") as f: 36 | f.write(codecs.BOM_UTF16_LE) 37 | f.write(code.encode("utf_16_le")) 38 | v.scavenge([non_utf_8_file]) 39 | assert v.exit_code == ExitCode.InvalidInput 40 | 41 | 42 | def test_utf8_with_bom(v, tmp_path): 43 | name = "utf8_bom" 44 | filepath = tmp_path / (name + ".py") 45 | # utf8_sig prepends the BOM to the file. 46 | filepath.write_text("", encoding="utf-8-sig") 47 | v.scavenge([filepath]) 48 | assert v.exit_code == ExitCode.NoDeadCode 49 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from vulture.utils import ExitCode 4 | 5 | from . import call_vulture, v 6 | 7 | assert v # Silence pyflakes. 8 | 9 | 10 | def test_syntax_error(v): 11 | v.scan("foo bar") 12 | assert int(v.report()) == ExitCode.InvalidInput 13 | 14 | 15 | def test_null_byte(v): 16 | v.scan("\x00") 17 | assert int(v.report()) == ExitCode.InvalidInput 18 | 19 | 20 | def test_confidence_range(v): 21 | v.scan( 22 | """\ 23 | def foo(): 24 | pass 25 | """ 26 | ) 27 | with pytest.raises(ValueError): 28 | v.get_unused_code(min_confidence=150) 29 | 30 | 31 | def test_invalid_cmdline_args(): 32 | assert ( 33 | call_vulture(["vulture/", "--invalid-argument"]) 34 | == ExitCode.InvalidCmdlineArguments 35 | ) 36 | -------------------------------------------------------------------------------- /tests/test_format_strings.py: -------------------------------------------------------------------------------- 1 | from . import check, v 2 | 3 | assert v # Silence pyflakes. 4 | 5 | 6 | def test_old_format_string(v): 7 | v.scan("'%(a)s, %(b)d' % locals()") 8 | check(v.used_names, ["a", "b", "locals"]) 9 | 10 | 11 | def test_new_format_string(v): 12 | v.scan("'{a}, {b:0d} {c:<30} {d:.2%}'.format(**locals())") 13 | check(v.used_names, ["a", "b", "c", "d", "format", "locals"]) 14 | 15 | 16 | def test_f_string(v): 17 | v.scan( 18 | """\ 19 | f'{a}, {b:0d} {c:<30} {d:.2%} {e()} {f:{width}.{precision}}' 20 | f'{ {x:y for (x, y) in ((1, 2), (3, 4))} }' 21 | """ 22 | ) 23 | check( 24 | v.used_names, 25 | ["a", "b", "c", "d", "e", "f", "precision", "width", "x", "y"], 26 | ) 27 | 28 | 29 | def test_new_format_string_access(v): 30 | v.scan("'{a.b}, {c.d.e} {f[g]} {h[i][j].k}'.format(**locals())") 31 | check( 32 | v.used_names, 33 | ["a", "b", "c", "d", "e", "f", "h", "k", "format", "locals"], 34 | ) 35 | 36 | 37 | def test_new_format_string_numbers(v): 38 | v.scan("'{0.b}, {0.d.e} {0[1]} {0[1][1].k}'.format(**locals())") 39 | check(v.used_names, ["b", "d", "e", "k", "format", "locals"]) 40 | 41 | 42 | def test_incorrect_format_string(v): 43 | v.scan('"{"') 44 | v.scan('"{!-a:}"') 45 | check(v.used_names, []) 46 | 47 | 48 | def test_format_string_not_using_locals(v): 49 | """Strings that are not formatted with locals() should not be parsed.""" 50 | v.scan( 51 | """\ 52 | "{variable}" 53 | 54 | def foobar(): 55 | ''' 56 | Return data of the form 57 | {this_looks_like_a_format_string: 1} 58 | ''' 59 | pass 60 | 61 | "%(thing)s" % {"thing": 1} 62 | 63 | "%(apple)s" * locals() 64 | 65 | "{} {a} {b}".format(1, a=used_var, b=locals()) 66 | """ 67 | ) 68 | check(v.used_names, ["used_var", "locals", "format"]) 69 | -------------------------------------------------------------------------------- /tests/test_ignore.py: -------------------------------------------------------------------------------- 1 | from vulture import core 2 | 3 | from . import check 4 | 5 | 6 | def check_ignore(code, ignore_names, ignore_decorators, expected): 7 | v = core.Vulture( 8 | verbose=True, 9 | ignore_names=ignore_names, 10 | ignore_decorators=ignore_decorators, 11 | ) 12 | v.scan(code) 13 | check(v.get_unused_code(), expected) 14 | 15 | 16 | def test_var(): 17 | code = """\ 18 | fio = 1 19 | fao = 2 20 | bar = 2 21 | ftobar = 3 22 | baz = 10000 23 | funny = True 24 | """ 25 | check_ignore(code, ["f?o*", "ba[rz]"], [], ["funny"]) 26 | 27 | 28 | def test_function(): 29 | code = """\ 30 | def foo_one(): 31 | pass 32 | def foo_two(): 33 | pass 34 | def foo(): 35 | pass 36 | def bar(): 37 | pass 38 | """ 39 | check_ignore(code, ["foo*"], [], ["bar"]) 40 | 41 | 42 | def test_async_function(): 43 | code = """\ 44 | async def foobar(): 45 | pass 46 | async def bar(): 47 | pass 48 | """ 49 | check_ignore(code, ["foo*"], [], ["bar"]) 50 | 51 | 52 | def test_class(): 53 | code = """\ 54 | class Foo: 55 | def __init__(self): 56 | pass 57 | """ 58 | check_ignore(code, ["Foo"], [], []) 59 | 60 | 61 | def test_class_ignore(): 62 | code = """\ 63 | @bar 64 | class Foo: 65 | pass 66 | 67 | class Bar: 68 | pass 69 | """ 70 | check_ignore(code, [], [], ["Foo", "Bar"]) 71 | 72 | 73 | def test_property(): 74 | code = """\ 75 | class Foo: 76 | @property 77 | def some_property(self, a): 78 | return a 79 | 80 | @property 81 | @bar 82 | def foo_bar(self): 83 | return 'bar' 84 | """ 85 | check_ignore(code, ["Foo"], ["@property"], []) 86 | check_ignore(code, ["Foo"], [], ["some_property", "foo_bar"]) 87 | 88 | 89 | def test_attribute(): 90 | code = """\ 91 | class Foo: 92 | def __init__(self, attr_foo, attr_bar): 93 | self._attr_foo = attr_foo 94 | self._attr_bar = attr_bar 95 | """ 96 | check_ignore(code, ["foo", "*_foo"], [], ["Foo", "_attr_bar"]) 97 | 98 | 99 | def test_decorated_functions(): 100 | code = """\ 101 | def decor(): 102 | return help 103 | 104 | class FooBar: 105 | def foobar(self): 106 | return help 107 | 108 | @property 109 | def prop_one(self): 110 | pass 111 | 112 | f = FooBar() 113 | 114 | @decor() 115 | def bar(): 116 | pass 117 | 118 | @f.foobar 119 | def foo(): 120 | pass 121 | 122 | @bar 123 | @foo 124 | @f.foobar() 125 | def barfoo(): 126 | pass 127 | """ 128 | check_ignore(code, [], ["@decor", "*@f.foobar"], ["prop_one"]) 129 | check_ignore(code, [], ["*decor", "@*f.foobar"], ["prop_one"]) 130 | 131 | 132 | def test_decorated_async_functions(): 133 | code = """\ 134 | @app.route('something') 135 | @foobar 136 | async def async_function(): 137 | pass 138 | 139 | @a.b.c 140 | async def foo(): 141 | pass 142 | """ 143 | check_ignore(code, [], ["@app.route", "@a.b"], ["foo"]) 144 | 145 | 146 | def test_decorated_property(): 147 | code = """\ 148 | @bar 149 | @property 150 | def foo(): 151 | pass 152 | """ 153 | check_ignore(code, [], ["@bar"], []) 154 | check_ignore(code, [], ["@baz"], ["foo"]) 155 | check_ignore(code, [], ["@property"], []) 156 | 157 | 158 | def test_decorated_property_reversed(): 159 | code = """\ 160 | @property 161 | @bar 162 | def foo(): 163 | pass 164 | """ 165 | check_ignore(code, [], ["@bar"], []) 166 | check_ignore(code, [], ["@property"], []) 167 | check_ignore(code, [], ["@b*r"], []) 168 | check_ignore(code, [], ["@barfoo"], ["foo"]) 169 | 170 | 171 | def test_decorated_class(): 172 | code = """\ 173 | @barfoo 174 | @foo.bar('foo') 175 | class Bar: 176 | def __init__(self): 177 | pass 178 | """ 179 | check_ignore(code, [], [], ["Bar"]) 180 | check_ignore(code, [], ["@bar*"], []) 181 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | from . import check, v 2 | 3 | assert v # Silence pyflakes. 4 | 5 | 6 | def test_import_star(v): 7 | v.scan( 8 | """\ 9 | from a import * 10 | from a.b import * 11 | """ 12 | ) 13 | check(v.defined_imports, []) 14 | check(v.unused_imports, []) 15 | 16 | 17 | def test_import_from_future(v): 18 | v.scan("""from __future__ import division""") 19 | check(v.defined_imports, []) 20 | check(v.unused_imports, []) 21 | 22 | 23 | def test_double_import(v): 24 | v.scan( 25 | """\ 26 | import foo as bar 27 | import foo 28 | """ 29 | ) 30 | check(v.defined_imports, ["bar", "foo"]) 31 | # Once the bar import is removed, the foo import will be detected. 32 | check(v.unused_imports, ["bar"]) 33 | 34 | 35 | def test_attribute_access(v): 36 | v.scan( 37 | """\ 38 | # foo.py 39 | class Foo: 40 | pass 41 | 42 | # bar.py 43 | from foo import Foo 44 | 45 | # main.py 46 | import bar 47 | bar.Foo 48 | """ 49 | ) 50 | check(v.defined_imports, ["Foo", "bar"]) 51 | check(v.unused_imports, []) 52 | 53 | 54 | def test_nested_import(v): 55 | v.scan( 56 | """\ 57 | import os.path 58 | os.path.expanduser("~") 59 | """ 60 | ) 61 | check(v.defined_imports, ["os"]) 62 | check(v.used_names, ["os", "path", "expanduser"]) 63 | check(v.unused_funcs, []) 64 | check(v.unused_imports, []) 65 | check(v.unused_vars, []) 66 | 67 | 68 | definitions = """\ 69 | class A(object): 70 | pass 71 | class B(object): 72 | pass 73 | def C(): 74 | pass 75 | D = 42 76 | """ 77 | 78 | imports = """\ 79 | from any_module import A 80 | import B, C 81 | import D 82 | """ 83 | 84 | aliased_imports = """\ 85 | from any_module import A as AliasA 86 | import B as AliasB, C as AliasC 87 | import D as AliasD 88 | """ 89 | 90 | uses = """\ 91 | A() 92 | B() 93 | C() 94 | D() 95 | """ 96 | 97 | aliased_uses = """\ 98 | AliasA() 99 | AliasB() 100 | AliasC() 101 | AliasD() 102 | """ 103 | 104 | 105 | def test_definitions(v): 106 | v.scan(definitions) 107 | check(v.defined_classes, ["A", "B"]) 108 | check(v.defined_funcs, ["C"]) 109 | check(v.defined_imports, []) 110 | check(v.defined_vars, ["D"]) 111 | check(v.used_names, []) 112 | check(v.unused_classes, ["A", "B"]) 113 | check(v.unused_funcs, ["C"]) 114 | check(v.unused_imports, []) 115 | check(v.unused_vars, ["D"]) 116 | 117 | 118 | def test_use_original(v): 119 | v.scan(definitions + uses) 120 | check(v.defined_classes, ["A", "B"]) 121 | check(v.defined_funcs, ["C"]) 122 | check(v.defined_imports, []) 123 | check(v.defined_vars, ["D"]) 124 | check(v.used_names, ["A", "B", "C", "D"]) 125 | check(v.unused_funcs, []) 126 | check(v.unused_classes, []) 127 | check(v.unused_imports, []) 128 | check(v.unused_vars, []) 129 | 130 | 131 | def test_import_original(v): 132 | v.scan(definitions + imports) 133 | check(v.defined_classes, ["A", "B"]) 134 | check(v.defined_funcs, ["C"]) 135 | check(v.defined_imports, ["A", "B", "C", "D"]) 136 | check(v.defined_vars, ["D"]) 137 | check(v.used_names, []) 138 | check(v.unused_classes, ["A", "B"]) 139 | check(v.unused_funcs, ["C"]) 140 | check(v.unused_imports, ["A", "B", "C", "D"]) 141 | check(v.unused_vars, ["D"]) 142 | 143 | 144 | def test_import_original_use_original(v): 145 | v.scan(definitions + imports + uses) 146 | check(v.defined_classes, ["A", "B"]) 147 | check(v.defined_funcs, ["C"]) 148 | check(v.defined_imports, ["A", "B", "C", "D"]) 149 | check(v.defined_vars, ["D"]) 150 | check(v.used_names, ["A", "B", "C", "D"]) 151 | check(v.unused_classes, []) 152 | check(v.unused_funcs, []) 153 | check(v.unused_imports, []) 154 | check(v.unused_vars, []) 155 | 156 | 157 | def test_import_original_use_alias(v): 158 | v.scan(definitions + imports + aliased_uses) 159 | check(v.defined_classes, ["A", "B"]) 160 | check(v.defined_funcs, ["C"]) 161 | check(v.defined_imports, ["A", "B", "C", "D"]) 162 | check(v.defined_vars, ["D"]) 163 | check(v.used_names, ["AliasA", "AliasB", "AliasC", "AliasD"]) 164 | check(v.unused_classes, ["A", "B"]) 165 | check(v.unused_funcs, ["C"]) 166 | check(v.unused_imports, ["A", "B", "C", "D"]) 167 | check(v.unused_vars, ["D"]) 168 | 169 | 170 | def test_import_alias(v): 171 | v.scan(definitions + aliased_imports) 172 | check(v.defined_classes, ["A", "B"]) 173 | check(v.defined_funcs, ["C"]) 174 | check(v.defined_imports, ["AliasA", "AliasB", "AliasC", "AliasD"]) 175 | check(v.defined_vars, ["D"]) 176 | check(v.used_names, ["A", "B", "C", "D"]) 177 | check(v.unused_classes, []) 178 | check(v.unused_funcs, []) 179 | check(v.unused_imports, ["AliasA", "AliasB", "AliasC", "AliasD"]) 180 | check(v.unused_vars, []) 181 | 182 | 183 | def test_import_alias_use_original(v): 184 | v.scan(definitions + aliased_imports + uses) 185 | check(v.defined_classes, ["A", "B"]) 186 | check(v.defined_funcs, ["C"]) 187 | check(v.defined_imports, ["AliasA", "AliasB", "AliasC", "AliasD"]) 188 | check(v.defined_vars, ["D"]) 189 | check(v.used_names, ["A", "B", "C", "D"]) 190 | check(v.unused_classes, []) 191 | check(v.unused_funcs, []) 192 | check(v.unused_imports, ["AliasA", "AliasB", "AliasC", "AliasD"]) 193 | check(v.unused_vars, []) 194 | 195 | 196 | def test_import_alias_use_alias(v): 197 | v.scan(definitions + aliased_imports + aliased_uses) 198 | check(v.defined_classes, ["A", "B"]) 199 | check(v.defined_funcs, ["C"]) 200 | check(v.defined_imports, ["AliasA", "AliasB", "AliasC", "AliasD"]) 201 | check(v.defined_vars, ["D"]) 202 | check( 203 | v.used_names, 204 | ["A", "B", "C", "D", "AliasA", "AliasB", "AliasC", "AliasD"], 205 | ) 206 | check(v.unused_classes, []) 207 | check(v.unused_funcs, []) 208 | check(v.unused_imports, []) 209 | check(v.unused_vars, []) 210 | 211 | 212 | def test_import_with__all__(v): 213 | v.scan( 214 | """\ 215 | # define.py 216 | class Foo: 217 | pass 218 | 219 | class Bar: 220 | pass 221 | 222 | # main.py 223 | from define import Foo, Bar 224 | 225 | __all__ = ["Foo"] 226 | 227 | """ 228 | ) 229 | check(v.defined_imports, ["Foo", "Bar"]) 230 | check(v.unused_imports, ["Bar"]) 231 | 232 | 233 | def test_import_with__all__normal_reference(v): 234 | v.scan( 235 | """\ 236 | # define.py 237 | class Foo: 238 | pass 239 | 240 | class Bar: 241 | pass 242 | 243 | # main.py 244 | from define import Foo, Bar 245 | 246 | __all__ = [Foo] 247 | 248 | """ 249 | ) 250 | check(v.defined_imports, ["Foo", "Bar"]) 251 | check(v.unused_imports, ["Bar"]) 252 | 253 | 254 | def test_import_with__all__string(v): 255 | v.scan( 256 | """\ 257 | # define.py 258 | class Foo: 259 | pass 260 | 261 | class Bar: 262 | pass 263 | 264 | # main.py 265 | from define import Foo, Bar 266 | 267 | __all__ = "Foo" 268 | 269 | """ 270 | ) 271 | check(v.defined_imports, ["Foo", "Bar"]) 272 | # __all__ is not a list or tuple, so Foo is unused. 273 | check(v.unused_imports, ["Foo", "Bar"]) 274 | 275 | 276 | def test_import_with__all__assign_other_module(v): 277 | v.scan( 278 | """\ 279 | # define.py 280 | class Foo: 281 | pass 282 | 283 | class Bar: 284 | pass 285 | 286 | # main.py 287 | import define 288 | from define import Foo, Bar 289 | 290 | define.__all__ = ["Foo"] 291 | 292 | """ 293 | ) 294 | check(v.defined_imports, ["define", "Foo", "Bar"]) 295 | # Only assignments to __all__ of the current module are covered. 296 | check(v.unused_imports, ["Foo", "Bar"]) 297 | 298 | 299 | def test_ignore_init_py_files(v): 300 | v.scan( 301 | """\ 302 | import bar 303 | from foo import * 304 | from zoo import zebra 305 | 306 | unused_var = 'monty' 307 | """, 308 | filename="nested/project/__init__.py", 309 | ) 310 | check(v.unused_imports, []) 311 | check(v.unused_vars, ["unused_var"]) 312 | -------------------------------------------------------------------------------- /tests/test_item.py: -------------------------------------------------------------------------------- 1 | from . import v 2 | 3 | assert v # Silence pyflakes 4 | 5 | 6 | def test_item_repr(v): 7 | v.scan( 8 | """\ 9 | import os 10 | 11 | message = "foobar" 12 | 13 | class Foo: 14 | def bar(): 15 | pass 16 | """ 17 | ) 18 | for item in v.get_unused_code(): 19 | assert repr(item) == f"{item.name!r}" 20 | 21 | 22 | def test_item_attr(v): 23 | v.scan("foo.bar = 'bar'") 24 | assert len(v.unused_attrs) == 1 25 | a = v.unused_attrs[0] 26 | assert a.name == "bar" 27 | assert a.first_lineno == 1 28 | assert a.last_lineno == 1 29 | 30 | 31 | def test_item_class(v): 32 | v.scan( 33 | """\ 34 | class Foo: 35 | pass 36 | """ 37 | ) 38 | assert len(v.unused_classes) == 1 39 | c = v.unused_classes[0] 40 | assert c.name == "Foo" 41 | assert c.first_lineno == 1 42 | assert c.last_lineno == 2 43 | 44 | 45 | def test_item_function(v): 46 | v.scan( 47 | """\ 48 | def add(a, b): 49 | return a + b 50 | """ 51 | ) 52 | assert len(v.unused_funcs) == 1 53 | f = v.unused_funcs[0] 54 | assert f.name == "add" 55 | assert f.first_lineno == 1 56 | assert f.last_lineno == 2 57 | 58 | 59 | def test_item_import(v): 60 | v.scan( 61 | """\ 62 | import bar 63 | from foo import * 64 | """ 65 | ) 66 | assert len(v.unused_imports) == 1 67 | i = v.unused_imports[0] 68 | assert i.name == "bar" 69 | assert i.first_lineno == 1 70 | assert i.last_lineno == 1 71 | 72 | 73 | def test_item_property(v): 74 | v.scan( 75 | """\ 76 | @awesomify 77 | class Foo: 78 | @property 79 | @wifi( 80 | username='dog', 81 | password='cat', 82 | ) 83 | def bar(self): 84 | pass 85 | Foo() 86 | """ 87 | ) 88 | assert len(v.unused_props) == 1 89 | p = v.unused_props[0] 90 | assert p.name == "bar" 91 | assert p.first_lineno == 3 92 | assert p.last_lineno == 9 93 | 94 | 95 | def test_item_variable(v): 96 | v.scan("v = 'Vulture'") 97 | assert len(v.unused_vars) == 1 98 | var = v.unused_vars[0] 99 | assert var.name == "v" 100 | assert var.first_lineno == 1 101 | assert var.last_lineno == 1 102 | -------------------------------------------------------------------------------- /tests/test_make_whitelist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import check, v 4 | 5 | assert v # silence pyflakes 6 | 7 | 8 | @pytest.fixture 9 | def check_whitelist(v): 10 | def examine(code, results_before, results_after): 11 | v.scan(code) 12 | check(v.get_unused_code(), results_before) 13 | for item in v.get_unused_code(): 14 | v.scan(item.get_whitelist_string()) 15 | check(v.get_unused_code(), results_after) 16 | 17 | return examine 18 | 19 | 20 | def test_unused_function(check_whitelist): 21 | code = """\ 22 | def func(): 23 | pass 24 | """ 25 | check_whitelist(code, ["func"], []) 26 | 27 | 28 | def test_unused_class(check_whitelist): 29 | code = """\ 30 | class Foo: 31 | def __init__(self): 32 | pass 33 | """ 34 | check_whitelist(code, ["Foo"], []) 35 | 36 | 37 | def test_unused_variables(check_whitelist): 38 | code = """\ 39 | foo = 'unused' 40 | bar = 'variable' 41 | """ 42 | check_whitelist(code, ["foo", "bar"], []) 43 | 44 | 45 | def test_unused_import(check_whitelist): 46 | code = """\ 47 | import xyz 48 | import foo as bar 49 | from abc import iou 50 | from lorem import ipsum as dolor 51 | """ 52 | check_whitelist(code, ["xyz", "bar", "iou", "dolor"], []) 53 | 54 | 55 | def test_unused_attribute(check_whitelist): 56 | code = """\ 57 | class Foo: 58 | def bar(self): 59 | self.foobar = 'unused attr' 60 | """ 61 | check_whitelist(code, ["Foo", "bar", "foobar"], []) 62 | 63 | 64 | def test_unused_property(check_whitelist): 65 | code = """\ 66 | class Foo: 67 | @property 68 | def bar(self): 69 | pass 70 | """ 71 | check_whitelist(code, ["Foo", "bar"], []) 72 | 73 | 74 | def test_unreachable_code(check_whitelist): 75 | code = """\ 76 | def foo(): 77 | return "Foo Bar" 78 | print("Hello") 79 | """ 80 | check_whitelist(code, ["foo", "return"], ["return"]) 81 | -------------------------------------------------------------------------------- /tests/test_noqa.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from vulture.core import ERROR_CODES 4 | from vulture.noqa import NOQA_CODE_MAP, NOQA_REGEXP, _parse_error_codes 5 | 6 | from . import check, v 7 | 8 | assert v # Silence pyflakes. 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "line, codes", 13 | [ 14 | ("# noqa", ["all"]), 15 | ("## noqa", ["all"]), 16 | ("# noqa Hi, go on.", ["all"]), 17 | ("# noqa: V101", ["V101"]), 18 | ("# noqa: V101, V106", ["V101", "V106"]), 19 | ("# NoQA: V101, V103, \t V104", ["V101", "V103", "V104"]), 20 | ], 21 | ) 22 | def test_noqa_regex_present(line, codes): 23 | match = NOQA_REGEXP.search(line) 24 | parsed = _parse_error_codes(match) 25 | assert parsed == codes 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "line", 30 | [ 31 | ("# noqa: 123V"), 32 | ("# noqa explanation: V012"), 33 | ("# noqa: ,V101"), 34 | ("# noqa: #noqa: V102"), 35 | ("# noqa: # noqa: V102"), 36 | ], 37 | ) 38 | def test_noqa_regex_no_groups(line): 39 | assert NOQA_REGEXP.search(line).groupdict()["codes"] is None 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "line", 44 | [("#noqa"), ("##noqa"), ("# n o q a"), ("#NOQA"), ("# Hello, noqa")], 45 | ) 46 | def test_noqa_regex_not_present(line): 47 | assert not NOQA_REGEXP.search(line) 48 | 49 | 50 | def test_noqa_without_codes(v): 51 | v.scan( 52 | """\ 53 | import this # noqa 54 | 55 | @underground # noqa 56 | class Cellar: 57 | @property # noqa 58 | def wine(self): 59 | grapes = True # noqa 60 | 61 | @without_ice # noqa 62 | def serve(self, quantity=50): 63 | self.quantity_served = quantity # noqa 64 | return 65 | self.pour() # noqa 66 | """ 67 | ) 68 | check(v.unused_attrs, []) 69 | check(v.unused_classes, []) 70 | check(v.unused_funcs, []) 71 | check(v.unused_imports, []) 72 | check(v.unused_props, []) 73 | check(v.unreachable_code, []) 74 | check(v.unused_vars, []) 75 | 76 | 77 | def test_noqa_specific_issue_codes(v): 78 | v.scan( 79 | """\ 80 | import this # noqa: V104 81 | 82 | @underground # noqa: V102 83 | class Cellar: 84 | @property # noqa: V106 85 | def wine(self): 86 | grapes = True # noqa: V107 87 | 88 | @without_ice # noqa: V103 89 | def serve(self, quantity=50): 90 | self.quantity_served = quantity # noqa: V101 91 | return 92 | self.pour() # noqa: V201 93 | """ 94 | ) 95 | check(v.unused_attrs, []) 96 | check(v.unused_classes, []) 97 | check(v.unused_funcs, []) 98 | check(v.unused_imports, []) 99 | check(v.unused_methods, ["serve"]) 100 | check(v.unused_props, []) 101 | check(v.unreachable_code, []) 102 | check(v.unused_vars, []) 103 | 104 | 105 | def test_noqa_attributes(v): 106 | v.scan( 107 | """\ 108 | something.x = 'x' # noqa: V101 109 | something.z = 'z' # noqa: V107 (code for unused variable) 110 | something.u = 'u' # noqa 111 | """ 112 | ) 113 | check(v.unused_attrs, ["z"]) 114 | 115 | 116 | def test_noqa_classes(v): 117 | v.scan( 118 | """\ 119 | class QtWidget: # noqa: V102 120 | pass 121 | 122 | class ABC(QtWidget): 123 | pass # noqa: V102 (should not ignore) 124 | 125 | class DEF: # noqa 126 | pass 127 | """ 128 | ) 129 | check(v.unused_classes, ["ABC"]) 130 | 131 | 132 | def test_noqa_functions(v): 133 | v.scan( 134 | """\ 135 | def play(tune, instrument='bongs', _hz='50'): # noqa: V103 136 | pass 137 | 138 | 139 | # noqa 140 | def problems(): # noqa: V104 141 | pass # noqa: V103 142 | 143 | def hello(name): # noqa 144 | print("Hello") 145 | """ 146 | ) 147 | check(v.unused_funcs, ["problems"]) 148 | check(v.unused_vars, ["instrument", "tune"]) 149 | 150 | 151 | def test_noqa_imports(v): 152 | v.scan( 153 | """\ 154 | import foo 155 | import this # noqa: V104 156 | import zoo 157 | from koo import boo # noqa 158 | from me import * 159 | import dis # noqa: V101 (code for unused attr) 160 | """ 161 | ) 162 | check(v.unused_imports, ["foo", "zoo", "dis"]) 163 | 164 | 165 | def test_noqa_properties(v): 166 | v.scan( 167 | """\ 168 | class Zoo: 169 | @property 170 | def no_of_koalas(self): # noqa 171 | pass 172 | 173 | @property 174 | def area(self, width, depth): # noqa: V105 175 | pass 176 | 177 | @property # noqa 178 | def entry_gates(self): 179 | pass 180 | 181 | @property # noqa: V103 (code for unused function) 182 | def tickets(self): 183 | pass 184 | """ 185 | ) 186 | check(v.unused_props, ["no_of_koalas", "area", "tickets"]) 187 | check(v.unused_classes, ["Zoo"]) 188 | check(v.unused_vars, ["width", "depth"]) 189 | 190 | 191 | def test_noqa_multiple_decorators(v): 192 | v.scan( 193 | """\ 194 | @bar # noqa: V102 195 | class Foo: 196 | @property # noqa: V106 197 | @make_it_cool 198 | @log 199 | def something(self): 200 | pass 201 | 202 | @coolify 203 | @property 204 | def something_else(self): # noqa: V106 205 | pass 206 | 207 | @a 208 | @property 209 | @b # noqa 210 | def abcd(self): 211 | pass 212 | """ 213 | ) 214 | check(v.unused_props, ["something_else", "abcd"]) 215 | check(v.unused_classes, []) 216 | 217 | 218 | def test_noqa_unreacahble_code(v): 219 | v.scan( 220 | """\ 221 | def shave_sheep(sheep): 222 | for a_sheep in sheep: 223 | if a_sheep.is_bald: 224 | continue 225 | a_sheep.grow_hair() # noqa: V201 226 | a_sheep.shave() 227 | return 228 | for a_sheep in sheep: # noqa: V201 229 | if a_sheep.still_has_hair: 230 | a_sheep.shave_again() 231 | """ 232 | ) 233 | check(v.unreachable_code, []) 234 | check(v.unused_funcs, ["shave_sheep"]) 235 | 236 | 237 | def test_noqa_variables(v): 238 | v.scan( 239 | """\ 240 | mitsi = "Mother" # noqa: V107 241 | harry = "Father" # noqa 242 | shero = "doggy" # noqa: V101, V104 (code for unused import, attr) 243 | shinchan.friend = ['masao'] # noqa: V107 (code for unused variable) 244 | """ 245 | ) 246 | check(v.unused_vars, ["shero"]) 247 | check(v.unused_attrs, ["friend"]) 248 | 249 | 250 | def test_noqa_with_multiple_issue_codes(v): 251 | v.scan( 252 | """\ 253 | def world(axis): # noqa: V103, V201 254 | pass 255 | 256 | 257 | for _ in range(3): 258 | continue 259 | xyz = hello(something, else): # noqa: V201, V107 260 | """ 261 | ) 262 | check(v.get_unused_code(), []) 263 | 264 | 265 | def test_noqa_on_empty_line(v): 266 | v.scan( 267 | """\ 268 | # noqa 269 | import this 270 | # noqa 271 | """ 272 | ) 273 | check(v.unused_imports, ["this"]) 274 | 275 | 276 | def test_noqa_with_invalid_codes(v): 277 | v.scan( 278 | """\ 279 | import this # V098, A123, F876 280 | """ 281 | ) 282 | check(v.unused_imports, ["this"]) 283 | 284 | 285 | @pytest.mark.parametrize( 286 | "first_file, second_file", 287 | [ 288 | ("foo = None", "bar = None # noqa"), 289 | ("bar = None # noqa", "foo = None"), 290 | ], 291 | ) 292 | def test_noqa_multiple_files(first_file, second_file, v): 293 | v.scan(first_file, filename="first_file.py") 294 | v.scan(second_file, filename="second_file.py") 295 | check(v.unused_vars, ["foo"]) 296 | 297 | 298 | def test_flake8_noqa_codes(v): 299 | assert NOQA_CODE_MAP["F401"] == ERROR_CODES["import"] 300 | assert NOQA_CODE_MAP["F841"] == ERROR_CODES["variable"] 301 | v.scan( 302 | """\ 303 | import this # noqa: F401 304 | 305 | def foo(): 306 | bar = 2 # noqa: F841 307 | """ 308 | ) 309 | check(v.unused_funcs, ["foo"]) 310 | check(v.unused_imports, []) 311 | check(v.unused_vars, []) 312 | -------------------------------------------------------------------------------- /tests/test_pytype.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | import pytest 5 | 6 | 7 | @pytest.mark.skipif( 8 | sys.version_info >= (3, 13), reason="needs Python < 3.13 for pytype" 9 | ) 10 | def test_pytype(): 11 | assert subprocess.run(["pytype", "vulture/core.py"]).returncode == 0 12 | -------------------------------------------------------------------------------- /tests/test_reachability.py: -------------------------------------------------------------------------------- 1 | from . import check_multiple_unreachable, check_unreachable, v 2 | 3 | assert v # Silence pyflakes 4 | 5 | 6 | def test_return_assignment(v): 7 | v.scan( 8 | """\ 9 | def foo(): 10 | print("Hello World") 11 | return 12 | a = 1 13 | """ 14 | ) 15 | check_unreachable(v, 4, 1, "return") 16 | 17 | 18 | def test_return_multiline_return_statements(v): 19 | v.scan( 20 | """\ 21 | def foo(): 22 | print("Something") 23 | return (something, 24 | that, 25 | spans, 26 | over, 27 | multiple, 28 | lines) 29 | print("Hello World") 30 | """ 31 | ) 32 | check_unreachable(v, 9, 1, "return") 33 | 34 | 35 | def test_return_multiple_return_statements(v): 36 | v.scan( 37 | """\ 38 | def foo(): 39 | return something 40 | return None 41 | return (some, statement) 42 | """ 43 | ) 44 | check_unreachable(v, 3, 2, "return") 45 | 46 | 47 | def test_return_pass(v): 48 | v.scan( 49 | """\ 50 | def foo(): 51 | return 52 | pass 53 | return something 54 | """ 55 | ) 56 | check_unreachable(v, 3, 2, "return") 57 | 58 | 59 | def test_return_multiline_return(v): 60 | v.scan( 61 | """ 62 | def foo(): 63 | return \ 64 | "Hello" 65 | print("Unreachable code") 66 | """ 67 | ) 68 | check_unreachable(v, 4, 1, "return") 69 | 70 | 71 | def test_return_recursive_functions(v): 72 | v.scan( 73 | """\ 74 | def foo(a): 75 | if a == 1: 76 | return 1 77 | else: 78 | return foo(a - 1) 79 | print("This line is never executed") 80 | """ 81 | ) 82 | check_unreachable(v, 6, 1, "return") 83 | 84 | 85 | def test_return_semicolon(v): 86 | v.scan( 87 | """\ 88 | def foo(): 89 | return; a = 1 90 | """ 91 | ) 92 | check_unreachable(v, 2, 1, "return") 93 | 94 | 95 | def test_return_list(v): 96 | v.scan( 97 | """\ 98 | def foo(a): 99 | return 100 | a[1:2] 101 | """ 102 | ) 103 | check_unreachable(v, 3, 1, "return") 104 | 105 | 106 | def test_return_continue(v): 107 | v.scan( 108 | """\ 109 | def foo(): 110 | if foo(): 111 | return True 112 | continue 113 | else: 114 | return False 115 | """ 116 | ) 117 | check_unreachable(v, 4, 1, "return") 118 | 119 | 120 | def test_return_function_definition(v): 121 | v.scan( 122 | """\ 123 | def foo(): 124 | return True 125 | def bar(): 126 | return False 127 | """ 128 | ) 129 | check_unreachable(v, 3, 2, "return") 130 | 131 | 132 | def test_raise_global(v): 133 | v.scan( 134 | """\ 135 | raise ValueError 136 | a = 1 137 | """ 138 | ) 139 | check_unreachable(v, 2, 1, "raise") 140 | 141 | 142 | def test_raise_assignment(v): 143 | v.scan( 144 | """\ 145 | def foo(): 146 | raise ValueError 147 | li = [] 148 | """ 149 | ) 150 | check_unreachable(v, 3, 1, "raise") 151 | 152 | 153 | def test_multiple_raise_statements(v): 154 | v.scan( 155 | """\ 156 | def foo(): 157 | a = 1 158 | raise 159 | raise KeyError 160 | # a comment 161 | b = 2 162 | raise CustomDefinedError 163 | """ 164 | ) 165 | check_unreachable(v, 4, 4, "raise") 166 | 167 | 168 | def test_return_with_raise(v): 169 | v.scan( 170 | """\ 171 | def foo(): 172 | a = 1 173 | return 174 | raise ValueError 175 | return 176 | """ 177 | ) 178 | check_unreachable(v, 4, 2, "return") 179 | 180 | 181 | def test_return_comment_and_code(v): 182 | v.scan( 183 | """\ 184 | def foo(): 185 | return 186 | # This is a comment 187 | print("Hello World") 188 | """ 189 | ) 190 | check_unreachable(v, 4, 1, "return") 191 | 192 | 193 | def test_raise_with_return(v): 194 | v.scan( 195 | """\ 196 | def foo(): 197 | a = 1 198 | raise 199 | return a 200 | """ 201 | ) 202 | check_unreachable(v, 4, 1, "raise") 203 | 204 | 205 | def test_raise_error_message(v): 206 | v.scan( 207 | """\ 208 | def foo(): 209 | raise SomeError("There is a problem") 210 | print("I am unreachable") 211 | """ 212 | ) 213 | check_unreachable(v, 3, 1, "raise") 214 | 215 | 216 | def test_raise_try_except(v): 217 | v.scan( 218 | """\ 219 | def foo(): 220 | try: 221 | a = 1 222 | raise 223 | except IOError as e: 224 | print("We have some problem.") 225 | raise 226 | print(":-(") 227 | """ 228 | ) 229 | check_unreachable(v, 8, 1, "raise") 230 | 231 | 232 | def test_raise_with_comment_and_code(v): 233 | v.scan( 234 | """\ 235 | def foo(): 236 | raise 237 | # This is a comment 238 | print("Something") 239 | return None 240 | """ 241 | ) 242 | check_unreachable(v, 4, 2, "raise") 243 | 244 | 245 | def test_continue_basic(v): 246 | v.scan( 247 | """\ 248 | def foo(): 249 | if bar(): 250 | a = 1 251 | else: 252 | continue 253 | a = 2 254 | """ 255 | ) 256 | check_unreachable(v, 6, 1, "continue") 257 | 258 | 259 | def test_continue_one_liner(v): 260 | v.scan( 261 | """\ 262 | def foo(): 263 | for i in range(1, 10): 264 | if i == 5: continue 265 | print(1 / i) 266 | """ 267 | ) 268 | assert v.unreachable_code == [] 269 | 270 | 271 | def test_continue_nested_loops(v): 272 | v.scan( 273 | """\ 274 | def foo(): 275 | a = 0 276 | if something(): 277 | foo() 278 | if bar(): 279 | a = 2 280 | continue 281 | # This is unreachable 282 | a = 1 283 | elif a == 1: 284 | pass 285 | else: 286 | a = 3 287 | continue 288 | else: 289 | continue 290 | """ 291 | ) 292 | check_unreachable(v, 9, 1, "continue") 293 | 294 | 295 | def test_continue_with_comment_and_code(v): 296 | v.scan( 297 | """\ 298 | def foo(): 299 | if bar1(): 300 | bar2() 301 | else: 302 | a = 1 303 | continue 304 | # Just a comment 305 | raise ValueError 306 | """ 307 | ) 308 | check_unreachable(v, 8, 1, "continue") 309 | 310 | 311 | def test_break_basic(v): 312 | v.scan( 313 | """\ 314 | def foo(): 315 | for i in range(123): 316 | break 317 | # A comment 318 | return 319 | dead = 1 320 | """ 321 | ) 322 | check_unreachable(v, 5, 2, "break") 323 | 324 | 325 | def test_break_one_liner(v): 326 | v.scan( 327 | """\ 328 | def foo(): 329 | for i in range(10): 330 | if i == 3: break 331 | print(i) 332 | """ 333 | ) 334 | assert v.unreachable_code == [] 335 | 336 | 337 | def test_break_with_comment_and_code(v): 338 | v.scan( 339 | """\ 340 | while True: 341 | break 342 | # some comment 343 | print("Hello") 344 | """ 345 | ) 346 | check_unreachable(v, 4, 1, "break") 347 | 348 | 349 | def test_if_false(v): 350 | v.scan( 351 | """\ 352 | if False: 353 | pass 354 | """ 355 | ) 356 | check_unreachable(v, 1, 2, "if") 357 | 358 | 359 | def test_elif_false(v): 360 | v.scan( 361 | """\ 362 | if bar(): 363 | pass 364 | elif False: 365 | print("Unreachable") 366 | """ 367 | ) 368 | check_unreachable(v, 3, 2, "if") 369 | 370 | 371 | def test_nested_if_statements_false(v): 372 | v.scan( 373 | """\ 374 | if foo(): 375 | if bar(): 376 | pass 377 | elif False: 378 | print("Unreachable") 379 | pass 380 | elif something(): 381 | print("Reachable") 382 | else: 383 | pass 384 | else: 385 | pass 386 | """ 387 | ) 388 | check_unreachable(v, 4, 3, "if") 389 | 390 | 391 | def test_if_false_same_line(v): 392 | v.scan( 393 | """\ 394 | if False: a = 1 395 | else: c = 3 396 | """ 397 | ) 398 | check_unreachable(v, 1, 1, "if") 399 | 400 | 401 | def test_if_true(v): 402 | v.scan( 403 | """\ 404 | if True: 405 | a = 1 406 | b = 2 407 | else: 408 | c = 3 409 | d = 3 410 | """ 411 | ) 412 | # For simplicity, we don't report the "else" line as dead code. 413 | check_unreachable(v, 5, 2, "else") 414 | 415 | 416 | def test_if_true_same_line(v): 417 | v.scan( 418 | """\ 419 | if True: 420 | a = 1 421 | b = 2 422 | else: c = 3 423 | d = 3 424 | """ 425 | ) 426 | check_unreachable(v, 4, 1, "else") 427 | 428 | 429 | def test_nested_if_statements_true(v): 430 | v.scan( 431 | """\ 432 | if foo(): 433 | if bar(): 434 | pass 435 | elif True: 436 | if something(): 437 | pass 438 | else: 439 | pass 440 | elif something_else(): 441 | print("foo") 442 | else: 443 | print("bar") 444 | else: 445 | pass 446 | """ 447 | ) 448 | check_unreachable(v, 9, 4, "else") 449 | 450 | 451 | def test_redundant_if(v): 452 | v.scan( 453 | """\ 454 | if [5]: 455 | pass 456 | """ 457 | ) 458 | print(v.unreachable_code[0].size) 459 | check_unreachable(v, 1, 2, "if") 460 | 461 | 462 | def test_if_exp_true(v): 463 | v.scan("foo if True else bar") 464 | check_unreachable(v, 1, 1, "ternary") 465 | 466 | 467 | def test_if_exp_false(v): 468 | v.scan("foo if False else bar") 469 | check_unreachable(v, 1, 1, "ternary") 470 | 471 | 472 | def test_if_true_return(v): 473 | v.scan( 474 | """\ 475 | def foo(a): 476 | if True: 477 | return 0 478 | print(":-(") 479 | """ 480 | ) 481 | check_multiple_unreachable(v, [(2, 2, "if"), (4, 1, "if")]) 482 | 483 | 484 | def test_if_true_return_else(v): 485 | v.scan( 486 | """\ 487 | def foo(a): 488 | if True: 489 | return 0 490 | else: 491 | return 1 492 | print(":-(") 493 | """ 494 | ) 495 | check_multiple_unreachable(v, [(5, 1, "else"), (6, 1, "if")]) 496 | 497 | 498 | def test_if_some_branches_return(v): 499 | v.scan( 500 | """\ 501 | def foo(a): 502 | if a == 0: 503 | return 0 504 | elif a == 1: 505 | pass 506 | else: 507 | return 2 508 | print(":-(") 509 | """ 510 | ) 511 | assert v.unreachable_code == [] 512 | 513 | 514 | def test_if_all_branches_return(v): 515 | v.scan( 516 | """\ 517 | def foo(a): 518 | if a == 0: 519 | return 0 520 | elif a == 1: 521 | return 1 522 | else: 523 | return 2 524 | print(":-(") 525 | """ 526 | ) 527 | check_unreachable(v, 8, 1, "if") 528 | 529 | 530 | def test_if_all_branches_return_nested(v): 531 | v.scan( 532 | """\ 533 | def foo(a, b): 534 | if a: 535 | if b: 536 | return 1 537 | return 2 538 | else: 539 | return 3 540 | print(":-(") 541 | """ 542 | ) 543 | check_unreachable(v, 8, 1, "if") 544 | 545 | 546 | def test_if_all_branches_return_or_raise(v): 547 | v.scan( 548 | """\ 549 | def foo(a): 550 | if a == 0: 551 | return 0 552 | else: 553 | raise Exception() 554 | print(":-(") 555 | """ 556 | ) 557 | check_unreachable(v, 6, 1, "if") 558 | 559 | 560 | def test_try_fall_through(v): 561 | v.scan( 562 | """\ 563 | def foo(): 564 | try: 565 | pass 566 | except IndexError as e: 567 | raise e 568 | print(":-(") 569 | """ 570 | ) 571 | assert v.unreachable_code == [] 572 | 573 | 574 | def test_try_some_branches_raise(v): 575 | v.scan( 576 | """\ 577 | def foo(e): 578 | try: 579 | raise e 580 | except IndexError as e: 581 | pass 582 | except Exception as e: 583 | raise e 584 | print(":-(") 585 | """ 586 | ) 587 | assert v.unreachable_code == [] 588 | 589 | 590 | def test_try_all_branches_return_or_raise(v): 591 | v.scan( 592 | """\ 593 | def foo(): 594 | try: 595 | return 2 596 | except IndexError as e: 597 | raise e 598 | except Exception as e: 599 | raise e 600 | print(":-(") 601 | """ 602 | ) 603 | check_unreachable(v, 8, 1, "try") 604 | 605 | 606 | def test_try_nested_no_fall_through(v): 607 | v.scan( 608 | """\ 609 | def foo(a): 610 | try: 611 | raise a 612 | except: 613 | try: 614 | return 615 | except Exception as e: 616 | raise e 617 | print(":-(") 618 | """ 619 | ) 620 | check_unreachable(v, 9, 1, "try") 621 | 622 | 623 | def test_try_reachable_else(v): 624 | v.scan( 625 | """\ 626 | def foo(): 627 | try: 628 | print(":-)") 629 | except: 630 | return 1 631 | else: 632 | print(":-(") 633 | """ 634 | ) 635 | assert v.unreachable_code == [] 636 | 637 | 638 | def test_try_unreachable_else(v): 639 | v.scan( 640 | """\ 641 | def foo(): 642 | try: 643 | raise Exception() 644 | except Exception as e: 645 | return 1 646 | else: 647 | print(":-(") 648 | """ 649 | ) 650 | check_unreachable(v, 7, 1, "else") 651 | 652 | 653 | def test_with_fall_through(v): 654 | v.scan( 655 | """\ 656 | def foo(a): 657 | with a(): 658 | raise Exception() 659 | print(":-(") 660 | """ 661 | ) 662 | assert v.unreachable_code == [] 663 | 664 | 665 | def test_async_with_fall_through(v): 666 | v.scan( 667 | """\ 668 | async def foo(a): 669 | async with a(): 670 | raise Exception() 671 | print(":-(") 672 | """ 673 | ) 674 | assert v.unreachable_code == [] 675 | 676 | 677 | def test_for_fall_through(v): 678 | v.scan( 679 | """\ 680 | def foo(a): 681 | for i in a: 682 | raise Exception() 683 | print(":-(") 684 | """ 685 | ) 686 | assert v.unreachable_code == [] 687 | 688 | 689 | def test_async_for_fall_through(v): 690 | v.scan( 691 | """\ 692 | async def foo(a): 693 | async for i in a: 694 | raise Exception() 695 | print(":-(") 696 | """ 697 | ) 698 | assert v.unreachable_code == [] 699 | 700 | 701 | def test_while_false(v): 702 | v.scan( 703 | """\ 704 | while False: 705 | pass 706 | """ 707 | ) 708 | check_unreachable(v, 1, 2, "while") 709 | 710 | 711 | def test_while_nested(v): 712 | v.scan( 713 | """\ 714 | while True: 715 | while False: 716 | pass 717 | """ 718 | ) 719 | check_unreachable(v, 2, 2, "while") 720 | 721 | 722 | def test_while_true_else(v): 723 | v.scan( 724 | """\ 725 | while True: 726 | print("I won't stop") 727 | else: 728 | print("I won't run") 729 | """ 730 | ) 731 | check_unreachable(v, 4, 1, "else") 732 | 733 | 734 | def test_while_true_no_fall_through(v): 735 | v.scan( 736 | """\ 737 | while True: 738 | raise Exception() 739 | print(":-(") 740 | """ 741 | ) 742 | check_unreachable(v, 3, 1, "while") 743 | 744 | 745 | def test_while_true_no_fall_through_nested(v): 746 | v.scan( 747 | """\ 748 | while True: 749 | if a > 3: 750 | raise Exception() 751 | else: 752 | pass 753 | print(":-(") 754 | """ 755 | ) 756 | check_unreachable(v, 6, 1, "while") 757 | 758 | 759 | def test_while_true_no_fall_through_nested_loops(v): 760 | v.scan( 761 | """\ 762 | while True: 763 | for _ in range(3): 764 | break 765 | while False: 766 | break 767 | print(":-(") 768 | """ 769 | ) 770 | check_multiple_unreachable(v, [(4, 2, "while"), (6, 1, "while")]) 771 | 772 | 773 | def test_while_true_fall_through(v): 774 | v.scan( 775 | """\ 776 | while True: 777 | break 778 | print(":-)") 779 | """ 780 | ) 781 | assert v.unreachable_code == [] 782 | 783 | 784 | def test_while_true_fall_through_nested(v): 785 | v.scan( 786 | """\ 787 | while True: 788 | if a > 3: 789 | raise Exception() 790 | else: 791 | break 792 | print(":-(") 793 | """ 794 | ) 795 | assert v.unreachable_code == [] 796 | 797 | 798 | def test_while_fall_through(v): 799 | v.scan( 800 | """\ 801 | def foo(a): 802 | while a > 0: 803 | return 1 804 | print(":-)") 805 | """ 806 | ) 807 | assert v.unreachable_code == [] 808 | -------------------------------------------------------------------------------- /tests/test_report.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import v 4 | 5 | assert v # Silence pyflakes 6 | 7 | mock_code = """\ 8 | import foo 9 | 10 | class Foo: 11 | def __init__(self): 12 | print("Initialized foo") 13 | 14 | def bar(self): 15 | self.foobar = "unused attribute" 16 | foobar = "unused variable" 17 | 日本人 = "unused variable" 18 | return 19 | print("unreachable") 20 | 21 | @property 22 | def myprop(self): 23 | pass 24 | 25 | def myfunc(): 26 | pass 27 | """ 28 | 29 | 30 | @pytest.fixture 31 | def check_report(v, capsys): 32 | def test_report(code, expected, make_whitelist=False): 33 | filename = "foo.py" 34 | v.scan(code, filename=filename) 35 | capsys.readouterr() 36 | ret = v.report(make_whitelist=make_whitelist) 37 | assert ret 38 | assert capsys.readouterr().out == expected.format(filename=filename) 39 | 40 | return test_report 41 | 42 | 43 | def test_logging(v, capsys): 44 | expected = "\u65e5\u672c\u4eba\xc0\n" 45 | v._log("日本人À") 46 | assert capsys.readouterr().out == expected 47 | 48 | 49 | def test_item_report(check_report): 50 | expected = """\ 51 | {filename}:1: unused import 'foo' (90% confidence) 52 | {filename}:3: unused class 'Foo' (60% confidence) 53 | {filename}:7: unused method 'bar' (60% confidence) 54 | {filename}:8: unused attribute 'foobar' (60% confidence) 55 | {filename}:9: unused variable 'foobar' (60% confidence) 56 | {filename}:10: unused variable '\u65e5\u672c\u4eba' (60% confidence) 57 | {filename}:12: unreachable code after 'return' (100% confidence) 58 | {filename}:14: unused property 'myprop' (60% confidence) 59 | {filename}:18: unused function 'myfunc' (60% confidence) 60 | """ 61 | check_report(mock_code, expected) 62 | 63 | 64 | def test_make_whitelist(check_report): 65 | expected = """\ 66 | foo # unused import ({filename}:1) 67 | Foo # unused class ({filename}:3) 68 | _.bar # unused method ({filename}:7) 69 | _.foobar # unused attribute ({filename}:8) 70 | foobar # unused variable ({filename}:9) 71 | \u65e5\u672c\u4eba # unused variable ({filename}:10) 72 | # unreachable code after 'return' ({filename}:12) 73 | _.myprop # unused property ({filename}:14) 74 | myfunc # unused function ({filename}:18) 75 | """ 76 | check_report(mock_code, expected, make_whitelist=True) 77 | -------------------------------------------------------------------------------- /tests/test_scavenging.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from vulture.utils import ExitCode 6 | 7 | from . import check, v 8 | 9 | assert v # Silence pyflakes. 10 | 11 | 12 | def test_function_object1(v): 13 | v.scan( 14 | """\ 15 | def func(): 16 | pass 17 | 18 | a = func 19 | """ 20 | ) 21 | check(v.defined_funcs, ["func"]) 22 | check(v.unused_funcs, []) 23 | 24 | 25 | def test_function_object2(v): 26 | v.scan( 27 | """\ 28 | def func(): 29 | pass 30 | 31 | func 32 | """ 33 | ) 34 | check(v.defined_funcs, ["func"]) 35 | check(v.unused_funcs, []) 36 | 37 | 38 | def test_function1(v): 39 | v.scan( 40 | """\ 41 | def func1(a): 42 | pass 43 | 44 | def func2(b): 45 | func1(b) 46 | """ 47 | ) 48 | check(v.defined_funcs, ["func1", "func2"]) 49 | check(v.unused_funcs, ["func2"]) 50 | 51 | 52 | def test_function2(v): 53 | v.scan( 54 | """\ 55 | def func(a): 56 | pass 57 | 58 | func(5) 59 | """ 60 | ) 61 | check(v.unused_funcs, []) 62 | check(v.defined_funcs, ["func"]) 63 | 64 | 65 | def test_function3(v): 66 | v.scan( 67 | """\ 68 | def foo(a): 69 | pass 70 | 71 | b = foo(5) 72 | """ 73 | ) 74 | check(v.unused_funcs, []) 75 | check(v.defined_funcs, ["foo"]) 76 | 77 | 78 | def test_async_function(v): 79 | v.scan( 80 | """\ 81 | async def foo(): 82 | pass 83 | """ 84 | ) 85 | check(v.defined_funcs, ["foo"]) 86 | check(v.unused_funcs, ["foo"]) 87 | 88 | 89 | def test_async_method(v): 90 | v.scan( 91 | """\ 92 | class Foo: 93 | async def bar(self): 94 | pass 95 | """ 96 | ) 97 | check(v.defined_classes, ["Foo"]) 98 | check(v.defined_funcs, []) 99 | check(v.defined_methods, ["bar"]) 100 | check(v.unused_classes, ["Foo"]) 101 | check(v.unused_methods, ["bar"]) 102 | 103 | 104 | def test_function_and_method1(v): 105 | v.scan( 106 | """\ 107 | class Bar(object): 108 | def func(self): 109 | pass 110 | 111 | def func(): 112 | pass 113 | 114 | func() 115 | """ 116 | ) 117 | check(v.defined_classes, ["Bar"]) 118 | check(v.defined_funcs, ["func"]) 119 | check(v.defined_methods, ["func"]) 120 | check(v.unused_classes, ["Bar"]) 121 | check(v.unused_funcs, []) 122 | # Bar.func is unused, but it's hard to detect this without producing a 123 | # false positive in test_function_and_method2. 124 | check(v.unused_methods, []) 125 | 126 | 127 | def test_function_and_method2(v): 128 | v.scan( 129 | """\ 130 | class Bar(object): 131 | def func(self): 132 | pass 133 | 134 | other_name_for_func = func 135 | 136 | Bar().other_name_for_func() 137 | """ 138 | ) 139 | check(v.defined_classes, ["Bar"]) 140 | check(v.defined_funcs, []) 141 | check(v.defined_methods, ["func"]) 142 | check(v.defined_vars, ["other_name_for_func"]) 143 | check(v.unused_classes, []) 144 | check(v.unused_funcs, []) 145 | check(v.unused_methods, []) 146 | check(v.unused_vars, []) 147 | 148 | 149 | def test_attribute1(v): 150 | v.scan( 151 | """\ 152 | foo.bar = 1 153 | foo.bar = 2 154 | """ 155 | ) 156 | check(v.unused_funcs, []) 157 | check(v.defined_funcs, []) 158 | check(v.defined_attrs, ["bar", "bar"]) 159 | check(v.used_names, ["foo"]) 160 | check(v.unused_attrs, ["bar", "bar"]) 161 | 162 | 163 | def test_ignored_attributes(v): 164 | v.scan( 165 | """\ 166 | A._ = 0 167 | A._a = 1 168 | A.__b = 2 169 | A.__c__ = 3 170 | A._d_ = 4 171 | """ 172 | ) 173 | check(v.defined_attrs, ["_", "_a", "__b", "__c__", "_d_"]) 174 | check(v.used_names, ["A"]) 175 | check(v.unused_attrs, ["_", "__b", "__c__", "_a", "_d_"]) 176 | check(v.unused_vars, []) 177 | 178 | 179 | def test_getattr(v): 180 | v.scan( 181 | """\ 182 | class Thing: 183 | used_attr1 = 1 184 | used_attr2 = 2 185 | used_attr3 = 3 186 | unused_attr = 4 187 | 188 | getattr(Thing, "used_attr1") 189 | getattr(Thing, "used_attr2", None) 190 | hasattr(Thing, "used_attr3") 191 | 192 | # Weird calls ignored 193 | hasattr(Thing, "unused_attr", None) 194 | getattr(Thing) 195 | getattr("unused_attr") 196 | getattr(Thing, "unused_attr", 1, 2) 197 | """ 198 | ) 199 | check(v.unused_vars, ["unused_attr"]) 200 | check( 201 | v.used_names, 202 | [ 203 | "Thing", 204 | "getattr", 205 | "hasattr", 206 | "used_attr1", 207 | "used_attr2", 208 | "used_attr3", 209 | ], 210 | ) 211 | 212 | 213 | def test_callback1(v): 214 | v.scan( 215 | """\ 216 | class Bar(object): 217 | def foo(self): 218 | pass 219 | 220 | b = Bar() 221 | b.foo 222 | """ 223 | ) 224 | check(v.used_names, ["Bar", "b", "foo"]) 225 | check(v.defined_classes, ["Bar"]) 226 | check(v.defined_funcs, []) 227 | check(v.defined_methods, ["foo"]) 228 | check(v.unused_classes, []) 229 | check(v.unused_funcs, []) 230 | 231 | 232 | def test_class1(v): 233 | v.scan( 234 | """\ 235 | class Bar(object): 236 | pass 237 | """ 238 | ) 239 | check(v.used_names, []) 240 | check(v.defined_classes, ["Bar"]) 241 | check(v.unused_classes, ["Bar"]) 242 | 243 | 244 | def test_class2(v): 245 | v.scan( 246 | """\ 247 | class Bar(): 248 | pass 249 | class Foo(Bar): 250 | pass 251 | Foo() 252 | """ 253 | ) 254 | check(v.used_names, ["Bar", "Foo"]) 255 | check(v.defined_classes, ["Bar", "Foo"]) 256 | check(v.unused_classes, []) 257 | 258 | 259 | def test_class3(v): 260 | v.scan( 261 | """\ 262 | class Bar(): 263 | pass 264 | [Bar] 265 | """ 266 | ) 267 | check(v.used_names, ["Bar"]) 268 | check(v.defined_classes, ["Bar"]) 269 | check(v.unused_classes, []) 270 | 271 | 272 | def test_class4(v): 273 | v.scan( 274 | """\ 275 | class Bar(): 276 | pass 277 | Bar() 278 | """ 279 | ) 280 | check(v.used_names, ["Bar"]) 281 | check(v.defined_classes, ["Bar"]) 282 | check(v.unused_classes, []) 283 | 284 | 285 | def test_class5(v): 286 | v.scan( 287 | """\ 288 | class Bar(): 289 | pass 290 | b = Bar() 291 | """ 292 | ) 293 | check(v.used_names, ["Bar"]) 294 | check(v.defined_classes, ["Bar"]) 295 | check(v.unused_classes, []) 296 | check(v.unused_vars, ["b"]) 297 | 298 | 299 | def test_class6(v): 300 | v.scan( 301 | """\ 302 | class Bar(): 303 | pass 304 | a = [] 305 | a.insert(0, Bar()) 306 | """ 307 | ) 308 | check(v.defined_classes, ["Bar"]) 309 | check(v.unused_classes, []) 310 | 311 | 312 | def test_class7(v): 313 | v.scan( 314 | """\ 315 | class Bar(object): 316 | pass 317 | 318 | class Foo(object): 319 | def __init__(self): 320 | self.b = xyz.Bar(self) 321 | """ 322 | ) 323 | check(v.defined_classes, ["Bar", "Foo"]) 324 | check(v.unused_classes, ["Foo"]) 325 | 326 | 327 | def test_method1(v): 328 | v.scan( 329 | """\ 330 | def __init__(self): 331 | self.a.foo() 332 | 333 | class Bar(object): 334 | def foo(self): 335 | pass 336 | 337 | @classmethod 338 | def bar(cls): 339 | pass 340 | 341 | @staticmethod 342 | def foobar(): 343 | pass 344 | """ 345 | ) 346 | check(v.defined_classes, ["Bar"]) 347 | check(v.defined_funcs, []) 348 | check(v.defined_methods, ["foo", "bar", "foobar"]) 349 | check(v.unused_classes, ["Bar"]) 350 | check(v.unused_funcs, []) 351 | check(v.unused_methods, ["bar", "foobar"]) 352 | 353 | 354 | def test_token_types(v): 355 | v.scan( 356 | """\ 357 | a 358 | b = 2 359 | c() 360 | x.d 361 | """ 362 | ) 363 | check(v.defined_funcs, []) 364 | check(v.defined_vars, ["b"]) 365 | check(v.used_names, ["a", "c", "d", "x"]) 366 | check(v.unused_attrs, []) 367 | check(v.unused_funcs, []) 368 | check(v.unused_props, []) 369 | check(v.unused_vars, ["b"]) 370 | 371 | 372 | def test_variable1(v): 373 | v.scan("a = 1\nb = a") 374 | check(v.defined_funcs, []) 375 | check(v.used_names, ["a"]) 376 | check(v.defined_vars, ["a", "b"]) 377 | check(v.unused_vars, ["b"]) 378 | 379 | 380 | def test_variable2(v): 381 | v.scan("a = 1\nc = b.a") 382 | check(v.defined_funcs, []) 383 | check(v.defined_vars, ["a", "c"]) 384 | check(v.used_names, ["a", "b"]) 385 | check(v.unused_vars, ["c"]) 386 | 387 | 388 | def test_variable3(v): 389 | v.scan("(a, b), c = (d, e, f)") 390 | check(v.defined_funcs, []) 391 | check(v.defined_vars, ["a", "b", "c"]) 392 | check(v.used_names, ["d", "e", "f"]) 393 | check(v.unused_vars, ["a", "b", "c"]) 394 | 395 | 396 | def test_variable4(v): 397 | v.scan("for a, b in func(): a") 398 | check(v.defined_funcs, []) 399 | check(v.defined_vars, ["a", "b"]) 400 | check(v.used_names, ["a", "func"]) 401 | check(v.unused_vars, ["b"]) 402 | 403 | 404 | def test_variable5(v): 405 | v.scan("[a for a, b in func()]") 406 | check(v.defined_vars, ["a", "b"]) 407 | check(v.used_names, ["a", "func"]) 408 | check(v.unused_vars, ["b"]) 409 | 410 | 411 | def test_ignored_variables(v): 412 | v.scan( 413 | """\ 414 | _ = 0 415 | _a = 1 416 | __b = 2 417 | __c__ = 3 418 | _d_ = 4 419 | """ 420 | ) 421 | check(v.defined_vars, ["__b"]) 422 | check(sorted(v.used_names), []) 423 | check(v.unused_vars, ["__b"]) 424 | 425 | 426 | def test_prop1(v): 427 | v.scan( 428 | """\ 429 | class Bar(object): 430 | @property 431 | def prop(self): 432 | pass 433 | 434 | c = Bar() 435 | c.prop 436 | """ 437 | ) 438 | check(v.defined_classes, ["Bar"]) 439 | check(v.defined_props, ["prop"]) 440 | check(v.unused_classes, []) 441 | check(v.unused_props, []) 442 | 443 | 444 | def test_prop2(v): 445 | v.scan( 446 | """\ 447 | class Bar(object): 448 | @property 449 | def prop(self): 450 | pass 451 | 452 | prop = 1 453 | """ 454 | ) 455 | check(v.defined_classes, ["Bar"]) 456 | check(v.defined_props, ["prop"]) 457 | check(v.defined_vars, ["prop"]) 458 | check(v.unused_classes, ["Bar"]) 459 | check(v.unused_props, ["prop"]) 460 | 461 | 462 | def test_object_attribute(v): 463 | v.scan( 464 | """\ 465 | class Bar(object): 466 | def __init__(self): 467 | self.a = [] 468 | """ 469 | ) 470 | check(v.defined_attrs, ["a"]) 471 | check(v.defined_classes, ["Bar"]) 472 | check(v.defined_vars, []) 473 | check(v.used_names, []) 474 | check(v.unused_attrs, ["a"]) 475 | check(v.unused_classes, ["Bar"]) 476 | 477 | 478 | def test_function_names_in_test_file(v): 479 | v.scan( 480 | """\ 481 | def setup_module(module): 482 | module 483 | 484 | def teardown_module(module): 485 | module 486 | 487 | def setup_function(function): 488 | function 489 | 490 | def teardown_function(function): 491 | function 492 | 493 | def test_func(): 494 | pass 495 | 496 | def other_func(): 497 | pass 498 | 499 | class TestClass: 500 | @classmethod 501 | def setup_class(cls): 502 | cls 503 | 504 | @classmethod 505 | def teardown_class(cls): 506 | pass 507 | 508 | def setup_method(self, method): 509 | method 510 | 511 | def teardown_method(self, method): 512 | pass 513 | 514 | class BasicTestCase: 515 | pass 516 | 517 | class OtherClass: 518 | pass 519 | """, 520 | filename="dir/test_function_names.py", 521 | ) 522 | check(v.defined_attrs, []) 523 | check(v.defined_classes, ["OtherClass"]) 524 | check(v.defined_funcs, ["other_func"]) 525 | check(v.defined_methods, []) 526 | check( 527 | v.defined_vars, 528 | [ 529 | "cls", 530 | "cls", 531 | "function", 532 | "function", 533 | "method", 534 | "method", 535 | "module", 536 | "module", 537 | ], 538 | ) 539 | check(v.used_names, ["classmethod", "cls", "function", "method", "module"]) 540 | check(v.unused_attrs, []) 541 | check(v.unused_classes, ["OtherClass"]) 542 | check(v.unused_funcs, ["other_func"]) 543 | check(v.unused_methods, []) 544 | check(v.unused_vars, []) 545 | 546 | 547 | def test_async_function_name_in_test_file(v): 548 | v.scan( 549 | """\ 550 | async def test_func(): 551 | pass 552 | 553 | async def other_func(): 554 | pass 555 | """, 556 | filename="dir/test_function_names.py", 557 | ) 558 | check(v.defined_funcs, ["other_func"]) 559 | check(v.unused_funcs, ["other_func"]) 560 | 561 | 562 | def test_async_function_name_in_normal_file(v): 563 | v.scan( 564 | """\ 565 | async def test_func(): 566 | pass 567 | 568 | async def other_func(): 569 | pass 570 | """, 571 | filename="dir/function_names.py", 572 | ) 573 | check(v.defined_funcs, ["test_func", "other_func"]) 574 | check(v.unused_funcs, ["other_func", "test_func"]) 575 | 576 | 577 | def test_function_names_in_normal_file(v): 578 | v.scan( 579 | """\ 580 | def test_func(): 581 | pass 582 | 583 | def other_func(): 584 | pass 585 | 586 | class TestClass: 587 | pass 588 | 589 | class BasicTestCase: 590 | pass 591 | 592 | class OtherClass: 593 | pass 594 | """ 595 | ) 596 | check(v.defined_attrs, []) 597 | check(v.defined_classes, ["BasicTestCase", "OtherClass", "TestClass"]) 598 | check(v.defined_funcs, ["test_func", "other_func"]) 599 | check(v.defined_vars, []) 600 | check(v.used_names, []) 601 | check(v.unused_attrs, []) 602 | check(v.unused_classes, ["BasicTestCase", "OtherClass", "TestClass"]) 603 | check(v.unused_funcs, ["other_func", "test_func"]) 604 | 605 | 606 | def test_global_attribute(v): 607 | v.scan( 608 | """\ 609 | # Module foo: 610 | a = 1 611 | if a == 1: 612 | pass 613 | 614 | # Module bar: 615 | import foo 616 | foo.a = 2 617 | """ 618 | ) 619 | check(v.defined_attrs, ["a"]) 620 | check(v.defined_vars, ["a"]) 621 | check(v.used_names, ["a", "foo"]) 622 | check(v.unused_attrs, []) 623 | 624 | 625 | def test_boolean(v): 626 | v.scan( 627 | """\ 628 | a = True 629 | a 630 | """ 631 | ) 632 | check(v.defined_vars, ["a"]) 633 | check(v.used_names, ["a"]) 634 | check(v.unused_vars, []) 635 | 636 | 637 | def test_builtin_types(v): 638 | v.scan( 639 | """\ 640 | a = b 641 | a = 1 642 | a = "s" 643 | a = object 644 | a = False 645 | """ 646 | ) 647 | check(v.defined_vars, ["a"] * 5) 648 | check(v.used_names, ["b"]) 649 | check(v.unused_vars, ["a"] * 5) 650 | 651 | 652 | def test_unused_args(v): 653 | v.scan( 654 | """\ 655 | def foo(x, y): 656 | return x + 1 657 | """ 658 | ) 659 | check(v.defined_vars, ["x", "y"]) 660 | check(v.used_names, ["x"]) 661 | check(v.unused_vars, ["y"]) 662 | 663 | 664 | def test_unused_kwargs(v): 665 | v.scan( 666 | """\ 667 | def foo(x, y=3, **kwargs): 668 | return x + 1 669 | """ 670 | ) 671 | check(v.defined_vars, ["kwargs", "x", "y"]) 672 | check(v.used_names, ["x"]) 673 | check(v.unused_vars, ["kwargs", "y"]) 674 | 675 | 676 | def test_unused_kwargs_with_odd_name(v): 677 | v.scan( 678 | """\ 679 | def foo(**bar): 680 | pass 681 | """ 682 | ) 683 | check(v.defined_vars, ["bar"]) 684 | check(v.used_names, []) 685 | check(v.unused_vars, ["bar"]) 686 | 687 | 688 | def test_unused_vararg(v): 689 | v.scan( 690 | """\ 691 | def foo(*bar): 692 | pass 693 | """ 694 | ) 695 | check(v.defined_vars, ["bar"]) 696 | check(v.used_names, []) 697 | check(v.unused_vars, ["bar"]) 698 | 699 | 700 | def test_multiple_definition(v): 701 | v.scan( 702 | """\ 703 | a = 1 704 | a = 2 705 | """ 706 | ) 707 | check(v.defined_vars, ["a", "a"]) 708 | check(v.used_names, []) 709 | check(v.unused_vars, ["a", "a"]) 710 | 711 | 712 | def test_arg_type_annotation(v): 713 | v.scan( 714 | """\ 715 | from typing import Iterable 716 | 717 | def f(n: int) -> Iterable[int]: 718 | yield n 719 | """ 720 | ) 721 | 722 | check(v.unused_vars, []) 723 | check(v.unused_funcs, ["f"]) 724 | check(v.unused_imports, []) 725 | 726 | 727 | def test_var_type_annotation(v): 728 | v.scan( 729 | """\ 730 | from typing import List 731 | 732 | x: List[int] = [1] 733 | """ 734 | ) 735 | 736 | check(v.unused_vars, ["x"]) 737 | check(v.unused_funcs, []) 738 | check(v.unused_imports, []) 739 | 740 | 741 | def test_type_hint_comments(v): 742 | v.scan( 743 | """\ 744 | from typing import Any, Dict, List, Text, Tuple 745 | 746 | 747 | def plain_function(arg): 748 | # type: (Text) -> None 749 | pass 750 | 751 | async def async_function(arg): 752 | # type: (List[int]) -> None 753 | pass 754 | 755 | some_var = {} # type: Dict[str, str] 756 | 757 | class Thing: 758 | def __init__(self): 759 | self.some_attr = (1, 2) # type: Tuple[int, int] 760 | 761 | for x in []: # type: Any 762 | print(x) 763 | """ 764 | ) 765 | 766 | check(v.unused_imports, []) 767 | assert v.exit_code == ExitCode.NoDeadCode 768 | 769 | 770 | def test_invalid_type_comment(v): 771 | v.scan( 772 | """\ 773 | def bad(): 774 | # type: bogus 775 | pass 776 | bad() 777 | """ 778 | ) 779 | 780 | assert v.exit_code == ExitCode.InvalidInput 781 | 782 | 783 | def test_unused_args_with_del(v): 784 | v.scan( 785 | """\ 786 | def foo(a, b, c, d=3): 787 | del c, d 788 | return a + b 789 | 790 | foo(1, 2) 791 | """ 792 | ) 793 | 794 | check(v.defined_funcs, ["foo"]) 795 | check(v.defined_vars, ["a", "b", "c", "d"]) 796 | check(v.used_names, ["foo", "a", "b", "c", "d"]) 797 | check(v.unused_vars, []) 798 | check(v.unused_funcs, []) 799 | 800 | 801 | @pytest.mark.skipif( 802 | sys.version_info < (3, 10), reason="requires python3.10 or higher" 803 | ) 804 | def test_match_class_simple(v): 805 | v.scan( 806 | """\ 807 | from dataclasses import dataclass 808 | 809 | 810 | @dataclass 811 | class X: 812 | a: int 813 | b: int 814 | c: int 815 | u: int 816 | 817 | x = input() 818 | 819 | match x: 820 | case X(a=0): 821 | print("a") 822 | case X(b=0, c=0): 823 | print("b c") 824 | """ 825 | ) 826 | check(v.defined_classes, ["X"]) 827 | check(v.defined_vars, ["a", "b", "c", "u", "x"]) 828 | 829 | check(v.unused_classes, []) 830 | check(v.unused_vars, ["u"]) 831 | 832 | 833 | @pytest.mark.skipif( 834 | sys.version_info < (3, 10), reason="requires python3.10 or higher" 835 | ) 836 | def test_match_class_embedded(v): 837 | v.scan( 838 | """\ 839 | from dataclasses import dataclass 840 | 841 | 842 | @dataclass 843 | class X: 844 | a: int 845 | b: int 846 | c: int 847 | d: int 848 | e: int 849 | u: int 850 | 851 | x = input() 852 | 853 | match x: 854 | case X(a=1) | X(b=0): 855 | print("Or") 856 | case [X(c=1), X(d=0)]: 857 | print("Sequence") 858 | case {"k": X(e=1)}: 859 | print("Mapping") 860 | """ 861 | ) 862 | check(v.defined_classes, ["X"]) 863 | check(v.defined_vars, ["a", "b", "c", "d", "e", "u", "x"]) 864 | 865 | check(v.unused_classes, []) 866 | check(v.unused_vars, ["u"]) 867 | 868 | 869 | @pytest.mark.skipif( 870 | sys.version_info < (3, 10), reason="requires python3.10 or higher" 871 | ) 872 | def test_match_enum(v): 873 | v.scan( 874 | """\ 875 | from enum import Enum 876 | 877 | 878 | class Color(Enum): 879 | RED = 0 880 | YELLOW = 1 881 | GREEN = 2 882 | BLUE = 3 883 | 884 | color = input() 885 | 886 | match color: 887 | case Color.RED: 888 | print("Real danger!") 889 | case Color.YELLOW | Color.GREEN: 890 | print("No danger!") 891 | """ 892 | ) 893 | check(v.defined_classes, ["Color"]) 894 | check(v.defined_vars, ["RED", "YELLOW", "GREEN", "BLUE", "color"]) 895 | 896 | check(v.unused_classes, []) 897 | check(v.unused_vars, ["BLUE"]) 898 | -------------------------------------------------------------------------------- /tests/test_script.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os.path 3 | import subprocess 4 | import sys 5 | 6 | from vulture.utils import ExitCode 7 | 8 | from . import REPO, WHITELISTS, call_vulture 9 | 10 | 11 | def test_module_with_explicit_whitelists(): 12 | assert call_vulture(["vulture/"] + WHITELISTS) == ExitCode.NoDeadCode 13 | 14 | 15 | def test_module_with_implicit_whitelists(): 16 | assert call_vulture(["vulture/"]) == ExitCode.NoDeadCode 17 | 18 | 19 | def test_module_without_whitelists(): 20 | assert ( 21 | call_vulture(["vulture/", "--exclude", "whitelists"]) 22 | == ExitCode.DeadCode 23 | ) 24 | 25 | 26 | def test_missing_file(): 27 | assert call_vulture(["missing.py"]) == ExitCode.InvalidInput 28 | 29 | 30 | def test_tests(): 31 | assert call_vulture(["tests/"]) == ExitCode.NoDeadCode 32 | 33 | 34 | def test_whitelists_with_python(): 35 | for whitelist in WHITELISTS: 36 | assert ( 37 | subprocess.call([sys.executable, whitelist], cwd=REPO) 38 | == ExitCode.NoDeadCode 39 | ) 40 | 41 | 42 | def test_pyc(): 43 | assert call_vulture(["missing.pyc"]) == 1 44 | 45 | 46 | def test_sort_by_size(): 47 | assert ( 48 | call_vulture(["vulture/utils.py", "--sort-by-size"]) 49 | == ExitCode.DeadCode 50 | ) 51 | 52 | 53 | def test_min_confidence(): 54 | assert ( 55 | call_vulture( 56 | [ 57 | "vulture/core.py", 58 | "--exclude", 59 | "whitelists", 60 | "--min-confidence", 61 | "100", 62 | ] 63 | ) 64 | == ExitCode.NoDeadCode 65 | ) 66 | 67 | 68 | def test_exclude(): 69 | def get_csv(paths): 70 | return ",".join(os.path.join("vulture", path) for path in paths) 71 | 72 | def call_vulture_with_excludes(excludes): 73 | return call_vulture(["vulture/", "--exclude", get_csv(excludes)]) 74 | 75 | assert ( 76 | call_vulture_with_excludes(["core.py", "utils.py"]) 77 | == ExitCode.DeadCode 78 | ) 79 | assert ( 80 | call_vulture_with_excludes(glob.glob("vulture/*.py")) 81 | == ExitCode.NoDeadCode 82 | ) 83 | 84 | 85 | def test_make_whitelist(): 86 | assert ( 87 | call_vulture( 88 | ["vulture/", "--make-whitelist", "--exclude", "whitelists"] 89 | ) 90 | == ExitCode.DeadCode 91 | ) 92 | assert ( 93 | call_vulture(["vulture/", "--make-whitelist"]) == ExitCode.NoDeadCode 94 | ) 95 | 96 | 97 | def test_version(): 98 | assert call_vulture(["--version"]) == ExitCode.NoDeadCode 99 | -------------------------------------------------------------------------------- /tests/test_size.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from vulture import lines 4 | 5 | 6 | def count_lines(node): 7 | """Estimate the number of lines of the given AST node.""" 8 | last_lineno = lines.get_last_line_number(node) 9 | return last_lineno - lines.get_first_line_number(node) + 1 10 | 11 | 12 | def check_size(example, size): 13 | tree = ast.parse(example) 14 | for node in tree.body: 15 | if isinstance(node, ast.ClassDef) and node.name == "Foo": 16 | assert count_lines(node) == size 17 | break 18 | else: 19 | raise AssertionError('Failed to find top-level class "Foo" in code') 20 | 21 | 22 | def test_size_basic(): 23 | example = """ 24 | class Foo: 25 | foo = 1 26 | bar = 2 27 | """ 28 | check_size(example, 3) 29 | 30 | 31 | def test_size_class(): 32 | example = """ 33 | class Foo(object): 34 | def bar(): 35 | pass 36 | 37 | @staticmethod 38 | def func(): 39 | if "foo" == "bar": 40 | return "xyz" 41 | import sys 42 | return len(sys.argv) 43 | """ 44 | check_size(example, 10) 45 | 46 | 47 | def test_size_if_else(): 48 | example = """ 49 | @identity 50 | class Foo(object): 51 | @identity 52 | @identity 53 | def bar(self): 54 | if "a" == "b": 55 | pass 56 | elif "b" == "c": 57 | pass 58 | else: 59 | pass 60 | """ 61 | size = 11 62 | check_size(example, size) 63 | 64 | 65 | def test_size_decorated_class(): 66 | example = """ 67 | @foo 68 | @property 69 | @xoo 70 | class Foo: 71 | def zoo(self): 72 | pass 73 | """ 74 | check_size(example, 6) 75 | 76 | 77 | def test_size_while(): 78 | example = """ 79 | class Foo: 80 | while 1: 81 | print(1) 82 | """ 83 | check_size(example, 3) 84 | 85 | 86 | def test_size_while_else(): 87 | example = """ 88 | class Foo: 89 | while "b" > "a": 90 | pass 91 | else: 92 | pass 93 | """ 94 | check_size(example, 5) 95 | 96 | 97 | def test_size_with(): 98 | example = """ 99 | class Foo: 100 | with open("/dev/null") as f: 101 | f.write("") 102 | """ 103 | check_size(example, 3) 104 | 105 | 106 | def test_size_try_except_else(): 107 | example = """ 108 | class Foo: 109 | try: 110 | x = sys.argv[99] 111 | except IndexError: 112 | pass 113 | except Exception: 114 | pass 115 | else: 116 | pass 117 | """ 118 | check_size(example, 9) 119 | 120 | 121 | def test_size_try_finally(): 122 | example = """ 123 | class Foo: 124 | try: 125 | 1/0 126 | finally: 127 | return 99 128 | """ 129 | check_size(example, 5) 130 | 131 | 132 | def test_size_try_except(): 133 | example = """ 134 | class Foo: 135 | try: 136 | foo() 137 | except: 138 | bar() 139 | """ 140 | check_size(example, 5) 141 | 142 | 143 | def test_size_try_excepts(): 144 | example = """ 145 | class Foo: 146 | try: 147 | foo() 148 | except IOError: 149 | bar() 150 | except AttributeError: 151 | pass 152 | """ 153 | check_size(example, 7) 154 | 155 | 156 | def test_size_for(): 157 | example = """ 158 | class Foo: 159 | for i in range(10): 160 | print(i) 161 | """ 162 | check_size(example, 3) 163 | 164 | 165 | def test_size_for_else(): 166 | example = """ 167 | class Foo: 168 | for arg in sys.argv: 169 | print("loop") 170 | else: 171 | print("else") 172 | """ 173 | check_size(example, 5) 174 | 175 | 176 | def test_size_class_nested(): 177 | example = """ 178 | class Foo: 179 | class Bar: 180 | pass 181 | """ 182 | check_size(example, 3) 183 | 184 | 185 | # We currently cannot handle code ending with multiline strings. 186 | def test_size_multi_line_return(): 187 | example = """ 188 | class Foo: 189 | def foo(): 190 | return ( 191 | 'very' 192 | 'long' 193 | 'string') 194 | """ 195 | check_size(example, 6) 196 | 197 | 198 | # We currently cannot handle code ending with comment lines. 199 | def test_size_comment_after_last_line(): 200 | example = """ 201 | class Foo: 202 | def bar(): 203 | # A comment. 204 | pass 205 | # This comment won't be detected. 206 | """ 207 | check_size(example, 4) 208 | 209 | 210 | def test_size_generator(): 211 | example = """ 212 | class Foo: 213 | def bar(): 214 | yield something 215 | """ 216 | check_size(example, 3) 217 | 218 | 219 | def test_size_exec(): 220 | example = """ 221 | class Foo: 222 | exec('a') 223 | """ 224 | check_size(example, 2) 225 | 226 | 227 | def test_size_print1(): 228 | example = """ 229 | class Foo: 230 | print( 231 | 'foo') 232 | """ 233 | check_size(example, 3) 234 | 235 | 236 | def test_size_print2(): 237 | example = """ 238 | class Foo: 239 | print( 240 | 'foo',) 241 | """ 242 | check_size(example, 3) 243 | 244 | 245 | def test_size_return(): 246 | example = """ 247 | class Foo: 248 | return (True and 249 | False) 250 | """ 251 | check_size(example, 3) 252 | 253 | 254 | def test_size_import_from(): 255 | example = """ 256 | class Foo: 257 | from a import b 258 | """ 259 | check_size(example, 2) 260 | 261 | 262 | def test_size_delete(): 263 | example = """ 264 | class Foo: 265 | del a[: 266 | foo()] 267 | """ 268 | check_size(example, 3) 269 | 270 | 271 | def test_size_list_comprehension(): 272 | example = """ 273 | class Foo: 274 | [a 275 | for a in 276 | b] 277 | """ 278 | check_size(example, 4) 279 | 280 | 281 | # We currently cannot handle closing brackets on a separate line. 282 | def test_size_list(): 283 | example = """ 284 | class Foo: 285 | [a, b 286 | ] 287 | """ 288 | check_size(example, 3) 289 | 290 | 291 | def test_size_ellipsis(): 292 | example = """ 293 | class Foo: 294 | bar[1:2, 295 | ...] 296 | """ 297 | check_size(example, 3) 298 | 299 | 300 | def test_size_starargs(): 301 | example = """ 302 | class Foo: 303 | def foo(): 304 | bar(*a, 305 | b=c) 306 | """ 307 | check_size(example, 4) 308 | 309 | 310 | # If we add a line break between a and b, the code is too greedy and moves 311 | # down to the slice which has no line numbers. If we took b or c into 312 | # account, the line count would be correct. 313 | def test_size_assign(): 314 | example = """ 315 | class Foo: 316 | bar = foo(a, b)[c,:] 317 | """ 318 | check_size(example, 2) 319 | 320 | 321 | def test_size_async_function_def(): 322 | example = """ 323 | class Foo: 324 | async def foo(some_attr): 325 | pass 326 | """ 327 | check_size(example, 3) 328 | 329 | 330 | def test_size_async_with(): 331 | example = """ 332 | class Foo: 333 | async def bar(): 334 | async with x: 335 | pass 336 | """ 337 | check_size(example, 4) 338 | 339 | 340 | def test_size_async_for(): 341 | example = """ 342 | class Foo: 343 | async def foo(): 344 | async for a in b: 345 | pass 346 | """ 347 | check_size(example, 4) 348 | -------------------------------------------------------------------------------- /tests/test_sorting.py: -------------------------------------------------------------------------------- 1 | from . import v 2 | 3 | assert v # Silence pyflakes 4 | 5 | 6 | def test_sorting(v): 7 | v.scan( 8 | """\ 9 | def foo(): 10 | print("Hello, I am a long function.") 11 | return "World" 12 | 13 | def bar(): 14 | pass 15 | """ 16 | ) 17 | assert [item.name for item in v.get_unused_code(sort_by_size=True)] == [ 18 | "bar", 19 | "foo", 20 | ] 21 | assert [item.name for item in v.get_unused_code(sort_by_size=False)] == [ 22 | "foo", 23 | "bar", 24 | ] 25 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import pathlib 4 | import sys 5 | 6 | import pytest 7 | 8 | from vulture import utils 9 | 10 | 11 | class TestFormatPath: 12 | @pytest.fixture 13 | def tmp_cwd(self, tmp_path, monkeypatch): 14 | cwd = tmp_path / "workingdir" 15 | cwd.mkdir() 16 | monkeypatch.chdir(cwd) 17 | return cwd 18 | 19 | def test_relative_inside(self): 20 | filepath = pathlib.Path("testfile.py") 21 | formatted = utils.format_path(filepath) 22 | assert formatted == filepath 23 | assert not formatted.is_absolute() 24 | 25 | def test_relative_outside(self, tmp_cwd): 26 | filepath = pathlib.Path(os.pardir) / "testfile.py" 27 | formatted = utils.format_path(filepath) 28 | assert formatted == filepath 29 | assert not formatted.is_absolute() 30 | 31 | def test_absolute_inside(self, tmp_cwd): 32 | filepath = tmp_cwd / "testfile.py" 33 | formatted = utils.format_path(filepath) 34 | assert formatted == pathlib.Path("testfile.py") 35 | assert not formatted.is_absolute() 36 | 37 | def test_absolute_outside(self, tmp_cwd): 38 | filepath = (tmp_cwd / os.pardir / "testfile.py").resolve() 39 | formatted = utils.format_path(filepath) 40 | assert formatted == filepath 41 | assert formatted.is_absolute() 42 | 43 | 44 | def check_decorator_names(code, expected_names): 45 | decorator_names = [] 46 | 47 | def visit_FunctionDef(node): 48 | for decorator in node.decorator_list: 49 | decorator_names.append(utils.get_decorator_name(decorator)) 50 | 51 | node_visitor = ast.NodeVisitor() 52 | node_visitor.visit_AsyncFunctionDef = visit_FunctionDef 53 | node_visitor.visit_ClassDef = visit_FunctionDef 54 | node_visitor.visit_FunctionDef = visit_FunctionDef 55 | node_visitor.visit(ast.parse(code)) 56 | assert expected_names == decorator_names 57 | 58 | 59 | def test_get_decorator_name_simple(): 60 | code = """\ 61 | @foobar 62 | def hoo(): 63 | pass 64 | """ 65 | check_decorator_names(code, ["@foobar"]) 66 | 67 | 68 | def test_get_decorator_name_call(): 69 | code = """\ 70 | @xyz() 71 | def bar(): 72 | pass 73 | """ 74 | check_decorator_names(code, ["@xyz"]) 75 | 76 | 77 | def test_get_decorator_name_async(): 78 | code = """\ 79 | @foo.bar.route('/foobar') 80 | async def async_function(request): 81 | print(request) 82 | """ 83 | check_decorator_names(code, ["@foo.bar.route"]) 84 | 85 | 86 | def test_get_decorator_name_multiple_attrs(): 87 | code = """\ 88 | @x.y.z 89 | def doo(): 90 | pass 91 | """ 92 | check_decorator_names(code, ["@x.y.z"]) 93 | 94 | 95 | def test_get_decorator_name_multiple_attrs_called(): 96 | code = """\ 97 | @a.b.c.d.foo("Foo and Bar") 98 | def hoofoo(): 99 | pass 100 | """ 101 | check_decorator_names(code, ["@a.b.c.d.foo"]) 102 | 103 | 104 | def test_get_decorator_name_multiple_decorators(): 105 | code = """\ 106 | @foo 107 | @bar() 108 | @x.y.z.a('foobar') 109 | def func(): 110 | pass 111 | """ 112 | check_decorator_names(code, ["@foo", "@bar", "@x.y.z.a"]) 113 | 114 | 115 | def test_get_decorator_name_class(): 116 | code = """\ 117 | @foo 118 | @bar.yz 119 | class Foo: 120 | pass 121 | """ 122 | check_decorator_names(code, ["@foo", "@bar.yz"]) 123 | 124 | 125 | def test_get_decorator_name_end_function_call(): 126 | code = """\ 127 | @foo.bar(x, y, z) 128 | def bar(): 129 | pass 130 | """ 131 | check_decorator_names(code, ["@foo.bar"]) 132 | 133 | 134 | @pytest.mark.skipif( 135 | sys.version_info < (3, 9), reason="requires Python 3.9 or higher" 136 | ) 137 | @pytest.mark.parametrize( 138 | "decorated", 139 | [ 140 | ("def foo():"), 141 | ("async def foo():"), 142 | ("class Foo:"), 143 | ], 144 | ) 145 | def test_get_decorator_name_multiple_callables(decorated): 146 | decorated = f"{decorated}\n pass" 147 | code = f"""\ 148 | @foo 149 | @bar.prop 150 | @z.func("hi").bar().k.foo 151 | @k("hello").doo("world").x 152 | @k.hello("world") 153 | @foo[2] 154 | {decorated} 155 | """ 156 | check_decorator_names( 157 | code, 158 | ["@foo", "@bar.prop", "@", "@", "@k.hello", "@"], 159 | ) 160 | -------------------------------------------------------------------------------- /tests/toml/mock_pyproject.toml: -------------------------------------------------------------------------------- 1 | # This file exists for the test case: test_config::test_toml_config_custom_path 2 | 3 | [tool.vulture] 4 | verbose = true 5 | ignore_names = ["name_from_toml_file"] 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = cleanup, py{38,310,311,312,313} # Skip py39 since it chokes on distutils. 3 | skip_missing_interpreters = true 4 | 5 | # Erase old coverage results, then accumulate them during this tox run. 6 | [testenv:cleanup] 7 | deps = 8 | coverage 9 | commands = 10 | coverage erase 11 | 12 | [testenv] 13 | deps = 14 | coverage 15 | pint # Use latest version to catch API changes. 16 | pytest 17 | pytest-cov 18 | pytype ; python_version < '3.13' 19 | commands = 20 | pytest {posargs} 21 | # Install package as wheel in all envs (https://hynek.me/articles/turbo-charge-tox/). 22 | package = wheel 23 | wheel_build_env = .pkg 24 | 25 | [pytest] 26 | filterwarnings = 27 | error::DeprecationWarning 28 | error::PendingDeprecationWarning 29 | -------------------------------------------------------------------------------- /vulture/__init__.py: -------------------------------------------------------------------------------- 1 | from vulture.core import Vulture 2 | from vulture.version import __version__ 3 | 4 | assert __version__ 5 | assert Vulture 6 | -------------------------------------------------------------------------------- /vulture/__main__.py: -------------------------------------------------------------------------------- 1 | from vulture.core import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /vulture/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module handles retrieval of configuration values from either the 3 | command-line arguments or the pyproject.toml file. 4 | """ 5 | import argparse 6 | import pathlib 7 | 8 | try: 9 | import tomllib 10 | except ModuleNotFoundError: 11 | import tomli as tomllib 12 | 13 | from .version import __version__ 14 | 15 | #: Possible configuration options and their respective defaults 16 | DEFAULTS = { 17 | "config": "pyproject.toml", 18 | "min_confidence": 0, 19 | "paths": [], 20 | "exclude": [], 21 | "ignore_decorators": [], 22 | "ignore_names": [], 23 | "make_whitelist": False, 24 | "sort_by_size": False, 25 | "verbose": False, 26 | } 27 | 28 | 29 | class InputError(Exception): 30 | def __init__(self, message): 31 | self.message = message 32 | 33 | 34 | def _check_input_config(data): 35 | """ 36 | Checks the types of the values in *data* against the expected types of 37 | config-values. If a value has the wrong type, raise an InputError. 38 | """ 39 | for key, value in data.items(): 40 | if key not in DEFAULTS: 41 | raise InputError(f"Unknown configuration key: {key}") 42 | # The linter suggests to use "isinstance" here but this fails to 43 | # detect the difference between `int` and `bool`. 44 | if type(value) is not type(DEFAULTS[key]): # noqa: E721 45 | expected_type = type(DEFAULTS[key]).__name__ 46 | raise InputError(f"Data type for {key} must be {expected_type!r}") 47 | 48 | 49 | def _check_output_config(config): 50 | """ 51 | Run sanity checks on the generated config after all parsing and 52 | preprocessing is done. 53 | 54 | Raise InputError if an error is encountered. 55 | """ 56 | if not config["paths"]: 57 | raise InputError("Please pass at least one file or directory") 58 | 59 | 60 | def _parse_toml(infile): 61 | """ 62 | Parse a TOML file for config values. 63 | 64 | It will search for a section named ``[tool.vulture]`` which contains the 65 | same keys as the CLI arguments seen with ``--help``. All leading dashes are 66 | removed and other dashes are replaced by underscores (so ``--sort-by-size`` 67 | becomes ``sort_by_size``). 68 | 69 | Arguments containing multiple values are standard TOML lists. 70 | 71 | Example:: 72 | 73 | [tool.vulture] 74 | exclude = ["file*.py", "dir/"] 75 | ignore_decorators = ["deco1", "deco2"] 76 | ignore_names = ["name1", "name2"] 77 | make_whitelist = true 78 | min_confidence = 10 79 | sort_by_size = true 80 | verbose = true 81 | paths = ["path1", "path2"] 82 | """ 83 | data = tomllib.load(infile) 84 | settings = data.get("tool", {}).get("vulture", {}) 85 | _check_input_config(settings) 86 | return settings 87 | 88 | 89 | def _parse_args(args=None): 90 | """ 91 | Parse CLI arguments. 92 | 93 | :param args: A list of strings representing the CLI arguments. If left to 94 | the default, this will default to ``sys.argv``. 95 | """ 96 | 97 | # Sentinel value to distinguish between "False" and "no default given". 98 | missing = object() 99 | 100 | def csv(exclude): 101 | return exclude.split(",") 102 | 103 | usage = "%(prog)s [options] [PATH ...]" 104 | version = f"vulture {__version__}" 105 | glob_help = "Patterns may contain glob wildcards (*, ?, [abc], [!abc])." 106 | parser = argparse.ArgumentParser(prog="vulture", usage=usage) 107 | parser.add_argument( 108 | "paths", 109 | nargs="*", 110 | metavar="PATH", 111 | default=missing, 112 | help="Paths may be Python files or directories. For each directory" 113 | " Vulture analyzes all contained *.py files.", 114 | ) 115 | parser.add_argument( 116 | "--exclude", 117 | metavar="PATTERNS", 118 | type=csv, 119 | default=missing, 120 | help=f"Comma-separated list of path patterns to ignore (e.g.," 121 | f' "*settings.py,docs,*/test_*.py,venv"). {glob_help} A PATTERN' 122 | f" without glob wildcards is treated as *PATTERN*. Patterns are" 123 | f" matched against absolute paths.", 124 | ) 125 | parser.add_argument( 126 | "--ignore-decorators", 127 | metavar="PATTERNS", 128 | type=csv, 129 | default=missing, 130 | help=f"Comma-separated list of decorators. Functions and classes using" 131 | f' these decorators are ignored (e.g., "@app.route,@require_*").' 132 | f" {glob_help}", 133 | ) 134 | parser.add_argument( 135 | "--ignore-names", 136 | metavar="PATTERNS", 137 | type=csv, 138 | default=missing, 139 | help=f'Comma-separated list of names to ignore (e.g., "visit_*,do_*").' 140 | f" {glob_help}", 141 | ) 142 | parser.add_argument( 143 | "--make-whitelist", 144 | action="store_true", 145 | default=missing, 146 | help="Report unused code in a format that can be added to a" 147 | " whitelist module.", 148 | ) 149 | parser.add_argument( 150 | "--min-confidence", 151 | type=int, 152 | default=missing, 153 | help="Minimum confidence (between 0 and 100) for code to be" 154 | " reported as unused.", 155 | ) 156 | parser.add_argument( 157 | "--sort-by-size", 158 | action="store_true", 159 | default=missing, 160 | help="Sort unused functions and classes by their lines of code.", 161 | ) 162 | parser.add_argument( 163 | "--config", 164 | type=str, 165 | default="pyproject.toml", 166 | help="Path to pyproject.toml config file.", 167 | ) 168 | parser.add_argument( 169 | "-v", "--verbose", action="store_true", default=missing 170 | ) 171 | parser.add_argument("--version", action="version", version=version) 172 | namespace = parser.parse_args(args) 173 | cli_args = { 174 | key: value 175 | for key, value in vars(namespace).items() 176 | if value is not missing 177 | } 178 | _check_input_config(cli_args) 179 | return cli_args 180 | 181 | 182 | def make_config(argv=None, tomlfile=None): 183 | """ 184 | Returns a config object for vulture, merging both ``pyproject.toml`` and 185 | CLI arguments (CLI arguments have precedence). 186 | 187 | :param argv: The CLI arguments to be parsed. This value is transparently 188 | passed through to :py:meth:`argparse.ArgumentParser.parse_args`. 189 | :param tomlfile: An IO instance containing TOML data. By default this will 190 | auto-detect an existing ``pyproject.toml`` file and exists solely for 191 | unit-testing. 192 | """ 193 | 194 | # Parse CLI first to skip sanity checks when --version or --help is given. 195 | cli_config = _parse_args(argv) 196 | 197 | # If we loaded data from a TOML file, we want to print this out on stdout 198 | # in verbose mode so we need to keep the value around. 199 | detected_toml_path = "" 200 | 201 | if tomlfile: 202 | config = _parse_toml(tomlfile) 203 | detected_toml_path = str(tomlfile) 204 | else: 205 | toml_path = pathlib.Path(cli_config["config"]).resolve() 206 | if toml_path.is_file(): 207 | with open(toml_path, "rb") as fconfig: 208 | config = _parse_toml(fconfig) 209 | detected_toml_path = str(toml_path) 210 | else: 211 | config = {} 212 | 213 | # Overwrite TOML options with CLI options, if given. 214 | config.update(cli_config) 215 | 216 | # Set defaults for missing options. 217 | for key, value in DEFAULTS.items(): 218 | config.setdefault(key, value) 219 | 220 | if detected_toml_path and config["verbose"]: 221 | print(f"Reading configuration from {detected_toml_path}") 222 | 223 | _check_output_config(config) 224 | 225 | return config 226 | -------------------------------------------------------------------------------- /vulture/core.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import pkgutil 3 | import re 4 | import string 5 | import sys 6 | from fnmatch import fnmatch, fnmatchcase 7 | from functools import partial 8 | from pathlib import Path 9 | from typing import List 10 | 11 | from vulture import lines, noqa, utils 12 | from vulture.config import InputError, make_config 13 | from vulture.reachability import Reachability 14 | from vulture.utils import ExitCode 15 | 16 | DEFAULT_CONFIDENCE = 60 17 | 18 | IGNORED_VARIABLE_NAMES = {"object", "self"} 19 | PYTEST_FUNCTION_NAMES = { 20 | "setup_module", 21 | "teardown_module", 22 | "setup_function", 23 | "teardown_function", 24 | } 25 | PYTEST_METHOD_NAMES = { 26 | "setup_class", 27 | "teardown_class", 28 | "setup_method", 29 | "teardown_method", 30 | } 31 | 32 | ERROR_CODES = { 33 | "attribute": "V101", 34 | "class": "V102", 35 | "function": "V103", 36 | "import": "V104", 37 | "method": "V105", 38 | "property": "V106", 39 | "variable": "V107", 40 | "unreachable_code": "V201", 41 | } 42 | 43 | 44 | def _get_unused_items(defined_items, used_names): 45 | unused_items = [ 46 | item for item in set(defined_items) if item.name not in used_names 47 | ] 48 | unused_items.sort(key=lambda item: item.name.lower()) 49 | return unused_items 50 | 51 | 52 | def _is_special_name(name): 53 | return name.startswith("__") and name.endswith("__") 54 | 55 | 56 | def _match(name, patterns, case=True): 57 | func = fnmatchcase if case else fnmatch 58 | return any(func(name, pattern) for pattern in patterns) 59 | 60 | 61 | def _is_test_file(filename): 62 | return _match( 63 | filename.resolve(), 64 | ["*/test/*", "*/tests/*", "*/test*.py", "*[-_]test.py"], 65 | case=False, 66 | ) 67 | 68 | 69 | def _assigns_special_variable__all__(node): 70 | assert isinstance(node, ast.Assign) 71 | return isinstance(node.value, (ast.List, ast.Tuple)) and any( 72 | target.id == "__all__" 73 | for target in node.targets 74 | if isinstance(target, ast.Name) 75 | ) 76 | 77 | 78 | def _ignore_class(filename, class_name): 79 | return _is_test_file(filename) and "Test" in class_name 80 | 81 | 82 | def _ignore_import(filename, import_name): 83 | """ 84 | Ignore star-imported names since we can't detect whether they are used. 85 | Ignore imports from __init__.py files since they're commonly used to 86 | collect objects from a package. 87 | """ 88 | return filename.name == "__init__.py" or import_name == "*" 89 | 90 | 91 | def _ignore_function(filename, function_name): 92 | return ( 93 | function_name in PYTEST_FUNCTION_NAMES 94 | or function_name.startswith("test_") 95 | ) and _is_test_file(filename) 96 | 97 | 98 | def _ignore_method(filename, method_name): 99 | return _is_special_name(method_name) or ( 100 | (method_name in PYTEST_METHOD_NAMES or method_name.startswith("test_")) 101 | and _is_test_file(filename) 102 | ) 103 | 104 | 105 | def _ignore_variable(filename, varname): 106 | """ 107 | Ignore _ (Python idiom), _x (pylint convention) and 108 | __x__ (special variable or method), but not __x. 109 | """ 110 | return ( 111 | varname in IGNORED_VARIABLE_NAMES 112 | or (varname.startswith("_") and not varname.startswith("__")) 113 | or _is_special_name(varname) 114 | ) 115 | 116 | 117 | class Item: 118 | """ 119 | Hold the name, type and location of defined code. 120 | """ 121 | 122 | __slots__ = ( 123 | "name", 124 | "typ", 125 | "filename", 126 | "first_lineno", 127 | "last_lineno", 128 | "message", 129 | "confidence", 130 | ) 131 | 132 | def __init__( 133 | self, 134 | name, 135 | typ, 136 | filename, 137 | first_lineno, 138 | last_lineno, 139 | message="", 140 | confidence=DEFAULT_CONFIDENCE, 141 | ): 142 | self.name: str = name 143 | self.typ: str = typ 144 | self.filename: Path = filename 145 | self.first_lineno: int = first_lineno 146 | self.last_lineno: int = last_lineno 147 | self.message: str = message or f"unused {typ} '{name}'" 148 | self.confidence: int = confidence 149 | 150 | @property 151 | def size(self): 152 | assert self.last_lineno >= self.first_lineno 153 | return self.last_lineno - self.first_lineno + 1 154 | 155 | def get_report(self, add_size=False): 156 | if add_size: 157 | line_format = "line" if self.size == 1 else "lines" 158 | size_report = f", {self.size:d} {line_format}" 159 | else: 160 | size_report = "" 161 | return ( 162 | f"{utils.format_path(self.filename)}:{self.first_lineno:d}: " 163 | f"{self.message} ({self.confidence}% confidence{size_report})" 164 | ) 165 | 166 | def get_whitelist_string(self): 167 | filename = utils.format_path(self.filename) 168 | if self.typ == "unreachable_code": 169 | return f"# {self.message} ({filename}:{self.first_lineno})" 170 | else: 171 | prefix = "" 172 | if self.typ in ["attribute", "method", "property"]: 173 | prefix = "_." 174 | return ( 175 | f"{prefix}{self.name} # unused {self.typ} " 176 | f"({filename}:{self.first_lineno:d})" 177 | ) 178 | 179 | def _tuple(self): 180 | return (self.filename, self.first_lineno, self.name) 181 | 182 | def __repr__(self): 183 | return repr(self.name) 184 | 185 | def __eq__(self, other): 186 | return self._tuple() == other._tuple() 187 | 188 | def __hash__(self): 189 | return hash(self._tuple()) 190 | 191 | 192 | class Vulture(ast.NodeVisitor): 193 | """Find dead code.""" 194 | 195 | def __init__( 196 | self, verbose=False, ignore_names=None, ignore_decorators=None 197 | ): 198 | self.verbose = verbose 199 | 200 | def get_list(typ): 201 | return utils.LoggingList(typ, self.verbose) 202 | 203 | self.defined_attrs = get_list("attribute") 204 | self.defined_classes = get_list("class") 205 | self.defined_funcs = get_list("function") 206 | self.defined_imports = get_list("import") 207 | self.defined_methods = get_list("method") 208 | self.defined_props = get_list("property") 209 | self.defined_vars = get_list("variable") 210 | self.unreachable_code = get_list("unreachable_code") 211 | 212 | self.used_names = utils.LoggingSet("name", self.verbose) 213 | 214 | self.ignore_names = ignore_names or [] 215 | self.ignore_decorators = ignore_decorators or [] 216 | 217 | self.filename = Path() 218 | self.code = [] 219 | self.exit_code = ExitCode.NoDeadCode 220 | self.noqa_lines = {} 221 | 222 | report = partial( 223 | self._define, 224 | collection=self.unreachable_code, 225 | confidence=100, 226 | ) 227 | self.reachability = Reachability(report=report) 228 | 229 | def scan(self, code, filename=""): 230 | filename = Path(filename) 231 | self.code = code.splitlines() 232 | self.noqa_lines = noqa.parse_noqa(self.code) 233 | self.filename = filename 234 | 235 | def handle_syntax_error(e): 236 | text = f' at "{e.text.strip()}"' if e.text else "" 237 | self._log( 238 | f"{utils.format_path(filename)}:{e.lineno}: {e.msg}{text}", 239 | file=sys.stderr, 240 | force=True, 241 | ) 242 | self.exit_code = ExitCode.InvalidInput 243 | 244 | try: 245 | node = ast.parse( 246 | code, filename=str(self.filename), type_comments=True 247 | ) 248 | except SyntaxError as err: 249 | handle_syntax_error(err) 250 | except ValueError as err: 251 | # ValueError is raised if source contains null bytes. 252 | self._log( 253 | f'{utils.format_path(filename)}: invalid source code "{err}"', 254 | file=sys.stderr, 255 | force=True, 256 | ) 257 | self.exit_code = ExitCode.InvalidInput 258 | else: 259 | # When parsing type comments, visiting can throw SyntaxError. 260 | try: 261 | self.visit(node) 262 | except SyntaxError as err: 263 | handle_syntax_error(err) 264 | 265 | # Reset the reachability internals for every module to reduce memory 266 | # usage. 267 | self.reachability.reset() 268 | 269 | def scavenge(self, paths, exclude=None): 270 | def prepare_pattern(pattern): 271 | if not any(char in pattern for char in "*?["): 272 | pattern = f"*{pattern}*" 273 | return pattern 274 | 275 | exclude = [prepare_pattern(pattern) for pattern in (exclude or [])] 276 | 277 | def exclude_path(path): 278 | return _match(path, exclude, case=False) 279 | 280 | paths = [Path(path) for path in paths] 281 | 282 | for module in utils.get_modules(paths): 283 | if exclude_path(module): 284 | self._log("Excluded:", module) 285 | continue 286 | 287 | self._log("Scanning:", module) 288 | try: 289 | module_string = utils.read_file(module) 290 | except utils.VultureInputException as err: # noqa: F841 291 | self._log( 292 | f"Error: Could not read file {module} - {err}\n" 293 | f"Try to change the encoding to UTF-8.", 294 | file=sys.stderr, 295 | force=True, 296 | ) 297 | self.exit_code = ExitCode.InvalidInput 298 | else: 299 | self.scan(module_string, filename=module) 300 | 301 | unique_imports = {item.name for item in self.defined_imports} 302 | for import_name in unique_imports: 303 | path = Path("whitelists") / (import_name + "_whitelist.py") 304 | if exclude_path(path): 305 | self._log("Excluded whitelist:", path) 306 | else: 307 | try: 308 | module_data = pkgutil.get_data("vulture", str(path)) 309 | self._log("Included whitelist:", path) 310 | except OSError: 311 | # Most imported modules don't have a whitelist. 312 | continue 313 | assert module_data is not None 314 | module_string = module_data.decode("utf-8") 315 | self.scan(module_string, filename=path) 316 | 317 | def get_unused_code( 318 | self, min_confidence=0, sort_by_size=False 319 | ) -> List[Item]: 320 | """ 321 | Return ordered list of unused Item objects. 322 | """ 323 | if not 0 <= min_confidence <= 100: 324 | raise ValueError("min_confidence must be between 0 and 100.") 325 | 326 | def by_name(item): 327 | return (str(item.filename).lower(), item.first_lineno) 328 | 329 | def by_size(item): 330 | return (item.size,) + by_name(item) 331 | 332 | unused_code = ( 333 | self.unused_attrs 334 | + self.unused_classes 335 | + self.unused_funcs 336 | + self.unused_imports 337 | + self.unused_methods 338 | + self.unused_props 339 | + self.unused_vars 340 | + self.unreachable_code 341 | ) 342 | 343 | confidently_unused = [ 344 | obj for obj in unused_code if obj.confidence >= min_confidence 345 | ] 346 | 347 | return sorted( 348 | confidently_unused, key=by_size if sort_by_size else by_name 349 | ) 350 | 351 | def report( 352 | self, min_confidence=0, sort_by_size=False, make_whitelist=False 353 | ): 354 | """ 355 | Print ordered list of Item objects to stdout. 356 | """ 357 | for item in self.get_unused_code( 358 | min_confidence=min_confidence, sort_by_size=sort_by_size 359 | ): 360 | self._log( 361 | item.get_whitelist_string() 362 | if make_whitelist 363 | else item.get_report(add_size=sort_by_size), 364 | force=True, 365 | ) 366 | self.exit_code = ExitCode.DeadCode 367 | return self.exit_code 368 | 369 | @property 370 | def unused_classes(self): 371 | return _get_unused_items(self.defined_classes, self.used_names) 372 | 373 | @property 374 | def unused_funcs(self): 375 | return _get_unused_items(self.defined_funcs, self.used_names) 376 | 377 | @property 378 | def unused_imports(self): 379 | return _get_unused_items(self.defined_imports, self.used_names) 380 | 381 | @property 382 | def unused_methods(self): 383 | return _get_unused_items(self.defined_methods, self.used_names) 384 | 385 | @property 386 | def unused_props(self): 387 | return _get_unused_items(self.defined_props, self.used_names) 388 | 389 | @property 390 | def unused_vars(self): 391 | return _get_unused_items(self.defined_vars, self.used_names) 392 | 393 | @property 394 | def unused_attrs(self): 395 | return _get_unused_items(self.defined_attrs, self.used_names) 396 | 397 | def _log(self, *args, file=None, force=False): 398 | if self.verbose or force: 399 | file = file or sys.stdout 400 | try: 401 | print(*args, file=file) 402 | except UnicodeEncodeError: 403 | # Some terminals can't print Unicode symbols. 404 | x = " ".join(map(str, args)) 405 | print(x.encode(), file=file) 406 | 407 | def _add_aliases(self, node): 408 | """ 409 | We delegate to this method instead of using visit_alias() to have 410 | access to line numbers and to filter imports from __future__. 411 | """ 412 | assert isinstance(node, (ast.Import, ast.ImportFrom)) 413 | for name_and_alias in node.names: 414 | # Store only top-level module name ("os.path" -> "os"). 415 | # We can't easily detect when "os.path" is used. 416 | name = name_and_alias.name.partition(".")[0] 417 | alias = name_and_alias.asname 418 | self._define( 419 | self.defined_imports, 420 | alias or name, 421 | node, 422 | confidence=90, 423 | ignore=_ignore_import, 424 | ) 425 | if alias is not None: 426 | self.used_names.add(name_and_alias.name) 427 | 428 | def _define( 429 | self, 430 | collection, 431 | name, 432 | first_node, 433 | last_node=None, 434 | message="", 435 | confidence=DEFAULT_CONFIDENCE, 436 | ignore=None, 437 | ): 438 | def ignored(lineno): 439 | return ( 440 | (ignore and ignore(self.filename, name)) 441 | or _match(name, self.ignore_names) 442 | or noqa.ignore_line(self.noqa_lines, lineno, ERROR_CODES[typ]) 443 | ) 444 | 445 | last_node = last_node or first_node 446 | typ = collection.typ 447 | first_lineno = lines.get_first_line_number(first_node) 448 | 449 | if ignored(first_lineno): 450 | self._log(f'Ignoring {typ} "{name}"') 451 | else: 452 | collection.append( 453 | Item( 454 | name, 455 | typ, 456 | self.filename, 457 | first_lineno, 458 | lines.get_last_line_number(last_node), 459 | message=message, 460 | confidence=confidence, 461 | ) 462 | ) 463 | 464 | def _define_variable(self, name, node, confidence=DEFAULT_CONFIDENCE): 465 | self._define( 466 | self.defined_vars, 467 | name, 468 | node, 469 | confidence=confidence, 470 | ignore=_ignore_variable, 471 | ) 472 | 473 | def visit_arg(self, node): 474 | """Function argument""" 475 | self._define_variable(node.arg, node, confidence=100) 476 | 477 | def visit_AsyncFunctionDef(self, node): 478 | return self.visit_FunctionDef(node) 479 | 480 | def visit_Attribute(self, node): 481 | if isinstance(node.ctx, ast.Store): 482 | self._define(self.defined_attrs, node.attr, node) 483 | elif isinstance(node.ctx, ast.Load): 484 | self.used_names.add(node.attr) 485 | 486 | def visit_BinOp(self, node): 487 | """ 488 | Parse variable names in old format strings: 489 | 490 | "%(my_var)s" % locals() 491 | """ 492 | if ( 493 | utils.is_ast_string(node.left) 494 | and isinstance(node.op, ast.Mod) 495 | and self._is_locals_call(node.right) 496 | ): 497 | self.used_names |= set(re.findall(r"%\((\w+)\)", node.left.value)) 498 | 499 | def visit_Call(self, node): 500 | # Count getattr/hasattr(x, "some_attr", ...) as usage of some_attr. 501 | if isinstance(node.func, ast.Name) and ( 502 | (node.func.id == "getattr" and 2 <= len(node.args) <= 3) 503 | or (node.func.id == "hasattr" and len(node.args) == 2) 504 | ): 505 | attr_name_arg = node.args[1] 506 | if utils.is_ast_string(attr_name_arg): 507 | self.used_names.add(attr_name_arg.value) 508 | 509 | # Parse variable names in new format strings: 510 | # "{my_var}".format(**locals()) 511 | if ( 512 | isinstance(node.func, ast.Attribute) 513 | and utils.is_ast_string(node.func.value) 514 | and node.func.attr == "format" 515 | and any( 516 | kw.arg is None and self._is_locals_call(kw.value) 517 | for kw in node.keywords 518 | ) 519 | ): 520 | self._handle_new_format_string(node.func.value.value) 521 | 522 | def _handle_new_format_string(self, s): 523 | def is_identifier(name): 524 | return bool(re.match(r"[a-zA-Z_][a-zA-Z0-9_]*", name)) 525 | 526 | parser = string.Formatter() 527 | try: 528 | names = [name for _, name, _, _ in parser.parse(s) if name] 529 | except ValueError: 530 | # Invalid format string. 531 | names = [] 532 | 533 | for field_name in names: 534 | # Remove brackets and their contents: "a[0][b].c[d].e" -> "a.c.e", 535 | # then split the resulting string: "a.b.c" -> ["a", "b", "c"] 536 | vars = re.sub(r"\[\w*\]", "", field_name).split(".") 537 | for var in vars: 538 | if is_identifier(var): 539 | self.used_names.add(var) 540 | 541 | @staticmethod 542 | def _is_locals_call(node): 543 | """Return True if the node is `locals()`.""" 544 | return ( 545 | isinstance(node, ast.Call) 546 | and isinstance(node.func, ast.Name) 547 | and node.func.id == "locals" 548 | and not node.args 549 | and not node.keywords 550 | ) 551 | 552 | def visit_ClassDef(self, node): 553 | for decorator in node.decorator_list: 554 | if _match( 555 | utils.get_decorator_name(decorator), self.ignore_decorators 556 | ): 557 | self._log( 558 | f'Ignoring class "{node.name}" (decorator whitelisted)' 559 | ) 560 | break 561 | else: 562 | self._define( 563 | self.defined_classes, node.name, node, ignore=_ignore_class 564 | ) 565 | 566 | def visit_FunctionDef(self, node): 567 | decorator_names = [ 568 | utils.get_decorator_name(decorator) 569 | for decorator in node.decorator_list 570 | ] 571 | 572 | first_arg = node.args.args[0].arg if node.args.args else None 573 | 574 | if "@property" in decorator_names: 575 | typ = "property" 576 | elif ( 577 | "@staticmethod" in decorator_names 578 | or "@classmethod" in decorator_names 579 | or first_arg == "self" 580 | ): 581 | typ = "method" 582 | else: 583 | typ = "function" 584 | 585 | if any( 586 | _match(name, self.ignore_decorators) for name in decorator_names 587 | ): 588 | self._log(f'Ignoring {typ} "{node.name}" (decorator whitelisted)') 589 | elif typ == "property": 590 | self._define(self.defined_props, node.name, node) 591 | elif typ == "method": 592 | self._define( 593 | self.defined_methods, node.name, node, ignore=_ignore_method 594 | ) 595 | else: 596 | self._define( 597 | self.defined_funcs, node.name, node, ignore=_ignore_function 598 | ) 599 | 600 | def visit_Import(self, node): 601 | self._add_aliases(node) 602 | 603 | def visit_ImportFrom(self, node): 604 | if node.module != "__future__": 605 | self._add_aliases(node) 606 | 607 | def visit_Name(self, node): 608 | if ( 609 | isinstance(node.ctx, (ast.Load, ast.Del)) 610 | and node.id not in IGNORED_VARIABLE_NAMES 611 | ): 612 | self.used_names.add(node.id) 613 | elif isinstance(node.ctx, (ast.Param, ast.Store)): 614 | self._define_variable(node.id, node) 615 | 616 | def visit_Assign(self, node): 617 | if _assigns_special_variable__all__(node): 618 | assert isinstance(node.value, (ast.List, ast.Tuple)) 619 | for elt in node.value.elts: 620 | if utils.is_ast_string(elt): 621 | self.used_names.add(elt.value) 622 | 623 | def visit_MatchClass(self, node): 624 | for kwd_attr in node.kwd_attrs: 625 | self.used_names.add(kwd_attr) 626 | 627 | def visit(self, node): 628 | # Visit children nodes first to allow recursive reachability analysis. 629 | self.generic_visit(node) 630 | 631 | self.reachability.visit(node) 632 | 633 | method = "visit_" + node.__class__.__name__ 634 | visitor = getattr(self, method, None) 635 | if self.verbose: 636 | lineno = getattr(node, "lineno", 1) 637 | line = self.code[lineno - 1] if self.code else "" 638 | self._log(lineno, ast.dump(node), line) 639 | if visitor: 640 | visitor(node) 641 | 642 | # There isn't a clean subset of node types that might have type 643 | # comments, so just check all of them. 644 | type_comment = getattr(node, "type_comment", None) 645 | if type_comment is not None: 646 | mode = ( 647 | "func_type" 648 | if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) 649 | else "eval" 650 | ) 651 | self.visit( 652 | ast.parse(type_comment, filename="", mode=mode) 653 | ) 654 | 655 | def generic_visit(self, node): 656 | """Called if no explicit visitor function exists for a node.""" 657 | for _, value in ast.iter_fields(node): 658 | if isinstance(value, list): 659 | for item in value: 660 | if isinstance(item, ast.AST): 661 | self.visit(item) 662 | elif isinstance(value, ast.AST): 663 | self.visit(value) 664 | 665 | 666 | def main(): 667 | try: 668 | config = make_config() 669 | except InputError as e: 670 | print(e, file=sys.stderr) 671 | sys.exit(ExitCode.InvalidCmdlineArguments) 672 | 673 | vulture = Vulture( 674 | verbose=config["verbose"], 675 | ignore_names=config["ignore_names"], 676 | ignore_decorators=config["ignore_decorators"], 677 | ) 678 | vulture.scavenge(config["paths"], exclude=config["exclude"]) 679 | sys.exit( 680 | vulture.report( 681 | min_confidence=config["min_confidence"], 682 | sort_by_size=config["sort_by_size"], 683 | make_whitelist=config["make_whitelist"], 684 | ) 685 | ) 686 | -------------------------------------------------------------------------------- /vulture/lines.py: -------------------------------------------------------------------------------- 1 | def get_last_line_number(node): 2 | return node.end_lineno 3 | 4 | 5 | def get_first_line_number(node): 6 | """ 7 | From Python 3.8 onwards, lineno for decorated objects is the line at which 8 | the object definition starts, which is different from what Python < 3.8 9 | reported -- the lineno of the first decorator. To preserve this behaviour 10 | of Vulture for newer Python versions, which is also more accurate for 11 | counting the size of the unused code chunk (if the property is unused, we 12 | also don't need it's decorators), we return the lineno of the first 13 | decorator, if there are any. 14 | """ 15 | decorators = getattr(node, "decorator_list", []) 16 | if decorators: 17 | return decorators[0].lineno 18 | return node.lineno 19 | -------------------------------------------------------------------------------- /vulture/noqa.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import defaultdict 3 | 4 | NOQA_REGEXP = re.compile( 5 | # Use the same regex as flake8 does. 6 | # https://github.com/pycqa/flake8/blob/main/src/flake8/defaults.py 7 | # We're looking for items that look like this: 8 | # `# noqa` 9 | # `# noqa: E123` 10 | # `# noqa: E123,W451,F921` 11 | # `# NoQA: E123,W451,F921` 12 | r"# noqa(?::[\s]?(?P([A-Z]+[0-9]+(?:[,\s]+)?)+))?", 13 | re.IGNORECASE, 14 | ) 15 | 16 | NOQA_CODE_MAP = { 17 | # flake8 F401: module imported but unused. 18 | "F401": "V104", 19 | # flake8 F841: local variable is assigned to but never used. 20 | "F841": "V107", 21 | } 22 | 23 | 24 | def _parse_error_codes(match): 25 | # If no error code is specified, add the line to the "all" category. 26 | return [ 27 | c.strip() for c in (match.groupdict()["codes"] or "all").split(",") 28 | ] 29 | 30 | 31 | def parse_noqa(code): 32 | noqa_lines = defaultdict(set) 33 | for lineno, line in enumerate(code, start=1): 34 | match = NOQA_REGEXP.search(line) 35 | if match: 36 | for error_code in _parse_error_codes(match): 37 | error_code = NOQA_CODE_MAP.get(error_code, error_code) 38 | noqa_lines[error_code].add(lineno) 39 | return noqa_lines 40 | 41 | 42 | def ignore_line(noqa_lines, lineno, error_code): 43 | """Check if the reported line is annotated with "# noqa".""" 44 | return lineno in noqa_lines[error_code] or lineno in noqa_lines["all"] 45 | -------------------------------------------------------------------------------- /vulture/reachability.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from vulture import utils 4 | 5 | 6 | class Reachability: 7 | def __init__(self, report): 8 | self._report = report 9 | self._no_fall_through_nodes = set() 10 | 11 | # Since we visit the children nodes first, we need to maintain a flag 12 | # that indicates if a break statement was seen. When visiting the 13 | # parent (While, For or AsyncFor), the value is checked (for While) 14 | # and reset. Assumes code is valid (break statements only in loops). 15 | self._current_loop_has_break_statement = False 16 | 17 | def visit(self, node): 18 | """When called, all children of this node have already been visited.""" 19 | if isinstance(node, (ast.Break, ast.Continue, ast.Return, ast.Raise)): 20 | self._mark_as_no_fall_through(node) 21 | if isinstance(node, ast.Break): 22 | self._current_loop_has_break_statement = True 23 | 24 | elif isinstance( 25 | node, 26 | ( 27 | ast.Module, 28 | ast.FunctionDef, 29 | ast.AsyncFunctionDef, 30 | ast.With, 31 | ast.AsyncWith, 32 | ), 33 | ): 34 | self._can_fall_through_statements_analysis(node.body) 35 | elif isinstance(node, ast.While): 36 | self._handle_reachability_while(node) 37 | self._current_loop_has_break_statement = False 38 | elif isinstance(node, (ast.For, ast.AsyncFor)): 39 | self._can_fall_through_statements_analysis(node.body) 40 | self._current_loop_has_break_statement = False 41 | elif isinstance(node, ast.If): 42 | self._handle_reachability_if(node) 43 | elif isinstance(node, ast.IfExp): 44 | self._handle_reachability_if_expr(node) 45 | elif isinstance(node, ast.Try): 46 | self._handle_reachability_try(node) 47 | 48 | def reset(self): 49 | self._no_fall_through_nodes = set() 50 | 51 | def _can_fall_through(self, node): 52 | return node not in self._no_fall_through_nodes 53 | 54 | def _mark_as_no_fall_through(self, node): 55 | self._no_fall_through_nodes.add(node) 56 | 57 | def _can_fall_through_statements_analysis(self, statements): 58 | """Report unreachable statements. 59 | Return True if we can execute the full list of statements. 60 | """ 61 | for idx, statement in enumerate(statements): 62 | if not self._can_fall_through(statement): 63 | try: 64 | next_sibling = statements[idx + 1] 65 | except IndexError: 66 | next_sibling = None 67 | if next_sibling is not None: 68 | class_name = statement.__class__.__name__.lower() 69 | self._report( 70 | name=class_name, 71 | first_node=next_sibling, 72 | last_node=statements[-1], 73 | message=f"unreachable code after '{class_name}'", 74 | ) 75 | return False 76 | return True 77 | 78 | def _handle_reachability_if(self, node): 79 | has_else = bool(node.orelse) 80 | 81 | if utils.condition_is_always_false(node.test): 82 | self._report( 83 | name="if", 84 | first_node=node, 85 | last_node=node.body 86 | if isinstance(node, ast.IfExp) 87 | else node.body[-1], 88 | message="unsatisfiable 'if' condition", 89 | ) 90 | if_can_fall_through = True 91 | else_can_fall_through = self._can_else_fall_through( 92 | node.orelse, condition_always_true=False 93 | ) 94 | 95 | elif utils.condition_is_always_true(node.test): 96 | if_can_fall_through = self._can_fall_through_statements_analysis( 97 | node.body 98 | ) 99 | else_can_fall_through = self._can_else_fall_through( 100 | node.orelse, condition_always_true=True 101 | ) 102 | 103 | if has_else: 104 | self._report( 105 | name="else", 106 | first_node=node.orelse[0], 107 | last_node=node.orelse[-1], 108 | message="unreachable 'else' block", 109 | ) 110 | else: 111 | # Redundant if-condition without else block. 112 | self._report( 113 | name="if", 114 | first_node=node, 115 | message="redundant if-condition", 116 | ) 117 | else: 118 | if_can_fall_through = self._can_fall_through_statements_analysis( 119 | node.body 120 | ) 121 | else_can_fall_through = self._can_else_fall_through( 122 | node.orelse, condition_always_true=False 123 | ) 124 | 125 | statement_can_fall_through = ( 126 | if_can_fall_through or else_can_fall_through 127 | ) 128 | 129 | if not statement_can_fall_through: 130 | self._mark_as_no_fall_through(node) 131 | 132 | def _can_else_fall_through(self, orelse, condition_always_true): 133 | if not orelse: 134 | return not condition_always_true 135 | return self._can_fall_through_statements_analysis(orelse) 136 | 137 | def _handle_reachability_if_expr(self, node): 138 | if utils.condition_is_always_false(node.test): 139 | self._report( 140 | name="ternary", 141 | first_node=node, 142 | last_node=node.body 143 | if isinstance(node, ast.IfExp) 144 | else node.body[-1], 145 | message="unsatisfiable 'ternary' condition", 146 | ) 147 | elif utils.condition_is_always_true(node.test): 148 | else_body = node.orelse 149 | self._report( 150 | name="ternary", 151 | first_node=else_body, 152 | message="unreachable 'else' expression", 153 | ) 154 | 155 | def _handle_reachability_while(self, node): 156 | if utils.condition_is_always_false(node.test): 157 | self._report( 158 | name="while", 159 | first_node=node, 160 | last_node=node.body 161 | if isinstance(node, ast.IfExp) 162 | else node.body[-1], 163 | message="unsatisfiable 'while' condition", 164 | ) 165 | 166 | elif utils.condition_is_always_true(node.test): 167 | else_body = node.orelse 168 | if else_body: 169 | self._report( 170 | name="else", 171 | first_node=else_body[0], 172 | last_node=else_body[-1], 173 | message="unreachable 'else' block", 174 | ) 175 | 176 | if not self._current_loop_has_break_statement: 177 | self._mark_as_no_fall_through(node) 178 | 179 | self._can_fall_through_statements_analysis(node.body) 180 | 181 | def _handle_reachability_try(self, node): 182 | try_can_fall_through = self._can_fall_through_statements_analysis( 183 | node.body 184 | ) 185 | 186 | has_else = bool(node.orelse) 187 | 188 | if not try_can_fall_through and has_else: 189 | else_body = node.orelse 190 | self._report( 191 | name="else", 192 | first_node=else_body[0], 193 | last_node=else_body[-1], 194 | message="unreachable 'else' block", 195 | ) 196 | 197 | any_except_can_fall_through = any( 198 | self._can_fall_through_statements_analysis(handler.body) 199 | for handler in node.handlers 200 | ) 201 | 202 | statement_can_fall_through = ( 203 | try_can_fall_through or any_except_can_fall_through 204 | ) 205 | 206 | if not statement_can_fall_through: 207 | self._mark_as_no_fall_through(node) 208 | -------------------------------------------------------------------------------- /vulture/utils.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import pathlib 3 | import sys 4 | import tokenize 5 | from enum import IntEnum 6 | 7 | 8 | class VultureInputException(Exception): 9 | pass 10 | 11 | 12 | class ExitCode(IntEnum): 13 | NoDeadCode = 0 14 | InvalidInput = 1 15 | InvalidCmdlineArguments = 2 16 | DeadCode = 3 17 | 18 | 19 | def _safe_eval(node, default): 20 | """ 21 | Safely evaluate the Boolean expression under the given AST node. 22 | 23 | Substitute `default` for all sub-expressions that cannot be 24 | evaluated (because variables or functions are undefined). 25 | 26 | We could use eval() to evaluate more sub-expressions. However, this 27 | function is not safe for arbitrary Python code. Even after 28 | overwriting the "__builtins__" dictionary, the original dictionary 29 | can be restored 30 | (https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html). 31 | 32 | """ 33 | if isinstance(node, ast.BoolOp): 34 | results = [_safe_eval(value, default) for value in node.values] 35 | if isinstance(node.op, ast.And): 36 | return all(results) 37 | else: 38 | return any(results) 39 | elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): 40 | return not _safe_eval(node.operand, not default) 41 | else: 42 | try: 43 | return ast.literal_eval(node) 44 | except ValueError: 45 | return default 46 | 47 | 48 | def condition_is_always_false(condition): 49 | return not _safe_eval(condition, True) 50 | 51 | 52 | def condition_is_always_true(condition): 53 | return _safe_eval(condition, False) 54 | 55 | 56 | def is_ast_string(node): 57 | return isinstance(node, ast.Constant) and isinstance(node.value, str) 58 | 59 | 60 | def format_path(path): 61 | try: 62 | return path.relative_to(pathlib.Path.cwd()) 63 | except ValueError: 64 | # Path is not below the current directory. 65 | return path 66 | 67 | 68 | def get_decorator_name(decorator): 69 | if isinstance(decorator, ast.Call): 70 | decorator = decorator.func 71 | try: 72 | parts = [] 73 | while isinstance(decorator, ast.Attribute): 74 | parts.append(decorator.attr) 75 | decorator = decorator.value 76 | parts.append(decorator.id) 77 | except AttributeError: 78 | parts = [] 79 | return "@" + ".".join(reversed(parts)) 80 | 81 | 82 | def get_modules(paths): 83 | """Retrieve Python files to check. 84 | 85 | Loop over all given paths, abort if any ends with .pyc, add the other given 86 | files (even those not ending with .py) and collect all .py files under the 87 | given directories. 88 | 89 | """ 90 | modules = [] 91 | for path in paths: 92 | path = path.resolve() 93 | if path.is_file(): 94 | if path.suffix == ".pyc": 95 | sys.exit(f"Error: *.pyc files are not supported: {path}") 96 | else: 97 | modules.append(path) 98 | elif path.is_dir(): 99 | modules.extend(path.rglob("*.py")) 100 | else: 101 | sys.exit(f"Error: {path} could not be found.") 102 | return modules 103 | 104 | 105 | def read_file(filename): 106 | try: 107 | # Use encoding detected by tokenize.detect_encoding(). 108 | with tokenize.open(filename) as f: 109 | return f.read() 110 | except (SyntaxError, UnicodeDecodeError) as err: 111 | raise VultureInputException from err 112 | 113 | 114 | class LoggingList(list): 115 | def __init__(self, typ, verbose): 116 | self.typ = typ 117 | self._verbose = verbose 118 | return super().__init__() 119 | 120 | def append(self, item): 121 | if self._verbose: 122 | print(f'define {self.typ} "{item.name}"') 123 | super().append(item) 124 | 125 | 126 | class LoggingSet(set): 127 | def __init__(self, typ, verbose): 128 | self.typ = typ 129 | self._verbose = verbose 130 | return super().__init__() 131 | 132 | def add(self, name): 133 | if self._verbose: 134 | print(f'use {self.typ} "{name}"') 135 | super().add(name) 136 | -------------------------------------------------------------------------------- /vulture/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.14" 2 | -------------------------------------------------------------------------------- /vulture/whitelists/argparse_whitelist.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | argparse.ArgumentParser().epilog 4 | 5 | argparse.ArgumentDefaultsHelpFormatter("prog")._fill_text 6 | argparse.ArgumentDefaultsHelpFormatter("prog")._get_help_string 7 | -------------------------------------------------------------------------------- /vulture/whitelists/ast_whitelist.py: -------------------------------------------------------------------------------- 1 | from whitelist_utils import Whitelist 2 | 3 | # NodeVisitor methods are called implicitly. 4 | whitelist_node_visitor = Whitelist() 5 | 6 | whitelist_node_visitor.visit_Assert 7 | whitelist_node_visitor.visit_Assign 8 | whitelist_node_visitor.visit_AsyncFor 9 | whitelist_node_visitor.visit_AsyncFunctionDef 10 | whitelist_node_visitor.visit_AsyncWith 11 | whitelist_node_visitor.visit_Attribute 12 | whitelist_node_visitor.visit_AugAssign 13 | whitelist_node_visitor.visit_Await 14 | whitelist_node_visitor.visit_BinOp 15 | whitelist_node_visitor.visit_BoolOp 16 | whitelist_node_visitor.visit_Bytes 17 | whitelist_node_visitor.visit_Call 18 | whitelist_node_visitor.visit_ClassDef 19 | whitelist_node_visitor.visit_Compare 20 | whitelist_node_visitor.visit_Constant 21 | whitelist_node_visitor.visit_Delete 22 | whitelist_node_visitor.visit_Dict 23 | whitelist_node_visitor.visit_DictComp 24 | whitelist_node_visitor.visit_ExceptHandler 25 | whitelist_node_visitor.visit_Exec 26 | whitelist_node_visitor.visit_Expr 27 | whitelist_node_visitor.visit_Expression 28 | whitelist_node_visitor.visit_ExtSlice 29 | whitelist_node_visitor.visit_For 30 | whitelist_node_visitor.visit_FunctionDef 31 | whitelist_node_visitor.visit_GeneratorExp 32 | whitelist_node_visitor.visit_Global 33 | whitelist_node_visitor.visit_If 34 | whitelist_node_visitor.visit_IfExp 35 | whitelist_node_visitor.visit_Import 36 | whitelist_node_visitor.visit_ImportFrom 37 | whitelist_node_visitor.visit_Index 38 | whitelist_node_visitor.visit_Interactive 39 | whitelist_node_visitor.visit_Lambda 40 | whitelist_node_visitor.visit_List 41 | whitelist_node_visitor.visit_ListComp 42 | whitelist_node_visitor.visit_MatchClass 43 | whitelist_node_visitor.visit_Module 44 | whitelist_node_visitor.visit_Name 45 | whitelist_node_visitor.visit_NameConstant 46 | whitelist_node_visitor.visit_Nonlocal 47 | whitelist_node_visitor.visit_Num 48 | whitelist_node_visitor.visit_Print 49 | whitelist_node_visitor.visit_Raise 50 | whitelist_node_visitor.visit_Repr 51 | whitelist_node_visitor.visit_Return 52 | whitelist_node_visitor.visit_Set 53 | whitelist_node_visitor.visit_SetComp 54 | whitelist_node_visitor.visit_Slice 55 | whitelist_node_visitor.visit_Starred 56 | whitelist_node_visitor.visit_Str 57 | whitelist_node_visitor.visit_Subscript 58 | whitelist_node_visitor.visit_Suite 59 | whitelist_node_visitor.visit_Try 60 | whitelist_node_visitor.visit_TryExcept 61 | whitelist_node_visitor.visit_TryFinally 62 | whitelist_node_visitor.visit_Tuple 63 | whitelist_node_visitor.visit_UnaryOp 64 | whitelist_node_visitor.visit_While 65 | whitelist_node_visitor.visit_With 66 | whitelist_node_visitor.visit_Yield 67 | whitelist_node_visitor.visit_YieldFrom 68 | whitelist_node_visitor.visit_alias 69 | whitelist_node_visitor.visit_arg 70 | whitelist_node_visitor.visit_arguments 71 | whitelist_node_visitor.visit_comprehension 72 | whitelist_node_visitor.visit_keyword 73 | -------------------------------------------------------------------------------- /vulture/whitelists/collections_whitelist.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | # To free memory, the "default_factory" attribute can be set to None. 4 | collections.defaultdict().default_factory 5 | -------------------------------------------------------------------------------- /vulture/whitelists/ctypes_whitelist.py: -------------------------------------------------------------------------------- 1 | from ctypes import _CFuncPtr, _Pointer 2 | 3 | _CFuncPtr.argtypes 4 | _CFuncPtr.errcheck 5 | _CFuncPtr.restype 6 | 7 | _Pointer.contents 8 | -------------------------------------------------------------------------------- /vulture/whitelists/enum_whitelist.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class EnumWhitelist(enum.Enum): 5 | EnumWhitelist = 1 6 | 7 | 8 | # Special attributes used by enum classes. 9 | EnumWhitelist.EnumWhitelist._name_ 10 | EnumWhitelist.EnumWhitelist._value_ 11 | -------------------------------------------------------------------------------- /vulture/whitelists/logging_whitelist.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.Filter.filter 4 | logging.getLogger().propagate 5 | logging.StreamHandler.emit 6 | -------------------------------------------------------------------------------- /vulture/whitelists/pint_whitelist.py: -------------------------------------------------------------------------------- 1 | import pint 2 | 3 | ureg = pint.UnitRegistry() 4 | ureg.default_format 5 | -------------------------------------------------------------------------------- /vulture/whitelists/socketserver_whitelist.py: -------------------------------------------------------------------------------- 1 | import socketserver 2 | 3 | socketserver.TCPServer.allow_reuse_address 4 | -------------------------------------------------------------------------------- /vulture/whitelists/string_whitelist.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | string.Formatter.check_unused_args 4 | string.Formatter.convert_field 5 | string.Formatter.format 6 | string.Formatter.format_field 7 | string.Formatter.get_field 8 | string.Formatter.get_value 9 | string.Formatter.parse 10 | string.Formatter.vformat 11 | -------------------------------------------------------------------------------- /vulture/whitelists/sys_whitelist.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.excepthook 4 | 5 | # Never report redirected streams as unused. 6 | sys.stderr 7 | sys.stdin 8 | sys.stdout 9 | -------------------------------------------------------------------------------- /vulture/whitelists/threading_whitelist.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | threading.Thread.daemon 4 | threading.Thread.name 5 | threading.Thread.run 6 | -------------------------------------------------------------------------------- /vulture/whitelists/unittest_whitelist.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | 3 | TestCase.setUp 4 | TestCase.tearDown 5 | TestCase.setUpClass 6 | TestCase.tearDownClass 7 | TestCase.run 8 | TestCase.skipTest 9 | TestCase.debug 10 | TestCase.failureException 11 | TestCase.longMessage 12 | TestCase.maxDiff 13 | TestCase.subTest 14 | 15 | mock.Mock.return_value 16 | mock.Mock.side_effect 17 | 18 | mock.MagicMock.return_value 19 | mock.MagicMock.side_effect 20 | -------------------------------------------------------------------------------- /vulture/whitelists/whitelist_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Vulture sometimes reports used code as unused. To avoid these 4 | false-positives, you can write a Python file that explicitly uses the 5 | code and pass it to vulture: 6 | 7 | vulture myscript.py mydir mywhitelist.py 8 | 9 | When creating a whitelist file, you have to make sure not to write code 10 | that hides unused code in other files. E.g., this is why we don't import 11 | and access the "sys" module below. If we did import it, vulture would 12 | not be able to detect whether other files import "sys" without using it. 13 | 14 | This file explicitly uses code from the Python standard library that is 15 | often incorrectly detected as unused. 16 | 17 | """ 18 | 19 | 20 | class Whitelist: 21 | """ 22 | Helper class that allows mocking Python objects. 23 | 24 | Use it to create whitelist files that are not only syntactically 25 | correct, but can also be executed. 26 | 27 | """ 28 | 29 | def __getattr__(self, _): 30 | pass 31 | 32 | 33 | assert Whitelist 34 | --------------------------------------------------------------------------------