├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.rst ├── examples └── tmp.py ├── pyproject.toml ├── requirements.txt └── uncalled ├── __init__.py ├── __main__.py ├── ast_finder.py ├── iterative.py ├── regex_finder.py ├── single_pass.py └── whitelist.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Upload Python Package 11 | 12 | on: 13 | push: 14 | tags: 15 | - 'v*' 16 | create: 17 | tags: 18 | - 'v*' 19 | 20 | jobs: 21 | build: 22 | name: Build distribution 📦 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.x" 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install build 35 | - name: Install pypa/build 36 | run: >- 37 | python3 -m 38 | pip install 39 | build 40 | --user 41 | - name: Build a binary wheel and a source tarball 42 | run: python3 -m build 43 | - name: Store the distribution packages 44 | uses: actions/upload-artifact@v3 45 | with: 46 | name: python-package-distributions 47 | path: dist/ 48 | # - name: Publish package 49 | # uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 50 | # with: 51 | # user: __token__ 52 | # password: ${{ secrets.PYPI_API_TOKEN }} 53 | publish-to-pypi: 54 | name: >- 55 | Publish Python 🐍 distribution 📦 to PyPI 56 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 57 | needs: 58 | - build 59 | runs-on: ubuntu-latest 60 | environment: 61 | name: release 62 | url: https://pypi.org/p/uncalled 63 | permissions: 64 | id-token: write # IMPORTANT: mandatory for trusted publishing 65 | steps: 66 | - name: Download all the dists 67 | uses: actions/download-artifact@v4.1.7 68 | with: 69 | name: python-package-distributions 70 | path: dist/ 71 | - name: Publish distribution 📦 to PyPI 72 | uses: pypa/gh-action-pypi-publish@release/v1 73 | github-release: 74 | name: >- 75 | Sign the Python 🐍 distribution 📦 with Sigstore 76 | and upload them to GitHub Release 77 | needs: 78 | - publish-to-pypi 79 | runs-on: ubuntu-latest 80 | 81 | permissions: 82 | contents: write # IMPORTANT: mandatory for making GitHub Releases 83 | id-token: write # IMPORTANT: mandatory for sigstore 84 | 85 | steps: 86 | - name: Download all the dists 87 | uses: actions/download-artifact@v4.1.7 88 | with: 89 | name: python-package-distributions 90 | path: dist/ 91 | - name: Sign the dists with Sigstore 92 | uses: sigstore/gh-action-sigstore-python@v2.1.1 93 | with: 94 | inputs: >- 95 | ./dist/*.tar.gz 96 | ./dist/*.whl 97 | - name: Create GitHub Release 98 | env: 99 | GITHUB_TOKEN: ${{ github.token }} 100 | run: >- 101 | gh release create 102 | '${{ github.ref_name }}' 103 | --repo '${{ github.repository }}' 104 | --notes "" 105 | - name: Upload artifact signatures to GitHub Release 106 | env: 107 | GITHUB_TOKEN: ${{ github.token }} 108 | # Upload to GitHub Release using the `gh` CLI. 109 | # `dist/` contains the built packages, and the 110 | # sigstore-produced signatures and certificates. 111 | run: >- 112 | gh release upload 113 | '${{ github.ref_name }}' dist/** 114 | --repo '${{ github.repository }}' 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | __pycache__/ 3 | dist/ 4 | *.egg-info/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Elazar Gershuni elazarg@gmail.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ``uncalled`` 2 | ============ 3 | 4 | Find unused functions in Python projects. 5 | 6 | 7 | This tool uses either regular expressions (the default) or AST traversal. 8 | The regular expressions are *fast* and has surprisingly few false-positives. 9 | To further reduce false positives, there is a combined mode ``both``. 10 | 11 | 12 | Usage 13 | ----- 14 | 15 | :: 16 | 17 | $ uncalled path/to/project 18 | 19 | for more options, see ``uncalled --help`` 20 | 21 | 22 | `vulture `_ is a similar package. 23 | -------------------------------------------------------------------------------- /examples/tmp.py: -------------------------------------------------------------------------------- 1 | def func1(): 2 | pass 3 | 4 | def func2(): 5 | pass 6 | 7 | dispatcher = {0:func1, 1:func2} 8 | dispatcher[0]() 9 | 10 | def func3(): 11 | """ 12 | def func4(): 13 | pass 14 | 15 | func3() 16 | :return: 17 | """ 18 | # func3() 19 | pass 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "uncalled" 7 | version = "0.1.8" 8 | authors = [ 9 | { name="Elazar Gershuni", email="elazarg@gmail.com" }, 10 | ] 11 | description = "Find unused functions in Python projects" 12 | readme = "README.md" 13 | requires-python = ">=3.10" 14 | license = { file = "LICENSE" } 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ] 20 | 21 | [project.urls] 22 | "Homepage" = "https://github.com/elazarg/uncalled" 23 | "Bug Tracker" = "https://github.com/elazarg/uncalled/issues" 24 | 25 | [project.scripts] 26 | uncalled = 'uncalled.__main__:main' 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elazarg/uncalled/925f8d19e0d2a8d3459c8b349a750197b0f1bd47/requirements.txt -------------------------------------------------------------------------------- /uncalled/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elazarg/uncalled/925f8d19e0d2a8d3459c8b349a750197b0f1bd47/uncalled/__init__.py -------------------------------------------------------------------------------- /uncalled/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from uncalled import single_pass 3 | from uncalled import ast_finder 4 | from uncalled import regex_finder 5 | 6 | 7 | def main(): 8 | parser = argparse.ArgumentParser( 9 | description="Find uncalled function in Python projects" 10 | ) 11 | parser.add_argument( 12 | "--how", 13 | choices=["ast", "regex", "both"], 14 | default="regex", 15 | help='technique to use. use "both" to reduce false positives [default: regex]', 16 | ) 17 | parser.add_argument("files", nargs="+", default=".", help="files to analyze") 18 | args = parser.parse_args() 19 | 20 | results_ast = set() 21 | if args.how in ["both", "ast"]: 22 | results_ast = single_pass.run(args.files, ast_finder.AstFinder) 23 | results_regex = set() 24 | if args.how in ["both", "regex"]: 25 | results_regex = single_pass.run(args.files, regex_finder.RegexFinder) 26 | if args.how == "both": 27 | result = results_ast & results_regex 28 | else: 29 | result = results_ast | results_regex 30 | single_pass.report(result) 31 | 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /uncalled/ast_finder.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | from pathlib import Path 4 | 5 | from uncalled.single_pass import Finder 6 | 7 | 8 | class AstFinder(Finder): 9 | def __init__(self, filename: Path, txt: str) -> None: 10 | super().__init__(filename, txt) 11 | self.collector = StoreLoadCollector() 12 | try: 13 | parsed = ast.parse(txt) 14 | self.collector.generic_visit(parsed) 15 | except SyntaxError: 16 | print("Note: cannot parse ast of ", filename, file=sys.stderr) 17 | pass 18 | 19 | def find_defs(self) -> set[str]: 20 | return set(self.collector.definitions) 21 | 22 | def find_uses(self) -> set[str]: 23 | return set(self.collector.references) 24 | 25 | def find_prefixes(self) -> set[str]: 26 | return set() 27 | 28 | 29 | class StoreLoadCollector(ast.NodeVisitor): 30 | def __init__(self) -> None: 31 | self.definitions: list[str] = [] 32 | self.references: list[str] = [] 33 | 34 | def visit_AsyncFunctionDef(self, fd: ast.AsyncFunctionDef) -> None: 35 | self.definitions.append(fd.name) 36 | self.generic_visit(fd) 37 | 38 | def visit_FunctionDef(self, fd: ast.FunctionDef) -> None: 39 | self.definitions.append(fd.name) 40 | self.generic_visit(fd) 41 | 42 | def visit_Attribute(self, attr: ast.Attribute) -> None: 43 | if isinstance(attr.ctx, ast.Store): 44 | pass 45 | else: 46 | self.references.append(attr.attr) 47 | self.generic_visit(attr.value) 48 | 49 | def visit_Name(self, name: ast.Name) -> None: 50 | if isinstance(name.ctx, ast.Store): 51 | pass 52 | else: 53 | self.references.append(name.id) 54 | 55 | def visit_Str(self, s: ast.Str) -> None: 56 | self.references.append(s.s) 57 | -------------------------------------------------------------------------------- /uncalled/iterative.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator, Iterable, TypeAlias 2 | from contextlib import contextmanager 3 | import ast 4 | from dataclasses import dataclass 5 | 6 | from uncalled import whitelist 7 | 8 | is_framework = whitelist.get_matcher() 9 | 10 | 11 | # TODO: import as alias, not use 12 | 13 | 14 | class Kind: 15 | MODULE = "module" 16 | CLASS = "class" 17 | FUNC = "function" 18 | NAME = "variable" 19 | 20 | 21 | @dataclass(frozen=True) 22 | class Namespace: 23 | kind: str 24 | name: str 25 | lineno: int 26 | 27 | 28 | XPath: TypeAlias = tuple[Namespace, ...] 29 | 30 | 31 | class Collector(ast.NodeVisitor): 32 | xpath: XPath 33 | definitions: list[XPath] 34 | references: list[XPath] 35 | 36 | def __init__(self) -> None: 37 | self.definitions = [] 38 | self.references = [] 39 | 40 | def visit_AsyncFunctionDef(self, fd: ast.AsyncFunctionDef): 41 | with self.enter_definition(Kind.FUNC, fd.name, fd.lineno): 42 | self.generic_visit(fd) 43 | 44 | def visit_FunctionDef(self, fd: ast.FunctionDef | ast.AsyncFunctionDef): 45 | with self.enter_definition(Kind.FUNC, fd.name, fd.lineno): 46 | self.generic_visit(fd) 47 | 48 | def visit_ClassDef(self, cd: ast.ClassDef): 49 | with self.enter_definition(Kind.CLASS, cd.name, cd.lineno): 50 | self.generic_visit(cd) 51 | 52 | @contextmanager 53 | def enter_definition(self, kind, name, lineno) -> Iterator[None]: 54 | if self.xpath[-1].kind is Kind.CLASS: 55 | name = "." + name 56 | self.xpath += (Namespace(kind, name, lineno),) 57 | self.definitions.append(self.xpath) 58 | yield 59 | self.xpath = self.xpath[:-1] 60 | 61 | def visit_Attribute(self, attr: ast.Attribute) -> None: 62 | name = attr.attr 63 | value = attr.value 64 | lineno = attr.lineno 65 | if isinstance(attr.ctx, ast.Store): 66 | if isinstance(value, ast.Name) and value.id == "self": 67 | namespace = Namespace(Kind.NAME, "." + name, lineno) 68 | self.definitions.append(self.xpath[:-1] + (namespace,)) 69 | self.visit(value) 70 | else: 71 | self.references.append(self.xpath + (Namespace(Kind.NAME, name, lineno),)) 72 | self.references.append( 73 | self.xpath + (Namespace(Kind.NAME, "." + name, lineno),) 74 | ) 75 | self.visit(value) 76 | 77 | def visit_Name(self, name: ast.Name) -> None: 78 | id = name.id 79 | if isinstance(name.ctx, ast.Store): 80 | with self.enter_definition(Kind.NAME, id, name.lineno): 81 | pass 82 | else: 83 | if self.xpath[-1].kind is Kind.CLASS: 84 | id = "." + id 85 | self.references.append( 86 | self.xpath + (Namespace(Kind.NAME, id, name.lineno),) 87 | ) 88 | 89 | # ImportFrom(identifier? module, alias* names, int? level) 90 | def visit_ImportFrom(self, imp: ast.ImportFrom) -> None: 91 | # TODO: handle aliases 92 | for alias in imp.names: 93 | if imp.module is None: 94 | self.references.append( 95 | ( 96 | Namespace(Kind.MODULE, alias.name, imp.lineno), 97 | Namespace(Kind.NAME, alias.name, imp.lineno), 98 | ) 99 | ) 100 | else: 101 | self.references.append( 102 | ( 103 | Namespace(Kind.MODULE, imp.module + ".py", imp.lineno), 104 | Namespace(Kind.NAME, alias.name, imp.lineno), 105 | ) 106 | ) 107 | 108 | 109 | def collect(filenames: Iterable[str]) -> tuple[list, list]: 110 | c = Collector() 111 | for module, filename in parse_modules(filenames): 112 | c.xpath = (Namespace(Kind.MODULE, filename, 0),) 113 | c.visit(module) 114 | return c.references, c.definitions 115 | 116 | 117 | def find_unused( 118 | all_references: list[XPath], all_definitions_paths: list[XPath] 119 | ) -> set[XPath]: 120 | references = set[str]() 121 | while True: 122 | new_references = { 123 | xpath[-1].name 124 | for xpath in all_references 125 | if is_reachable(xpath[:-1], references) 126 | } 127 | if new_references <= references: 128 | break 129 | references.update(new_references) 130 | all_references = [ 131 | xpath for xpath in all_references if xpath[-1].name not in references 132 | ] 133 | return { 134 | xpath for xpath in all_definitions_paths if not is_reachable(xpath, references) 135 | } 136 | 137 | 138 | def is_reachable(xpath: XPath, references: set[str]) -> bool: 139 | for item in xpath: 140 | if ( 141 | item.kind is not Kind.CLASS 142 | and item.kind is not Kind.MODULE 143 | and item.name not in references 144 | and not is_framework(item.name) 145 | ): 146 | return False 147 | return True 148 | 149 | 150 | def username_xpath(xpath: XPath) -> tuple[str, str]: 151 | x1, x2 = xpath[-2], xpath[-1] 152 | k1, k2 = x1.kind, x2.kind 153 | if k1 is Kind.CLASS: 154 | pair = x1.name + x2.name 155 | if k2 is Kind.CLASS: 156 | return "inner class", pair 157 | if k2 is Kind.FUNC: 158 | return "method", pair 159 | if k2 is Kind.NAME: 160 | return "attribute", pair 161 | else: 162 | assert False, str(k2) 163 | if k2 == Kind.NAME: 164 | return "variable", x2.name 165 | if k2 == Kind.FUNC: 166 | return "function", x2.name 167 | if k2 == Kind.CLASS: 168 | return "class", x2.name 169 | assert False, str(x2) 170 | 171 | 172 | def parse_modules(filenames: Iterable[str]) -> Iterator[tuple[ast.Module, str]]: 173 | for filename in filenames: 174 | with open(filename, encoding="utf-8") as f: 175 | source = f.read() 176 | try: 177 | module = ast.parse(source, filename=filename) 178 | except SyntaxError: 179 | from sys import stderr 180 | 181 | print("Could not parse " + filename, file=stderr) 182 | else: 183 | yield module, filename 184 | 185 | 186 | def print_unused(names: set[XPath]) -> None: 187 | for xpath in sorted(names): 188 | if xpath[-1].kind is Kind.CLASS and not whitelist.Flags.track_classes: 189 | continue 190 | if xpath[-1].kind is Kind.NAME and not whitelist.Flags.track_variables: 191 | continue 192 | kind, fullname = username_xpath(xpath) 193 | module, *_, file = xpath 194 | print(f"{module.name}:{file.lineno}: Unused {kind} '{fullname}'") 195 | 196 | 197 | def run(files: Iterable[str]) -> None: 198 | all_references, all_definitions_paths = collect(files) 199 | print_unused(find_unused(all_references, all_definitions_paths)) 200 | -------------------------------------------------------------------------------- /uncalled/regex_finder.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | from uncalled.single_pass import Finder 5 | 6 | 7 | def prepare(txt: str) -> str: 8 | # this is a hack to remove comments. It may remove too much, but rarely 9 | txt = re.sub(r"#[^\r\n]*", "", txt) 10 | 11 | return re.sub(r'"""[^\r]*?"""' + r"|'''[^\r]*?'''", "...", txt) 12 | 13 | 14 | class RegexFinder(Finder): 15 | def __init__(self, filename: Path, txt: str) -> None: 16 | super().__init__(filename, txt) 17 | self.txt = prepare(txt) 18 | 19 | def find_defs(self) -> set[str]: 20 | return set( 21 | [ 22 | x.strip()[4:] 23 | for x in re.findall(r"^\s+def [^\d\W]\w*", self.txt, re.MULTILINE) 24 | ] 25 | ) 26 | 27 | def find_uses(self) -> set[str]: 28 | return set(re.findall(r"(? set[str]: 31 | return set( 32 | re.findall(r"""(?<=['"])[a-z_]+(?=['"]\s*[+])""", self.txt, re.IGNORECASE) 33 | ) 34 | -------------------------------------------------------------------------------- /uncalled/single_pass.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing 3 | from pathlib import Path 4 | from typing import Iterable, Iterator 5 | 6 | from uncalled.whitelist import get_matcher 7 | 8 | is_framework = get_matcher("") 9 | 10 | 11 | class Finder(typing.Protocol): 12 | def __init__(self, filename: Path, txt: str) -> None: ... 13 | def find_defs(self) -> set[str]: ... 14 | 15 | def find_uses(self) -> set[str]: ... 16 | 17 | def find_prefixes(self) -> set[str]: ... 18 | 19 | 20 | def is_venv(filename: Path) -> bool: 21 | basename = os.path.basename(filename) 22 | return basename in ["venv", ".venv", "virtualenv", ".virtualenv"] 23 | 24 | 25 | def read_file(paths: Iterable[Path]) -> Iterator[tuple[Path, str]]: 26 | for path in paths: 27 | basename = path.name 28 | if basename.startswith("."): 29 | continue 30 | if path.is_file() and path.suffix == ".py": 31 | with open(path, encoding="utf-8") as f: 32 | yield (path, f.read()) 33 | elif path.is_dir(): 34 | if basename.startswith("__"): 35 | continue 36 | if is_venv(path): 37 | continue 38 | yield from read_file([path / f for f in os.listdir(path)]) 39 | 40 | 41 | def run( 42 | filenames: Iterable[str], make_finder: typing.Callable[[Path, str], Finder] 43 | ) -> set[tuple[Path, str]]: 44 | # normalize filenames 45 | file_paths = [Path(f) for f in filenames] 46 | file_text = dict(read_file(file_paths)) 47 | files = list(file_text.keys()) 48 | finders = {f: make_finder(f, txt) for f, txt in file_text.items()} 49 | file_defs = {f: finders[f].find_defs() for f, txt in file_text.items()} 50 | file_uses = {f: finders[f].find_uses() for f, txt in file_text.items()} 51 | file_pref = {f: finders[f].find_prefixes() for f, txt in file_text.items()} 52 | prefs = [p for f, prefs in file_pref.items() for p in prefs] 53 | uses = {call for calls in file_uses.values() for call in calls} 54 | file_unused_defs = { 55 | (file, name) 56 | for file in files 57 | for name in file_defs[file] - uses 58 | if not is_framework(name) and not any(name.startswith(p) for p in prefs) 59 | } 60 | return file_unused_defs 61 | 62 | 63 | def report(file_unused_defs: set[tuple[Path, str]]) -> None: 64 | for file, name in sorted(file_unused_defs): 65 | print("{}: Unused function {}".format(file, name)) 66 | -------------------------------------------------------------------------------- /uncalled/whitelist.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class Flags: 5 | include_strings = True 6 | ignore_underscored_methods = True 7 | ignore_underscored = True 8 | track_classes = False 9 | track_variables = True 10 | 11 | 12 | class Frameworks: 13 | ast = True 14 | pytest = True 15 | unittest = True 16 | flask = True 17 | 18 | 19 | def get_matcher(method_prefix=r'\.'): 20 | def methods(*items): 21 | return [method_prefix + x for x in items] 22 | 23 | prefixes = methods('__.+') 24 | if Frameworks.ast: 25 | prefixes += methods('generic_visit', 'visit_.+') 26 | if Frameworks.unittest: 27 | prefixes += ['tearDown', 'setUp'] 28 | if Frameworks.pytest: 29 | prefixes += ['test_.+', 'call', 'pytest_.*'] 30 | prefixes += methods('test_.+', 'runtest', 'run_test', 'set_up', 'setup', 'teardown', 'cases') 31 | if Frameworks.flask: 32 | prefixes += ['before_request', 'after_request', 'put', 'get', 'post', 'delete', 'patch', 33 | 'head', 'options', 'trace', 'route', 'errorhandler'] 34 | if Flags.ignore_underscored_methods: 35 | prefixes += methods('_.+') 36 | if Flags.ignore_underscored: 37 | prefixes.append('_.+') 38 | 39 | return re.compile('|'.join('({})'.format(p) for p in prefixes)).fullmatch 40 | --------------------------------------------------------------------------------