pylon-analyzer 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
File without changes
@@ -0,0 +1,29 @@
1
+ # explain.py receives a response regardless of the LLM provider of choice
2
+ from pylon_analyzer.ai.providers.base import LLMProvider
3
+ from pylon_analyzer.rules.base import Finding
4
+
5
+ def explain_finding(finding: Finding, source_snippet: str, provider: LLMProvider) -> str:
6
+ """Explain a finding using an LLM and return its response, runnning Prompt V1"""
7
+ # provide finding in destructured form, specifically finding.rule_id and finding.message
8
+ # provide source_snippet in python markdown
9
+ # respond in 2-3 sentences at most
10
+ # dont be overly confident
11
+ # indicate the finding line is the one starting with '---->' from get_snippet
12
+ prompt = f"""
13
+ You operate as a key part of a static Python analyzer tool that employs an LLM to explain various findings:
14
+ structured patterned rule violations. You receive the following crucial information about a finding:
15
+ Rule id: {finding.rule_id},
16
+ Line number: {finding.line},
17
+ Message: "{finding.message}",
18
+ and a file with the source code where the violating line is indicated '---->' and the rest is surrounding context:
19
+
20
+ ```python
21
+ {source_snippet}
22
+ ```
23
+
24
+ You must now explain to the user of the Python analyzer, in no more than 2-3 sentences, exactly the nature of the
25
+ finding and just as importantly, whether or not this is likely a real issue or potentially a false positive by the static
26
+ analyzer. Do not be overconfident and do not hesitate. Be incredibly concise, and shorten some sentences like
27
+ 'there is a high likelihood that this is a false positive because...' to 'likely a false positive as....'."""
28
+
29
+ return provider.complete(prompt)
@@ -0,0 +1,30 @@
1
+ from pylon_analyzer.ai.providers.base import LLMProvider
2
+ import importlib
3
+ # from pylon_analyzer.ai.providers.anthropic_provider import AnthropicProvider
4
+ # from pylon_analyzer.ai.providers.ollama_provider import OllamaProvider
5
+ # from pylon_analyzer.ai.providers.openai_provider import OpenAIProvider
6
+ # from pylon_analyzer.ai.providers.google_provider import GoogleProvider
7
+
8
+ # converstion from explicit calls to a dict string-matching
9
+ _ALL_PROVIDERS = {
10
+ "ollama": ("ollama_provider", "OllamaProvider", "ollama"),
11
+ "anthropic": ("anthropic_provider", "AnthropicProvider", "anthropic"),
12
+ "openai": ("openai_provider", "OpenAIProvider", "openai"),
13
+ "google": ("google_provider", "GoogleProvider", "google-genai")
14
+ }
15
+
16
+ def get_provider(name: str) -> LLMProvider:
17
+
18
+ if name not in _ALL_PROVIDERS:
19
+ raise ValueError(f"Unknown LLM provider: {name}. Choose from: {list(_ALL_PROVIDERS)}")
20
+
21
+ module_name, class_name, extra = _ALL_PROVIDERS[name]
22
+ try:
23
+ module = importlib.import_module(f"pylon_analyzer.ai.providers.{module_name}")
24
+ except ImportError as e:
25
+ raise ImportError(
26
+ f"The '{name}' provider requires installation of an optional dependency. Do you need to install it? "
27
+ f'Install with: pip install "pylon-analyzer[{extra}]"'
28
+ ) from e
29
+
30
+ return getattr(module, class_name)() # callable
@@ -0,0 +1,15 @@
1
+ import anthropic
2
+ from pylon_analyzer.ai.providers.base import LLMProvider
3
+
4
+ class AnthropicProvider(LLMProvider):
5
+ def __init__(self, model: str = "claude-sonnet-5"): # maybe opus is better?
6
+ self.client = anthropic.Anthropic()
7
+ self.model = model
8
+
9
+ def complete(self, prompt: str) -> str:
10
+ response = self.client.messages.create(
11
+ model = self.model,
12
+ max_tokens = 500, # limit size since claude drones on
13
+ messages = [{"role": "user", "content": prompt}]
14
+ )
15
+ return response.content[0].text
@@ -0,0 +1,8 @@
1
+ # ABC = abstract base class, much like a java interface
2
+ # subclasses must implement complete(), much like subclasses of interfaces abstract methods
3
+ from abc import ABC, abstractmethod
4
+
5
+ class LLMProvider(ABC):
6
+ @abstractmethod
7
+ def complete(self, prompt: str) -> str:
8
+ """Send a prompt and return the model's response"""
@@ -0,0 +1,14 @@
1
+ from google import genai
2
+ from pylon_analyzer.ai.providers.base import LLMProvider
3
+
4
+ class GoogleProvider(LLMProvider):
5
+ def __init__(self, model: str = "gemini-3.6-flash"):
6
+ self.client = genai.Client()
7
+ self.model = model
8
+
9
+ def complete(self, prompt: str) -> str:
10
+ response = self.client.models.generate_content(
11
+ model=self.model,
12
+ contents=prompt
13
+ )
14
+ return response.text
@@ -0,0 +1,14 @@
1
+ import requests
2
+ from pylon_analyzer.ai.providers.base import LLMProvider
3
+
4
+ class OllamaProvider(LLMProvider):
5
+ def __init__(self, model: str = "qwen2.5-coder:7b"):
6
+ self.model = model
7
+
8
+ def complete(self, prompt):
9
+ response = requests.post(
10
+ "http://localhost:11434/api/generate", # post api endpoint
11
+ json={"model": self.model, "prompt": prompt, "stream": False} # dont stream since we want it to return one chunk
12
+ )
13
+ response.raise_for_status() # raises http error if one occurred
14
+ return response.json()["response"]
@@ -0,0 +1,15 @@
1
+ from openai import OpenAI
2
+ from pylon_analyzer.ai.providers.base import LLMProvider
3
+
4
+ class OpenAIProvider(LLMProvider):
5
+ def __init__(self, model: str = "gpt-5.5"):
6
+ self.client = OpenAI()
7
+ self.model = model
8
+
9
+ def complete(self, prompt: str) -> str:
10
+ response = self.client.chat.completions.create(
11
+ model = self.model,
12
+ stream = False,
13
+ messages = [{"role": "user", "content": prompt}]
14
+ )
15
+ return response.choices[0].message.content
pylon_analyzer/cli.py ADDED
@@ -0,0 +1,82 @@
1
+ # https://click.palletsprojects.com/en/stable/
2
+ import click
3
+ from pathlib import Path
4
+ from pylon_analyzer.rules.registry import ALL_RULES
5
+ from pylon_analyzer.ai.providers import get_provider
6
+ from pylon_analyzer.ai.explain import explain_finding
7
+ from dotenv import load_dotenv, find_dotenv
8
+ load_dotenv(find_dotenv(usecwd=True)) # use cwd to find envs
9
+
10
+ def find_python_files(filepath: str) -> list[Path]:
11
+ """Find all python files in a directory and return as list[Path]"""
12
+ p = Path(filepath)
13
+ # check if path is valid
14
+ if not p.exists():
15
+ click.echo(f"{filepath} is not a valid directory.")
16
+ return []
17
+
18
+ if p.is_file() and p.suffix == ".py":
19
+ return [p] # singleton file to append
20
+ if p.is_file():
21
+ return [] # not a python file
22
+
23
+ return list(p.rglob("*.py")) # recursively search the directory
24
+
25
+
26
+ def get_snippet(filepath: Path, finding_line, margin = 25) -> str:
27
+ """Receive a file and line number for a known finding, grab surrounding `margin` lines of code, and return the snippet as a surrounding context"""
28
+ with open(filepath) as f:
29
+ all_lines = f.readlines()
30
+
31
+ first = max(0, finding_line - margin - 1) # -1 for from-zero indexing
32
+ last = min(len(all_lines), finding_line + margin)
33
+
34
+ snippet_lines = []
35
+ for i in range(first, last):
36
+ line_num = i + 1
37
+ prefix = "----> " if line_num == finding_line else " "
38
+ snippet_lines.append(f"{prefix}{line_num}: {all_lines[i]}")
39
+
40
+ return "".join(snippet_lines) # concatenate lines from first to last and return as a new string
41
+
42
+ @click.command()
43
+ @click.argument("filepath")
44
+ @click.option("--explain", is_flag=True, help="Generate LLM explanations for each finding, using surrounding context.") # explain flag calls LLM layer
45
+ @click.option("--provider", default="ollama", help="LLM provider for --explain: ollama (local, included), anthropic (requires [anthropic] extra), openai (requires [openai] extra), google (requires [google-genai] extra)")
46
+ def check(filepath, explain, provider):
47
+ """Run all Pylon validation rules on the specified Python file or directory and print findings with LLM insight if explaining."""
48
+ files = find_python_files(filepath)
49
+ click.echo(f"Validating {len(files)} file(s)...")
50
+
51
+ try:
52
+ llm = get_provider(provider) if explain else None # dont fill if not explaining. here rather than in 'if explain' so it isnt recomputed
53
+ except (ValueError, ImportError) as e:
54
+ raise click.ClickException(str(e)) # carry raise as a click exception
55
+
56
+ all_findings = []
57
+ for file in files:
58
+ for rule in ALL_RULES:
59
+ # need source file as well as the finding
60
+ findings = rule(str(file))
61
+ for finding in findings:
62
+ all_findings.append((file, finding)) # returning a zipped tuple for later report
63
+
64
+
65
+ if not all_findings: # if all_findings == []
66
+ click.echo("No issues found! :)")
67
+ return
68
+
69
+ else: # all_findings != []
70
+ for file, finding in sorted(all_findings, key=lambda pair: pair[1].line): # sorts by file by line number, with (file, _) used
71
+ click.echo(f"{file}:{finding.line}: [{finding.rule_id}] {finding.message}")
72
+
73
+ if explain: # employ the LLM layer!
74
+ source_snippet = get_snippet(file, finding.line) # helper
75
+
76
+ explanation = explain_finding(finding, source_snippet, llm)
77
+ click.echo(f" --> {explanation}")
78
+
79
+ raise SystemExit(1)
80
+
81
+ if __name__ == "__main__":
82
+ check()
File without changes
@@ -0,0 +1,24 @@
1
+ from pylon_analyzer.rules.base import Finding
2
+ import ast
3
+
4
+ def find_bare_excepts(filepath: str) -> list[Finding]:
5
+ """given a python file path, return a list of Findings where try-catch uses a bare except"""
6
+ with open(filepath) as f:
7
+ src = f.read()
8
+ tree = ast.parse(src)
9
+
10
+ result = []
11
+ for node in ast.walk(tree):
12
+ if isinstance(node, ast.ExceptHandler) and node.type is None:
13
+ last_statement = node.body[-1] if node.body else None # [-1] gives the tail of the list every time regardless of size
14
+ if isinstance(last_statement, ast.Raise) and last_statement.exc is None:
15
+ continue # skip, as this is a catch-and-re-raise scenario
16
+ result.append(Finding(
17
+ rule_id="bare-except",
18
+ message="Bare except clause found in try block",
19
+ line=node.lineno
20
+ ))
21
+
22
+ # notice: i dont want through and find tries and then match tries with exception handlers
23
+ # the tree walk finds ALL nodes, recursively, so it will find exception handlers anyway.
24
+ return result
@@ -0,0 +1,21 @@
1
+ # defining a standard structure for rules to output
2
+ from dataclasses import dataclass
3
+ import hashlib
4
+
5
+ @dataclass # basically a python shortcut for a class with no manual __init__
6
+ class Finding:
7
+ rule_id: str # an identifier ex. "unused-imports", "mutable-default-args"
8
+ message: str
9
+ line: int
10
+
11
+ @property # property attribute class
12
+ def id(self) -> str:
13
+ # give each instance Finding a unique id stable across runs,
14
+ # so the same file-line-finding gives the same ID every time (without a database)
15
+ # IDs needed for user flagging and metric collection
16
+ raw_id = f"{self.rule_id}:{self.line}:{self.message}" # combo of fields
17
+ encoded = hashlib.sha256(raw_id.encode()).hexdigest() # 64 digit encode
18
+ return encoded[:8]
19
+ # return the first 8 digits like a commit ID, since 64 is way too many
20
+ # 2^8 = worst case 256 incredibly similarly hashed issues. very unlikely
21
+
@@ -0,0 +1,32 @@
1
+ from pylon_analyzer.rules.base import Finding
2
+ import ast
3
+
4
+ def find_improper_compares(filepath: str) -> list[Finding]:
5
+ """given a python file path, return a list of Findings for improper comparisons using '==' rather than 'is' with keyword constants"""
6
+ with open(filepath) as f:
7
+ src = f.read()
8
+ tree = ast.parse(src)
9
+
10
+ result = []
11
+ for node in ast.walk(tree):
12
+ if isinstance(node, ast.Compare):
13
+ compare_node_left = node.left
14
+ compare_node_ops = node.ops[0]
15
+ compare_node_right = node.comparators[0]
16
+ if isValidConst(compare_node_right) or isValidConst(compare_node_left):
17
+ if isinstance(compare_node_ops, (ast.Eq, ast.NotEq)):
18
+ result.append(Finding(
19
+ rule_id="improper-compares",
20
+ message=f"Improper comparison using operator '{'!=' if isinstance(compare_node_ops, ast.NotEq) else '=='}' with keyword constant in line {node.lineno}",
21
+ line=node.lineno
22
+ ))
23
+
24
+ return result
25
+
26
+ def isValidConst(node):
27
+ if isinstance(node, ast.Constant):
28
+ val = node.value
29
+ if isinstance(val, bool) or val is None:
30
+ return True
31
+ else:
32
+ return False
@@ -0,0 +1,40 @@
1
+ # def add_item(item, items=[]):
2
+ # items.append(item)
3
+ # return items
4
+ # "if no list is passed, start with an empty list."
5
+ # but in python, the same list is shared every time the function is called
6
+ # this means the default list grows as it accumulates new items
7
+ # avoid with ast detection!
8
+ from pylon_analyzer.rules.base import Finding
9
+ import ast
10
+
11
+ def find_mutable_defaults(filepath: str) -> list[Finding]:
12
+ """given a python file path, return a list of Findings for functions with mutable default arguments"""
13
+ with open(filepath) as f:
14
+ src = f.read()
15
+ tree = ast.parse(src)
16
+
17
+ result = []
18
+ for node in ast.walk(tree):
19
+ if isinstance(node, ast.FunctionDef):
20
+ for default in node.args.defaults:
21
+ # args is an argument node
22
+ # args.defaults is a list of default values passed positionally
23
+ # check if default is a mutable literal
24
+ if is_mutable(default):
25
+ result.append(Finding(
26
+ rule_id="mutable-default",
27
+ message=f"Mutable default argument in function def '{node.name}'",
28
+ line=node.lineno
29
+ ))
30
+ return result
31
+
32
+ def is_mutable(node):
33
+ if isinstance(node, (ast.List, ast.Dict, ast.Set)):
34
+ # if its a direct object of one of our mutable types
35
+ return True
36
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
37
+ # if its a call to a function with id belonging to one of our mutable types
38
+ # i.e. set(), which is an initialization returning an empty set
39
+ return node.func.id in ("list", "dict", "set")
40
+ return False
@@ -0,0 +1,17 @@
1
+ from pylon_analyzer.rules.bare_excepts import find_bare_excepts
2
+ from pylon_analyzer.rules.unused_imports import find_unused_imports
3
+ from pylon_analyzer.rules.mutable_default_args import find_mutable_defaults
4
+ from pylon_analyzer.rules.wildcard_imports import find_wildcard_imports
5
+ from pylon_analyzer.rules.improper_compare_keyword_const import find_improper_compares
6
+ from pylon_analyzer.rules.shadowed_functions import find_shadowed_functions
7
+ from pylon_analyzer.rules.shadowed_builtins import find_shadowed_builtins
8
+
9
+ ALL_RULES = [
10
+ find_bare_excepts,
11
+ find_unused_imports,
12
+ find_mutable_defaults,
13
+ find_wildcard_imports,
14
+ find_improper_compares,
15
+ find_shadowed_functions,
16
+ find_shadowed_builtins
17
+ ]
@@ -0,0 +1,38 @@
1
+ import ast
2
+ import builtins
3
+
4
+ from pylon_analyzer.rules.base import Finding
5
+
6
+
7
+ BUILTIN_NAMES = frozenset(name for name in dir(builtins) if not name.startswith('_'))
8
+
9
+
10
+ def find_shadowed_builtins(filepath: str) -> list[Finding]:
11
+ """Find bindings that shadow Python builtins."""
12
+ with open(filepath) as f:
13
+ tree = ast.parse(f.read())
14
+
15
+ result = []
16
+ for node in ast.walk(tree):
17
+ if isinstance(node, ast.arg):
18
+ name = node.arg
19
+ elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
20
+ name = node.id
21
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
22
+ name = node.name
23
+ elif isinstance(node, ast.alias):
24
+ name = node.asname or node.name.split(".")[0]
25
+ elif isinstance(node, ast.ExceptHandler):
26
+ name = node.name
27
+ else:
28
+ continue
29
+
30
+ if name in BUILTIN_NAMES:
31
+ result.append(Finding(
32
+ rule_id='shadowed-builtin',
33
+ message=f"'{name}' shadows a Python builtin",
34
+ line=node.lineno,
35
+ ))
36
+
37
+ result.sort(key=lambda finding: finding.line)
38
+ return result
@@ -0,0 +1,80 @@
1
+ from pylon_analyzer.rules.base import Finding
2
+ import ast
3
+
4
+ KIND = {
5
+ ast.FunctionDef: "function",
6
+ ast.AsyncFunctionDef: "async function",
7
+ ast.ClassDef: "class"
8
+ }
9
+ DEFS = tuple(KIND)
10
+
11
+ def find_shadowed_functions(filepath: str) -> list[Finding]:
12
+ """Given a python file path, return a list of Findings where functions have been redefined, silently shadowing. This includes `def`, `async def`, and `class`"""
13
+ with open(filepath) as f:
14
+ src = f.read()
15
+ tree = ast.parse(src)
16
+
17
+ # return
18
+ # 'test_create' redefined; the definition on line 12 is unreachable
19
+ result = []
20
+ seen = {} # dict[str, ast.AST]
21
+ for node in ast.walk(tree):
22
+ # per-scope
23
+ for field in ("body", "orelse", "finalbody"):
24
+ stmts = getattr(node, field, None) # get field if it has one, equiv to node.field
25
+ if isinstance(stmts, list):
26
+ # lambda / if exp have a non-list 'body' field
27
+ _scan_statements(stmts, result)
28
+
29
+ result.sort(key=lambda f: f.line) # sort by line since it wont match walk order
30
+ return result
31
+
32
+ def _scan_statements(stmts: list[ast.stmt], result: list[Finding]) -> None:
33
+ seen: dict[str, ast.AST] = {}
34
+ for stmt in stmts:
35
+ if isinstance(stmt, DEFS):
36
+ first_seen = seen.get(stmt.name)
37
+ if first_seen is not None and not _exempt(first_seen, stmt):
38
+ result.append(Finding(
39
+ rule_id="shadowed-function",
40
+ message=(f"'{stmt.name}' redefined; the {KIND[type(first_seen)]} "
41
+ f"on line {first_seen.lineno} is shadowed"), # "myFunc redefined; the function on line 2 is shadowed"
42
+ line=stmt.lineno
43
+ ))
44
+ seen[stmt.name] = stmt
45
+
46
+ def _exempt(first, second) -> bool:
47
+ # overload stubs, exempt if either first or second is marked as overloaded
48
+ if _has_decorator(first, "overload") or _has_decorator(second, "overload"):
49
+ return True
50
+ # single dispatch: 'singledispatch' creates a generic function and '@x.register' registers special variants of it
51
+ # property accessors (getters, setters) and @x.register on function named x
52
+ if _is_attribute_of_self(second):
53
+ return True
54
+ # singledispatch convention matching repeated 'def _'s
55
+ return second.name == "_"
56
+
57
+ def _has_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, name: str) -> bool:
58
+ """Is true for @name, @mod.name, @name(...), @mod.name(...), false otherwise"""
59
+ for deco in node.decorator_list:
60
+ deco = deco.func if isinstance(deco, ast.Call) else deco # unwrap it if its a function call
61
+
62
+ # ast.Name matching name
63
+ if isinstance(deco, ast.Name) and deco.id == name:
64
+ return True
65
+ # ast.Attribute matching name
66
+ if isinstance(deco, ast.Attribute) and deco.attr == name:
67
+ return True
68
+
69
+ return False
70
+
71
+ def _is_attribute_of_self(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> bool:
72
+ """Is true for @x.setter, @x.getter, @x.deleter, @x.register on 'def x' or any 'def foo'"""
73
+ for deco in node.decorator_list:
74
+ deco = deco.func if isinstance(deco, ast.Call) else deco # unwrap it if its a function call
75
+
76
+ # if its an attribute, it has a Name label and the name matches the function def x / foo
77
+ if isinstance(deco, ast.Attribute) and isinstance(deco.value, ast.Name) and deco.value.id == node.name:
78
+ return True
79
+
80
+ return False
@@ -0,0 +1,59 @@
1
+ from pylon_analyzer.rules.base import Finding
2
+ import ast
3
+
4
+ def all_string_literals(value_node):
5
+ """Pull string literals out of a list/tuple AST node — used for __all__ = [...]"""
6
+ names = set()
7
+ if isinstance(value_node, (ast.List, ast.Tuple)):
8
+ for elt in value_node.elts:
9
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
10
+ names.add(elt.value)
11
+ return names
12
+
13
+ def find_unused_imports(filepath: str) -> list[Finding]:
14
+ """Given a Python file path, return a list of Findings, import names that are never used"""
15
+ # get tree
16
+ with open(filepath) as f:
17
+ source = f.read() # string
18
+ tree = ast.parse(source)
19
+
20
+ # get list of imports
21
+ imported = {}
22
+ for node in ast.walk(tree):
23
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
24
+ for name in node.names:
25
+ # check if .name or .asname (import os || import os as opersys)
26
+ if name.asname is not None and name.asname == name.name:
27
+ continue # skip for deliberate pattern "import X as "
28
+ imported[name.asname or name.name] = node.lineno
29
+
30
+ # every time code references a name: os.path.join(), path.exists(),... parser creates ast.Name with .id attribute
31
+ used_names = set()
32
+ for node in ast.walk(tree):
33
+ if isinstance(node, ast.Name):
34
+ used_names.add(node.id)
35
+
36
+ # __all__ = list of strings
37
+ # names listed in __all__ = [...] or __all__ += [...] count as "used"
38
+ # this catches the re-export-via-__all__ idiom (e.g. in numpy's __init__.py),
39
+ # since those names never appear as an ast.Name, and only as string literals
40
+ if isinstance(node, ast.Assign):
41
+ for target in node.targets:
42
+ if isinstance(target, ast.Name) and target.id == "__all__":
43
+ used_names |= all_string_literals(node.value) # union
44
+ if isinstance(node, ast.AugAssign):
45
+ if isinstance(node.target, ast.Name) and node.target.id == "__all__":
46
+ used_names |= all_string_literals(node.value)
47
+
48
+ # compare
49
+ result = []
50
+ for name,lineno in imported.items():
51
+ if name not in used_names:
52
+ # unused import: create a Finding
53
+ result.append(Finding(
54
+ rule_id="unused-import",
55
+ message=f"Unused import from '{name}'",
56
+ line=lineno
57
+ ))
58
+
59
+ return result
@@ -0,0 +1,22 @@
1
+ from pylon_analyzer.rules.base import Finding
2
+ import ast
3
+
4
+ def find_wildcard_imports(filepath: str) -> list[Finding]:
5
+ """given a python file path, return a list Findings of wilcard imports"""
6
+ with open(filepath) as f:
7
+ src = f.read()
8
+ tree = ast.parse(src)
9
+
10
+ result = []
11
+ for node in ast.walk(tree):
12
+ if isinstance(node, ast.ImportFrom):
13
+ for name in node.names:
14
+ if name.name == "*":
15
+ result.append(Finding(
16
+ rule_id="wildcard-import",
17
+ message=f"Wildcard import from '{node.module}'",
18
+ line=node.lineno
19
+ ))
20
+ # deliberately not using aliased names as these will always be '*'
21
+
22
+ return result
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: pylon-analyzer
3
+ Version: 0.1.1
4
+ Summary: A static analysis CLI for Python scripts with various enhancements and an LLM layer
5
+ Author: Ethan Gat
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Anonymoose725/Pylon
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Software Development :: Quality Assurance
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: click
14
+ Requires-Dist: python-dotenv
15
+ Requires-Dist: requests
16
+ Provides-Extra: ollama
17
+ Requires-Dist: anthropic; extra == "ollama"
18
+ Provides-Extra: openai
19
+ Requires-Dist: openai; extra == "openai"
20
+ Provides-Extra: anthropic
21
+ Requires-Dist: anthropic; extra == "anthropic"
22
+ Provides-Extra: google-genai
23
+ Requires-Dist: google-genai; extra == "google-genai"
24
+ Provides-Extra: llm-all
25
+ Requires-Dist: anthropic; extra == "llm-all"
26
+ Requires-Dist: openai; extra == "llm-all"
27
+ Requires-Dist: google-genai; extra == "llm-all"
28
+ Dynamic: license-file
29
+
30
+ ## Pylon
31
+
32
+ Pylon is a static analysis CLI for Python that flags real issues in your code and, on request, explains them and suggests fixes using an LLM.
33
+
34
+ Pylon mostly utilises Python's included [ast module](https://docs.python.org/3/library/ast.html) to traverse source code.
35
+
36
+ Built in collaboration with [Soham200613](https://github.com/Soham200613) and [GraydenDonaldson](https://github.com/GraydenDonaldson)
37
+
38
+ ## Implemented rules
39
+
40
+ - **Unused imports**: imports that are never referenced. Correctly handles Python's re-export idioms (`from .app import Flask as Flask`, `__all__ = [...]`) so it doesn't flag deliberate public re-exports as dead code.
41
+ - **Mutable default arguments**: `def f(x=[])` shares the same object across every call, a classic source of hard-to-trace bugs. Catches both literal syntax (`[]`, `{}`) and constructor calls (`list()`, `dict()`, `set()`).
42
+ - **Bare `except:` clauses**: catching all exceptions, including `KeyboardInterrupt` and `SystemExit`, silently swallows bugs. Recognizes and skips the deliberate catch-and-reraise pattern (`except: ... raise`), rather than flagging every bare except indiscriminately.
43
+ - **Wildcard imports**: `from module import *` pollutes the namespace and makes it impossible to tell where a name came from.
44
+ - **Comparing with `==` instead of `is` for `None`/`True`/`False`** — non-idiomatic and can misbehave with custom `__eq__`. Check `ast.Compare` nodes where one side is an `ast.Constant` with value `None`/`True`/`False` and the operator is `Eq`/`NotEq`.
45
+ - **Shadowed funcions/methods/class definitions**: functions later shadowed without an @overload stub and other exceptions are silently shadowed by python
46
+
47
+ - **Shadowed builtins**: parameters and variable bindings named `list`, `dict`, `id`, `type`, etc. hide Python's builtins. Attribute names and ordinary builtin references are not flagged.
48
+
49
+ ## Rules yet to be implemented
50
+
51
+ - **Excessive function arguments**: functions with too many parameters are hard to maintain and often signal the function is doing too much.
52
+ - **Unused function arguments**: parameters never referenced in the function body, often signaling incomplete refactoring. Same "walk and collect `ast.Name` usages" pattern as unused imports, applied per-function.
53
+ - **Global variable mutation inside functions**: using `global` to mutate outer-scope state is a common source of hard-to-trace bugs. Check for `ast.Global` nodes within functions.
54
+ - **Deeply nested code (high cyclomatic complexity)**: functions with many nested `if`/`for`/`while`/`try` blocks are hard to read and test. Requires tracking nesting depth/branch count while walking, rather than matching a single node type.
55
+ - **Redefining function in same scope**: functions that are later redefined in the same scope silently shadow, hinting to dead code
56
+ - **Format-string arg count mismatch**: `%` operator and `logging` calls with a mismatch in the number of format requests and args supplied
57
+
58
+ ## LLM layer
59
+
60
+ Passing `--explain` sends each finding, along with its surrounding source code, to an LLM for a short, specific judgment on whether it's a genuine issue or a likely false positive. This is deliberately kept separate from the deterministic rule engine: rules encode patterns worth hard-coding that have few special deliberate cases, while the LLM layer handles judgment calls that don't generalize cleanly into a fixed rule. For example, correctly identifying that an unused `from __future__ import annotations` import is not actually dead code, despite never appearing as a referenced name in its respective file.
61
+
62
+ Supports multiple providers behind a shared interface — local models via [ollama](https://ollama.com) for fast, free iteration, or Anthropic/OpenAI for production-quality explanations:
63
+
64
+ ```bash
65
+ pylon path/to/file.py --explain --provider ollama # default
66
+ pylon path/to/file.py --explain --provider anthropic
67
+ pylon path/to/file.py --explain --provider openai
68
+ pylon path/to/file.py --explain # will use default ollama qwen2.5:7b-coder
69
+ ```
70
+
71
+ Note that users of OpenAI/Anthropic models should configure their API keys by following the [Configuration section](#-configuration-set-up-environment-variables) above.
72
+
73
+ ## Installation, as PyPI package
74
+
75
+
76
+ Plain installation without external provider support ie. Ollama only
77
+ ```bash
78
+ pip install pylon-analyzer
79
+ ```
80
+
81
+ To install specific or all providers,
82
+ ```bash
83
+ # Just Anthropic
84
+ pip install "pylon-analyzer[anthropic]"
85
+ # Just OpenAI
86
+ pip install "pylon-analyzer[openai]"
87
+ # Just Google
88
+ pip install "pylon-analyzer[google]"
89
+ # Full provider support
90
+ pip install "pylon-analyzer[llm-all]"
91
+ ```
92
+
93
+ ## Installation, from source
94
+
95
+ ```bash
96
+ git clone https://github.com/Anonymoose725/pylon.git
97
+ cd pylon
98
+ python3 -m venv .venv
99
+ source .venv/bin/activate # for linux/mac, or .venv\Scripts\activate on Windows
100
+ pip install -r requirements.txt
101
+ ```
102
+
103
+ ### Minimal install (Ollama only, no API keys required)
104
+ ```bash
105
+ pip install -e ".[ollama]"
106
+ ```
107
+
108
+ ### Full install with all LLM providers
109
+ ```bash
110
+ pip install -e ".[llm-all]"
111
+ ```
112
+
113
+ ### Or install specific providers
114
+ ```bash
115
+ # Just OpenAI
116
+ pip install -e ".[openai]"
117
+ # Just Anthropic
118
+ pip install -e ".[anthropic]"
119
+ ```
120
+
121
+ ## Configuration: Set Up Environment Variables
122
+
123
+ **After installation**, you'll need to configure environment variables for LLM API access:
124
+
125
+ 1. **Copy the example file**: Rename `.env.example` to `.env.local` first to review the template, then rename it to `.env`:
126
+ ```bash
127
+ cp .env.example .env.local # Review what needs to be set
128
+ mv .env.local .env # Now you can edit it safely
129
+ ```
130
+
131
+ 2. **Edit `.env`**: Add your API keys to the `.env` file:
132
+ ```bash
133
+ nano .env # or use your preferred editor
134
+ ```
135
+
136
+ 3. **Load environment variables** (if your shell doesn't auto-source):
137
+ ```bash
138
+ source .env # Linux/mac, or .venv\Scripts\activate.ps1 for Windows
139
+ ```
140
+
141
+ ## Detailed setup, for contributors
142
+
143
+ Clone the repo:
144
+ ```bash
145
+ git clone https://github.com/Anonymoose725/pylon.git
146
+ cd pylon
147
+ ```
148
+
149
+ Create and activate a virtual environment:
150
+ ```bash
151
+ python3 -m venv .venv
152
+ source .venv/bin/activate
153
+ ```
154
+
155
+ Install dependencies from requirements.txt:
156
+ ```bash
157
+ pip install -r requirements.txt
158
+ ```
159
+
160
+ Run the test suite to confirm everything works:
161
+ ```bash
162
+ pytest testing/ -v
163
+ ```
164
+
165
+ **Optional**: Configure LLM API keys by following the [Configuration section](#-configuration-set-up-environment-variables). The tests will run with Ollama by default (no API keys needed) unless you've installed other providers and set up `.env`.
166
+
167
+ ## Milestones & metrics
168
+
169
+ Validated against 5 actively maintained production codebases to confirm the tool works at real scale and to catch genuine false-positive patterns before they'd be found by an actual user.
170
+
171
+ | Repo | Files | Lines | Findings |
172
+ |---|---|---|---|
173
+ | Flask | 24 | 9,513 | 32 |
174
+ | Requests | 19 | 6,394 | 56 |
175
+ | Click | 17 | 12,821 | 19 |
176
+ | Django | 907 | 165,529 | 198 |
177
+ | NumPy | 428 | 260,868 | 764 |
178
+ | **Total** | **1,395** | **455,125** | **1,069** |
179
+
180
+ - **Reduced false-positive unused-import findings by 34%** (1,479 → 976) by diagnosing and fixing two Python re-export idioms (`from x import Y as Y` and `__all__` re-exports) that the AST-based detector initially misread as dead code
181
+ - **Correctly identified a genuine bare-except anti-pattern** in Flask's core WSGI dispatch logic (`app.py`), then refined the rule to distinguish safe catch-and-reraise handlers from true error suppression via AST-level exception analysis
182
+ - **Processes ~365 files/sec** (Django) and remains performant on dense, large codebases (~83 files/sec on NumPy's 260K-line source tree due to larger file sizes)
183
+ - Every rule is covered by positive *and* negative test cases (`pytest`) — proving each rule catches real issues without false-flagging safe, idiomatic code
184
+ - Uploaded to PyPI, where now users can 'pip install pylon-analyzer' on any system!
185
+
186
+ ## Roadmap
187
+
188
+ 1. Implement the roadmap rules listed above!
189
+ 2. Implement a --fat flag that assumes a user is testing on a large number of files/repos, compacting the large list of findings into a more digestible (and collapsible?) format, potentially outside the CLI
190
+ 3. Add a false-positive flagging mechanism to the CLI, so users can mark findings as false positives, with and without having seen an `--explain` explanation first. This is the basis for measuring whether the LLM layer's context genuinely reduces false-positive flags in practice
@@ -0,0 +1,26 @@
1
+ pylon_analyzer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pylon_analyzer/cli.py,sha256=MzFwtbQFRznV1laXJjr1_TOll6Byv7mAGsvVEF3NVME,3591
3
+ pylon_analyzer/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pylon_analyzer/ai/explain.py,sha256=zgBgBSMaP_1aLiXJSdeKoPExGwtk1754NeLi__iZTUE,1682
5
+ pylon_analyzer/ai/providers/__init__.py,sha256=tcjUtr8AMXWNEjCLxN3XrZkgt2YDKp3K8Kgw_Wy3yqA,1372
6
+ pylon_analyzer/ai/providers/anthropic_provider.py,sha256=uD8aaDOod4Vq3ys4bYoVen-NKRpFKPdreuzxIIYY558,569
7
+ pylon_analyzer/ai/providers/base.py,sha256=hMOBSl3XOUdVuw6c3PXka0znQzYs0rhQX_C7YPQbFoM,336
8
+ pylon_analyzer/ai/providers/google_provider.py,sha256=EnCmMXQddlLgkeWFX2-jCDJr0pX8khZps6P8mvo3SBs,441
9
+ pylon_analyzer/ai/providers/ollama_provider.py,sha256=cGuhtJ2EdDoA4cr_5PoKTo6Gq76tN7K6lqa5MENNFPo,584
10
+ pylon_analyzer/ai/providers/openai_provider.py,sha256=ohPA3XxO0ZXaGOn-AbZQSloWBBX5HE9iYVDBmUUjcww,515
11
+ pylon_analyzer/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ pylon_analyzer/rules/bare_excepts.py,sha256=iyjmpwO-YJDewWxT-0Kecy0-JQdLlzk26ckgEBIjdu0,1097
13
+ pylon_analyzer/rules/base.py,sha256=nfXUAMnO72ToE_YnlBMy0s-l5RLXD87IYC2qtN3hh2I,947
14
+ pylon_analyzer/rules/improper_compare_keyword_const.py,sha256=zbU_3zmWDK77GRBycCv5n8eWtYGABGNpOsO4dMcOZLE,1266
15
+ pylon_analyzer/rules/mutable_default_args.py,sha256=9NhPR9NDScjOf6iLWWyT63z_yqnBs1MrIlJnE8OQ0ak,1690
16
+ pylon_analyzer/rules/registry.py,sha256=i6EHbspIAIpgrfHdc-Vk0h6T2AabZDoc9JIQotvd9kY,719
17
+ pylon_analyzer/rules/shadowed_builtins.py,sha256=pxmbqL7En42Ln3KD-u4QZi5GO870UIMPUw8PZTx8XYg,1184
18
+ pylon_analyzer/rules/shadowed_functions.py,sha256=GcZjPitmk_D44WSNH8dNJucrPFMNhiw3-ZVL2nTEkOo,3556
19
+ pylon_analyzer/rules/unused_imports.py,sha256=ZiqFe-0lFrbxQQP6r0X__dsQ7qk7OB_iRGT7GXckjCM,2466
20
+ pylon_analyzer/rules/wildcard_imports.py,sha256=PLJJzRpivc1U7A2JJRK63nY807e-GEWZX8ifgVYJ6Pg,781
21
+ pylon_analyzer-0.1.1.dist-info/licenses/LICENSE,sha256=YONk9sVULP0hmIoHkHCxIphaZVoVKmqwf76zwz_ovJo,1065
22
+ pylon_analyzer-0.1.1.dist-info/METADATA,sha256=co5kNd8FQ7WYBY4_utW-uXvY2MwRJcyzvvXE9CGSFfA,9394
23
+ pylon_analyzer-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
24
+ pylon_analyzer-0.1.1.dist-info/entry_points.txt,sha256=tRbMe4id8FlNUktJm7JzeD6O58xmN_CbhVacJvDEd7Q,51
25
+ pylon_analyzer-0.1.1.dist-info/top_level.txt,sha256=mODmLSxRAiAI0jzC9nQMWD04A7WXWZiPo9Mj9_T_rZM,15
26
+ pylon_analyzer-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pylon = pylon_analyzer.cli:check
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethan Gat
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.
@@ -0,0 +1 @@
1
+ pylon_analyzer