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.
- pylon_analyzer/__init__.py +0 -0
- pylon_analyzer/ai/__init__.py +0 -0
- pylon_analyzer/ai/explain.py +29 -0
- pylon_analyzer/ai/providers/__init__.py +30 -0
- pylon_analyzer/ai/providers/anthropic_provider.py +15 -0
- pylon_analyzer/ai/providers/base.py +8 -0
- pylon_analyzer/ai/providers/google_provider.py +14 -0
- pylon_analyzer/ai/providers/ollama_provider.py +14 -0
- pylon_analyzer/ai/providers/openai_provider.py +15 -0
- pylon_analyzer/cli.py +82 -0
- pylon_analyzer/rules/__init__.py +0 -0
- pylon_analyzer/rules/bare_excepts.py +24 -0
- pylon_analyzer/rules/base.py +21 -0
- pylon_analyzer/rules/improper_compare_keyword_const.py +32 -0
- pylon_analyzer/rules/mutable_default_args.py +40 -0
- pylon_analyzer/rules/registry.py +17 -0
- pylon_analyzer/rules/shadowed_builtins.py +38 -0
- pylon_analyzer/rules/shadowed_functions.py +80 -0
- pylon_analyzer/rules/unused_imports.py +59 -0
- pylon_analyzer/rules/wildcard_imports.py +22 -0
- pylon_analyzer-0.1.1.dist-info/METADATA +190 -0
- pylon_analyzer-0.1.1.dist-info/RECORD +26 -0
- pylon_analyzer-0.1.1.dist-info/WHEEL +5 -0
- pylon_analyzer-0.1.1.dist-info/entry_points.txt +2 -0
- pylon_analyzer-0.1.1.dist-info/licenses/LICENSE +21 -0
- pylon_analyzer-0.1.1.dist-info/top_level.txt +1 -0
|
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,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
|