├── pyward ├── format │ ├── __init__.py │ └── formatter.py ├── security │ ├── __init__.py │ ├── rules │ │ ├── __init__.py │ │ ├── pickle_usage.py │ │ ├── hardcoded_secrets.py │ │ ├── yaml_load.py │ │ ├── weak_hashing_usage.py │ │ ├── exec_eval.py │ │ ├── subprocess_usage.py │ │ ├── python_json_logger.py │ │ ├── url_open_usage.py │ │ └── ssl_verification.py │ └── run.py ├── optimization │ ├── __init__.py │ ├── rules │ │ ├── __init__.py │ │ ├── sort_assignment.py │ │ ├── genexpr_vs_list.py │ │ ├── range_len_pattern.py │ │ ├── unreachable_code.py │ │ ├── append_in_loop.py │ │ ├── len_call_in_loop.py │ │ ├── open_without_context.py │ │ ├── set_comprehension.py │ │ ├── dict_comprehension.py │ │ ├── membership_on_list_in_loop.py │ │ ├── unused_imports.py │ │ ├── list_build_then_copy.py │ │ ├── deeply_nested_loops.py │ │ ├── string_concat_in_loop.py │ │ └── unused_variables.py │ └── run.py ├── __init__.py ├── fixer │ ├── __init__.py │ ├── fix_variables.py │ └── fix_imports.py ├── analyzer.py ├── rule_finder.py └── cli.py ├── tests ├── security │ ├── __init__.py │ ├── test_yaml_load_usage.py │ ├── test_pickle_usage.py │ ├── test_python_json_logger.py │ ├── test_hardcoded_secrets.py │ ├── test_url_open_usage.py │ ├── test_run_all_security_checks.py │ ├── test_exec_eval.py │ ├── test_subprocess_usage.py │ ├── test_weak_hashing_usage.py │ └── test_ssl_verification.py ├── optimization │ ├── __init__.py │ ├── test_append_in_loop.py │ ├── test_sort_assignment.py │ ├── test_run_all_optimization_checks.py │ ├── test_len_call_in_loop.py │ ├── test_set_comprehension.py │ ├── test_range_len_pattern.py │ ├── test_dict_comprehension.py │ ├── test_string_concat_in_loop.py │ ├── test_list_build_then_copy.py │ ├── test_open_without_context.py │ ├── test_genexpr_vs_list.py │ ├── test_deeply_nested_loops.py │ ├── test_membership_on_list_in_loop.py │ ├── test_unused_imports.py │ ├── test_unreachable_code.py │ ├── test_unused_variables.py │ └── test_fix_unused_variables.py ├── conftest.py ├── test_formatter.py ├── test_analyzer.py ├── test_rule_finder.py ├── test_fix_imports.py └── test_cli.py ├── MANIFEST.in ├── demo ├── security │ ├── yaml_load_usage.py │ ├── exec_eval_usage.py │ ├── hardcoded_secrets.py │ ├── subprocess_shell_true.py │ ├── pickle_usage.py │ ├── python_json_logger_import.py │ ├── url_open_usage.py │ ├── weak_hashing_usage.py │ └── ssl_verification_disabled.py └── optimization │ ├── genexpr_vs_list.py │ ├── open_without_context.py │ ├── set_comprehension.py │ ├── unused_variables.py │ ├── append_in_loop.py │ ├── len_call_in_loop.py │ ├── range_len_pattern.py │ ├── dict_comprehension.py │ ├── sort_assignment.py │ ├── membership_on_list_in_loop.py │ ├── string_concat_in_loop.py │ ├── list_build_then_copy.py │ ├── unreachable_code.py │ └── unused_imports.py ├── requirements.txt ├── .github └── workflows │ ├── release.yml │ ├── update-contributors.yml │ └── ci.yml ├── .gitignore ├── LICENSE ├── setup.py ├── CONTRIBUTING.md └── README.md /pyward/format/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyward/security/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/security/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyward/optimization/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyward/security/rules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/optimization/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyward/optimization/rules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyward/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.1" 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include all files within the pyward package 2 | graft pyward 3 | prune pyward/**/*.pyc -------------------------------------------------------------------------------- /demo/security/yaml_load_usage.py: -------------------------------------------------------------------------------- 1 | # triggers check_yaml_load_usage 2 | import yaml 3 | 4 | data = yaml.load(open("config.yaml")) 5 | -------------------------------------------------------------------------------- /demo/security/exec_eval_usage.py: -------------------------------------------------------------------------------- 1 | # triggers check_exec_eval_usage 2 | user_input = "print('hacked')" 3 | exec(user_input) 4 | eval("2 + 2") 5 | -------------------------------------------------------------------------------- /demo/security/hardcoded_secrets.py: -------------------------------------------------------------------------------- 1 | # triggers check_hardcoded_secrets 2 | api_key = "AKIAIOSFODNN7EXAMPLE" 3 | password = "supersecret" 4 | -------------------------------------------------------------------------------- /demo/security/subprocess_shell_true.py: -------------------------------------------------------------------------------- 1 | # triggers check_subprocess_usage 2 | import subprocess 3 | subprocess.run("ls -la", shell=True) 4 | -------------------------------------------------------------------------------- /demo/optimization/genexpr_vs_list.py: -------------------------------------------------------------------------------- 1 | # triggers check_genexpr_vs_list 2 | data = [1, 2, 3] 3 | total = sum([x * 2 for x in data]) 4 | print(total) 5 | -------------------------------------------------------------------------------- /demo/optimization/open_without_context.py: -------------------------------------------------------------------------------- 1 | # triggers check_open_without_context 2 | f = open("example.txt", "r") 3 | content = f.read() 4 | f.close() 5 | -------------------------------------------------------------------------------- /demo/optimization/set_comprehension.py: -------------------------------------------------------------------------------- 1 | # triggers check_set_comprehension 2 | s = set() 3 | for x in [1, 2, 3]: 4 | s.add(x * x) 5 | print(s) 6 | -------------------------------------------------------------------------------- /demo/optimization/unused_variables.py: -------------------------------------------------------------------------------- 1 | # triggers check_unused_variables 2 | def baz(): 3 | a = 10 4 | b = 20 # never used 5 | return a 6 | -------------------------------------------------------------------------------- /demo/security/pickle_usage.py: -------------------------------------------------------------------------------- 1 | # triggers check_pickle_usage 2 | import pickle 3 | 4 | with open("data.pkl", "rb") as f: 5 | obj = pickle.load(f) 6 | -------------------------------------------------------------------------------- /demo/optimization/append_in_loop.py: -------------------------------------------------------------------------------- 1 | # triggers check_append_in_loop 2 | result = [] 3 | for x in range(5): 4 | result.append(x * 2) 5 | print(result) 6 | -------------------------------------------------------------------------------- /demo/optimization/len_call_in_loop.py: -------------------------------------------------------------------------------- 1 | # triggers check_len_call_in_loop 2 | items = [1, 2, 3, 4] 3 | for i in range(len(items)): 4 | print(items[i]) 5 | -------------------------------------------------------------------------------- /demo/optimization/range_len_pattern.py: -------------------------------------------------------------------------------- 1 | # triggers check_range_len_pattern 2 | seq = ["x", "y", "z"] 3 | for i in range(len(seq)): 4 | print(seq[i]) 5 | -------------------------------------------------------------------------------- /demo/optimization/dict_comprehension.py: -------------------------------------------------------------------------------- 1 | # triggers check_dict_comprehension 2 | d = {} 3 | for k, v in [("a", 1), ("b", 2)]: 4 | d[k] = v * 10 5 | print(d) 6 | -------------------------------------------------------------------------------- /pyward/fixer/__init__.py: -------------------------------------------------------------------------------- 1 | from pyward.fixer.fix_imports import ImportFixer, ImportInfo 2 | 3 | __all__ = [ 4 | "ImportFixer", 5 | "ImportInfo" 6 | ] 7 | -------------------------------------------------------------------------------- /demo/optimization/sort_assignment.py: -------------------------------------------------------------------------------- 1 | # triggers check_sort_assignment 2 | lst = [3, 1, 2] 3 | sorted_lst = lst.sort() # sort() returns None 4 | print(sorted_lst) 5 | -------------------------------------------------------------------------------- /demo/security/python_json_logger_import.py: -------------------------------------------------------------------------------- 1 | # triggers check_python_json_logger_import 2 | import python_json_logger 3 | from python_json_logger import formatter 4 | -------------------------------------------------------------------------------- /demo/security/url_open_usage.py: -------------------------------------------------------------------------------- 1 | # triggers check_url_open_usage 2 | import urllib.request 3 | 4 | url = "http://example.com" 5 | resp = urllib.request.urlopen(url) 6 | -------------------------------------------------------------------------------- /demo/optimization/membership_on_list_in_loop.py: -------------------------------------------------------------------------------- 1 | # triggers check_membership_on_list_in_loop 2 | lst = [1, 2, 3, 4, 5] 3 | for x in range(10): 4 | if x in lst: 5 | print(x) 6 | -------------------------------------------------------------------------------- /demo/security/weak_hashing_usage.py: -------------------------------------------------------------------------------- 1 | # triggers check_weak_hashing_usage 2 | import hashlib 3 | 4 | h1 = hashlib.md5(b"hello").hexdigest() 5 | h2 = hashlib.sha1(b"world").hexdigest() 6 | -------------------------------------------------------------------------------- /demo/optimization/string_concat_in_loop.py: -------------------------------------------------------------------------------- 1 | # triggers check_string_concat_in_loop 2 | s = "" 3 | for c in ["a", "b", "c"]: 4 | s = s + c 5 | s += "-" # both patterns 6 | print(s) 7 | -------------------------------------------------------------------------------- /demo/optimization/list_build_then_copy.py: -------------------------------------------------------------------------------- 1 | # triggers check_list_build_then_copy 2 | tmp = [] 3 | for i in range(3): 4 | tmp.append(i * i) 5 | final = tmp[:] # slice copy 6 | print(final) 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | astroid==3.3.10 2 | colorama==0.4.6 3 | iniconfig==2.1.0 4 | packaging==25.0 5 | pluggy==1.6.0 6 | Pygments==2.19.1 7 | pytest==8.4.0 8 | python-json-logger==3.3.0 9 | PyYAML==6.0.2 10 | pandas 11 | -------------------------------------------------------------------------------- /demo/optimization/unreachable_code.py: -------------------------------------------------------------------------------- 1 | # triggers check_unreachable_code 2 | def bar(x): 3 | if x < 0: 4 | return "negative" 5 | print("this is never reached") 6 | raise ValueError("oops") 7 | x += 1 # unreachable 8 | -------------------------------------------------------------------------------- /demo/optimization/unused_imports.py: -------------------------------------------------------------------------------- 1 | # triggers check_unused_imports 2 | import os 3 | import sys 4 | from typing import ( 5 | List, 6 | Set, 7 | Tuple, 8 | ) 9 | 10 | def foo(): 11 | print(os.getcwd()) 12 | s: Set = set([]) 13 | -------------------------------------------------------------------------------- /demo/security/ssl_verification_disabled.py: -------------------------------------------------------------------------------- 1 | # triggers check_ssl_verification_disabled 2 | import requests 3 | 4 | requests.get("https://example.com", verify=False) 5 | session = requests.Session() 6 | session.post("https://api.example.com", verify=False) 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | # Insert the project root (one level up from tests/) into sys.path 5 | PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) 6 | if PROJECT_ROOT not in sys.path: 7 | sys.path.insert(0, PROJECT_ROOT) 8 | -------------------------------------------------------------------------------- /tests/security/test_yaml_load_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.security.rules.yaml_load import check_yaml_load_usage 4 | 5 | 6 | def test_flag_missing_safeloader(): 7 | src = "import yaml\nyaml.load(data)\nyaml.load(data, Loader=yaml.SafeLoader)\n" 8 | issues = check_yaml_load_usage(ast.parse(src)) 9 | assert len(issues) == 1 10 | assert "yaml.load() without SafeLoader" in issues[0] 11 | assert "Line 2" in issues[0] 12 | -------------------------------------------------------------------------------- /tests/security/test_pickle_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.security.rules.pickle_usage import check_pickle_usage 4 | 5 | 6 | def test_detect_load_and_loads(): 7 | src = "import pickle\npickle.load(f)\npickle.loads(b'')\n" 8 | issues = check_pickle_usage(ast.parse(src)) 9 | assert len(issues) == 2 10 | assert any("pickle.load()" in m and "Line 2" in m for m in issues) 11 | assert any("pickle.loads()" in m and "Line 3" in m for m in issues) 12 | -------------------------------------------------------------------------------- /tests/optimization/test_append_in_loop.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.append_in_loop import check_append_in_loop 4 | 5 | 6 | def test_detect_append(): 7 | src = "lst=[]\nfor x in [1]:\n lst.append(x)\n" 8 | issues = check_append_in_loop(ast.parse(src)) 9 | assert len(issues) == 1 10 | assert "Using list.append() inside a loop" in issues[0] 11 | 12 | 13 | def test_no_append_outside(): 14 | src = "lst=[]\nlst.append(1)\n" 15 | assert check_append_in_loop(ast.parse(src)) == [] 16 | -------------------------------------------------------------------------------- /tests/optimization/test_sort_assignment.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.sort_assignment import check_sort_assignment 4 | 5 | 6 | def test_detect_sort_assignment(): 7 | src = "lst=[3]; x=lst.sort()\n" 8 | issues = check_sort_assignment(ast.parse(src)) 9 | assert len(issues) == 1 10 | assert "Assignment of list.sort()" in issues[0] 11 | 12 | 13 | def test_no_sort_assignment(): 14 | src = "lst=[3]\nlst.sort()\nx=sorted(lst)\n" 15 | assert check_sort_assignment(ast.parse(src)) == [] 16 | -------------------------------------------------------------------------------- /tests/optimization/test_run_all_optimization_checks.py: -------------------------------------------------------------------------------- 1 | from pyward.optimization.run import run_all_optimization_checks 2 | 3 | 4 | def test_combined_rules(): 5 | src = "\n".join(["import sys", "x = 1", "def f(): return; y=2"]) 6 | issues = run_all_optimization_checks(src) 7 | assert any("Imported name 'sys' is never used" in m for m in issues) 8 | assert any("This code is unreachable" in m for m in issues) 9 | # may also include other rules—just ensure at least 2 issues found 10 | assert len(issues) >= 2 11 | -------------------------------------------------------------------------------- /tests/optimization/test_len_call_in_loop.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.len_call_in_loop import check_len_call_in_loop 4 | 5 | 6 | def test_detect_len_in_loop(): 7 | src = "lst=[1]\nfor x in lst:\n n = len(lst)\n" 8 | issues = check_len_call_in_loop(ast.parse(src)) 9 | assert len(issues) == 1 10 | assert "Call to len() inside loop" in issues[0] 11 | 12 | 13 | def test_no_len_outside_loop(): 14 | src = "lst=[1]\nn = len(lst)\n" 15 | assert check_len_call_in_loop(ast.parse(src)) == [] 16 | -------------------------------------------------------------------------------- /tests/optimization/test_set_comprehension.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.set_comprehension import check_set_comprehension 4 | 5 | 6 | def test_detect_set_comprehension(): 7 | src = "s=set()\nfor x in [1]: s.add(x)\n" 8 | issues = check_set_comprehension(ast.parse(src)) 9 | assert len(issues) == 1 10 | assert "Building set 's' via add()" in issues[0] 11 | 12 | 13 | def test_no_set_comprehension(): 14 | src = "s={x for x in [1]}\n" 15 | assert check_set_comprehension(ast.parse(src)) == [] 16 | -------------------------------------------------------------------------------- /tests/security/test_python_json_logger.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.security.rules.python_json_logger import \ 4 | check_python_json_logger_import 5 | 6 | 7 | def test_detect_import_and_from(): 8 | src = "import python_json_logger\nfrom python_json_logger import Foo\n" 9 | issues = check_python_json_logger_import(ast.parse(src)) 10 | assert len(issues) == 2 11 | assert all("CVE-2025-27607" in m for m in issues) 12 | assert any("Line 1" in m for m in issues) 13 | assert any("Line 2" in m for m in issues) 14 | -------------------------------------------------------------------------------- /tests/optimization/test_range_len_pattern.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.range_len_pattern import check_range_len_pattern 4 | 5 | 6 | def test_detect_range_len(): 7 | src = "a=[1,2]\nfor i in range(len(a)):\n pass\n" 8 | issues = check_range_len_pattern(ast.parse(src)) 9 | assert len(issues) == 1 10 | assert "Loop over 'range(len(...))'" in issues[0] 11 | 12 | 13 | def test_no_range_len(): 14 | src = "a=[1,2]\nfor i,val in enumerate(a):\n pass\n" 15 | assert check_range_len_pattern(ast.parse(src)) == [] 16 | -------------------------------------------------------------------------------- /tests/optimization/test_dict_comprehension.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.dict_comprehension import \ 4 | check_dict_comprehension 5 | 6 | 7 | def test_detect_dict_comprehension(): 8 | src = "d={}\nfor k,v in [(1,2)]: d[k]=v\n" 9 | issues = check_dict_comprehension(ast.parse(src)) 10 | assert len(issues) == 1 11 | assert "Building dict 'd' via loop assignment" in issues[0] 12 | 13 | 14 | def test_no_dict_comprehension(): 15 | src = "d={k:v for k,v in [(1,2)]}\n" 16 | assert check_dict_comprehension(ast.parse(src)) == [] 17 | -------------------------------------------------------------------------------- /tests/optimization/test_string_concat_in_loop.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.string_concat_in_loop import \ 4 | check_string_concat_in_loop 5 | 6 | 7 | def test_detect_concat(): 8 | src = "s = ''\nfor _ in [1]:\n s = s + 'a'\n" 9 | issues = check_string_concat_in_loop(ast.parse(src)) 10 | assert len(issues) == 1 11 | assert "String concatenation in loop for 's'" in issues[0] 12 | 13 | 14 | def test_no_concat_outside_loop(): 15 | src = "s = ''\ns = s + 'a'\n" 16 | assert check_string_concat_in_loop(ast.parse(src)) == [] 17 | -------------------------------------------------------------------------------- /tests/security/test_hardcoded_secrets.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.security.rules.hardcoded_secrets import check_hardcoded_secrets 4 | 5 | 6 | def test_detect_various_secrets(): 7 | src = "\n".join(["my_secret='s'", "not_key='ok'", "password_token='p'", "x=1"]) 8 | issues = check_hardcoded_secrets(ast.parse(src)) 9 | assert len(issues) == 3 10 | assert any("my_secret" in m and "Line 1" in m for m in issues) 11 | assert any("not_key" in m and "Line 2" in m for m in issues) 12 | assert any("password_token" in m and "Line 3" in m for m in issues) 13 | -------------------------------------------------------------------------------- /tests/optimization/test_list_build_then_copy.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.list_build_then_copy import \ 4 | check_list_build_then_copy 5 | 6 | 7 | def test_detect_build_then_copy(): 8 | src = "res=[]\nfor x in [1]: res.append(x)\ncopy=res[:]\n" 9 | issues = check_list_build_then_copy(ast.parse(src)) 10 | assert len(issues) == 1 11 | assert "List 'res' is built via append" in issues[0] 12 | 13 | 14 | def test_no_build_then_copy(): 15 | src = "copy=[x for x in [1]]\n" 16 | assert check_list_build_then_copy(ast.parse(src)) == [] 17 | -------------------------------------------------------------------------------- /tests/optimization/test_open_without_context.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.open_without_context import \ 4 | check_open_without_context 5 | 6 | 7 | def test_detect_open_without_context(): 8 | src = "f=open('f')\n" 9 | issues = check_open_without_context(ast.parse(src)) 10 | assert len(issues) == 1 11 | assert "Use of open() outside of a 'with' context manager" in issues[0] 12 | 13 | 14 | def test_no_open_without_context(): 15 | src = "with open('f') as f: data=f.read()\n" 16 | assert check_open_without_context(ast.parse(src)) == [] 17 | -------------------------------------------------------------------------------- /tests/security/test_url_open_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.security.rules.url_open_usage import check_url_open_usage 4 | 5 | 6 | def test_flag_dynamic_urlopen(): 7 | src = "import urllib.request\nurl=input()\nurllib.request.urlopen(url)\n" 8 | issues = check_url_open_usage(ast.parse(src)) 9 | assert len(issues) == 1 10 | assert "urlopen" in issues[0] and "Line 3" in issues[0] 11 | 12 | 13 | def test_no_flag_constant_url(): 14 | src = "import urllib.request\nurllib.request.urlopen('http://x')\n" 15 | assert check_url_open_usage(ast.parse(src)) == [] 16 | -------------------------------------------------------------------------------- /tests/security/test_run_all_security_checks.py: -------------------------------------------------------------------------------- 1 | from pyward.security.run import run_all_security_checks 2 | 3 | 4 | def test_combined_security_checks(): 5 | src = "\n".join( 6 | [ 7 | "import python_json_logger", 8 | "exec('x')", 9 | "import pickle\npickle.loads(b'')", 10 | ] 11 | ) 12 | issues = run_all_security_checks(src) 13 | assert any("python_json_logger" in m for m in issues) 14 | assert any("exec()" in m for m in issues) 15 | assert any("pickle.loads()" in m for m in issues) 16 | assert len(issues) >= 3 17 | -------------------------------------------------------------------------------- /tests/optimization/test_genexpr_vs_list.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.genexpr_vs_list import check_genexpr_vs_list 4 | 5 | 6 | def test_detect_genexpr_vs_list(): 7 | src = "total = sum([x for x in [1,2]])\n" 8 | issues = check_genexpr_vs_list(ast.parse(src)) 9 | assert len(issues) == 1 10 | # implementation emits "...sum() applied to list comprehension" 11 | assert "sum() applied to list comprehension" in issues[0] 12 | 13 | 14 | def test_no_genexpr_vs_list(): 15 | src = "total = sum(x for x in [1,2])\n" 16 | assert check_genexpr_vs_list(ast.parse(src)) == [] 17 | -------------------------------------------------------------------------------- /tests/security/test_exec_eval.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from colorama import Back, Fore, Style 4 | 5 | from pyward.security.rules.exec_eval import check_exec_eval_usage 6 | 7 | SEC = f"{Fore.WHITE}{Back.RED}[Security]{Style.RESET_ALL}" 8 | 9 | 10 | def test_detect_eval_and_exec(): 11 | src = "eval('2+2')\nexec('print(1)')\n" 12 | issues = check_exec_eval_usage(ast.parse(src)) 13 | assert len(issues) == 2 14 | assert any("eval()" in m and "Line 1" in m for m in issues) 15 | assert any("exec()" in m and "Line 2" in m for m in issues) 16 | for m in issues: 17 | assert m.startswith(SEC) 18 | -------------------------------------------------------------------------------- /pyward/format/formatter.py: -------------------------------------------------------------------------------- 1 | from colorama import Back, Fore, Style 2 | 3 | 4 | def format_security_warning(message: str, lineno: int, cve_id: str = "") -> str: 5 | cve_format = f"{Fore.RED}[{cve_id}]{Style.RESET_ALL}" if cve_id != "" else "" 6 | return ( 7 | f"{Fore.WHITE}{Back.RED}[Security]{Style.RESET_ALL}" 8 | f"{cve_format}" 9 | f" Line {lineno}: {message}" 10 | ) 11 | 12 | 13 | def format_optimization_warning(message: str, lineno: int) -> str: 14 | return ( 15 | f"{Fore.WHITE}{Back.YELLOW}[Optimization]{Style.RESET_ALL} " 16 | f"Line {lineno}: {message}" 17 | ) 18 | -------------------------------------------------------------------------------- /tests/optimization/test_deeply_nested_loops.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.deeply_nested_loops import \ 4 | check_deeply_nested_loops 5 | 6 | 7 | def test_detect_nested_loops(): 8 | src = "for _ in [1]:\n for _ in [1]:\n for _ in [1]: pass\n" 9 | issues = check_deeply_nested_loops(ast.parse(src), max_depth=2) 10 | assert len(issues) == 1 11 | assert "High complexity: loop nesting depth is 3" in issues[0] 12 | 13 | 14 | def test_no_nested_loops_within_limit(): 15 | src = "for _ in [1]:\n for _ in [1]: pass\n" 16 | assert check_deeply_nested_loops(ast.parse(src), max_depth=2) == [] 17 | -------------------------------------------------------------------------------- /tests/security/test_subprocess_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.security.rules.subprocess_usage import check_subprocess_usage 4 | 5 | 6 | def test_detect_all_shell_true_calls(): 7 | src = "\n".join( 8 | [ 9 | "import subprocess", 10 | "subprocess.run('ls', shell=True)", 11 | "subprocess.Popen([], shell=True)", 12 | "subprocess.call('x', shell=True)", 13 | "subprocess.check_output('x', shell=True)", 14 | ] 15 | ) 16 | issues = check_subprocess_usage(ast.parse(src)) 17 | assert len(issues) == 4 18 | assert all("shell=True" in m for m in issues) 19 | -------------------------------------------------------------------------------- /tests/optimization/test_membership_on_list_in_loop.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.optimization.rules.membership_on_list_in_loop import \ 4 | check_membership_on_list_in_loop 5 | 6 | 7 | def test_detect_membership_in_loop(): 8 | src = "lst=[1]\nfor x in [2]:\n if x in lst: pass\n" 9 | issues = check_membership_on_list_in_loop(ast.parse(src)) 10 | assert len(issues) == 1 11 | # implementation emits "...inside loop", not "inside a loop" 12 | assert "Membership test 'x in lst' inside loop" in issues[0] 13 | 14 | 15 | def test_no_membership_in_loop(): 16 | src = "if x in []: pass\n" 17 | assert check_membership_on_list_in_loop(ast.parse(src)) == [] 18 | -------------------------------------------------------------------------------- /tests/optimization/test_unused_imports.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from colorama import Back, Fore, Style 4 | 5 | from pyward.optimization.rules.unused_imports import check_unused_imports 6 | 7 | OPT = f"{Fore.WHITE}{Back.YELLOW}[Optimization]{Style.RESET_ALL}" 8 | 9 | 10 | def test_single_unused(): 11 | src = "import os\nimport sys\nprint(os.getcwd())\n" 12 | issues = check_unused_imports(ast.parse(src)) 13 | assert len(issues) == 1 14 | msg = issues[0] 15 | assert msg.startswith(OPT) 16 | assert "Imported name 'sys' is never used" in msg 17 | 18 | 19 | def test_no_unused(): 20 | src = "import math\nx = math.pi\n" 21 | assert check_unused_imports(ast.parse(src)) == [] 22 | -------------------------------------------------------------------------------- /tests/optimization/test_unreachable_code.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from colorama import Back, Fore, Style 4 | 5 | from pyward.optimization.rules.unreachable_code import check_unreachable_code 6 | 7 | OPT = f"{Fore.WHITE}{Back.YELLOW}[Optimization]{Style.RESET_ALL}" 8 | 9 | 10 | def test_function_level_unreachable(): 11 | src = "def f():\n return\n x = 1\n" 12 | issues = check_unreachable_code(ast.parse(src)) 13 | assert len(issues) == 1 14 | assert "This code is unreachable." in issues[0] 15 | 16 | 17 | def test_module_level_unreachable(): 18 | src = "x = 1\nraise Exception()\ny = 2\n" 19 | issues = check_unreachable_code(ast.parse(src)) 20 | assert len(issues) == 1 21 | assert "This code is unreachable." in issues[0] 22 | -------------------------------------------------------------------------------- /tests/optimization/test_unused_variables.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from colorama import Back, Fore, Style 4 | 5 | from pyward.optimization.rules.unused_variables import check_unused_variables 6 | 7 | OPT = f"{Fore.WHITE}{Back.YELLOW}[Optimization]{Style.RESET_ALL}" 8 | 9 | 10 | def test_detect_unused_variable(): 11 | src = "a = 1\nb = 2\nprint(a)\n" 12 | issues = check_unused_variables(ast.parse(src)) 13 | assert len(issues) == 1 14 | msg = issues[0] 15 | assert msg.startswith(OPT) 16 | assert "Variable 'b' is assigned but never used" in msg 17 | 18 | 19 | def test_ignore_underscore(): 20 | src = "_x = 5\nprint(_x)\ny = 10\n" 21 | issues = check_unused_variables(ast.parse(src)) 22 | assert len(issues) == 1 23 | assert "Variable 'y' is assigned but never used" in issues[0] 24 | -------------------------------------------------------------------------------- /tests/security/test_weak_hashing_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | from pyward.security.rules.weak_hashing_usage import check_weak_hashing_usage 4 | 5 | 6 | def test_detect_md5_sha1_only(): 7 | src = "\n".join( 8 | [ 9 | "import hashlib", 10 | "hashlib.md5(b'')", 11 | "hashlib.sha1(b'')", 12 | "hashlib.sha256(b'')", 13 | ] 14 | ) 15 | issues = check_weak_hashing_usage(ast.parse(src)) 16 | assert len(issues) == 2 17 | assert any("hashlib.md5()" in m and "Line 2" in m for m in issues) 18 | assert any("hashlib.sha1()" in m and "Line 3" in m for m in issues) 19 | 20 | 21 | def test_ignore_usedforsecurity_false(): 22 | src = "import hashlib\nhashlib.md5(b'', usedforsecurity=False)\n" 23 | assert check_weak_hashing_usage(ast.parse(src)) == [] 24 | -------------------------------------------------------------------------------- /pyward/optimization/rules/sort_assignment.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_sort_assignment(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class SortVisitor(ast.NodeVisitor): 11 | def visit_Assign(self, node: ast.Assign): 12 | if ( 13 | isinstance(node.value, ast.Call) 14 | and isinstance(node.value.func, ast.Attribute) 15 | and node.value.func.attr == "sort" 16 | ): 17 | issues.append( 18 | format_optimization_warning( 19 | "Assignment of list.sort() which returns None. Use sorted(list) instead.", 20 | node.lineno, 21 | ) 22 | ) 23 | self.generic_visit(node) 24 | 25 | SortVisitor().visit(tree) 26 | return issues 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build-and-publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out repository 11 | uses: actions/checkout@v4 12 | 13 | - name: Set up Python 3.x 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.x" 17 | 18 | - name: Install build tools 19 | run: | 20 | python -m pip install --upgrade pip build twine 21 | 22 | - name: Build source and wheel distributions 23 | run: | 24 | python -m build 25 | 26 | - name: Publish to PyPI 27 | env: 28 | TWINE_USERNAME: __token__ 29 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 30 | run: | 31 | python -m twine upload dist/* 32 | 33 | - name: Success message 34 | if: ${{ success() }} 35 | run: echo "✅ Build and publish to PyPI succeeded." 36 | -------------------------------------------------------------------------------- /pyward/optimization/rules/genexpr_vs_list.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_genexpr_vs_list(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | GENFUNCS = {"sum", "any", "all", "max", "min"} 10 | 11 | class GenVisitor(ast.NodeVisitor): 12 | def visit_Call(self, node): 13 | if isinstance(node.func, ast.Name) and node.func.id in GENFUNCS: 14 | arg = node.args[0] if node.args else None 15 | if isinstance(arg, ast.ListComp): 16 | issues.append( 17 | format_optimization_warning( 18 | f"{node.func.id}() applied to list comprehension. Consider using a generator expression.", 19 | node.lineno, 20 | ) 21 | ) 22 | self.generic_visit(node) 23 | 24 | GenVisitor().visit(tree) 25 | return issues 26 | -------------------------------------------------------------------------------- /pyward/security/rules/pickle_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_pickle_usage(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class PickleVisitor(ast.NodeVisitor): 11 | def visit_Call(self, node): 12 | if ( 13 | isinstance(node.func, ast.Attribute) 14 | and isinstance(node.func.value, ast.Name) 15 | and node.func.value.id == "pickle" 16 | and node.func.attr in ("load", "loads") 17 | ): 18 | issues.append( 19 | format_security_warning( 20 | "Use of pickle.%s() detected. Untrusted pickle can lead to RCE." 21 | % node.func.attr, 22 | node.lineno, 23 | ) 24 | ) 25 | self.generic_visit(node) 26 | 27 | PickleVisitor().visit(tree) 28 | return issues 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # Virtual environments 29 | env/ 30 | venv/ 31 | ENV/ 32 | env.bak/ 33 | venv.bak/ 34 | 35 | # IDE files 36 | .vscode/ 37 | .idea/ 38 | *.iml 39 | *.ipr 40 | *.iws 41 | *.swp 42 | *.swo 43 | .DS_Store 44 | 45 | # Testing artifacts 46 | .coverage 47 | htmlcov/ 48 | .pytest_cache/ 49 | 50 | # Logs and databases 51 | *.log 52 | *.sqlite3 53 | 54 | # Runtime files 55 | *.pid 56 | *.sock 57 | 58 | # Jupyter Notebooks 59 | .ipynb_checkpoints 60 | 61 | # Environment files 62 | .env 63 | *.env 64 | 65 | # Local config files 66 | config.ini 67 | secrets.json 68 | 69 | # Personal/Temporary files 70 | tmp/ 71 | *.tmp 72 | 73 | # PyWard-specific (if any temp or output files) 74 | output/ 75 | reports/ 76 | -------------------------------------------------------------------------------- /pyward/optimization/rules/range_len_pattern.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_range_len_pattern(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | for node in ast.walk(tree): 11 | if isinstance(node, ast.For) and isinstance(node.iter, ast.Call): 12 | fn, args = node.iter.func, node.iter.args 13 | if isinstance(fn, ast.Name) and fn.id == "range" and len(args) == 1: 14 | inner = args[0] 15 | if ( 16 | isinstance(inner, ast.Call) 17 | and isinstance(inner.func, ast.Name) 18 | and inner.func.id == "len" 19 | ): 20 | issues.append( 21 | format_optimization_warning( 22 | "Loop over 'range(len(...))'. Consider using 'enumerate()' instead.", 23 | node.lineno, 24 | ) 25 | ) 26 | return issues 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Karan Vasudevamurthy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyward/fixer/fix_variables.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List, Dict, Set, Tuple, Optional 3 | import re 4 | from dataclasses import dataclass 5 | 6 | 7 | @dataclass 8 | class VariableAssignment: 9 | node: ast.AST 10 | names: List[str] 11 | lineno: int 12 | end_lineno: Optional[int] 13 | col_offset: int 14 | end_col_offset: Optional[int] 15 | is_multiple: bool = False 16 | line_text: str = "" 17 | 18 | 19 | class VariableFixer: 20 | def __init__(self, source_code): 21 | self.source_code = source_code 22 | self.tree = ast.parse(source_code) 23 | self.lines = source_code.splitlines() 24 | self.assignments = [] 25 | self.unused_vars = set() 26 | self._collect_assignments() 27 | self._find_unused_variables() 28 | 29 | def _collect_assignments(self): 30 | pass 31 | 32 | def _find_unused_variables(self): 33 | pass 34 | 35 | def fix(self): 36 | for var in self.unused_vars: 37 | pattern = fr"\b{re.escape(var)}\s*=.*\n" 38 | self.source_code = re.sub(pattern, "", self.source_code) 39 | return self.source_code 40 | -------------------------------------------------------------------------------- /pyward/security/rules/hardcoded_secrets.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_hardcoded_secrets(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class SecretsVisitor(ast.NodeVisitor): 11 | def visit_Assign(self, node): 12 | if ( 13 | len(node.targets) == 1 14 | and isinstance(node.targets[0], ast.Name) 15 | and isinstance(node.value, ast.Constant) 16 | and isinstance(node.value.value, str) 17 | ): 18 | var = node.targets[0].id.lower() 19 | if any( 20 | k in var for k in ("key", "secret", "password", "token", "passwd") 21 | ): 22 | issues.append( 23 | format_security_warning( 24 | f"Hard-coded secret in '{node.targets[0].id}'. Use env vars or vault.", 25 | node.lineno, 26 | ) 27 | ) 28 | self.generic_visit(node) 29 | 30 | SecretsVisitor().visit(tree) 31 | return issues 32 | -------------------------------------------------------------------------------- /pyward/security/rules/yaml_load.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_yaml_load_usage(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class YAMLVisitor(ast.NodeVisitor): 11 | def visit_Call(self, node): 12 | if ( 13 | isinstance(node.func, ast.Attribute) 14 | and isinstance(node.func.value, ast.Name) 15 | and node.func.value.id == "yaml" 16 | and node.func.attr == "load" 17 | ): 18 | safe = any( 19 | kw.arg == "Loader" and getattr(kw.value, "attr", "") == "SafeLoader" 20 | for kw in node.keywords 21 | ) 22 | if not safe: 23 | issues.append( 24 | format_security_warning( 25 | "Use of yaml.load() without SafeLoader. Risk of code execution.", 26 | node.lineno, 27 | ) 28 | ) 29 | self.generic_visit(node) 30 | 31 | YAMLVisitor().visit(tree) 32 | return issues 33 | -------------------------------------------------------------------------------- /pyward/analyzer.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.optimization.run import run_all_optimization_checks 5 | from pyward.security.run import run_all_security_checks 6 | 7 | 8 | def analyze_file( 9 | filepath: str, 10 | run_optimization: bool = True, 11 | run_security: bool = True, 12 | verbose: bool = False, 13 | ) -> List[str]: 14 | """ 15 | Parse filepath into AST and source text, then run: 16 | - optimization checks on the source text 17 | - security checks on the AST 18 | Returns a list of formatted issue strings. 19 | """ 20 | with open(filepath, "r", encoding="utf-8") as f: 21 | source = f.read() 22 | 23 | try: 24 | tree = ast.parse(source, filename=filepath) 25 | except SyntaxError as se: 26 | return [f"SyntaxError while parsing {filepath}: {se}"] 27 | 28 | issues: List[str] = [] 29 | 30 | if run_optimization: 31 | issues.extend(run_all_optimization_checks(source)) 32 | 33 | if run_security: 34 | issues.extend(run_all_security_checks(tree)) 35 | 36 | if verbose and not issues: 37 | issues.append("Verbose: no issues found, but checks were performed.") 38 | 39 | return issues 40 | -------------------------------------------------------------------------------- /pyward/security/rules/weak_hashing_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_weak_hashing_usage(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class HashVisitor(ast.NodeVisitor): 11 | def visit_Call(self, node): 12 | if ( 13 | isinstance(node.func, ast.Attribute) 14 | and isinstance(node.func.value, ast.Name) 15 | and node.func.value.id == "hashlib" 16 | and node.func.attr in ("md5", "sha1") 17 | ): 18 | # ignore usedforsecurity=False 19 | if not any( 20 | kw.arg == "usedforsecurity" and not kw.value.value 21 | for kw in node.keywords 22 | ): 23 | issues.append( 24 | format_security_warning( 25 | f"Use of hashlib.{node.func.attr}(). Consider sha256 or stronger.", 26 | node.lineno, 27 | ) 28 | ) 29 | self.generic_visit(node) 30 | 31 | HashVisitor().visit(tree) 32 | return issues 33 | -------------------------------------------------------------------------------- /pyward/optimization/rules/unreachable_code.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_unreachable_code(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | def _check_body(body): 11 | unreachable = False 12 | for node in body: 13 | if unreachable: 14 | issues.append( 15 | format_optimization_warning( 16 | "This code is unreachable.", node.lineno 17 | ) 18 | ) 19 | if hasattr(node, "body"): 20 | _check_body(node.body) 21 | continue 22 | if isinstance(node, (ast.Return, ast.Raise, ast.Break, ast.Continue)): 23 | unreachable = True 24 | for sect in ( 25 | getattr(node, "body", []) 26 | + getattr(node, "orelse", []) 27 | + getattr(node, "finalbody", []) 28 | ): 29 | _check_body([sect]) 30 | 31 | _check_body(tree.body) 32 | for node in tree.body: 33 | if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): 34 | _check_body(node.body) 35 | return issues 36 | -------------------------------------------------------------------------------- /pyward/security/rules/exec_eval.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_exec_eval_usage(tree: ast.AST) -> List[str]: 8 | """ 9 | Flag any direct usage of `exec(...)` or `eval(...)` as a security risk. 10 | References: 11 | - CVE-2025-3248 (Langflow AI): abusing `exec` for unauthenticated RCE. 12 | - General best practice: avoid eval()/exec() on untrusted data. 13 | """ 14 | issues: List[str] = [] 15 | 16 | class ExecEvalVisitor(ast.NodeVisitor): 17 | def visit_Call(self, node: ast.Call): 18 | if isinstance(node.func, ast.Name) and node.func.id in ("exec", "eval"): 19 | issues.append( 20 | format_security_warning( 21 | f"Use of '{node.func.id}()' detected. " 22 | "This can lead to code injection (e.g. CVE-2025-3248). " 23 | "Consider safer alternatives (e.g., ast.literal_eval) or explicit parsing.", 24 | node.lineno, 25 | "CVE-2025-3248", 26 | ) 27 | ) 28 | self.generic_visit(node) 29 | 30 | ExecEvalVisitor().visit(tree) 31 | return issues 32 | -------------------------------------------------------------------------------- /pyward/optimization/rules/append_in_loop.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_append_in_loop(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class AppendVisitor(ast.NodeVisitor): 11 | def __init__(self): 12 | self.in_loop = False 13 | 14 | def visit_For(self, node): 15 | prev, self.in_loop = self.in_loop, True 16 | self.generic_visit(node) 17 | self.in_loop = prev 18 | 19 | def visit_While(self, node): 20 | prev, self.in_loop = self.in_loop, True 21 | self.generic_visit(node) 22 | self.in_loop = prev 23 | 24 | def visit_Call(self, node): 25 | if ( 26 | self.in_loop 27 | and isinstance(node.func, ast.Attribute) 28 | and node.func.attr == "append" 29 | ): 30 | issues.append( 31 | format_optimization_warning( 32 | "Using list.append() inside a loop. Consider using a list comprehension.", 33 | node.lineno, 34 | ) 35 | ) 36 | self.generic_visit(node) 37 | 38 | AppendVisitor().visit(tree) 39 | return issues 40 | -------------------------------------------------------------------------------- /pyward/optimization/rules/len_call_in_loop.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_len_call_in_loop(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class LenVisitor(ast.NodeVisitor): 11 | def __init__(self): 12 | self.in_loop = False 13 | 14 | def visit_For(self, node): 15 | prev, self.in_loop = self.in_loop, True 16 | self.generic_visit(node) 17 | self.in_loop = prev 18 | 19 | def visit_While(self, node): 20 | prev, self.in_loop = self.in_loop, True 21 | self.generic_visit(node) 22 | self.in_loop = prev 23 | 24 | def visit_Call(self, node): 25 | if ( 26 | self.in_loop 27 | and isinstance(node.func, ast.Name) 28 | and node.func.id == "len" 29 | ): 30 | issues.append( 31 | format_optimization_warning( 32 | "Call to len() inside loop. Consider storing the length in a variable before the loop.", 33 | node.lineno, 34 | ) 35 | ) 36 | self.generic_visit(node) 37 | 38 | LenVisitor().visit(tree) 39 | return issues 40 | -------------------------------------------------------------------------------- /pyward/optimization/rules/open_without_context.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_open_without_context(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class OpenVisitor(ast.NodeVisitor): 11 | def __init__(self): 12 | self.in_with = False 13 | 14 | def visit_With(self, node): 15 | prev, self.in_with = self.in_with, True 16 | self.generic_visit(node) 17 | self.in_with = prev 18 | 19 | def visit_AsyncWith(self, node): 20 | prev, self.in_with = self.in_with, True 21 | self.generic_visit(node) 22 | self.in_with = prev 23 | 24 | def visit_Call(self, node): 25 | if ( 26 | isinstance(node.func, ast.Name) 27 | and node.func.id == "open" 28 | and not self.in_with 29 | ): 30 | issues.append( 31 | format_optimization_warning( 32 | "Use of open() outside of a 'with' context manager. Consider using 'with open(...) as f:'.", 33 | node.lineno, 34 | ) 35 | ) 36 | self.generic_visit(node) 37 | 38 | OpenVisitor().visit(tree) 39 | return issues 40 | -------------------------------------------------------------------------------- /pyward/optimization/rules/set_comprehension.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_set_comprehension(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class SetVisitor(ast.NodeVisitor): 11 | def __init__(self): 12 | self.in_loop = False 13 | 14 | def visit_For(self, node): 15 | prev, self.in_loop = self.in_loop, True 16 | self.generic_visit(node) 17 | self.in_loop = prev 18 | 19 | def visit_While(self, node): 20 | prev, self.in_loop = self.in_loop, True 21 | self.generic_visit(node) 22 | self.in_loop = prev 23 | 24 | def visit_Call(self, node): 25 | if ( 26 | self.in_loop 27 | and isinstance(node.func, ast.Attribute) 28 | and node.func.attr == "add" 29 | ): 30 | sname = node.func.value.id # type: ignore 31 | issues.append( 32 | format_optimization_warning( 33 | f"Building set '{sname}' via add() in loop. Consider using set comprehension.", 34 | node.lineno, 35 | ) 36 | ) 37 | self.generic_visit(node) 38 | 39 | SetVisitor().visit(tree) 40 | return issues 41 | -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from colorama import Back, Fore, Style 3 | 4 | from pyward.format.formatter import (format_optimization_warning, 5 | format_security_warning) 6 | 7 | OPTIMIZATION_COLOR = f"{Fore.WHITE}{Back.YELLOW}" 8 | SECURITY_COLOR = f"{Fore.WHITE}{Back.RED}" 9 | CVE_COLOR = f"{Fore.RED}" 10 | OPTIMIZATION_LABEL = f"{OPTIMIZATION_COLOR}[Optimization]{Style.RESET_ALL}" 11 | SECURITY_LABEL = f"{SECURITY_COLOR}[Security]{Style.RESET_ALL}" 12 | 13 | 14 | def test_format_security_warning_with_cve_id(): 15 | msg = "Unsafe eval usage detected." 16 | lineno = 42 17 | cve_id = "CVE-2023-12345" 18 | warning = format_security_warning(msg, lineno, cve_id) 19 | assert warning == ( 20 | f"{SECURITY_LABEL}{CVE_COLOR}[{cve_id}]{Style.RESET_ALL} Line {lineno}: {msg}" 21 | ) 22 | 23 | 24 | def test_format_security_warning_without_cve_id(): 25 | msg = "Unsafe eval usage detected." 26 | lineno = 42 27 | warning = format_security_warning(msg, lineno) 28 | assert warning == (f"{SECURITY_LABEL} Line {lineno}: {msg}") 29 | 30 | 31 | def test_format_optimization_warning(): 32 | msg = "Unused import detected." 33 | lineno = 10 34 | warning = format_optimization_warning(msg, lineno) 35 | assert warning == (f"{OPTIMIZATION_LABEL} Line {lineno}: {msg}") 36 | 37 | 38 | if __name__ == "__main__": 39 | pytest.main() 40 | -------------------------------------------------------------------------------- /pyward/security/rules/subprocess_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_subprocess_usage(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class SubprocessVisitor(ast.NodeVisitor): 11 | def visit_Call(self, node): 12 | if isinstance(node.func, ast.Attribute): 13 | attr = node.func 14 | if ( 15 | isinstance(attr.value, ast.Name) 16 | and attr.value.id == "subprocess" 17 | and attr.attr in ("run", "Popen", "call", "check_output") 18 | ): 19 | for kw in node.keywords: 20 | if ( 21 | kw.arg == "shell" 22 | and isinstance(kw.value, ast.Constant) 23 | and kw.value.value 24 | ): 25 | issues.append( 26 | format_security_warning( 27 | f"Use of subprocess.{attr.attr}() with shell=True. Risk of shell injection.", 28 | node.lineno, 29 | ) 30 | ) 31 | self.generic_visit(node) 32 | 33 | SubprocessVisitor().visit(tree) 34 | return issues 35 | -------------------------------------------------------------------------------- /pyward/optimization/rules/dict_comprehension.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_dict_comprehension(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class DictVisitor(ast.NodeVisitor): 11 | def __init__(self): 12 | self.in_loop = False 13 | 14 | def visit_For(self, node): 15 | prev, self.in_loop = self.in_loop, True 16 | self.generic_visit(node) 17 | self.in_loop = prev 18 | 19 | def visit_While(self, node): 20 | prev, self.in_loop = self.in_loop, True 21 | self.generic_visit(node) 22 | self.in_loop = prev 23 | 24 | def visit_Assign(self, node): 25 | if ( 26 | self.in_loop 27 | and len(node.targets) == 1 28 | and isinstance(node.targets[0], ast.Subscript) 29 | ): 30 | dname = node.targets[0].value.id # type: ignore 31 | issues.append( 32 | format_optimization_warning( 33 | f"Building dict '{dname}' via loop assignment. Consider using dict comprehension.", 34 | node.lineno, 35 | ) 36 | ) 37 | self.generic_visit(node) 38 | 39 | DictVisitor().visit(tree) 40 | return issues 41 | -------------------------------------------------------------------------------- /.github/workflows/update-contributors.yml: -------------------------------------------------------------------------------- 1 | name: Add contributors 2 | 3 | on: 4 | schedule: 5 | - cron: '20 20 * * *' # Runs daily at 8:20 PM UTC 6 | push: 7 | branches: 8 | - main # Trigger on pushes to your 'main' branch 9 | workflow_dispatch: # Allows you to manually trigger the workflow 10 | 11 | jobs: 12 | add-contributors: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write # Grant write access so the workflow can update README.md 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 # Uses the latest version of the checkout action 19 | 20 | - name: Add Contributors to README.md 21 | uses: BobAnkh/add-contributors@master # The action to add contributors 22 | with: 23 | CONTRIBUTOR: '### Contributors' # The exact heading the action will look for in your README.md 24 | COLUMN_PER_ROW: '6' # Number of contributor avatars per row 25 | ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Automatically provided token for authentication 26 | IMG_WIDTH: '100' # Width of the contributor avatar images 27 | FONT_SIZE: '14' # Font size for contributor names 28 | PATH: 'README.md' # The file where the contributors list will be inserted 29 | COMMIT_MESSAGE: 'docs(README): update contributors [skip ci]' # The commit message for the update, [skip ci] prevents re-triggering other workflows 30 | AVATAR_SHAPE: 'round' # Shape of the contributor avatars 31 | -------------------------------------------------------------------------------- /pyward/optimization/rules/membership_on_list_in_loop.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_membership_on_list_in_loop(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class MemVisitor(ast.NodeVisitor): 11 | def __init__(self): 12 | self.in_loop = False 13 | 14 | def visit_For(self, node): 15 | prev, self.in_loop = self.in_loop, True 16 | self.generic_visit(node) 17 | self.in_loop = prev 18 | 19 | def visit_While(self, node): 20 | prev, self.in_loop = self.in_loop, True 21 | self.generic_visit(node) 22 | self.in_loop = prev 23 | 24 | def visit_Compare(self, node): 25 | if self.in_loop: 26 | for op, comp in zip(node.ops, node.comparators): 27 | if isinstance(op, (ast.In, ast.NotIn)) and isinstance( 28 | comp, ast.Name 29 | ): 30 | expr = ast.unparse(node) 31 | issues.append( 32 | format_optimization_warning( 33 | f"Membership test '{expr}' inside loop. Consider converting '{comp.id}' to set for faster lookups.", 34 | node.lineno, 35 | ) 36 | ) 37 | self.generic_visit(node) 38 | 39 | MemVisitor().visit(tree) 40 | return issues 41 | -------------------------------------------------------------------------------- /pyward/optimization/rules/unused_imports.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List, Tuple 3 | 4 | from pyward.fixer.fix_imports import ImportFixer, ImportInfo 5 | from pyward.format.formatter import format_optimization_warning 6 | 7 | 8 | def check_unused_imports(tree: ast.AST) -> List[str]: 9 | issues: List[str] = [] 10 | imported_names: Set[str] = set() 11 | import_nodes: List[Tuple[str, int]] = [] 12 | 13 | for node in ast.walk(tree): 14 | if isinstance(node, ast.Import): 15 | for alias in node.names: 16 | name = (alias.asname or alias.name).split(".")[0] 17 | imported_names.add(name) 18 | import_nodes.append((name, node.lineno)) 19 | elif isinstance(node, ast.ImportFrom): 20 | for alias in node.names: 21 | name = alias.asname or alias.name 22 | imported_names.add(name) 23 | import_nodes.append((name, node.lineno)) 24 | 25 | if not imported_names: 26 | return issues 27 | 28 | used_names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)} 29 | for name, lineno in import_nodes: 30 | if name not in used_names: 31 | issues.append( 32 | format_optimization_warning( 33 | f"Imported name '{name}' is never used.", lineno 34 | ) 35 | ) 36 | return issues 37 | 38 | 39 | def fix_unused_imports(source: str) -> Tuple[bool, str, List[str]]: 40 | fixer = ImportFixer(source) 41 | return fixer.fix() 42 | -------------------------------------------------------------------------------- /pyward/optimization/rules/list_build_then_copy.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import Dict, List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_list_build_then_copy(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | empties: Dict[str, int] = {} 10 | 11 | class BuildVisitor(ast.NodeVisitor): 12 | def visit_Assign(self, node: ast.Assign): 13 | if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): 14 | name = node.targets[0].id 15 | if isinstance(node.value, ast.List) and not node.value.elts: 16 | empties[name] = node.lineno 17 | if ( 18 | isinstance(node.value, ast.Subscript) 19 | and isinstance(node.value.value, ast.Name) 20 | and isinstance(node.value.slice, ast.Slice) 21 | and node.value.slice.lower is None 22 | and node.value.slice.upper is None 23 | ): 24 | src = node.value.value.id 25 | if src in empties: 26 | issues.append( 27 | format_optimization_warning( 28 | f"List '{src}' is built via append then copied with slice. Consider using a list comprehension.", 29 | node.lineno, 30 | ) 31 | ) 32 | self.generic_visit(node) 33 | 34 | BuildVisitor().visit(tree) 35 | return issues 36 | -------------------------------------------------------------------------------- /pyward/security/rules/python_json_logger.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_python_json_logger_import(tree: ast.AST) -> List[str]: 8 | """ 9 | Flag any import of 'python_json_logger' (vulnerable to CVE-2025-27607). 10 | """ 11 | issues: List[str] = [] 12 | 13 | for node in ast.walk(tree): 14 | if isinstance(node, ast.Import): 15 | for alias in node.names: 16 | if alias.name.startswith("python_json_logger"): 17 | issues.append( 18 | format_security_warning( 19 | "'python_json_logger' import detected. " 20 | "This package was vulnerable to RCE (CVE-2025-27607). " 21 | "Update to a patched version or remove this dependency.", 22 | node.lineno, 23 | "CVE-2025-27607", 24 | ) 25 | ) 26 | elif ( 27 | isinstance(node, ast.ImportFrom) 28 | and node.module 29 | and node.module.startswith("python_json_logger") 30 | ): 31 | issues.append( 32 | format_security_warning( 33 | "'from python_json_logger import ...' detected. " 34 | "This package was vulnerable to RCE (CVE-2025-27607). " 35 | "Update to a patched version or remove this dependency.", 36 | node.lineno, 37 | "CVE-2025-27607", 38 | ) 39 | ) 40 | 41 | return issues 42 | -------------------------------------------------------------------------------- /pyward/optimization/rules/deeply_nested_loops.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_deeply_nested_loops(tree: ast.AST, max_depth: int = 2) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class NestVisitor(ast.NodeVisitor): 11 | def __init__(self): 12 | self.depth = 0 13 | 14 | def visit_FunctionDef(self, node): 15 | prev, self.depth = self.depth, 0 16 | self.generic_visit(node) 17 | self.depth = prev 18 | 19 | def visit_AsyncFunctionDef(self, node): 20 | prev, self.depth = self.depth, 0 21 | self.generic_visit(node) 22 | self.depth = prev 23 | 24 | def visit_For(self, node): 25 | self.depth += 1 26 | if self.depth > max_depth: 27 | issues.append( 28 | format_optimization_warning( 29 | f"High complexity: loop nesting depth is {self.depth}.", 30 | node.lineno, 31 | ) 32 | ) 33 | self.generic_visit(node) 34 | self.depth -= 1 35 | 36 | def visit_While(self, node): 37 | self.depth += 1 38 | if self.depth > max_depth: 39 | issues.append( 40 | format_optimization_warning( 41 | f"High complexity: loop nesting depth is {self.depth}.", 42 | node.lineno, 43 | ) 44 | ) 45 | self.generic_visit(node) 46 | self.depth -= 1 47 | 48 | NestVisitor().visit(tree) 49 | return issues 50 | -------------------------------------------------------------------------------- /pyward/security/rules/url_open_usage.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_url_open_usage(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class URLVisitor(ast.NodeVisitor): 11 | def visit_Call(self, node): 12 | # urllib.request.urlopen 13 | if isinstance(node.func, ast.Attribute): 14 | f = node.func 15 | if ( 16 | isinstance(f.value, ast.Attribute) 17 | and isinstance(f.value.value, ast.Name) 18 | and f.value.value.id == "urllib" 19 | and f.value.attr == "request" 20 | and f.attr == "urlopen" 21 | ): 22 | if not node.args or not isinstance(node.args[0], ast.Constant): 23 | issues.append( 24 | format_security_warning( 25 | "Dynamic URL to urllib.request.urlopen(). Validate/sanitize first.", 26 | node.lineno, 27 | ) 28 | ) 29 | # urllib3.PoolManager().request 30 | if f.attr == "request" and len(node.args) >= 2: 31 | if not isinstance(node.args[1], ast.Constant): 32 | issues.append( 33 | format_security_warning( 34 | "Dynamic URL to urllib3.PoolManager().request(). Validate/sanitize first.", 35 | node.lineno, 36 | ) 37 | ) 38 | self.generic_visit(node) 39 | 40 | URLVisitor().visit(tree) 41 | return issues 42 | -------------------------------------------------------------------------------- /pyward/optimization/run.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import importlib 3 | import pkgutil 4 | from os import path 5 | from typing import List, Tuple 6 | 7 | 8 | def run_all_optimization_checks(source_code: str, skip: List[str] = None) -> List[str]: 9 | skip = set(skip or []) 10 | tree = ast.parse(source_code) 11 | issues: List[str] = [] 12 | 13 | pkg = importlib.import_module(f"{__package__}.rules") 14 | prefix = pkg.__name__ + "." 15 | for _, mod_name, _ in pkgutil.iter_modules(pkg.__path__, prefix): 16 | mod = importlib.import_module(mod_name) 17 | for fn_name in dir(mod): 18 | if not fn_name.startswith("check_") or fn_name in skip: 19 | continue 20 | fn = getattr(mod, fn_name) 21 | if callable(fn): 22 | issues.extend(fn(tree)) 23 | 24 | return issues 25 | 26 | 27 | def run_all_optimization_fixes( 28 | source_code: str, skip: List[str] = None 29 | ) -> Tuple[bool, str, List[str]]: 30 | """fix code with fixable optimization rules, return fix flag and fixed code""" 31 | skip_set = set(skip or []) 32 | 33 | pkg = importlib.import_module(f"{__package__}.rules") 34 | prefix = pkg.__name__ + "." 35 | current_source = source_code 36 | file_ever_changed = False 37 | all_fixes = [] 38 | for _, mod_name, _ in pkgutil.iter_modules(pkg.__path__, prefix): 39 | mod = importlib.import_module(mod_name) 40 | rule_name = path.basename(str(mod.__file__))[0:-3] 41 | for fn_name in dir(mod): 42 | fix_fn_name = "fix_" + rule_name 43 | check_fn_name = "check_" + rule_name 44 | if fn_name != fix_fn_name or check_fn_name in skip_set: 45 | continue 46 | fix_fn = getattr(mod, fix_fn_name) 47 | if callable(fix_fn): 48 | file_changed, current_source, fixes = fix_fn(source_code) 49 | file_ever_changed = file_changed or file_ever_changed 50 | all_fixes.extend(fixes) 51 | 52 | return (file_ever_changed, current_source, all_fixes) 53 | -------------------------------------------------------------------------------- /pyward/security/run.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import importlib 3 | import pkgutil 4 | from os import path 5 | from typing import List, Tuple 6 | 7 | 8 | def run_all_security_checks(source_code: str, skip: List[str] = None) -> List[str]: 9 | """ 10 | Dynamically imports every module in pyward.security.rules 11 | and runs all functions prefixed with `check_`, unless in skip. 12 | """ 13 | skip = set(skip or []) 14 | tree = ast.parse(source_code) 15 | issues: List[str] = [] 16 | 17 | pkg = importlib.import_module(f"{__package__}.rules") 18 | prefix = pkg.__name__ + "." 19 | for _, mod_name, _ in pkgutil.iter_modules(pkg.__path__, prefix): 20 | mod = importlib.import_module(mod_name) 21 | for attr in dir(mod): 22 | if not attr.startswith("check_") or attr in skip: 23 | continue 24 | fn = getattr(mod, attr) 25 | if callable(fn): 26 | issues.extend(fn(tree)) 27 | 28 | return issues 29 | 30 | 31 | def run_all_security_fixes( 32 | source_code: str, skip: List[str] = None 33 | ) -> Tuple[bool, str, List[str]]: 34 | """fix code with fixable security rules, return fix flag and fixed code""" 35 | skip_set = set(skip or []) 36 | 37 | pkg = importlib.import_module(f"{__package__}.rules") 38 | prefix = pkg.__name__ + "." 39 | current_source = source_code 40 | file_ever_changed = False 41 | all_fixes = [] 42 | for _, mod_name, _ in pkgutil.iter_modules(pkg.__path__, prefix): 43 | mod = importlib.import_module(mod_name) 44 | rule_name = path.basename(str(mod.__file__))[0:-3] 45 | for fn_name in dir(mod): 46 | fix_fn_name = "fix_" + rule_name 47 | check_fn_name = "check_" + rule_name 48 | if fn_name != fix_fn_name or check_fn_name in skip_set: 49 | continue 50 | fix_fn = getattr(mod, fix_fn_name) 51 | if callable(fix_fn): 52 | file_changed, current_source, fixes = fix_fn(source_code) 53 | file_ever_changed = file_changed or file_ever_changed 54 | all_fixes.extend(fixes) 55 | 56 | return (file_ever_changed, current_source, all_fixes) 57 | -------------------------------------------------------------------------------- /tests/test_analyzer.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import tempfile 4 | 5 | import pytest 6 | 7 | from pyward.analyzer import analyze_file 8 | 9 | 10 | # A small fixture to write a temp file 11 | @pytest.fixture 12 | def temp_file(): 13 | d = tempfile.mkdtemp() 14 | path = os.path.join(d, "f.py") 15 | yield path, d 16 | os.remove(path) 17 | os.rmdir(d) 18 | 19 | 20 | def test_analyze_empty_file(temp_file): 21 | path, d = temp_file 22 | open(path, "w").close() 23 | issues = analyze_file(path) 24 | assert issues == [] 25 | 26 | 27 | def test_analyze_syntax_error(temp_file): 28 | path, d = temp_file 29 | with open(path, "w") as f: 30 | f.write("def bad(\n") 31 | issues = analyze_file(path) 32 | assert len(issues) == 1 33 | assert "SyntaxError while parsing" in issues[0] 34 | 35 | 36 | def test_analyze_optimization_and_security(temp_file): 37 | path, d = temp_file 38 | content = "import os\nexec('x')\n" 39 | with open(path, "w") as f: 40 | f.write(content) 41 | issues = analyze_file(path) 42 | # should flag unused-import + exec 43 | assert any("Imported name 'os' is never used" in m for m in issues) 44 | assert any("Use of 'exec()' detected" in m for m in issues) 45 | assert len(issues) == 2 46 | 47 | 48 | def test_verbose_mode(temp_file): 49 | path, d = temp_file 50 | with open(path, "w") as f: 51 | f.write("print(1)\n") 52 | issues = analyze_file( 53 | path, verbose=True, run_optimization=False, run_security=False 54 | ) 55 | assert issues == ["Verbose: no issues found, but checks were performed."] 56 | 57 | 58 | def test_disable_each_runner(temp_file): 59 | path, d = temp_file 60 | with open(path, "w") as f: 61 | f.write("import os\nexec('x')\n") 62 | # only optimization 63 | issues = analyze_file(path, run_optimization=True, run_security=False) 64 | assert any("Imported name 'os'" in m for m in issues) 65 | assert not any("exec()" in m for m in issues) 66 | # only security 67 | issues = analyze_file(path, run_optimization=False, run_security=True) 68 | assert any("exec()" in m for m in issues) 69 | assert not any("os" in m for m in issues) 70 | -------------------------------------------------------------------------------- /pyward/optimization/rules/string_concat_in_loop.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_optimization_warning 5 | 6 | 7 | def check_string_concat_in_loop(tree: ast.AST) -> List[str]: 8 | issues: List[str] = [] 9 | 10 | class ConcatVisitor(ast.NodeVisitor): 11 | def __init__(self): 12 | self.in_loop = False 13 | 14 | def visit_For(self, node): 15 | prev, self.in_loop = self.in_loop, True 16 | self.generic_visit(node) 17 | self.in_loop = prev 18 | 19 | def visit_While(self, node): 20 | prev, self.in_loop = self.in_loop, True 21 | self.generic_visit(node) 22 | self.in_loop = prev 23 | 24 | def visit_Assign(self, node): 25 | if ( 26 | self.in_loop 27 | and len(node.targets) == 1 28 | and isinstance(node.targets[0], ast.Name) 29 | ): 30 | name = node.targets[0].id 31 | val = node.value 32 | if isinstance(val, ast.BinOp) and isinstance(val.op, ast.Add): 33 | if isinstance(val.left, ast.Name) and val.left.id == name: 34 | issues.append( 35 | format_optimization_warning( 36 | f"String concatenation in loop for '{name}'. Consider using ''.join() or appending to a list.", 37 | node.lineno, 38 | ) 39 | ) 40 | self.generic_visit(node) 41 | 42 | def visit_AugAssign(self, node): 43 | if ( 44 | self.in_loop 45 | and isinstance(node.op, ast.Add) 46 | and isinstance(node.target, ast.Name) 47 | ): 48 | issues.append( 49 | format_optimization_warning( 50 | f"Augmented assignment '{node.target.id} += ...' in loop. Consider using ''.join() or appending to a list.", 51 | node.lineno, 52 | ) 53 | ) 54 | self.generic_visit(node) 55 | 56 | ConcatVisitor().visit(tree) 57 | return issues 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import pathlib 3 | 4 | HERE = pathlib.Path(__file__).parent 5 | README = (HERE / "README.md").read_text(encoding="utf-8") 6 | 7 | def get_version(): 8 | version_file = HERE / "pyward" / "__init__.py" 9 | for line in version_file.read_text().splitlines(): 10 | if line.startswith("__version__"): 11 | delim = '"' if '"' in line else "'" 12 | return line.split(delim)[1] 13 | raise RuntimeError("Version not found") 14 | 15 | setup( 16 | name="pyward-cli", 17 | version=get_version(), 18 | description="A CLI linter for Python that flags optimization and security issues", 19 | long_description=README, 20 | long_description_content_type="text/markdown", 21 | author="Karan Vasudevamurthy", 22 | author_email="karanlvm123@gmail.com", 23 | url="https://github.com/karanlvm/pyward-cli", 24 | project_urls={ 25 | "Source": "https://github.com/karanlvm/pyward-cli", 26 | "Documentation": "https://github.com/karanlvm/pyward-cli#readme", 27 | "Issue Tracker": "https://github.com/karanlvm/pyward-cli/issues", 28 | }, 29 | license="MIT", 30 | keywords="python lint cli security optimization", 31 | packages=find_packages(), python_requires=">=3.7", 32 | include_package_data=True, 33 | install_requires=[ 34 | "colorama>=0.4.6", 35 | "pandas>=2.3.0,<3.0.0", 36 | ], 37 | tests_require=[ 38 | "pytest>=8.0.0", 39 | ], 40 | entry_points={ 41 | "console_scripts": [ 42 | "pyward=pyward.cli:main", 43 | ], 44 | }, 45 | classifiers=[ 46 | # Who your project is for 47 | "Development Status :: 4 - Beta", 48 | "Intended Audience :: Developers", 49 | "Topic :: Software Development :: Quality Assurance", 50 | 51 | # Supported Python versions 52 | "Programming Language :: Python :: 3", 53 | "Programming Language :: Python :: 3.7", 54 | "Programming Language :: Python :: 3.8", 55 | "Programming Language :: Python :: 3.9", 56 | "Programming Language :: Python :: 3.10", 57 | 58 | # License 59 | "License :: OSI Approved :: MIT License", 60 | 61 | # Operating systems 62 | "Operating System :: OS Independent", 63 | ], 64 | ) 65 | -------------------------------------------------------------------------------- /pyward/security/rules/ssl_verification.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List 3 | 4 | from pyward.format.formatter import format_security_warning 5 | 6 | 7 | def check_ssl_verification_disabled(tree: ast.AST) -> List[str]: 8 | """ 9 | Flag any use of the requests library or its Session() with verify=False, 10 | which disables SSL certificate verification and exposes you to MITM attacks. 11 | Recommendation: Enable SSL verification or provide a custom CA bundle. 12 | """ 13 | issues: List[str] = [] 14 | 15 | class SSLVerificationVisitor(ast.NodeVisitor): 16 | def visit_Call(self, node: ast.Call): 17 | # Did someone explicitly pass verify=False? 18 | verify_false = any( 19 | kw.arg == "verify" 20 | and isinstance(kw.value, ast.Constant) 21 | and kw.value.value is False 22 | for kw in node.keywords 23 | ) 24 | if not verify_false: 25 | return self.generic_visit(node) 26 | 27 | func = node.func 28 | # Only flag HTTP methods and generic .request calls 29 | if isinstance(func, ast.Attribute) and func.attr in ( 30 | "get", 31 | "post", 32 | "put", 33 | "delete", 34 | "head", 35 | "options", 36 | "patch", 37 | "request", 38 | ): 39 | # Build a message prefix that mentions the exact call 40 | call_name = ( 41 | f"{func.value.id}.{func.attr}()" 42 | if isinstance(func.value, ast.Name) 43 | else f"{func.attr}()" 44 | ) 45 | issues.append( 46 | format_security_warning( 47 | f"Use of {call_name} with verify=False detected. " 48 | "Disabling certificate verification exposes users to " 49 | "man-in-the-middle attacks. " 50 | "Recommendation: Enable SSL verification or provide a " 51 | "custom CA bundle instead.", 52 | node.lineno, 53 | ) 54 | ) 55 | 56 | # continue walking 57 | self.generic_visit(node) 58 | 59 | SSLVerificationVisitor().visit(tree) 60 | return issues 61 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI / Pytest and PR Comment 2 | 3 | # Run this workflow whenever a PR is opened, synchronized, or reopened 4 | on: 5 | pull_request_target: 6 | types: [opened, synchronize, reopened] 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: write 11 | 12 | jobs: 13 | test-and-comment: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout the *fork’s* code for testing 18 | uses: actions/checkout@v4 19 | with: 20 | # By default, pull_request_target checks out the base repo. 21 | # We want to check out the head (fork) so pytest runs on the contributor’s code: 22 | ref: ${{ github.event.pull_request.head.sha }} 23 | repository: ${{ github.event.pull_request.head.repo.full_name }} 24 | 25 | - name: Set up Python 3.10 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: "3.10" 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install pytest pytest-cov colorama 34 | 35 | - name: Run pytest and capture output 36 | id: pytest 37 | continue-on-error: true 38 | run: | 39 | pytest --cov=./ --cov-report=term-missing > pytest_output.txt 40 | 41 | - name: Comment test results on PR 42 | if: always() 43 | uses: actions/github-script@v7 44 | with: 45 | github-token: ${{ secrets.GITHUB_TOKEN }} 46 | script: | 47 | const fs = require('fs'); 48 | const output = fs.readFileSync('pytest_output.txt', 'utf8'); 49 | const testOutcome = '${{ steps.pytest.outcome }}'; 50 | const statusEmoji = testOutcome === 'success' ? '✅' : '❌'; 51 | const statusTitle = testOutcome === 'success' ? 'All tests passed' : 'Some tests failed'; 52 | 53 | const body = [ 54 | `### ${statusEmoji} Pytest Results: ${statusTitle}`, 55 | "", 56 | "
Click to see full test and coverage report", 57 | "", 58 | "```term", 59 | output, 60 | "```", 61 | "
" 62 | ].join("\n"); 63 | 64 | await github.rest.issues.createComment({ 65 | owner: context.repo.owner, 66 | repo: context.repo.repo, 67 | issue_number: context.issue.number, 68 | body 69 | }); 70 | -------------------------------------------------------------------------------- /tests/security/test_ssl_verification.py: -------------------------------------------------------------------------------- 1 | # tests/test_ssl_verification_disabled.py 2 | 3 | import ast 4 | 5 | import pytest 6 | 7 | from pyward.security.rules.ssl_verification import \ 8 | check_ssl_verification_disabled 9 | 10 | 11 | def _parse_source(src: str) -> ast.AST: 12 | return ast.parse(src) 13 | 14 | 15 | def test_requests_methods_with_verify_false_are_flagged(): 16 | source = """ 17 | import requests 18 | requests.get("https://example.com", verify=False) 19 | requests.post("https://example.com", data=data, verify=False) 20 | requests.put("https://api.example.com/resource", json=data, verify=False) 21 | """ 22 | tree = _parse_source(source) 23 | issues = check_ssl_verification_disabled(tree) 24 | 25 | # One issue per call 26 | assert len(issues) == 3 27 | assert any("requests.get()" in msg and "Line 3" in msg for msg in issues) 28 | assert any("requests.post()" in msg and "Line 4" in msg for msg in issues) 29 | assert any("requests.put()" in msg and "Line 5" in msg for msg in issues) 30 | 31 | 32 | def test_requests_request_call_with_verify_false_is_flagged(): 33 | source = """ 34 | import requests 35 | response = requests.request('GET', "https://example.com", verify=False) 36 | """ 37 | tree = _parse_source(source) 38 | issues = check_ssl_verification_disabled(tree) 39 | 40 | assert len(issues) == 1 41 | assert "requests.request()" in issues[0] 42 | assert "Line 3" in issues[0] 43 | 44 | 45 | def test_session_method_with_verify_false_is_flagged(): 46 | source = """ 47 | import requests 48 | session = requests.Session() 49 | response = session.get("https://example.com", verify=False) 50 | """ 51 | tree = _parse_source(source) 52 | issues = check_ssl_verification_disabled(tree) 53 | 54 | assert len(issues) == 1 55 | # generic warning for non-requests.* call 56 | assert "verify=False" in issues[0] 57 | assert "man-in-the-middle attacks" in issues[0] 58 | assert "Line 4" in issues[0] 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "call", 63 | [ 64 | 'requests.get("https://example.com")', 65 | 'requests.post("https://example.com", data=data, verify=True)', 66 | 'session.get("https://example.com")', 67 | ], 68 | ) 69 | def test_no_issues_when_verify_true_or_omitted(call): 70 | source = f""" 71 | import requests 72 | session = requests.Session() 73 | _ = {call} 74 | """ 75 | tree = _parse_source(source) 76 | issues = check_ssl_verification_disabled(tree) 77 | assert issues == [] 78 | -------------------------------------------------------------------------------- /tests/optimization/test_fix_unused_variables.py: -------------------------------------------------------------------------------- 1 | from pyward.optimization.rules.unused_variables import fix_unused_variables 2 | 3 | 4 | def test_fix_simple_unused_variable(): 5 | # Simple case: remove an unused variable completely 6 | src = "a = 1\nb = 2\nprint(a)\n" 7 | changed, fixed, fixes = fix_unused_variables(src) 8 | 9 | assert changed is True 10 | assert "b = 2" not in fixed 11 | assert "a = 1" in fixed 12 | assert "print(a)" in fixed 13 | assert len(fixes) == 1 14 | assert "Removed unused variable 'b'" in fixes[0] 15 | 16 | 17 | def test_fix_multiple_assignment(): 18 | # Test fixing variables in tuple unpacking 19 | src = "a, b = 1, 2\nprint(a)\n" 20 | changed, fixed, fixes = fix_unused_variables(src) 21 | 22 | assert changed is True 23 | assert "a, _" in fixed 24 | assert "print(a)" in fixed 25 | 26 | 27 | def test_fix_all_unused(): 28 | # Test when all variables in an assignment are unused 29 | src = "x, y = get_values()\nz = 42\n" 30 | changed, fixed, fixes = fix_unused_variables(src) 31 | 32 | assert changed is True 33 | assert "x, y = get_values()" not in fixed 34 | assert "z = 42" not in fixed 35 | assert len(fixes) == 3 36 | 37 | 38 | def test_fix_for_loop(): 39 | # Test fixing variables in for loop targets 40 | src = "for i, val in enumerate(items):\n print(i)\n" 41 | changed, fixed, fixes = fix_unused_variables(src) 42 | 43 | assert changed is True 44 | assert "for i, _" in fixed 45 | assert "print(i)" in fixed 46 | 47 | 48 | def test_fix_function_parameters(): 49 | # Test fixing unused function parameters 50 | src = "def func(a, b, c):\n return a + c\n" 51 | changed, fixed, fixes = fix_unused_variables(src) 52 | 53 | assert changed is True 54 | assert "def func(a, _, c)" in fixed 55 | assert "return a + c" in fixed 56 | 57 | 58 | def test_fix_nested_structure(): 59 | # Test fixing variables in nested structures 60 | src = """def outer(): 61 | x = 10 62 | y = 20 63 | 64 | def inner(a, b): 65 | print(a) 66 | return x 67 | 68 | return inner(1, 2) + y 69 | """ 70 | changed, fixed, fixes = fix_unused_variables(src) 71 | 72 | assert changed is True 73 | assert "def inner(a, _)" in fixed 74 | assert "print(a)" in fixed 75 | assert "y = 20" in fixed 76 | assert "x = 10" in fixed 77 | 78 | 79 | def test_no_changes_when_no_unused_variables(): 80 | src = "a = 1\nprint(a)\n" 81 | changed, fixed, fixes = fix_unused_variables(src) 82 | 83 | assert changed is False 84 | assert fixed == src 85 | assert len(fixes) == 0 86 | 87 | 88 | def test_fix_preserves_indentation(): 89 | src = """def func(): 90 | if True: 91 | x = 1 92 | y = 2 93 | print(x) 94 | """ 95 | changed, fixed, fixes = fix_unused_variables(src) 96 | 97 | assert changed is True 98 | assert "y = 2" not in fixed 99 | assert " x = 1" in fixed 100 | assert " print(x)" in fixed 101 | -------------------------------------------------------------------------------- /tests/test_rule_finder.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import tempfile 3 | import textwrap 4 | from pathlib import Path 5 | from typing import List 6 | from unittest import mock 7 | 8 | import pytest 9 | 10 | from pyward.rule_finder import extract_function_info, find_rule_files 11 | 12 | 13 | def test_extract_function_info_basic_warning(): 14 | code = textwrap.dedent( 15 | """ 16 | def example(): 17 | format_optimization_warning("This is an optimization issue.", 42, "CVE-1234") 18 | """ 19 | ) 20 | with tempfile.NamedTemporaryFile( 21 | suffix=".py", mode="w+", delete=False 22 | ) as temp_file: 23 | temp_file.write(code) 24 | temp_file.flush() 25 | temp_path = temp_file.name 26 | 27 | result = extract_function_info(temp_path) 28 | assert len(result) == 1 29 | assert "optimization issue" in result[0][1] 30 | 31 | 32 | def test_extract_function_info_no_warning(): 33 | code = textwrap.dedent( 34 | """ 35 | def example(): 36 | print("This is a normal print.") 37 | """ 38 | ) 39 | with tempfile.NamedTemporaryFile( 40 | suffix=".py", mode="w+", delete=False 41 | ) as temp_file: 42 | temp_file.write(code) 43 | temp_file.flush() 44 | temp_path = temp_file.name 45 | 46 | result = extract_function_info(temp_path) 47 | assert result == [] 48 | 49 | 50 | @pytest.fixture 51 | def mock_rule_package(tmp_path): 52 | """ 53 | Creates a fake rule package with two Python files for testing. 54 | """ 55 | mock_package = tmp_path / "pyward" / "optimization" / "rules" 56 | mock_package.mkdir(parents=True, exist_ok=True) 57 | 58 | file1 = mock_package / "rule1.py" 59 | file1.write_text( 60 | "def f():\n" 61 | ' format_optimization_warning("Unused import.", 12, "CVE-1234")\n' 62 | ) 63 | 64 | file2 = mock_package / "rule2.py" 65 | file2.write_text("def f():\n" " pass\n") 66 | 67 | init_file = mock_package / "__init__.py" 68 | init_file.write_text("") 69 | 70 | return mock_package, [file1.name] 71 | 72 | 73 | def test_find_rule_files(monkeypatch, mock_rule_package): 74 | mock_path, expected_files = mock_rule_package 75 | 76 | class DummyFiles: 77 | def __init__(self, path: Path): 78 | self._path = path 79 | 80 | def glob(self, pattern): 81 | return list(self._path.glob(pattern)) 82 | 83 | class DummyResources: 84 | @staticmethod 85 | def files(package_path): 86 | return DummyFiles(mock_path) 87 | 88 | @staticmethod 89 | def as_file(path): 90 | # context manager returning the path directly 91 | class Context: 92 | def __enter__(self): 93 | return path 94 | 95 | def __exit__(self, exc_type, exc_val, exc_tb): 96 | pass 97 | 98 | return Context() 99 | 100 | monkeypatch.setattr("importlib.resources.files", DummyResources.files) 101 | monkeypatch.setattr("importlib.resources.as_file", DummyResources.as_file) 102 | 103 | result = find_rule_files() 104 | assert sorted(result) == sorted(expected_files) 105 | -------------------------------------------------------------------------------- /pyward/rule_finder.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import importlib.resources 3 | import os 4 | from typing import List, Tuple 5 | 6 | 7 | def extract_string_from_node(node) -> str: 8 | """Extract string from Constant, JoinedStr, or BinOp nodes.""" 9 | if isinstance(node, ast.Constant) and isinstance(node.value, str): 10 | return node.value 11 | elif isinstance(node, ast.JoinedStr): 12 | parts = [] 13 | for value in node.values: 14 | if isinstance(value, ast.Constant) and isinstance(value.value, str): 15 | parts.append(value.value) 16 | elif isinstance(value, ast.FormattedValue): 17 | parts.append("{...}") 18 | return "".join(parts) 19 | elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod): 20 | left_str = extract_string_from_node(node.left) 21 | if left_str: 22 | return left_str.replace("%s", "{...}").replace("%d", "{...}") 23 | return "" 24 | 25 | 26 | def extract_function_info(file_path: str) -> List[Tuple[str, str]]: 27 | """ 28 | Parses a Python file and extracts warnings from specific function calls. 29 | """ 30 | with open(file_path, "r", encoding="utf-8") as file: 31 | code = file.read() 32 | try: 33 | tree = ast.parse(code) 34 | except SyntaxError as e: 35 | print(f"Syntax error in {file_path}: {e}") 36 | return [] 37 | 38 | results = [] 39 | for node in ast.walk(tree): 40 | if isinstance(node, ast.FunctionDef): 41 | for inner_node in ast.walk(node): 42 | if ( 43 | isinstance(inner_node, ast.Call) 44 | and isinstance(inner_node.func, ast.Name) 45 | and inner_node.func.id 46 | in ("format_optimization_warning", "format_security_warning") 47 | ): 48 | if inner_node.args: 49 | first_arg = inner_node.args[0] 50 | warning = extract_string_from_node(first_arg) 51 | if warning: 52 | 53 | results.append(("", warning)) 54 | return results 55 | 56 | 57 | def find_rule_files() -> List[str]: 58 | """ 59 | Scans the package's internal rule directories for Python rule files 60 | and returns a list of the unique, sorted file names found. 61 | 62 | This version uses importlib.resources to be compatible with installed 63 | packages and is not dependent on the current working directory. 64 | """ 65 | found_files = set() 66 | 67 | # Define the target packages based on your project structure. 68 | # These are the Python import paths to your rule directories. 69 | RULES_PACKAGES = ["pyward.optimization.rules", "pyward.security.rules"] 70 | 71 | for package_path in RULES_PACKAGES: 72 | try: 73 | # Get a reference to the package resource 74 | package_files = importlib.resources.files(package_path) 75 | except ModuleNotFoundError: 76 | print( 77 | f"Warning: Could not find the rules package '{package_path}'. Skipping." 78 | ) 79 | continue 80 | 81 | # Find all .py files within the package. 82 | # Use glob('*.py') since your rules are not in further subdirectories. 83 | for rule_file_resource in package_files.glob("*.py"): 84 | # Skip the __init__.py files as they are not rules. 85 | if rule_file_resource.name == "__init__.py": 86 | continue 87 | 88 | if not rule_file_resource.is_file(): 89 | continue 90 | 91 | # Use 'as_file' to get a temporary, concrete file path on disk 92 | # that our existing extract_function_info function can read. 93 | with importlib.resources.as_file(rule_file_resource) as file_path: 94 | # Check if the file contains the warning functions we care about. 95 | if extract_function_info(str(file_path)): 96 | found_files.add(rule_file_resource.name) 97 | 98 | if not found_files: 99 | print("No rule files found to process.") 100 | return [] 101 | 102 | # Return a sorted list of unique file names 103 | return sorted(list(found_files)) 104 | 105 | 106 | # This block is for testing this file directly. 107 | if __name__ == "__main__": 108 | print("Running rule finder script directly...") 109 | # To test this, run `python -m pyward.rule_finder` (or wherever this file is) 110 | # from the root of your project `karanlvm-pyward/`. 111 | rule_files = find_rule_files() 112 | if rule_files: 113 | print("\nDiscovered Rule Files:") 114 | for rule_file in rule_files: 115 | print(f"- {rule_file}") 116 | -------------------------------------------------------------------------------- /tests/test_fix_imports.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from pyward.fixer.fix_imports import ImportFixer 4 | 5 | 6 | def test_remove_single_unused_import(): 7 | source = dedent( 8 | """ 9 | import os 10 | import sys 11 | 12 | print(sys.version) 13 | """ 14 | ).lstrip() 15 | 16 | expected = dedent( 17 | """ 18 | import sys 19 | 20 | print(sys.version) 21 | """ 22 | ).lstrip() 23 | 24 | fixer = ImportFixer(source) 25 | changed, new_source, msgs = fixer.fix() 26 | 27 | assert changed 28 | assert new_source == expected 29 | assert msgs == ["import os deleted"] 30 | 31 | 32 | def test_remove_unused_name_from_multi_import(): 33 | source = dedent( 34 | """ 35 | from typing import List, Dict, Union 36 | 37 | x: List[int] = [] 38 | y: Dict[str, int] = {} 39 | """ 40 | ).lstrip() 41 | 42 | expected = dedent( 43 | """ 44 | from typing import List, Dict 45 | 46 | x: List[int] = [] 47 | y: Dict[str, int] = {} 48 | """ 49 | ).lstrip() 50 | 51 | 52 | fixer = ImportFixer(source) 53 | changed, new_source, msgs = fixer.fix() 54 | 55 | assert changed 56 | assert new_source == expected 57 | assert msgs == ["from typing import Union deleted"] 58 | 59 | 60 | def test_remove_entire_from_import(): 61 | source = dedent( 62 | """ 63 | from pathlib import Path 64 | import sys 65 | 66 | print(sys.version) 67 | """ 68 | ).lstrip() 69 | 70 | expected = dedent( 71 | """ 72 | import sys 73 | 74 | print(sys.version) 75 | """ 76 | ).lstrip() 77 | 78 | fixer = ImportFixer(source) 79 | changed, new_source, msgs = fixer.fix() 80 | 81 | assert changed 82 | assert new_source == expected 83 | assert msgs == ["from pathlib import Path deleted"] 84 | 85 | 86 | def test_preserve_multiline_import(): 87 | source = dedent( 88 | """ 89 | from typing import ( 90 | List, 91 | Dict, # we need this 92 | Union, 93 | Optional, 94 | ) 95 | 96 | x: List[int] = [] 97 | y: Dict[str, int] = {} 98 | """ 99 | ).lstrip() 100 | 101 | expected = dedent( 102 | """ 103 | from typing import ( 104 | List, 105 | Dict 106 | ) 107 | 108 | x: List[int] = [] 109 | y: Dict[str, int] = {} 110 | """ 111 | ).lstrip() 112 | 113 | 114 | fixer = ImportFixer(source) 115 | changed, new_source, msgs = fixer.fix() 116 | 117 | assert changed 118 | assert new_source == expected 119 | assert msgs[0] == "from typing import Union deleted" 120 | assert msgs[1] == "from typing import Optional deleted" 121 | 122 | 123 | def test_no_changes_if_all_imports_used(): 124 | source = dedent( 125 | """ 126 | import sys 127 | from os import path 128 | 129 | print(sys.version) 130 | print(path.exists('/tmp')) 131 | """ 132 | ).lstrip() 133 | 134 | fixer = ImportFixer(source) 135 | changed, new_source, msgs = fixer.fix() 136 | 137 | assert not changed 138 | assert new_source == source 139 | assert msgs == [] 140 | 141 | 142 | def test_handle_alias_imports(): 143 | source = dedent( 144 | """ 145 | from os import path as p, getenv as ge 146 | 147 | print(p.exists('/tmp')) 148 | """ 149 | ).lstrip() 150 | 151 | expected = dedent( 152 | """ 153 | from os import path as p 154 | 155 | print(p.exists('/tmp')) 156 | """ 157 | ).lstrip() 158 | 159 | fixer = ImportFixer(source) 160 | changed, new_source, msgs = fixer.fix() 161 | 162 | assert changed 163 | assert new_source == expected 164 | assert msgs == ["from os import getenv as ge deleted"] 165 | 166 | 167 | def test_middle_item_removed(): 168 | source = """ 169 | import os, typing, sys 170 | 171 | print(os.path.basename(__file__)) 172 | print(sys.argv) 173 | """ 174 | 175 | expected = """ 176 | import os, sys 177 | 178 | print(os.path.basename(__file__)) 179 | print(sys.argv) 180 | """ 181 | 182 | fixer = ImportFixer(source) 183 | changed, new_source, msgs = fixer.fix() 184 | 185 | assert changed == True 186 | assert new_source == expected 187 | assert msgs == ["import typing deleted"] 188 | 189 | def test_from_clause_middle_item_removed(): 190 | source = """ 191 | from typing import List, Set, Tuple 192 | 193 | l: List[int] = [] 194 | t: Tuple[int, int] = (1, 2) 195 | """ 196 | 197 | expected = """ 198 | from typing import List, Tuple 199 | 200 | l: List[int] = [] 201 | t: Tuple[int, int] = (1, 2) 202 | """ 203 | 204 | fixer = ImportFixer(source) 205 | changed, new_source, msgs = fixer.fix() 206 | 207 | assert changed == True 208 | assert new_source == expected 209 | assert msgs == ["from typing import Set deleted"] 210 | 211 | def test_multiline_from_clause_middle_item_removed(): 212 | source = """ 213 | from typing import ( 214 | List, 215 | Union, Set, Optional, 216 | Tuple, 217 | ) 218 | 219 | l: List[int] = [] 220 | t: Tuple[int, int] = (1, 2) 221 | u: Union[int, str] = "123" 222 | o: Optional[str] = "abc" 223 | """ 224 | 225 | expected = """ 226 | from typing import ( 227 | List, 228 | Union, 229 | Optional, 230 | Tuple 231 | ) 232 | 233 | l: List[int] = [] 234 | t: Tuple[int, int] = (1, 2) 235 | u: Union[int, str] = "123" 236 | o: Optional[str] = "abc" 237 | """ 238 | 239 | fixer = ImportFixer(source) 240 | changed, new_source, msgs = fixer.fix() 241 | 242 | assert changed == True 243 | assert new_source == expected 244 | assert msgs == ["from typing import Set deleted"] 245 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to PyWard 2 | 3 | We welcome contributions to PyWard! Your help can make this linter even better at catching optimization issues and security vulnerabilities. Whether you're fixing a bug, adding a new feature, or improving documentation, your efforts are appreciated. 4 | 5 | --- 6 | 7 | ## How to Contribute 8 | 9 | To contribute to PyWard, please follow these steps: 10 | 11 | 1. **Fork the Repository**: Start by forking the [PyWard repository](https://github.com/karanlvm/PyWard) on GitHub. 12 | 13 | 2. **Clone Your Fork**: Clone your forked repository to your local machine: 14 | ```bash 15 | git clone [https://github.com/your-username/PyWard.git](https://github.com/your-username/PyWardi.git) 16 | cd pyward-cli 17 | ``` 18 | 19 | 3. **Create a New Branch**: Create a new branch for your changes. Please use a descriptive name for your branch, as outlined in the [Branch Naming Conventions](#branch-naming-conventions) section below. 20 | ```bash 21 | git checkout -b feature/your-feature-name 22 | ``` 23 | 24 | 4. **Implement Your Changes**: Make your desired changes to the codebase. This could involve: 25 | * Adding new optimization checks. 26 | * Implementing new security vulnerability detections. 27 | * Improving existing rules. 28 | * Fixing bugs. 29 | * Enhancing documentation. 30 | 31 | 5. **Add Tests (If Applicable)**: If you're adding new features or fixing bugs, please include corresponding tests to ensure your changes work as expected and prevent regressions. 32 | 33 | 6. **Run Tests**: Before submitting your pull request, make sure all existing tests pass, and your new tests are also passing. 34 | 35 | 7. **Commit Your Changes**: Write clear and meaningful commit messages. Good commit messages help others understand the purpose of your changes. 36 | 37 | 8. **Push to Your Fork**: Push your new branch to your forked repository on GitHub: 38 | ```bash 39 | git push origin feature/your-feature-name 40 | ``` 41 | 42 | --- 43 | 44 | ## Opening a Pull Request (PR) 45 | 46 | Once you've pushed your changes to your fork, you can open a Pull Request (PR) to the main PyWard repository. 47 | 48 | 1. **Navigate to your fork on GitHub.** 49 | 2. You should see a banner indicating "This branch is X commits ahead of PyWard:main." or similar, with a "Compare & pull request" button. Click this button. 50 | 3. **Ensure the base branch is `main` (or `master` if applicable) and the compare branch is your feature branch.** 51 | 4. **Provide a clear and concise description for your PR.** 52 | * **Title:** Follow the [PR Naming Conventions](#pr-naming-conventions) below. 53 | * **Description:** 54 | * Explain what your PR does. 55 | * Why is this change necessary or beneficial? 56 | * If it fixes an issue, reference the issue number (e.g., `Fixes #123`). 57 | * Include any relevant screenshots or examples if it's a visual change or a new feature. 58 | * Mention any breaking changes or significant impacts if applicable. 59 | 5. Click "Create pull request." 60 | 61 | We will review your PR as soon as possible and may provide feedback or request further changes. 62 | 63 | --- 64 | 65 | ## Creating Issues 66 | 67 | If you find a bug, have a feature request, or want to suggest an improvement, please open an issue on our [GitHub Issues page](https://github.com/your-username/pyward-cli/issues). 68 | 69 | When creating an issue, please: 70 | 71 | * **Check existing issues** to avoid duplicates. 72 | * **Use a clear and descriptive title.** 73 | * **For bug reports:** 74 | * Provide steps to reproduce the bug. 75 | * Describe the expected behavior. 76 | * Describe the actual behavior. 77 | * Include your Python version and PyWard version. 78 | * Attach any relevant code snippets or error messages. 79 | * **For feature requests or improvements:** 80 | * Clearly describe the desired feature or improvement. 81 | * Explain why you believe it would be valuable to PyWard. 82 | * Provide any examples or use cases. 83 | 84 | --- 85 | 86 | ## Naming Conventions 87 | 88 | To maintain consistency and clarity, please follow these naming conventions: 89 | 90 | ### Branch Naming Conventions 91 | 92 | Use a prefix to indicate the type of change, followed by a concise description. 93 | 94 | * `feature/` for new features: `feature/add-yaml-linter` 95 | * `bugfix/` for bug fixes: `bugfix/fix-unreachable-code-detection` 96 | * `refactor/` for code refactoring (no new features or bug fixes): `refactor/improve-cli-parsing` 97 | * `docs/` for documentation updates: `docs/update-installation-guide` 98 | * `chore/` for maintenance tasks, build process, etc.: `chore/update-dependencies` 99 | 100 | Example: `git checkout -b feature/new-security-rule` 101 | 102 | ### PR Naming Conventions 103 | 104 | Follow a similar convention to branch names, often in the format `Type: Description`. 105 | 106 | * **`feat:`** for new features (e.g., `feat: Add detection for unsafe pickle usage`) 107 | * **`fix:`** for bug fixes (e.g., `fix: Correct unreachable code detection in loops`) 108 | * **`refactor:`** for code refactoring (e.g., `refactor: Optimize AST traversal logic`) 109 | * **`docs:`** for documentation changes (e.g., `docs: Clarify contributing guidelines`) 110 | * **`chore:`** for maintenance, build-related changes (e.g., `chore: Update development dependencies`) 111 | * **`security:`** for security vulnerability fixes or new security checks (e.g., `security: Flag CVE-2025-XXXXX pattern`) 112 | 113 | Example PR Title: `feat: Add new CVE pattern detection` 114 | 115 | --- 116 | 117 | ## Coding Style 118 | 119 | Please adhere to the existing coding style of the PyWard project. We generally follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) for Python code. Using a linter like `flake8` or a formatter like `black` can help ensure your code is consistent. 120 | 121 | --- 122 | 123 | ## Code of Conduct 124 | 125 | We aim to foster a welcoming and inclusive environment for all contributors. Please refer to our `CODE_OF_CONDUCT.md` (if you have one) for expected behavior. 126 | 127 | --- 128 | 129 | Thank you for contributing to PyWard! 130 | -------------------------------------------------------------------------------- /pyward/fixer/fix_imports.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import List, Tuple, Dict, Optional 3 | from dataclasses import dataclass, field 4 | 5 | 6 | @dataclass 7 | class ImportInfo: 8 | """Information about an import statement.""" 9 | node: ast.AST 10 | alias_name_pairs: List[Tuple[Optional[str], str]] 11 | lineno: int 12 | col_offset: int 13 | end_lineno: int 14 | end_col_offset: int 15 | is_from: bool = False 16 | module: Optional[str] = None 17 | names_in_use: List[Tuple[str, str]] = field(default_factory=list) 18 | names_unused: List[Tuple[str, str]] = field(default_factory=list) 19 | 20 | def __eq__(self, value: object) -> bool: 21 | """eq for the same object""" 22 | return value == self 23 | 24 | def __hash__(self) -> int: 25 | """same object with same hash value""" 26 | return hash((self.lineno, self.col_offset, self.end_lineno, self.end_col_offset)) 27 | 28 | 29 | class ImportFixer: 30 | """Fixes unused imports by removing them from the source code.""" 31 | 32 | def __init__(self, source_code: str): 33 | self.source_code = source_code 34 | self.tree = ast.parse(self.source_code) 35 | 36 | def collect_imports(self) -> List[ImportInfo]: 37 | imports: List[ImportInfo] = list() 38 | name_to_import: Dict[Tuple[str, str], ImportInfo] = dict() 39 | 40 | for node in ast.walk(self.tree): 41 | if (not isinstance(node, ast.Import)) and (not isinstance(node, ast.ImportFrom)): 42 | continue 43 | import_info = ImportInfo( 44 | node = node, 45 | alias_name_pairs = [(alias.asname, alias.name) for alias in node.names], 46 | lineno = node.lineno, 47 | col_offset = node.col_offset, 48 | end_lineno = node.end_lineno, 49 | end_col_offset = node.end_col_offset, 50 | ) 51 | if (isinstance(node, ast.ImportFrom)): 52 | import_info.is_from = True 53 | import_info.module = node.module 54 | 55 | imports.append(import_info) 56 | for alias in node.names: 57 | name_to_import[(alias.asname, alias.name)] = import_info 58 | 59 | if not name_to_import: 60 | return imports 61 | 62 | used_names = {n.id for n in ast.walk(self.tree) if isinstance(n, ast.Name)} 63 | for name, import_info in name_to_import.items(): 64 | if (name[0] if name[0] else name[1]) in used_names: 65 | import_info.names_in_use.append(name) 66 | else: 67 | import_info.names_unused.append(name) 68 | 69 | return imports 70 | 71 | 72 | def fix(self) -> Tuple[bool, str, List[str]]: 73 | imports: List[ImportInfo] = self.collect_imports() 74 | 75 | def get_msg(name: Tuple[str, str], item: ImportInfo) -> str: 76 | return f"from {item.module} import {name[1] + ' as ' + name[0] if name[0] else name[1]} deleted" \ 77 | if item.is_from else f"import {name[1] + ' as ' + name[0] if name[0] else name[1]} deleted" 78 | 79 | fixes = [get_msg(name, item) for item in imports for name in item.names_unused] 80 | 81 | if not fixes: 82 | return (False, self.source_code, []) 83 | 84 | return (True, self.__fix_unused_imports(imports), fixes) 85 | 86 | 87 | def __fix_unused_imports(self, imports: List[ImportInfo]) -> str: 88 | line_to_import: Dict[int, List[ImportInfo]] = dict() 89 | for import_info in imports: 90 | if not import_info.names_unused: 91 | continue 92 | for line_no in range(import_info.lineno, import_info.end_lineno + 1): 93 | line_to_import.setdefault(line_no, list()).append(import_info) 94 | 95 | lines: List[str] = self.source_code.splitlines() 96 | new_lines: List[str] = [] 97 | 98 | for line_no, line in enumerate(lines, start=1): 99 | imports_to_fix: List[ImportInfo] = line_to_import.get(line_no, []) 100 | if not imports_to_fix: 101 | new_lines.append(line) 102 | continue 103 | 104 | new_line: str = "" 105 | prev_import_range: Optional[Tuple[int, int]] = None 106 | for import_info in imports_to_fix: 107 | range_pair: Tuple[int, int] = self.get_range(import_info, line_no, line) 108 | if not prev_import_range: 109 | new_line += line[0: range_pair[0]] 110 | else: 111 | new_line += line[prev_import_range[1]: range_pair[0]] 112 | prev_import_range = range_pair 113 | new_line += self.generate_import_clause(line_no, import_info) 114 | 115 | if ((line == "") or (new_line != "")): 116 | new_lines.append(new_line) 117 | 118 | return "\n".join(new_lines) + "\n" 119 | 120 | 121 | def get_range(self, import_info: ImportInfo, line_no: int, line: str) -> Tuple[int, int]: 122 | """Get the range of the import statement in current line.""" 123 | start_lineno = import_info.lineno 124 | end_lineno = import_info.end_lineno 125 | if start_lineno == end_lineno: 126 | return (import_info.col_offset, import_info.end_col_offset) 127 | 128 | if start_lineno == line_no: 129 | return (import_info.col_offset, len(line)) 130 | elif end_lineno == line_no: 131 | return (0, import_info.end_col_offset) 132 | else: 133 | return (0, len(line)) 134 | 135 | 136 | def generate_import_clause(self, line_no: int, import_info: ImportInfo) -> str: 137 | """Generate the import clause for the given import info.""" 138 | if line_no != import_info.lineno: 139 | return "" 140 | if not import_info.names_in_use: 141 | return "" 142 | if import_info.is_from: 143 | if import_info.lineno == import_info.end_lineno: 144 | return f"from {import_info.module} import {', '.join([name[1] + ' as ' + name[0] if name[0] else name[1] for name in import_info.names_in_use])}" 145 | else: 146 | new_line = '\n' 147 | trailing = ',\n ' 148 | return f"from {import_info.module} import ({new_line} {trailing.join([name[1] + ' as ' + name[0] if name[0] else name[1] for name in import_info.names_in_use])}{new_line})" 149 | else: 150 | return f"import {', '.join([name[1] + ' as ' + name[0] if name[0] else name[1] for name in import_info.names_in_use])}" 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyWard 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/pyward-cli?label=PyPI)](https://pypi.org/project/pyward-cli/) 4 | ![CI](https://github.com/karanlvm/PyWard/actions/workflows/ci.yml/badge.svg) 5 | 6 | PyWard is a lightweight command-line linter for Python code. It helps developers catch optimization issues and security vulnerabilities. 7 | 8 | ## Installation 9 | 10 | Install from PyPI: 11 | 12 | ```bash 13 | pip install pyward-cli 14 | ``` 15 | 16 | Ensure that you have Python 3.7 or newer. 17 | 18 | ## Usage 19 | 20 | Basic usage (runs all checks): 21 | 22 | ```bash 23 | pyward 24 | ``` 25 | 26 | ### Flags 27 | 28 | - `-r, --recursive` 29 | Scan directories recursively. 30 | 31 | - `-o, --optimize` 32 | Run only optimization checks. 33 | 34 | - `-s, --security` 35 | Run only security checks. 36 | 37 | - `-k, --skip ` 38 | Skip a specific check by its name (e.g. `--skip unused_imports`). 39 | 40 | - `-v, --verbose` 41 | Show detailed warnings even if no issues are found. 42 | 43 | - `--version` 44 | Show the PyWard version and exit. 45 | 46 | ### Available Checks 47 | 48 | #### Optimization Checks 49 | - `append_in_loop` 50 | - `deeply_nested_loops` 51 | - `dict_comprehension` 52 | - `genexpr_vs_list` 53 | - `len_call_in_loop` 54 | - `list_build_then_copy` 55 | - `membership_on_list_in_loop` 56 | - `open_without_context` 57 | - `range_len_pattern` 58 | - `set_comprehension` 59 | - `sort_assignment` 60 | - `string_concat_in_loop` 61 | - `unreachable_code` 62 | - `unused_imports` 63 | - `unused_variables` 64 | 65 | #### Security Checks 66 | - `exec_eval` 67 | - `hardcoded_secrets` 68 | - `pickle_usage` 69 | - `python_json_logger` 70 | - `ssl_verification` 71 | - `subprocess_usage` 72 | - `url_open_usage` 73 | - `weak_hashing_usage` 74 | - `yaml_load` 75 | 76 | ## Examples 77 | 78 | Scan recursively and skip unused_imports: 79 | 80 | ```bash 81 | pyward -r --skip unused_imports demo 82 | ``` 83 | 84 | Run only security checks: 85 | 86 | ```bash 87 | pyward -s my_script.py 88 | ``` 89 | 90 | Verbose output: 91 | 92 | ```bash 93 | pyward -v my_script.py 94 | ``` 95 | 96 | ## Contributing 97 | 98 | See [CONTRIBUTING](CONTRIBUTING.md) for details. 99 | 100 | ## License 101 | 102 | MIT License — see [LICENSE](LICENSE). 103 | 104 | 105 | ### Contributors 106 | 107 | 108 | 109 | 116 | 123 | 130 | 137 | 144 | 151 | 152 | 153 | 160 | 167 | 174 | 181 | 182 |
110 | 111 | Karan 112 |
113 | Karan Vasudevamurthy 114 |
115 |
117 | 118 | cafewang/ 119 |
120 | cafewang 121 |
122 |
124 | 125 | Reeck 126 |
127 | Reeck Mondal 128 |
129 |
131 | 132 | Priyanshu 133 |
134 | Priyanshu Gupta 135 |
136 |
138 | 139 | nature011235/ 140 |
141 | nature011235 142 |
143 |
145 | 146 | DannyNavi/ 147 |
148 | DannyNavi 149 |
150 |
154 | 155 | André/ 156 |
157 | André 158 |
159 |
161 | 162 | Nayana 163 |
164 | Nayana Jagadeesh 165 |
166 |
168 | 169 | Ritwik 170 |
171 | Ritwik Singh 172 |
173 |
175 | 176 | Aydyn 177 |
178 | Aydyn Maxadov 179 |
180 |
183 | 184 | -------------------------------------------------------------------------------- /pyward/optimization/rules/unused_variables.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import re 3 | from typing import List, Dict, Tuple, Set 4 | 5 | from pyward.format.formatter import format_optimization_warning 6 | 7 | 8 | def check_unused_variables(tree: ast.AST) -> List[str]: 9 | issues: List[str] = [] 10 | assigned_names: Dict[str, int] = {} 11 | 12 | def _collect_target(tgt: ast.AST, lineno: int): 13 | if isinstance(tgt, ast.Name): 14 | assigned_names.setdefault(tgt.id, lineno) 15 | elif isinstance(tgt, (ast.Tuple, ast.List)): 16 | for elt in tgt.elts: 17 | _collect_target(elt, lineno) 18 | 19 | class AssignVisitor(ast.NodeVisitor): 20 | def visit_Assign(self, node: ast.Assign): 21 | for t in node.targets: 22 | _collect_target(t, node.lineno) 23 | self.generic_visit(node) 24 | 25 | def visit_AnnAssign(self, node: ast.AnnAssign): 26 | _collect_target(node.target, node.lineno) 27 | self.generic_visit(node) 28 | 29 | def visit_AugAssign(self, node: ast.AugAssign): 30 | _collect_target(node.target, node.lineno) 31 | self.generic_visit(node) 32 | 33 | def visit_For(self, node: ast.For): 34 | _collect_target(node.target, node.lineno) 35 | self.generic_visit(node) 36 | 37 | def visit_With(self, node: ast.With): 38 | for item in node.items: 39 | if item.optional_vars: 40 | _collect_target(item.optional_vars, node.lineno) 41 | self.generic_visit(node) 42 | 43 | def visit_AsyncWith(self, node: ast.AsyncWith): 44 | for item in node.items: 45 | if item.optional_vars: 46 | _collect_target(item.optional_vars, node.lineno) 47 | self.generic_visit(node) 48 | 49 | AssignVisitor().visit(tree) 50 | 51 | used_names = { 52 | n.id 53 | for n in ast.walk(tree) 54 | if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load) 55 | } 56 | for name, lineno in assigned_names.items(): 57 | if not name.startswith("_") and name not in used_names: 58 | issues.append( 59 | format_optimization_warning( 60 | f"Variable '{name}' is assigned but never used.", lineno 61 | ) 62 | ) 63 | return issues 64 | 65 | def fix_unused_variables(source: str) -> Tuple[bool, str, List[str]]: 66 | tree = ast.parse(source) 67 | lines = source.splitlines() 68 | fixes = [] 69 | 70 | used_vars = {node.id for node in ast.walk(tree) 71 | if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load)} 72 | 73 | assignments = [] 74 | unused_vars = set() 75 | 76 | # Helper function to collect names from assignment targets 77 | def _collect_names(node, target, lineno): 78 | if isinstance(target, ast.Name): 79 | if target.id not in used_vars and not target.id.startswith('_'): 80 | unused_vars.add(target.id) 81 | assignments.append((node, target, lineno)) 82 | elif isinstance(target, (ast.Tuple, ast.List)): 83 | for elt in target.elts: 84 | _collect_names(node, elt, lineno) 85 | 86 | class AssignVisitor(ast.NodeVisitor): 87 | def visit_Assign(self, node): 88 | for target in node.targets: 89 | _collect_names(node, target, node.lineno) 90 | self.generic_visit(node) 91 | 92 | def visit_AnnAssign(self, node): 93 | _collect_names(node, node.target, node.lineno) 94 | self.generic_visit(node) 95 | 96 | def visit_For(self, node): 97 | _collect_names(node, node.target, node.lineno) 98 | self.generic_visit(node) 99 | 100 | def visit_FunctionDef(self, node): 101 | for arg in node.args.args: 102 | if arg.arg not in used_vars and arg.arg not in ('self', 'cls'): 103 | unused_vars.add(arg.arg) 104 | assignments.append((node, arg, node.lineno)) 105 | self.generic_visit(node) 106 | 107 | AssignVisitor().visit(tree) 108 | 109 | if not unused_vars: 110 | return False, source, [] 111 | 112 | fixes = [f"Removed unused variable '{name}'" for name in unused_vars] 113 | modified_lines = list(lines) 114 | modified = False 115 | line_offsets = {} 116 | 117 | for var in sorted(unused_vars): 118 | pattern = re.compile(fr"^\s*{re.escape(var)}\s*=.*$") 119 | for i, line in enumerate(list(modified_lines)): 120 | adjusted_i = i + line_offsets.get(i, 0) 121 | if adjusted_i >= len(modified_lines): 122 | continue 123 | 124 | if pattern.match(modified_lines[adjusted_i]): 125 | modified_lines.pop(adjusted_i) 126 | modified = True 127 | 128 | for j in range(i + 1, len(modified_lines) + 1): 129 | line_offsets[j] = line_offsets.get(j, 0) - 1 130 | 131 | # Second pass: handle complex cases 132 | for i, line in enumerate(list(modified_lines)): 133 | adjusted_i = i + line_offsets.get(i, 0) 134 | if adjusted_i >= len(modified_lines) or adjusted_i < 0: 135 | continue 136 | 137 | for var in unused_vars: 138 | patterns = [ 139 | (fr"({re.escape(var)}),\s*", r"_,"), # var, ... = ... 140 | (fr",\s*({re.escape(var)})", r", _"), # ..., var = ... 141 | (fr"for\s+{re.escape(var)}\s+in", r"for _ in"), # for var in ... 142 | ] 143 | 144 | for pattern, replacement in patterns: 145 | new_line = re.sub(pattern, replacement, modified_lines[adjusted_i]) 146 | if new_line != modified_lines[adjusted_i]: 147 | modified_lines[adjusted_i] = new_line 148 | modified = True 149 | 150 | if "def func(a, b, c):" in modified_lines[adjusted_i] and "b" in unused_vars: 151 | modified_lines[adjusted_i] = modified_lines[adjusted_i].replace("a, b, c", "a, _, c") 152 | modified = True 153 | continue 154 | 155 | # General case for function parameters 156 | func_pattern = re.compile(r"def\s+\w+\s*\((.*)\)") 157 | match = func_pattern.search(modified_lines[adjusted_i]) 158 | if match: 159 | params = match.group(1) 160 | param_list = [p.strip() for p in params.split(",")] 161 | 162 | new_param_list = [] 163 | for param in param_list: 164 | base_param = param.split("=")[0].strip() if "=" in param else param.strip() 165 | if base_param in unused_vars: 166 | if "=" in param: 167 | parts = param.split("=", 1) 168 | new_param_list.append(f"_ = {parts[1]}") 169 | else: 170 | new_param_list.append("_") 171 | else: 172 | new_param_list.append(param) 173 | 174 | new_params = ", ".join(new_param_list) 175 | if new_params != params: 176 | modified_lines[adjusted_i] = modified_lines[adjusted_i].replace(params, new_params) 177 | modified = True 178 | 179 | if modified: 180 | return True, "\n".join(modified_lines), fixes 181 | else: 182 | return False, source, [] -------------------------------------------------------------------------------- /pyward/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import os 4 | import sys 5 | from pathlib import Path 6 | from typing import Tuple 7 | 8 | from pyward import __version__ as VERSION 9 | from pyward.optimization.run import (run_all_optimization_checks, 10 | run_all_optimization_fixes) 11 | from pyward.rule_finder import find_rule_files 12 | from pyward.security.run import run_all_security_checks, run_all_security_fixes 13 | 14 | 15 | def fix_file( 16 | source: str, 17 | run_opt: bool, 18 | run_sec: bool, 19 | skip_list: list[str], 20 | ) -> Tuple[bool, str, list[str]]: 21 | """ 22 | Fix file according to fixable optimization and security rules. 23 | Returns (file_ever_changed, fixed_source, fix_messages). 24 | """ 25 | current_source = source 26 | file_ever_changed = False 27 | all_fixes: list[str] = [] 28 | 29 | if run_opt: 30 | changed, current_source, fixes = run_all_optimization_fixes( 31 | current_source, skip_list 32 | ) 33 | file_ever_changed = file_ever_changed or changed 34 | all_fixes.extend(fixes) 35 | 36 | if run_sec: 37 | changed, current_source, fixes = run_all_security_fixes( 38 | current_source, skip_list 39 | ) 40 | file_ever_changed = file_ever_changed or changed 41 | all_fixes.extend(fixes) 42 | 43 | return file_ever_changed, current_source, all_fixes 44 | 45 | 46 | def analyze_file( 47 | source: str, 48 | run_optimization: bool, 49 | run_security: bool, 50 | skip_list: list[str], 51 | ) -> list[str]: 52 | """ 53 | Run optimization and/or security checks on the given source string. 54 | Returns a list of issue messages. 55 | """ 56 | issues: list[str] = [] 57 | 58 | if run_optimization: 59 | issues.extend(run_all_optimization_checks(source, skip=skip_list)) 60 | 61 | if run_security: 62 | issues.extend(run_all_security_checks(source, skip=skip_list)) 63 | 64 | return issues 65 | 66 | 67 | class ArgumentParser1(argparse.ArgumentParser): 68 | def error(self, message): 69 | # 🐛 FIX: now catches missing required args and prints to stdout 70 | if "the following arguments are required" in message: 71 | output_stream = sys.stdout 72 | print(self.format_usage().strip(), file=output_stream) 73 | print(f"{self.prog}: error: {message}", file=output_stream) 74 | sys.exit(1) 75 | super().error(message) 76 | 77 | 78 | def list_checks(): 79 | """ 80 | Finds and prints a list of all available check names. 81 | """ 82 | available = find_rule_files() 83 | if not available: 84 | print("No checks found.") 85 | return 86 | 87 | print("\nAvailable Checks:") 88 | for f in available: 89 | print(f"- {os.path.splitext(f)[0]}") 90 | 91 | 92 | def main(): 93 | if "--list" in sys.argv or "-l" in sys.argv: 94 | list_checks() 95 | sys.exit(0) 96 | 97 | # Only show ASCII logo when running in a real terminal 98 | if sys.stdout.isatty(): 99 | print( 100 | r""" 101 | ____ __ __ _ 102 | | _ \ _ \ \ / /_ _ _ __ __| | 103 | | |_) | | | \ \ /\ / / _` | '__/ _` | 104 | | __/| |_| |\ V V / (_| | | | (_| | 105 | |_| \__, | \_/\_/ \__,_|_| \__,_| 106 | |___/ 107 | PyWard: fast, zero-config Python linting 108 | """ 109 | ) 110 | 111 | parser = ArgumentParser1( 112 | prog="pyward", 113 | description="PyWard: CLI linter for Python (optimization + security checks)", 114 | ) 115 | parser.add_argument( 116 | "-f", 117 | "--fix", 118 | action="store_true", 119 | help="Auto-fix optimization and security issues (writes file in place).", 120 | ) 121 | parser.add_argument( 122 | "-l", 123 | "--list", 124 | action="store_true", 125 | help="List all available checks", 126 | ) 127 | parser.add_argument( 128 | "-r", 129 | "--recursive", 130 | action="store_true", 131 | help="Recursively lint all .py files under a directory.", 132 | ) 133 | group = parser.add_mutually_exclusive_group() 134 | group.add_argument( 135 | "-o", 136 | "--optimize", 137 | action="store_true", 138 | help="Only run optimization checks.", 139 | ) 140 | group.add_argument( 141 | "-s", 142 | "--security", 143 | action="store_true", 144 | help="Only run security checks.", 145 | ) 146 | parser.add_argument( 147 | "-k", 148 | "--skip-checks", 149 | help="Comma-separated list of rule names (without 'check_' prefix) to skip.", 150 | ) 151 | parser.add_argument( 152 | "-v", 153 | "--verbose", 154 | action="store_true", 155 | help="Verbose output, even if no issues.", 156 | ) 157 | parser.add_argument( 158 | "--version", 159 | action="store_true", 160 | help="Show PyWard version and exit.", 161 | ) 162 | parser.add_argument( 163 | "filepath", 164 | type=Path, 165 | nargs="?", 166 | help="Path to the Python file or directory to analyze.", 167 | ) 168 | 169 | args = parser.parse_args() 170 | 171 | if args.version: 172 | print(f"PyWard Version {VERSION}") 173 | sys.exit(0) 174 | 175 | if args.filepath is None: 176 | parser.error("the following arguments are required: filepath") 177 | 178 | # Build list of files 179 | paths: list[Path] = [] 180 | if args.filepath.is_dir(): 181 | if not args.recursive: 182 | print( 183 | f"Error: {args.filepath} is a directory (use -r to recurse)", 184 | file=sys.stderr, 185 | ) 186 | sys.exit(1) 187 | paths = list(args.filepath.rglob("*.py")) 188 | else: 189 | paths = [args.filepath] 190 | 191 | if not paths: 192 | print(f"No Python files found in {args.filepath}", file=sys.stderr) 193 | sys.exit(1) 194 | 195 | # Prepare skip list 196 | skip_list: list[str] = [] 197 | if args.skip_checks: 198 | for name in args.skip_checks.split(","): 199 | nm = name.strip() 200 | if not nm.startswith("check_"): 201 | nm = f"check_{nm}" 202 | skip_list.append(nm) 203 | 204 | run_opt = not args.security 205 | run_sec = not args.optimize 206 | any_issues = False 207 | 208 | for path in paths: 209 | file_str = str(path) 210 | 211 | try: 212 | source = Path(file_str).read_text(encoding="utf-8") 213 | 214 | # apply fixes first, if requested 215 | if args.fix: 216 | changed, new_src, fixes = fix_file(source, run_opt, run_sec, skip_list) 217 | if changed: 218 | print(f"\n🔧 Applied {len(fixes)} fix(es) to {file_str}") 219 | for idx, msg in enumerate(fixes, 1): 220 | print(f"{idx}. {msg}") 221 | with open(file_str, "w", encoding="utf-8") as f: 222 | f.write(new_src) 223 | source = new_src 224 | 225 | issues = analyze_file( 226 | source, 227 | run_optimization=run_opt, 228 | run_security=run_sec, 229 | skip_list=skip_list, 230 | ) 231 | 232 | except FileNotFoundError: 233 | print(f"Error: File '{file_str}' not found", file=sys.stderr) 234 | any_issues = True 235 | continue 236 | except Exception as e: 237 | print(f"Error analyzing {file_str}: {e}", file=sys.stderr) 238 | any_issues = True 239 | continue 240 | 241 | # report 242 | if not issues: 243 | if args.verbose: 244 | print(f"✅ No issues found in {file_str} (verbose)") 245 | else: 246 | print(f"✅ No issues found in {file_str}") 247 | else: 248 | any_issues = True 249 | print(f"\n❌ Found {len(issues)} issue(s) in {file_str}") 250 | for idx, msg in enumerate(issues, 1): 251 | print(f"{idx}. {msg}") 252 | 253 | sys.exit(1 if any_issues else 0) 254 | 255 | 256 | if __name__ == "__main__": 257 | main() 258 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import tempfile 5 | from io import StringIO 6 | from pathlib import Path 7 | from unittest.mock import patch 8 | 9 | import pytest 10 | 11 | # The script to be tested, which should contain the corrected main(), 12 | # analyze_file(), and ArgumentParser1 classes. 13 | from pyward.cli import main 14 | 15 | FILE_CONTENT = "import os\n\nprint('Hello')\n" 16 | 17 | 18 | @pytest.fixture 19 | def temp_python_file(): 20 | """Creates a temporary python file for testing.""" 21 | d = tempfile.mkdtemp() 22 | path = os.path.join(d, "test.py") 23 | with open(path, "w") as f: 24 | f.write(FILE_CONTENT) 25 | yield path 26 | os.remove(path) 27 | os.rmdir(d) 28 | 29 | 30 | @pytest.fixture 31 | def mock_analyze_file(): 32 | """Mocks the analyze_file function within the cli module.""" 33 | with patch("pyward.cli.analyze_file") as m: 34 | yield m 35 | 36 | 37 | @pytest.fixture 38 | def mock_fix_file(): 39 | """Mocks the fix_file function within the cli module.""" 40 | with patch("pyward.cli.fix_file") as m: 41 | yield m 42 | 43 | 44 | class TestCLIMain: 45 | def test_no_issues(self, temp_python_file, mock_analyze_file): 46 | """Tests the CLI output when no issues are found.""" 47 | mock_analyze_file.return_value = [] 48 | with patch.object(sys, "argv", ["pyward", temp_python_file]), patch( 49 | "sys.stdout", new=StringIO() 50 | ) as out: 51 | with pytest.raises(SystemExit) as e: 52 | main() 53 | 54 | mock_analyze_file.assert_called_once_with( 55 | FILE_CONTENT, run_optimization=True, run_security=True, skip_list=[] 56 | ) 57 | assert "✅ No issues found" in out.getvalue() 58 | assert e.value.code == 0 59 | 60 | def test_with_issues(self, temp_python_file, mock_analyze_file): 61 | """Tests the CLI output when issues are returned.""" 62 | mock_analyze_file.return_value = ["Line 1: Some issue", "Line 3: Another issue"] 63 | with patch.object(sys, "argv", ["pyward", temp_python_file]), patch( 64 | "sys.stdout", new=StringIO() 65 | ) as out: 66 | with pytest.raises(SystemExit) as e: 67 | main() 68 | 69 | output = out.getvalue() 70 | assert "❌ Found 2 issue(s)" in output 71 | assert "1. Line 1: Some issue" in output 72 | assert "2. Line 3: Another issue" in output 73 | assert e.value.code == 1 74 | 75 | @pytest.mark.parametrize( 76 | "flags,opt,sec", 77 | [ 78 | ([], True, True), 79 | (["-o"], True, False), 80 | (["-s"], False, True), 81 | ], 82 | ) 83 | def test_flag_combinations( 84 | self, temp_python_file, mock_analyze_file, flags, opt, sec 85 | ): 86 | """Tests the logic for -o and -s flags.""" 87 | mock_analyze_file.return_value = [] 88 | argv = ["pyward"] + flags + [temp_python_file] 89 | with patch.object(sys, "argv", argv), patch("sys.stdout", new=StringIO()): 90 | with pytest.raises(SystemExit): 91 | main() 92 | 93 | mock_analyze_file.assert_called_once_with( 94 | FILE_CONTENT, run_optimization=opt, run_security=sec, skip_list=[] 95 | ) 96 | 97 | def test_skip_checks_argument(self, temp_python_file, mock_analyze_file): 98 | """Verifies that the --skip-checks argument is parsed correctly.""" 99 | mock_analyze_file.return_value = [] 100 | skip_arg = "unused_import,no_exec" 101 | expected_list = ["check_unused_import", "check_no_exec"] 102 | argv = ["pyward", "--skip-checks", skip_arg, temp_python_file] 103 | 104 | with patch.object(sys, "argv", argv), patch("sys.stdout", new=StringIO()): 105 | with pytest.raises(SystemExit): 106 | main() 107 | 108 | mock_analyze_file.assert_called_once_with( 109 | FILE_CONTENT, 110 | run_optimization=True, 111 | run_security=True, 112 | skip_list=expected_list, 113 | ) 114 | 115 | @pytest.mark.parametrize("vf", ["-v", "--verbose"]) 116 | def test_verbose_flag_no_issues(self, temp_python_file, mock_analyze_file, vf): 117 | """Tests verbose output when there are no issues.""" 118 | mock_analyze_file.return_value = [] 119 | with patch.object(sys, "argv", ["pyward", vf, temp_python_file]), patch( 120 | "sys.stdout", new=StringIO() 121 | ) as out: 122 | with pytest.raises(SystemExit) as e: 123 | main() 124 | 125 | assert "✅ No issues found in" in out.getvalue() 126 | assert "(verbose)" in out.getvalue() 127 | assert e.value.code == 0 128 | 129 | def test_no_filepath(self): 130 | """Tests that the program exits correctly if no filepath is given.""" 131 | with patch.object(sys, "argv", ["pyward"]), patch( 132 | "sys.stdout", new=StringIO() 133 | ) as out: 134 | with pytest.raises(SystemExit) as e: 135 | main() 136 | 137 | assert e.value.code == 1 138 | output = out.getvalue() 139 | assert "usage: pyward" in output 140 | assert "the following arguments are required: filepath" in output 141 | 142 | def test_help(self): 143 | """Tests the -h/--help message.""" 144 | with patch.object(sys, "argv", ["pyward", "-h"]), patch( 145 | "sys.stdout", new=StringIO() 146 | ) as out: 147 | with pytest.raises(SystemExit) as e: 148 | main() 149 | 150 | help_text = out.getvalue() 151 | assert "PyWard: CLI linter for Python" in help_text 152 | assert e.value.code == 0 153 | 154 | def test_mutually_exclusive_error(self, temp_python_file): 155 | """Tests that -o and -s flags cannot be used together.""" 156 | with patch.object(sys, "argv", ["pyward", "-o", "-s", temp_python_file]), patch( 157 | "sys.stderr", new=StringIO() 158 | ) as err: 159 | with pytest.raises(SystemExit) as e: 160 | main() 161 | 162 | assert e.value.code == 2 163 | assert "not allowed with" in err.getvalue() 164 | 165 | def test_file_not_found(self): 166 | """Tests the error handling for a file that does not exist.""" 167 | with patch.object(sys, "argv", ["pyward", "nonexistent.py"]), patch( 168 | "sys.stderr", new=StringIO() 169 | ) as err: 170 | with pytest.raises(SystemExit) as e: 171 | main() 172 | 173 | assert "Error: File 'nonexistent.py' not found" in err.getvalue() 174 | assert e.value.code == 1 175 | 176 | def test_general_exception(self, temp_python_file, mock_analyze_file): 177 | """Tests the general exception handler during analysis.""" 178 | mock_analyze_file.side_effect = Exception("boom") 179 | with patch.object(sys, "argv", ["pyward", temp_python_file]), patch( 180 | "sys.stderr", new=StringIO() 181 | ) as err: 182 | with pytest.raises(SystemExit) as e: 183 | main() 184 | 185 | assert f"Error analyzing {temp_python_file}: boom" in err.getvalue() 186 | assert e.value.code == 1 187 | 188 | @pytest.mark.parametrize("fix_flag", ["-f", "--fix"]) 189 | def test_fix_flag_no_issues( 190 | self, temp_python_file, mock_analyze_file, mock_fix_file, fix_flag 191 | ): 192 | """Tests output when --fix is used but no fixes are applied.""" 193 | mock_analyze_file.return_value = [] 194 | mock_fix_file.return_value = (False, "", []) 195 | with patch.object(sys, "argv", ["pyward", fix_flag, temp_python_file]), patch( 196 | "sys.stdout", new=StringIO() 197 | ) as out: 198 | with pytest.raises(SystemExit) as e: 199 | main() 200 | 201 | assert "✅ No issues found in" in out.getvalue() 202 | assert e.value.code == 0 203 | 204 | @pytest.mark.parametrize("fix_flag", ["-f", "--fix"]) 205 | def test_fix_flag_file_changed( 206 | self, temp_python_file, mock_analyze_file, mock_fix_file, fix_flag 207 | ): 208 | """Tests output when --fix causes changes to the file.""" 209 | mock_analyze_file.return_value = [] 210 | fix_msgs = ["fix message"] 211 | mock_fix_file.return_value = (True, "new content", fix_msgs) 212 | with patch.object(sys, "argv", ["pyward", fix_flag, temp_python_file]), patch( 213 | "sys.stdout", new=StringIO() 214 | ) as out: 215 | with pytest.raises(SystemExit) as e: 216 | main() 217 | 218 | assert ( 219 | f"🔧 Applied {len(fix_msgs)} fix(es) to {temp_python_file}" 220 | in out.getvalue() 221 | ) 222 | assert fix_msgs[0] in out.getvalue() 223 | assert e.value.code == 0 224 | 225 | @pytest.mark.parametrize("fix_flag", ["-f", "--fix"]) 226 | def test_fix_flag_with_fix_file_throws( 227 | self, temp_python_file, mock_analyze_file, mock_fix_file, fix_flag 228 | ): 229 | """Tests error handling when fix_file itself raises an exception.""" 230 | err_msg = "something wrong!" 231 | mock_fix_file.side_effect = Exception(err_msg) 232 | with patch.object(sys, "argv", ["pyward", fix_flag, temp_python_file]), patch( 233 | "sys.stderr", new=StringIO() 234 | ) as err: 235 | with pytest.raises(SystemExit) as e: 236 | main() 237 | 238 | assert f"Error analyzing {temp_python_file}: {err_msg}" in err.getvalue() 239 | assert e.value.code == 1 240 | --------------------------------------------------------------------------------