bubble-analysis 0.2.0__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.
- bubble/__init__.py +3 -0
- bubble/cache.py +207 -0
- bubble/cli.py +470 -0
- bubble/config.py +52 -0
- bubble/detectors.py +90 -0
- bubble/enums.py +65 -0
- bubble/extractor.py +829 -0
- bubble/formatters.py +887 -0
- bubble/integrations/__init__.py +92 -0
- bubble/integrations/base.py +98 -0
- bubble/integrations/cli_scripts/__init__.py +49 -0
- bubble/integrations/cli_scripts/cli.py +108 -0
- bubble/integrations/cli_scripts/detector.py +149 -0
- bubble/integrations/django/__init__.py +63 -0
- bubble/integrations/django/cli.py +111 -0
- bubble/integrations/django/detector.py +331 -0
- bubble/integrations/django/semantics.py +40 -0
- bubble/integrations/fastapi/__init__.py +57 -0
- bubble/integrations/fastapi/cli.py +110 -0
- bubble/integrations/fastapi/detector.py +176 -0
- bubble/integrations/fastapi/semantics.py +14 -0
- bubble/integrations/flask/__init__.py +57 -0
- bubble/integrations/flask/cli.py +110 -0
- bubble/integrations/flask/detector.py +191 -0
- bubble/integrations/flask/semantics.py +19 -0
- bubble/integrations/formatters.py +268 -0
- bubble/integrations/generic/__init__.py +13 -0
- bubble/integrations/generic/config.py +106 -0
- bubble/integrations/generic/detector.py +346 -0
- bubble/integrations/generic/frameworks.py +145 -0
- bubble/integrations/models.py +68 -0
- bubble/integrations/queries.py +481 -0
- bubble/loader.py +118 -0
- bubble/models.py +397 -0
- bubble/propagation.py +737 -0
- bubble/protocols.py +104 -0
- bubble/queries.py +627 -0
- bubble/results.py +211 -0
- bubble/stubs.py +89 -0
- bubble/timing.py +144 -0
- bubble_analysis-0.2.0.dist-info/METADATA +264 -0
- bubble_analysis-0.2.0.dist-info/RECORD +46 -0
- bubble_analysis-0.2.0.dist-info/WHEEL +5 -0
- bubble_analysis-0.2.0.dist-info/entry_points.txt +2 -0
- bubble_analysis-0.2.0.dist-info/licenses/LICENSE +21 -0
- bubble_analysis-0.2.0.dist-info/top_level.txt +1 -0
bubble/results.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Result dataclasses for query functions.
|
|
2
|
+
|
|
3
|
+
These define the contract between queries and formatters.
|
|
4
|
+
All query functions return one of these typed results.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from bubble.models import (
|
|
10
|
+
CallSite,
|
|
11
|
+
CatchSite,
|
|
12
|
+
ClassHierarchy,
|
|
13
|
+
Entrypoint,
|
|
14
|
+
GlobalHandler,
|
|
15
|
+
RaiseSite,
|
|
16
|
+
)
|
|
17
|
+
from bubble.propagation import ExceptionFlow
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RaisesResult:
|
|
22
|
+
"""Result of finding raise sites for an exception."""
|
|
23
|
+
|
|
24
|
+
exception_type: str
|
|
25
|
+
include_subclasses: bool
|
|
26
|
+
types_searched: set[str]
|
|
27
|
+
matches: list[RaiseSite]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ExceptionClass:
|
|
32
|
+
"""Info about an exception class."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
bases: list[str]
|
|
36
|
+
file: str | None
|
|
37
|
+
line: int | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ExceptionsResult:
|
|
42
|
+
"""Result of listing exception hierarchy."""
|
|
43
|
+
|
|
44
|
+
classes: dict[str, ExceptionClass]
|
|
45
|
+
roots: set[str]
|
|
46
|
+
hierarchy: ClassHierarchy
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class StatsResult:
|
|
51
|
+
"""Result of codebase statistics."""
|
|
52
|
+
|
|
53
|
+
functions: int
|
|
54
|
+
classes: int
|
|
55
|
+
raise_sites: int
|
|
56
|
+
catch_sites: int
|
|
57
|
+
call_sites: int
|
|
58
|
+
entrypoints: int
|
|
59
|
+
http_routes: int
|
|
60
|
+
cli_scripts: int
|
|
61
|
+
global_handlers: int
|
|
62
|
+
imports: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class CallersResult:
|
|
67
|
+
"""Result of finding callers of a function."""
|
|
68
|
+
|
|
69
|
+
function_name: str
|
|
70
|
+
calls: list[CallSite]
|
|
71
|
+
suggestions: list[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class EntrypointTrace:
|
|
76
|
+
"""A single raise site traced to its entrypoints."""
|
|
77
|
+
|
|
78
|
+
raise_site: RaiseSite
|
|
79
|
+
paths: list[list[str]]
|
|
80
|
+
entrypoints: list[Entrypoint]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class EntrypointsToResult:
|
|
85
|
+
"""Result of tracing exception to entrypoints."""
|
|
86
|
+
|
|
87
|
+
exception_type: str
|
|
88
|
+
include_subclasses: bool
|
|
89
|
+
types_searched: set[str]
|
|
90
|
+
traces: list[EntrypointTrace]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class EntrypointsResult:
|
|
95
|
+
"""Result of listing all entrypoints."""
|
|
96
|
+
|
|
97
|
+
http_routes: list[Entrypoint]
|
|
98
|
+
cli_scripts: list[Entrypoint]
|
|
99
|
+
other: dict[str, list[Entrypoint]]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class EscapesResult:
|
|
104
|
+
"""Result of finding escaping exceptions."""
|
|
105
|
+
|
|
106
|
+
function_name: str
|
|
107
|
+
entrypoint: Entrypoint | None
|
|
108
|
+
flow: ExceptionFlow
|
|
109
|
+
global_handlers: list[GlobalHandler]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class CatchesResult:
|
|
114
|
+
"""Result of finding catch sites for an exception."""
|
|
115
|
+
|
|
116
|
+
exception_type: str
|
|
117
|
+
include_subclasses: bool
|
|
118
|
+
types_searched: set[str]
|
|
119
|
+
local_catches: list[CatchSite]
|
|
120
|
+
global_handlers: list[GlobalHandler]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class AuditIssue:
|
|
125
|
+
"""An entrypoint with uncaught exceptions."""
|
|
126
|
+
|
|
127
|
+
entrypoint: Entrypoint
|
|
128
|
+
uncaught: dict[str, list[RaiseSite]]
|
|
129
|
+
caught: dict[str, list[RaiseSite]]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class AuditResult:
|
|
134
|
+
"""Result of auditing all entrypoints."""
|
|
135
|
+
|
|
136
|
+
total_entrypoints: int
|
|
137
|
+
issues: list[AuditIssue]
|
|
138
|
+
clean_count: int
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class CacheStats:
|
|
143
|
+
"""Result of cache statistics."""
|
|
144
|
+
|
|
145
|
+
file_count: int
|
|
146
|
+
size_bytes: int
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class TraceNode:
|
|
151
|
+
"""A node in the trace tree."""
|
|
152
|
+
|
|
153
|
+
function: str
|
|
154
|
+
qualified: str
|
|
155
|
+
direct_raises: list[str]
|
|
156
|
+
propagated_raises: list[str]
|
|
157
|
+
calls: list["TraceNode | PolymorphicNode"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class PolymorphicNode:
|
|
162
|
+
"""A polymorphic call with multiple implementations."""
|
|
163
|
+
|
|
164
|
+
function: str
|
|
165
|
+
implementations: list[TraceNode]
|
|
166
|
+
raises: list[str]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class TraceResult:
|
|
171
|
+
"""Result of tracing exception flow."""
|
|
172
|
+
|
|
173
|
+
function_name: str
|
|
174
|
+
entrypoint: Entrypoint | None
|
|
175
|
+
root: TraceNode | None
|
|
176
|
+
escaping_exceptions: set[str]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class SubclassInfo:
|
|
181
|
+
"""Info about a subclass."""
|
|
182
|
+
|
|
183
|
+
name: str
|
|
184
|
+
file: str | None
|
|
185
|
+
line: int | None
|
|
186
|
+
is_abstract: bool
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class SubclassesResult:
|
|
191
|
+
"""Result of finding subclasses."""
|
|
192
|
+
|
|
193
|
+
class_name: str
|
|
194
|
+
base_class_file: str | None
|
|
195
|
+
base_class_line: int | None
|
|
196
|
+
is_abstract: bool
|
|
197
|
+
abstract_methods: set[str]
|
|
198
|
+
subclasses: list[SubclassInfo]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class InitResult:
|
|
203
|
+
"""Result of initializing .flow directory."""
|
|
204
|
+
|
|
205
|
+
flow_dir: str
|
|
206
|
+
functions_count: int
|
|
207
|
+
http_routes_count: int
|
|
208
|
+
cli_scripts_count: int
|
|
209
|
+
exception_classes_count: int
|
|
210
|
+
global_handlers_count: int
|
|
211
|
+
frameworks_detected: list[str]
|
bubble/stubs.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Exception stubs for external libraries."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class StubLibrary:
|
|
11
|
+
"""Collection of exception stubs for external libraries."""
|
|
12
|
+
|
|
13
|
+
stubs: dict[str, dict[str, list[str]]] = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
def get_raises(self, module: str, function: str) -> list[str]:
|
|
16
|
+
"""Get exceptions that a function can raise."""
|
|
17
|
+
module_stubs = self.stubs.get(module, {})
|
|
18
|
+
return module_stubs.get(function, [])
|
|
19
|
+
|
|
20
|
+
def add_stub(self, module: str, function: str, exceptions: list[str]) -> None:
|
|
21
|
+
"""Add a stub for a function."""
|
|
22
|
+
if module not in self.stubs:
|
|
23
|
+
self.stubs[module] = {}
|
|
24
|
+
self.stubs[module][function] = exceptions
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_stub_file(library: StubLibrary, yaml_file: Path) -> None:
|
|
28
|
+
"""Load stubs from a YAML file into the library."""
|
|
29
|
+
with open(yaml_file) as f:
|
|
30
|
+
data = yaml.safe_load(f)
|
|
31
|
+
|
|
32
|
+
if not data:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
module = data.get("module", yaml_file.stem)
|
|
36
|
+
functions = data.get("functions", {})
|
|
37
|
+
|
|
38
|
+
for func_name, exceptions in functions.items():
|
|
39
|
+
if isinstance(exceptions, list):
|
|
40
|
+
library.add_stub(module, func_name, exceptions)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_stubs(directory: Path) -> StubLibrary:
|
|
44
|
+
"""Load all stub files from built-in and user directories."""
|
|
45
|
+
library = StubLibrary()
|
|
46
|
+
|
|
47
|
+
builtin_dir = Path(__file__).parent / "stubs"
|
|
48
|
+
user_dir = directory / ".flow" / "stubs"
|
|
49
|
+
|
|
50
|
+
for stub_dir in [builtin_dir, user_dir]:
|
|
51
|
+
if stub_dir.exists():
|
|
52
|
+
for yaml_file in stub_dir.glob("*.yaml"):
|
|
53
|
+
_load_stub_file(library, yaml_file)
|
|
54
|
+
|
|
55
|
+
return library
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_stub_file(yaml_file: Path) -> list[str]:
|
|
59
|
+
"""Validate a stub file and return any errors."""
|
|
60
|
+
errors: list[str] = []
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with open(yaml_file) as f:
|
|
64
|
+
data = yaml.safe_load(f)
|
|
65
|
+
except yaml.YAMLError as e:
|
|
66
|
+
errors.append(f"YAML syntax error: {e}")
|
|
67
|
+
return errors
|
|
68
|
+
|
|
69
|
+
if not isinstance(data, dict):
|
|
70
|
+
errors.append("Root must be a dictionary")
|
|
71
|
+
return errors
|
|
72
|
+
|
|
73
|
+
if "module" not in data:
|
|
74
|
+
errors.append("Missing 'module' key")
|
|
75
|
+
|
|
76
|
+
if "functions" not in data:
|
|
77
|
+
errors.append("Missing 'functions' key")
|
|
78
|
+
elif not isinstance(data["functions"], dict):
|
|
79
|
+
errors.append("'functions' must be a dictionary")
|
|
80
|
+
else:
|
|
81
|
+
for func_name, exceptions in data["functions"].items():
|
|
82
|
+
if not isinstance(exceptions, list):
|
|
83
|
+
errors.append(f"'{func_name}' must map to a list of exceptions")
|
|
84
|
+
else:
|
|
85
|
+
for exc in exceptions:
|
|
86
|
+
if not isinstance(exc, str):
|
|
87
|
+
errors.append(f"Exception in '{func_name}' must be a string")
|
|
88
|
+
|
|
89
|
+
return errors
|
bubble/timing.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Simple timing instrumentation for performance analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TimingStats:
|
|
18
|
+
"""Collected timing statistics."""
|
|
19
|
+
|
|
20
|
+
timings: dict[str, float] = field(default_factory=dict)
|
|
21
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
22
|
+
enabled: bool = False
|
|
23
|
+
_console: Console | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_stats = TimingStats()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def enable(console: Console | None = None) -> None:
|
|
30
|
+
"""Enable timing collection."""
|
|
31
|
+
_stats.enabled = True
|
|
32
|
+
_stats.timings.clear()
|
|
33
|
+
_stats.counts.clear()
|
|
34
|
+
_stats._console = console
|
|
35
|
+
atexit.register(_print_report_on_exit)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def disable() -> None:
|
|
39
|
+
"""Disable timing collection."""
|
|
40
|
+
_stats.enabled = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_enabled() -> bool:
|
|
44
|
+
"""Check if timing is enabled."""
|
|
45
|
+
return _stats.enabled
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def timed(name: str) -> Generator[None, None, None]:
|
|
50
|
+
"""Context manager to time a block of code."""
|
|
51
|
+
if not _stats.enabled:
|
|
52
|
+
yield
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
start = time.perf_counter()
|
|
56
|
+
yield
|
|
57
|
+
elapsed = time.perf_counter() - start
|
|
58
|
+
|
|
59
|
+
_stats.timings[name] = _stats.timings.get(name, 0.0) + elapsed
|
|
60
|
+
_stats.counts[name] = _stats.counts.get(name, 0) + 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def record(name: str, elapsed: float) -> None:
|
|
64
|
+
"""Record a timing directly."""
|
|
65
|
+
if not _stats.enabled:
|
|
66
|
+
return
|
|
67
|
+
_stats.timings[name] = _stats.timings.get(name, 0.0) + elapsed
|
|
68
|
+
_stats.counts[name] = _stats.counts.get(name, 0) + 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def record_count(name: str, count: int) -> None:
|
|
72
|
+
"""Record a counter value (not a timing)."""
|
|
73
|
+
if not _stats.enabled:
|
|
74
|
+
return
|
|
75
|
+
_stats.timings[name] = 0.0
|
|
76
|
+
_stats.counts[name] = count
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_report() -> dict[str, dict[str, float | int]]:
|
|
80
|
+
"""Get timing report as dict."""
|
|
81
|
+
return {
|
|
82
|
+
name: {
|
|
83
|
+
"total_seconds": _stats.timings[name],
|
|
84
|
+
"count": _stats.counts[name],
|
|
85
|
+
"avg_ms": (_stats.timings[name] / _stats.counts[name]) * 1000,
|
|
86
|
+
}
|
|
87
|
+
for name in sorted(_stats.timings, key=lambda k: _stats.timings[k], reverse=True)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_report() -> str:
|
|
92
|
+
"""Format timing report as a string."""
|
|
93
|
+
report = get_report()
|
|
94
|
+
if not report:
|
|
95
|
+
return "No timing data collected."
|
|
96
|
+
|
|
97
|
+
time_metrics = []
|
|
98
|
+
counter_metrics = []
|
|
99
|
+
|
|
100
|
+
for name, data in report.items():
|
|
101
|
+
total = data["total_seconds"]
|
|
102
|
+
count = data["count"]
|
|
103
|
+
is_counter = (
|
|
104
|
+
total == 0.0
|
|
105
|
+
or name.startswith("propagation_")
|
|
106
|
+
and name
|
|
107
|
+
not in (
|
|
108
|
+
"propagation_setup",
|
|
109
|
+
"propagation_fixpoint",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if is_counter:
|
|
114
|
+
counter_metrics.append((name, count))
|
|
115
|
+
elif count == 1:
|
|
116
|
+
time_metrics.append((name, f"{total:>8.3f}s"))
|
|
117
|
+
else:
|
|
118
|
+
avg_ms = data["avg_ms"]
|
|
119
|
+
time_metrics.append((name, f"{total:>8.3f}s ({count:,} calls, {avg_ms:.2f}ms avg)"))
|
|
120
|
+
|
|
121
|
+
lines = ["", "Timing breakdown:"]
|
|
122
|
+
for name, value in time_metrics:
|
|
123
|
+
lines.append(f" {name:30s} {value}")
|
|
124
|
+
|
|
125
|
+
if counter_metrics:
|
|
126
|
+
lines.append("")
|
|
127
|
+
lines.append("Propagation stats:")
|
|
128
|
+
for name, count in counter_metrics:
|
|
129
|
+
display_name = name.replace("propagation_", " ")
|
|
130
|
+
lines.append(f" {display_name:30s} {count:,}")
|
|
131
|
+
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _print_report_on_exit() -> None:
|
|
136
|
+
"""Print timing report on program exit."""
|
|
137
|
+
if not _stats.enabled or not _stats.timings:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
report_str = format_report()
|
|
141
|
+
if _stats._console:
|
|
142
|
+
_stats._console.print(report_str)
|
|
143
|
+
else:
|
|
144
|
+
print(report_str)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bubble-analysis
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Static analysis tool for tracing exception flow through Python codebases
|
|
5
|
+
Author-email: Ian McLaughlin <ianm199@github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ianm199/flow
|
|
8
|
+
Project-URL: Repository, https://github.com/ianm199/flow
|
|
9
|
+
Project-URL: Issues, https://github.com/ianm199/flow/issues
|
|
10
|
+
Keywords: static-analysis,exceptions,python,code-analysis,flask,fastapi
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: libcst>=1.1.0
|
|
26
|
+
Requires-Dist: typer>=0.12.0
|
|
27
|
+
Requires-Dist: rich>=13.0.0
|
|
28
|
+
Requires-Dist: msgpack>=1.0.0
|
|
29
|
+
Requires-Dist: pyyaml>=6.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# flow
|
|
37
|
+
|
|
38
|
+
Static analysis tool for tracing exception flow through Python codebases.
|
|
39
|
+
|
|
40
|
+
**What can escape from my API endpoints?** Flow answers this by parsing your code, building a call graph, and computing which exceptions propagate to each entrypoint.
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install bubble-analysis
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Check Flask routes for uncaught exceptions
|
|
50
|
+
bubble flask audit -d /path/to/project
|
|
51
|
+
|
|
52
|
+
# Check FastAPI routes
|
|
53
|
+
bubble fastapi audit -d /path/to/project
|
|
54
|
+
|
|
55
|
+
# Deep dive into one function
|
|
56
|
+
bubble escapes create_user -d /path/to/project
|
|
57
|
+
|
|
58
|
+
# Visualize the call tree
|
|
59
|
+
bubble trace create_user -d /path/to/project
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What It Does
|
|
63
|
+
|
|
64
|
+
Flow finds your HTTP routes and CLI scripts, traces the call graph, and reports which exceptions can escape:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
$ flow flask audit
|
|
68
|
+
|
|
69
|
+
Scanning 23 flask entrypoints...
|
|
70
|
+
|
|
71
|
+
3 entrypoints have uncaught exceptions:
|
|
72
|
+
|
|
73
|
+
POST /users/import
|
|
74
|
+
└─ FileNotFoundError (importers.py:45)
|
|
75
|
+
└─ ValidationError (validators.py:12)
|
|
76
|
+
|
|
77
|
+
GET /reports/{id}
|
|
78
|
+
└─ PermissionError (auth.py:89)
|
|
79
|
+
|
|
80
|
+
20 entrypoints fully covered by exception handlers
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For a specific endpoint, see the full picture:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
$ flow escapes create_user
|
|
87
|
+
|
|
88
|
+
Exceptions that can escape from POST /users:
|
|
89
|
+
|
|
90
|
+
FRAMEWORK-HANDLED (converted to HTTP response):
|
|
91
|
+
HTTPException
|
|
92
|
+
└─ becomes: HTTP 404
|
|
93
|
+
└─ raised in: routes/users.py:45 (get_user) [high confidence]
|
|
94
|
+
|
|
95
|
+
CAUGHT BY GLOBAL HANDLER:
|
|
96
|
+
ValidationError (@errorhandler(AppError))
|
|
97
|
+
└─ raised in: validators.py:27 (validate_input) [high confidence]
|
|
98
|
+
|
|
99
|
+
UNCAUGHT (will propagate to caller):
|
|
100
|
+
ConnectionError
|
|
101
|
+
└─ raised in: db/client.py:45 (execute) [medium confidence]
|
|
102
|
+
└─ call path: create_user → save_user → db.execute
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Visualize as a tree:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
$ flow trace create_user
|
|
109
|
+
|
|
110
|
+
POST /users → escapes: ValidationError, ConnectionError
|
|
111
|
+
├── validate_input() → ValidationError
|
|
112
|
+
│ └── raises ValidationError (validators.py:27)
|
|
113
|
+
└── save_user() → ConnectionError
|
|
114
|
+
└── db.execute() → ConnectionError
|
|
115
|
+
└── raises ConnectionError (db/client.py:45)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Features
|
|
119
|
+
|
|
120
|
+
- **Entrypoint detection**: Flask routes, FastAPI routes, CLI scripts (`if __name__ == "__main__"`)
|
|
121
|
+
- **Global handler awareness**: Understands `@errorhandler`, `add_exception_handler`
|
|
122
|
+
- **Exception hierarchy**: Knows that catching `AppError` also catches `ValidationError` if it's a subclass
|
|
123
|
+
- **Polymorphism**: Expands abstract method calls to all concrete implementations
|
|
124
|
+
- **Framework-handled exceptions**: Detects HTTPException, ValidationError → HTTP responses
|
|
125
|
+
- **Confidence levels**: Shows high/medium/low confidence based on resolution quality
|
|
126
|
+
- **Resolution modes**: `--strict` for precision, `--aggressive` for recall
|
|
127
|
+
- **Exception stubs**: Declare what external libraries can raise (requests, sqlalchemy, etc.)
|
|
128
|
+
- **JSON output**: All commands support `-f json` for CI/automation
|
|
129
|
+
- **Caching**: SQLite-based caching for fast repeated analysis
|
|
130
|
+
|
|
131
|
+
## Commands
|
|
132
|
+
|
|
133
|
+
### Core Commands (framework-agnostic)
|
|
134
|
+
|
|
135
|
+
| Command | Description |
|
|
136
|
+
|---------|-------------|
|
|
137
|
+
| `bubble raises <exception>` | Find all places an exception is raised |
|
|
138
|
+
| `bubble escapes <function>` | Show what can escape from a specific function |
|
|
139
|
+
| `bubble callers <function>` | Find all callers of a function |
|
|
140
|
+
| `bubble catches <exception>` | Find all places an exception is caught |
|
|
141
|
+
| `bubble trace <function>` | Visualize exception flow as a call tree |
|
|
142
|
+
| `bubble exceptions` | Show the exception class hierarchy |
|
|
143
|
+
| `bubble subclasses <class>` | Show class inheritance tree |
|
|
144
|
+
| `bubble stubs <action>` | Manage exception stubs (`list`, `init`, `validate`) |
|
|
145
|
+
| `bubble stats` | Show codebase statistics |
|
|
146
|
+
|
|
147
|
+
### Framework-Specific Commands
|
|
148
|
+
|
|
149
|
+
| Command | Description |
|
|
150
|
+
|---------|-------------|
|
|
151
|
+
| `bubble flask audit` | Check Flask routes for escaping exceptions |
|
|
152
|
+
| `bubble flask entrypoints` | List Flask HTTP routes |
|
|
153
|
+
| `bubble flask routes-to <exc>` | Which Flask routes can trigger this exception? |
|
|
154
|
+
| `bubble fastapi audit` | Check FastAPI routes for escaping exceptions |
|
|
155
|
+
| `bubble fastapi entrypoints` | List FastAPI HTTP routes |
|
|
156
|
+
| `bubble fastapi routes-to <exc>` | Which FastAPI routes can trigger this exception? |
|
|
157
|
+
| `bubble cli audit` | Check CLI scripts for escaping exceptions |
|
|
158
|
+
| `bubble cli entrypoints` | List CLI scripts |
|
|
159
|
+
| `bubble cli scripts-to <exc>` | Which CLI scripts can trigger this exception? |
|
|
160
|
+
|
|
161
|
+
All commands accept:
|
|
162
|
+
- `-d, --directory`: Directory to analyze (default: current)
|
|
163
|
+
- `-f, --format`: Output format (`text` or `json`)
|
|
164
|
+
- `--no-cache`: Disable caching
|
|
165
|
+
|
|
166
|
+
The `escapes` command accepts additional flags:
|
|
167
|
+
- `--strict`: High precision mode - only includes precisely resolved calls
|
|
168
|
+
- `--aggressive`: High recall mode - includes fuzzy matches
|
|
169
|
+
|
|
170
|
+
## Supported Frameworks
|
|
171
|
+
|
|
172
|
+
**Detected automatically:**
|
|
173
|
+
- Flask (`@app.route`, `@blueprint.route`, `@app.errorhandler`)
|
|
174
|
+
- FastAPI (`@router.get/post/put/delete`, `add_exception_handler`)
|
|
175
|
+
- CLI scripts (`if __name__ == "__main__"`)
|
|
176
|
+
|
|
177
|
+
**Not yet supported:**
|
|
178
|
+
- Django
|
|
179
|
+
- Celery tasks
|
|
180
|
+
- Scheduled jobs (APScheduler, etc.)
|
|
181
|
+
|
|
182
|
+
Custom patterns can be added via `.flow/detectors/` (run `bubble init` to set up).
|
|
183
|
+
|
|
184
|
+
## Adding Custom Detectors
|
|
185
|
+
|
|
186
|
+
Flow is designed to be extended with AI coding agents. The detector interface is intentionally simple: implement a protocol that returns entrypoints and handlers from parsed code.
|
|
187
|
+
|
|
188
|
+
To add support for a new framework (Django, Celery, your internal RPC layer, etc.):
|
|
189
|
+
|
|
190
|
+
1. Run `bubble init` to create the `.flow/` directory structure
|
|
191
|
+
2. Point your AI agent at `flow/protocols.py` to see the `EntrypointDetector` interface
|
|
192
|
+
3. Ask it to implement a detector for your framework in `.flow/detectors/`
|
|
193
|
+
|
|
194
|
+
Example prompt for an AI agent:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
Read flow/protocols.py and flow/detectors.py to understand how entrypoint
|
|
198
|
+
detection works. Then implement a detector for Django that finds:
|
|
199
|
+
- Views decorated with @api_view
|
|
200
|
+
- Class-based views inheriting from APIView
|
|
201
|
+
- URL patterns in urls.py
|
|
202
|
+
|
|
203
|
+
Put the implementation in .flow/detectors/django.py
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The detector just needs to implement:
|
|
207
|
+
- `detect_entrypoints(functions, classes, ...)` → list of `Entrypoint`
|
|
208
|
+
- `detect_global_handlers(...)` → list of `GlobalHandler`
|
|
209
|
+
|
|
210
|
+
Flow will automatically load any `.py` files in `.flow/detectors/` and use them alongside the built-in Flask/FastAPI detectors.
|
|
211
|
+
|
|
212
|
+
## Configuration
|
|
213
|
+
|
|
214
|
+
Flow can be configured via `.flow/config.yaml`:
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
resolution_mode: default # "strict", "default", or "aggressive"
|
|
218
|
+
exclude:
|
|
219
|
+
- vendor
|
|
220
|
+
- migrations
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Exception Stubs
|
|
224
|
+
|
|
225
|
+
Flow includes built-in stubs for common libraries (requests, sqlalchemy, httpx, redis, boto3). These declare what exceptions external library functions can raise.
|
|
226
|
+
|
|
227
|
+
Add custom stubs in `.flow/stubs/`:
|
|
228
|
+
|
|
229
|
+
```yaml
|
|
230
|
+
# .flow/stubs/mylib.yaml
|
|
231
|
+
mylib:
|
|
232
|
+
do_thing:
|
|
233
|
+
- MyLibError
|
|
234
|
+
- TimeoutError
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Manage stubs with `bubble stubs list` and `bubble stubs validate`.
|
|
238
|
+
|
|
239
|
+
## How It Works
|
|
240
|
+
|
|
241
|
+
1. **Parse**: LibCST parses all Python files
|
|
242
|
+
2. **Extract**: Find functions, classes, raise/catch sites, calls, entrypoints
|
|
243
|
+
3. **Build call graph**: Track who calls whom, resolve method calls
|
|
244
|
+
4. **Propagate**: Fixed-point iteration computes which exceptions escape each function
|
|
245
|
+
5. **Report**: For each entrypoint, show caught vs uncaught exceptions
|
|
246
|
+
|
|
247
|
+
## Limitations
|
|
248
|
+
|
|
249
|
+
- **Over-approximation**: May report more exceptions than actually possible (e.g., all implementations of an abstract method)
|
|
250
|
+
- **Under-approximation**: Dynamic dispatch, `eval()`, and external libraries can't be fully traced
|
|
251
|
+
- **No runtime info**: Analysis is purely static
|
|
252
|
+
|
|
253
|
+
## Development
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
git clone https://github.com/ianm199/flow
|
|
257
|
+
cd flow
|
|
258
|
+
pip install -e ".[dev]"
|
|
259
|
+
pytest
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT
|