bubble-analysis 0.2.0__py3-none-any.whl → 0.3.4__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/detectors.py +7 -4
- bubble/extractor.py +37 -10
- bubble/formatters.py +7 -1
- bubble/integrations/cli_scripts/cli.py +5 -1
- bubble/integrations/django/cli.py +42 -6
- bubble/integrations/django/detector.py +57 -12
- bubble/integrations/fastapi/cli.py +47 -7
- bubble/integrations/flask/__init__.py +4 -0
- bubble/integrations/flask/cli.py +47 -7
- bubble/integrations/flask/detector.py +243 -5
- bubble/integrations/formatters.py +7 -0
- bubble/integrations/models.py +9 -1
- bubble/integrations/queries.py +40 -13
- bubble/propagation.py +176 -15
- bubble/queries.py +105 -13
- bubble/results.py +1 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/METADATA +56 -35
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/RECORD +22 -22
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/WHEEL +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/entry_points.txt +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/top_level.txt +0 -0
bubble/propagation.py
CHANGED
|
@@ -39,10 +39,20 @@ class PropagatedRaise:
|
|
|
39
39
|
|
|
40
40
|
@dataclass
|
|
41
41
|
class ExceptionFlow:
|
|
42
|
-
"""The computed exception flow for a function or entrypoint.
|
|
42
|
+
"""The computed exception flow for a function or entrypoint.
|
|
43
|
+
|
|
44
|
+
Categories:
|
|
45
|
+
- caught_locally: Caught by try/except in the function
|
|
46
|
+
- caught_by_global: Caught by a global handler in the same file as the entrypoint
|
|
47
|
+
- caught_by_remote_global: Caught by a global handler in a different file
|
|
48
|
+
- caught_by_generic: Caught by a generic handler (@errorhandler(Exception))
|
|
49
|
+
- uncaught: No handler found
|
|
50
|
+
- framework_handled: Converted to HTTP response by framework (e.g., HTTPException)
|
|
51
|
+
"""
|
|
43
52
|
|
|
44
53
|
caught_locally: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
45
54
|
caught_by_global: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
55
|
+
caught_by_remote_global: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
46
56
|
caught_by_generic: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
47
57
|
uncaught: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
48
58
|
framework_handled: dict[str, list[tuple[RaiseSite, str]]] = field(default_factory=dict)
|
|
@@ -61,14 +71,79 @@ class PropagationResult:
|
|
|
61
71
|
)
|
|
62
72
|
|
|
63
73
|
|
|
74
|
+
def _build_func_name_index(model: ProgramModel) -> dict[str, list[str]]:
|
|
75
|
+
"""Build an index from function name suffixes to qualified keys."""
|
|
76
|
+
index: dict[str, list[str]] = {}
|
|
77
|
+
for key in model.functions:
|
|
78
|
+
if "::" in key:
|
|
79
|
+
func_name = key.split("::")[-1]
|
|
80
|
+
elif ":" in key:
|
|
81
|
+
func_name = key.split(":")[-1]
|
|
82
|
+
else:
|
|
83
|
+
func_name = key
|
|
84
|
+
if func_name not in index:
|
|
85
|
+
index[func_name] = []
|
|
86
|
+
index[func_name].append(key)
|
|
87
|
+
return index
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_normalize_cache: dict[str, str] = {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_callee_to_file_format(
|
|
94
|
+
callee_qualified: str,
|
|
95
|
+
model: ProgramModel,
|
|
96
|
+
func_index: dict[str, list[str]] | None = None,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Normalize a module-dot callee to file::function format.
|
|
99
|
+
|
|
100
|
+
Converts: blueprints.orchestrators.CallbackOrchestrators.BalanceCallbackOrchestrator.run
|
|
101
|
+
To: blueprints/orchestrators/CallbackOrchestrators.py::BalanceCallbackOrchestrator.run
|
|
102
|
+
"""
|
|
103
|
+
if "::" in callee_qualified:
|
|
104
|
+
return callee_qualified
|
|
105
|
+
|
|
106
|
+
if callee_qualified in _normalize_cache:
|
|
107
|
+
return _normalize_cache[callee_qualified]
|
|
108
|
+
|
|
109
|
+
parts = callee_qualified.split(".")
|
|
110
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
111
|
+
module_path = "/".join(parts[:i]) + ".py"
|
|
112
|
+
func_name = ".".join(parts[i:])
|
|
113
|
+
|
|
114
|
+
for sep in ("::", ":"):
|
|
115
|
+
candidate_key = f"{module_path}{sep}{func_name}"
|
|
116
|
+
if candidate_key in model.functions:
|
|
117
|
+
normalized = f"{module_path}::{func_name}"
|
|
118
|
+
_normalize_cache[callee_qualified] = normalized
|
|
119
|
+
return normalized
|
|
120
|
+
|
|
121
|
+
if func_index is not None:
|
|
122
|
+
candidates = func_index.get(func_name, [])
|
|
123
|
+
module_normalized = module_path.replace("/", ".").replace(".py", "")
|
|
124
|
+
for key in candidates:
|
|
125
|
+
if module_normalized in key.replace("/", "."):
|
|
126
|
+
file_part = key.split(":")[0]
|
|
127
|
+
result = f"{file_part}::{func_name}"
|
|
128
|
+
_normalize_cache[callee_qualified] = result
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
_normalize_cache[callee_qualified] = callee_qualified
|
|
132
|
+
return callee_qualified
|
|
133
|
+
|
|
134
|
+
|
|
64
135
|
def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
|
|
65
136
|
"""Build a map from caller to callees."""
|
|
66
137
|
graph: dict[str, set[str]] = {}
|
|
138
|
+
func_index = _build_func_name_index(model)
|
|
67
139
|
|
|
68
140
|
for call_site in model.call_sites:
|
|
69
141
|
caller = call_site.caller_qualified or f"{call_site.file}::{call_site.caller_function}"
|
|
70
142
|
callee = call_site.callee_qualified or call_site.callee_name
|
|
71
143
|
|
|
144
|
+
if call_site.callee_qualified and "::" not in call_site.callee_qualified:
|
|
145
|
+
callee = _normalize_callee_to_file_format(call_site.callee_qualified, model, func_index)
|
|
146
|
+
|
|
72
147
|
if caller not in graph:
|
|
73
148
|
graph[caller] = set()
|
|
74
149
|
graph[caller].add(callee)
|
|
@@ -76,6 +151,59 @@ def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
|
|
|
76
151
|
return graph
|
|
77
152
|
|
|
78
153
|
|
|
154
|
+
def compute_forward_reachability(
|
|
155
|
+
start_func: str,
|
|
156
|
+
model: ProgramModel,
|
|
157
|
+
forward_graph: dict[str, set[str]] | None = None,
|
|
158
|
+
) -> set[str]:
|
|
159
|
+
"""Compute all functions reachable from a starting function via forward calls.
|
|
160
|
+
|
|
161
|
+
Unlike compute_reachable_functions, this doesn't require propagation results,
|
|
162
|
+
making it suitable for scoping propagation before it runs.
|
|
163
|
+
|
|
164
|
+
Returns both qualified names and simple names for efficient scope checking.
|
|
165
|
+
"""
|
|
166
|
+
if forward_graph is None:
|
|
167
|
+
forward_graph = build_forward_call_graph(model)
|
|
168
|
+
|
|
169
|
+
simple_to_qualified: dict[str, list[str]] = {}
|
|
170
|
+
for key in forward_graph:
|
|
171
|
+
simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
|
|
172
|
+
if simple not in simple_to_qualified:
|
|
173
|
+
simple_to_qualified[simple] = []
|
|
174
|
+
simple_to_qualified[simple].append(key)
|
|
175
|
+
|
|
176
|
+
reachable: set[str] = set()
|
|
177
|
+
start_simple = start_func.split("::")[-1].split(".")[-1] if "::" in start_func else start_func.split(".")[-1]
|
|
178
|
+
worklist = [start_func] + simple_to_qualified.get(start_simple, [])
|
|
179
|
+
|
|
180
|
+
while worklist:
|
|
181
|
+
current = worklist.pop()
|
|
182
|
+
if current in reachable:
|
|
183
|
+
continue
|
|
184
|
+
reachable.add(current)
|
|
185
|
+
|
|
186
|
+
current_simple = (
|
|
187
|
+
current.split("::")[-1].split(".")[-1] if "::" in current else current.split(".")[-1]
|
|
188
|
+
)
|
|
189
|
+
reachable.add(current_simple)
|
|
190
|
+
|
|
191
|
+
callees = forward_graph.get(current, set())
|
|
192
|
+
if not callees:
|
|
193
|
+
for qualified_key in simple_to_qualified.get(current_simple, []):
|
|
194
|
+
if qualified_key not in reachable:
|
|
195
|
+
callees = forward_graph.get(qualified_key, set())
|
|
196
|
+
if callees:
|
|
197
|
+
reachable.add(qualified_key)
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
for callee in callees:
|
|
201
|
+
if callee not in reachable:
|
|
202
|
+
worklist.append(callee)
|
|
203
|
+
|
|
204
|
+
return reachable
|
|
205
|
+
|
|
206
|
+
|
|
79
207
|
def build_name_to_qualified(propagation: PropagationResult) -> dict[str, list[str]]:
|
|
80
208
|
"""Build a map from simple function names to their qualified names."""
|
|
81
209
|
name_to_qualified: dict[str, list[str]] = {}
|
|
@@ -218,6 +346,23 @@ def _build_call_site_lookup(
|
|
|
218
346
|
return lookup
|
|
219
347
|
|
|
220
348
|
|
|
349
|
+
def _build_is_method_lookup(model: ProgramModel) -> dict[tuple[str, str], bool]:
|
|
350
|
+
"""Build a lookup for is_method_call by (caller, callee) pairs.
|
|
351
|
+
|
|
352
|
+
This is always needed for correct fallback resolution, even when
|
|
353
|
+
skip_evidence=True. When a call is in the form obj.method(), we
|
|
354
|
+
should prefer matching methods over standalone functions.
|
|
355
|
+
"""
|
|
356
|
+
lookup: dict[tuple[str, str], bool] = {}
|
|
357
|
+
for cs in model.call_sites:
|
|
358
|
+
caller = cs.caller_qualified or f"{cs.file}::{cs.caller_function}"
|
|
359
|
+
callee = cs.callee_qualified or cs.callee_name
|
|
360
|
+
key = (caller, callee)
|
|
361
|
+
if key not in lookup:
|
|
362
|
+
lookup[key] = cs.is_method_call
|
|
363
|
+
return lookup
|
|
364
|
+
|
|
365
|
+
|
|
221
366
|
def _build_raise_site_lookup(
|
|
222
367
|
model: ProgramModel,
|
|
223
368
|
) -> dict[tuple[str, str, int], RaiseSite]:
|
|
@@ -278,9 +423,7 @@ def _scoped_fallback_lookup(
|
|
|
278
423
|
candidates = name_to_qualified.get(fallback_key, [])
|
|
279
424
|
|
|
280
425
|
if not candidates:
|
|
281
|
-
|
|
282
|
-
_fallback_cache[cache_key] = result
|
|
283
|
-
return result
|
|
426
|
+
return ([], "none")
|
|
284
427
|
|
|
285
428
|
same_file = [c for c in candidates if c.startswith(f"{caller_file}::")]
|
|
286
429
|
if same_file:
|
|
@@ -314,6 +457,7 @@ def propagate_exceptions(
|
|
|
314
457
|
resolution_mode: ResolutionMode = ResolutionMode.DEFAULT,
|
|
315
458
|
stub_library: StubLibrary | None = None,
|
|
316
459
|
skip_evidence: bool = False,
|
|
460
|
+
scope: set[str] | None = None,
|
|
317
461
|
) -> PropagationResult:
|
|
318
462
|
"""
|
|
319
463
|
Propagate exceptions through the call graph.
|
|
@@ -324,6 +468,8 @@ def propagate_exceptions(
|
|
|
324
468
|
Args:
|
|
325
469
|
skip_evidence: If True, skip building evidence paths for faster propagation.
|
|
326
470
|
Use for audit commands where only exception types matter.
|
|
471
|
+
scope: If provided, only propagate through functions in this set.
|
|
472
|
+
Use compute_forward_reachability() to get the scope for a single function.
|
|
327
473
|
|
|
328
474
|
Resolution modes:
|
|
329
475
|
- strict: Only follow resolved calls (no name_fallback or polymorphic)
|
|
@@ -332,21 +478,35 @@ def propagate_exceptions(
|
|
|
332
478
|
"""
|
|
333
479
|
from bubble import timing
|
|
334
480
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
481
|
+
if scope is not None:
|
|
482
|
+
cache_key = None
|
|
483
|
+
else:
|
|
484
|
+
cache_key = (
|
|
485
|
+
id(model),
|
|
486
|
+
resolution_mode,
|
|
487
|
+
id(stub_library) if stub_library else None,
|
|
488
|
+
skip_evidence,
|
|
489
|
+
)
|
|
341
490
|
|
|
342
|
-
|
|
343
|
-
|
|
491
|
+
if cache_key in _propagation_cache:
|
|
492
|
+
return _propagation_cache[cache_key]
|
|
344
493
|
|
|
345
494
|
with timing.timed("propagation_setup"):
|
|
346
495
|
direct_raises = compute_direct_raises(model)
|
|
347
496
|
catches_by_function = compute_catches_by_function(model)
|
|
348
|
-
|
|
497
|
+
full_forward_graph = build_forward_call_graph(model)
|
|
498
|
+
|
|
499
|
+
if scope is not None:
|
|
500
|
+
forward_graph = {}
|
|
501
|
+
for caller, callees in full_forward_graph.items():
|
|
502
|
+
caller_simple = caller.split("::")[-1].split(".")[-1] if "::" in caller else caller.split(".")[-1]
|
|
503
|
+
if caller in scope or caller_simple in scope:
|
|
504
|
+
forward_graph[caller] = callees
|
|
505
|
+
else:
|
|
506
|
+
forward_graph = full_forward_graph
|
|
507
|
+
|
|
349
508
|
call_site_lookup = _build_call_site_lookup(model) if not skip_evidence else {}
|
|
509
|
+
is_method_lookup = _build_is_method_lookup(model)
|
|
350
510
|
|
|
351
511
|
propagated: dict[str, set[str]] = {}
|
|
352
512
|
propagated_evidence: dict[str, dict[tuple[str, str, int], PropagatedRaise]] = {}
|
|
@@ -418,7 +578,7 @@ def propagate_exceptions(
|
|
|
418
578
|
if "::" in expanded_callee
|
|
419
579
|
else expanded_callee.split(".")[-1]
|
|
420
580
|
)
|
|
421
|
-
is_method =
|
|
581
|
+
is_method = is_method_lookup.get((caller, callee), False)
|
|
422
582
|
caller_file = caller.split("::")[0] if "::" in caller else caller
|
|
423
583
|
import_map = model.import_maps.get(caller_file, {})
|
|
424
584
|
|
|
@@ -533,7 +693,8 @@ def propagate_exceptions(
|
|
|
533
693
|
propagated_with_evidence=propagated_evidence,
|
|
534
694
|
)
|
|
535
695
|
|
|
536
|
-
|
|
696
|
+
if cache_key is not None:
|
|
697
|
+
_propagation_cache[cache_key] = result
|
|
537
698
|
return result
|
|
538
699
|
|
|
539
700
|
|
bubble/queries.py
CHANGED
|
@@ -7,11 +7,12 @@ and returns a typed result dataclass. No formatting here.
|
|
|
7
7
|
from difflib import get_close_matches
|
|
8
8
|
|
|
9
9
|
from bubble.enums import EntrypointKind, Framework, ResolutionMode
|
|
10
|
-
from bubble.models import Entrypoint, ProgramModel
|
|
10
|
+
from bubble.models import CatchSite, ClassHierarchy, Entrypoint, ProgramModel, RaiseSite
|
|
11
11
|
from bubble.propagation import (
|
|
12
12
|
build_forward_call_graph,
|
|
13
13
|
build_reverse_call_graph,
|
|
14
14
|
compute_exception_flow,
|
|
15
|
+
compute_forward_reachability,
|
|
15
16
|
propagate_exceptions,
|
|
16
17
|
)
|
|
17
18
|
from bubble.results import (
|
|
@@ -309,7 +310,15 @@ def find_escapes(
|
|
|
309
310
|
entrypoint = e
|
|
310
311
|
break
|
|
311
312
|
|
|
312
|
-
|
|
313
|
+
forward_graph = build_forward_call_graph(model)
|
|
314
|
+
scope = compute_forward_reachability(function_name, model, forward_graph)
|
|
315
|
+
|
|
316
|
+
propagation = propagate_exceptions(
|
|
317
|
+
model,
|
|
318
|
+
resolution_mode=resolution_mode,
|
|
319
|
+
skip_evidence=True,
|
|
320
|
+
scope=scope,
|
|
321
|
+
)
|
|
313
322
|
flow = compute_exception_flow(function_name, model, propagation)
|
|
314
323
|
|
|
315
324
|
return EscapesResult(
|
|
@@ -320,28 +329,110 @@ def find_escapes(
|
|
|
320
329
|
)
|
|
321
330
|
|
|
322
331
|
|
|
332
|
+
def _compute_reverse_reachability(
|
|
333
|
+
raise_sites: list[RaiseSite],
|
|
334
|
+
qualified_graph: dict[str, set[str]],
|
|
335
|
+
name_graph: dict[str, set[str]],
|
|
336
|
+
) -> set[str]:
|
|
337
|
+
"""Compute all functions that can transitively call the raise site functions."""
|
|
338
|
+
reachable: set[str] = set()
|
|
339
|
+
|
|
340
|
+
for raise_site in raise_sites:
|
|
341
|
+
func_key = f"{raise_site.file}::{raise_site.function}"
|
|
342
|
+
worklist = [func_key]
|
|
343
|
+
visited: set[str] = set()
|
|
344
|
+
|
|
345
|
+
while worklist:
|
|
346
|
+
current = worklist.pop()
|
|
347
|
+
if current in visited:
|
|
348
|
+
continue
|
|
349
|
+
visited.add(current)
|
|
350
|
+
reachable.add(current)
|
|
351
|
+
|
|
352
|
+
simple_name = (
|
|
353
|
+
current.split("::")[-1].split(".")[-1]
|
|
354
|
+
if "::" in current
|
|
355
|
+
else current.split(".")[-1]
|
|
356
|
+
)
|
|
357
|
+
reachable.add(simple_name)
|
|
358
|
+
|
|
359
|
+
for caller in qualified_graph.get(current, set()):
|
|
360
|
+
if caller not in visited:
|
|
361
|
+
worklist.append(caller)
|
|
362
|
+
|
|
363
|
+
for caller in name_graph.get(simple_name, set()):
|
|
364
|
+
if caller not in visited:
|
|
365
|
+
worklist.append(caller)
|
|
366
|
+
|
|
367
|
+
return reachable
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _catch_site_catches_exception(
|
|
371
|
+
catch_site: CatchSite,
|
|
372
|
+
types_to_find: set[str],
|
|
373
|
+
hierarchy: ClassHierarchy,
|
|
374
|
+
) -> bool:
|
|
375
|
+
"""Check if a catch site would catch the given exception type."""
|
|
376
|
+
if catch_site.has_bare_except:
|
|
377
|
+
return True
|
|
378
|
+
|
|
379
|
+
for caught_type in catch_site.caught_types:
|
|
380
|
+
caught_simple = caught_type.split(".")[-1]
|
|
381
|
+
|
|
382
|
+
if caught_type in types_to_find or caught_simple in types_to_find:
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
if caught_simple in ("Exception", "BaseException"):
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
for t in types_to_find:
|
|
389
|
+
t_simple = t.split(".")[-1]
|
|
390
|
+
if hierarchy.is_subclass_of(t_simple, caught_simple):
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
|
|
323
396
|
def find_catches(
|
|
324
397
|
model: ProgramModel,
|
|
325
398
|
exception_type: str,
|
|
326
399
|
include_subclasses: bool = False,
|
|
327
400
|
) -> CatchesResult:
|
|
328
|
-
"""Find
|
|
401
|
+
"""Find catch sites that are in the call path from where the exception is raised."""
|
|
329
402
|
types_to_find: set[str] = {exception_type}
|
|
330
403
|
if include_subclasses:
|
|
331
404
|
subclasses = model.exception_hierarchy.get_subclasses(exception_type)
|
|
332
405
|
types_to_find.update(subclasses)
|
|
333
406
|
|
|
334
|
-
|
|
407
|
+
raises_result = find_raises(model, exception_type, include_subclasses)
|
|
408
|
+
|
|
409
|
+
if not raises_result.matches:
|
|
410
|
+
return CatchesResult(
|
|
411
|
+
exception_type=exception_type,
|
|
412
|
+
include_subclasses=include_subclasses,
|
|
413
|
+
types_searched=types_to_find,
|
|
414
|
+
local_catches=[],
|
|
415
|
+
global_handlers=[],
|
|
416
|
+
raise_site_count=0,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
qualified_graph, name_graph = build_reverse_call_graph(model)
|
|
420
|
+
reachable_functions = _compute_reverse_reachability(
|
|
421
|
+
raises_result.matches, qualified_graph, name_graph
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
matching_catches: list[CatchSite] = []
|
|
335
425
|
for catch_site in model.catch_sites:
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
426
|
+
catch_func_key = f"{catch_site.file}::{catch_site.function}"
|
|
427
|
+
catch_func_simple = catch_site.function.split(".")[-1]
|
|
428
|
+
|
|
429
|
+
if catch_func_key not in reachable_functions and catch_func_simple not in reachable_functions:
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
if _catch_site_catches_exception(
|
|
433
|
+
catch_site, types_to_find, model.exception_hierarchy
|
|
434
|
+
):
|
|
435
|
+
matching_catches.append(catch_site)
|
|
345
436
|
|
|
346
437
|
global_handlers = [
|
|
347
438
|
h
|
|
@@ -360,6 +451,7 @@ def find_catches(
|
|
|
360
451
|
types_searched=types_to_find,
|
|
361
452
|
local_catches=matching_catches,
|
|
362
453
|
global_handlers=global_handlers,
|
|
454
|
+
raise_site_count=len(raises_result.matches),
|
|
363
455
|
)
|
|
364
456
|
|
|
365
457
|
|
bubble/results.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bubble-analysis
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Static analysis tool for tracing exception flow through Python codebases
|
|
5
5
|
Author-email: Ian McLaughlin <ianm199@github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,13 +13,14 @@ Classifier: Environment :: Console
|
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: Operating System :: OS Independent
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
20
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
21
|
Classifier: Topic :: Software Development :: Testing
|
|
21
22
|
Classifier: Typing :: Typed
|
|
22
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.10
|
|
23
24
|
Description-Content-Type: text/markdown
|
|
24
25
|
License-File: LICENSE
|
|
25
26
|
Requires-Dist: libcst>=1.1.0
|
|
@@ -33,11 +34,11 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
|
33
34
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
34
35
|
Dynamic: license-file
|
|
35
36
|
|
|
36
|
-
#
|
|
37
|
+
# Bubble
|
|
37
38
|
|
|
38
39
|
Static analysis tool for tracing exception flow through Python codebases.
|
|
39
40
|
|
|
40
|
-
**What can escape from my API endpoints?**
|
|
41
|
+
**What can escape from my API endpoints?** Bubble answers this by parsing your code, building a call graph, and computing which exceptions propagate to each entrypoint.
|
|
41
42
|
|
|
42
43
|
## Quick Start
|
|
43
44
|
|
|
@@ -61,10 +62,10 @@ bubble trace create_user -d /path/to/project
|
|
|
61
62
|
|
|
62
63
|
## What It Does
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
Bubble finds your HTTP routes and CLI scripts, traces the call graph, and reports which exceptions can escape:
|
|
65
66
|
|
|
66
67
|
```
|
|
67
|
-
$
|
|
68
|
+
$ bubble flask audit
|
|
68
69
|
|
|
69
70
|
Scanning 23 flask entrypoints...
|
|
70
71
|
|
|
@@ -83,7 +84,7 @@ Scanning 23 flask entrypoints...
|
|
|
83
84
|
For a specific endpoint, see the full picture:
|
|
84
85
|
|
|
85
86
|
```
|
|
86
|
-
$
|
|
87
|
+
$ bubble escapes create_user
|
|
87
88
|
|
|
88
89
|
Exceptions that can escape from POST /users:
|
|
89
90
|
|
|
@@ -105,7 +106,7 @@ Exceptions that can escape from POST /users:
|
|
|
105
106
|
Visualize as a tree:
|
|
106
107
|
|
|
107
108
|
```
|
|
108
|
-
$
|
|
109
|
+
$ bubble trace create_user
|
|
109
110
|
|
|
110
111
|
POST /users → escapes: ValidationError, ConnectionError
|
|
111
112
|
├── validate_input() → ValidationError
|
|
@@ -117,6 +118,7 @@ POST /users → escapes: ValidationError, ConnectionError
|
|
|
117
118
|
|
|
118
119
|
## Features
|
|
119
120
|
|
|
121
|
+
- **Extensible**: Adapt to any framework or custom pattern ([see extending guide](docs/EXTENDING.md))
|
|
120
122
|
- **Entrypoint detection**: Flask routes, FastAPI routes, CLI scripts (`if __name__ == "__main__"`)
|
|
121
123
|
- **Global handler awareness**: Understands `@errorhandler`, `add_exception_handler`
|
|
122
124
|
- **Exception hierarchy**: Knows that catching `AppError` also catches `ValidationError` if it's a subclass
|
|
@@ -124,9 +126,10 @@ POST /users → escapes: ValidationError, ConnectionError
|
|
|
124
126
|
- **Framework-handled exceptions**: Detects HTTPException, ValidationError → HTTP responses
|
|
125
127
|
- **Confidence levels**: Shows high/medium/low confidence based on resolution quality
|
|
126
128
|
- **Resolution modes**: `--strict` for precision, `--aggressive` for recall
|
|
127
|
-
- **Exception stubs**:
|
|
129
|
+
- **Exception stubs**: Built-in stubs for requests, sqlalchemy, httpx, redis, boto3
|
|
128
130
|
- **JSON output**: All commands support `-f json` for CI/automation
|
|
129
131
|
- **Caching**: SQLite-based caching for fast repeated analysis
|
|
132
|
+
- **Python 3.10+**: Supports Python 3.10, 3.11, 3.12, and 3.13
|
|
130
133
|
|
|
131
134
|
## Commands
|
|
132
135
|
|
|
@@ -179,39 +182,55 @@ The `escapes` command accepts additional flags:
|
|
|
179
182
|
- Celery tasks
|
|
180
183
|
- Scheduled jobs (APScheduler, etc.)
|
|
181
184
|
|
|
182
|
-
Custom patterns can be added via `.flow/detectors
|
|
185
|
+
Custom patterns can be added via `.flow/detectors/`.
|
|
183
186
|
|
|
184
|
-
##
|
|
187
|
+
## Extending Bubble
|
|
185
188
|
|
|
186
|
-
|
|
189
|
+
> **Bubble is designed to be adapted to your codebase.** The core engine handles exception propagation—you just tell it where your entrypoints are.
|
|
187
190
|
|
|
188
|
-
|
|
191
|
+
Every codebase has its own patterns: internal RPC frameworks, custom decorators, queue handlers, scheduled jobs. Bubble's extension system lets you teach it your patterns without modifying the core.
|
|
189
192
|
|
|
190
|
-
|
|
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
|
+
**Full guide: [docs/EXTENDING.md](docs/EXTENDING.md)**
|
|
193
194
|
|
|
194
|
-
|
|
195
|
+
### What You Can Extend
|
|
195
196
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
| Pattern | Example | How to add |
|
|
198
|
+
|---------|---------|------------|
|
|
199
|
+
| Custom entrypoints | Celery tasks, Django views, gRPC handlers | `EntrypointDetector` |
|
|
200
|
+
| Custom error handlers | Middleware, decorators | `GlobalHandlerDetector` |
|
|
201
|
+
| Full framework | Django REST Framework, Airflow | `Integration` protocol |
|
|
202
|
+
|
|
203
|
+
### Quick Example
|
|
204
|
+
|
|
205
|
+
Drop a detector in `.flow/detectors/` and bubble auto-loads it:
|
|
202
206
|
|
|
203
|
-
|
|
207
|
+
```python
|
|
208
|
+
# .flow/detectors/celery.py
|
|
209
|
+
from bubble.protocols import EntrypointDetector
|
|
210
|
+
from bubble.integrations.base import Entrypoint
|
|
211
|
+
from bubble.enums import EntrypointKind
|
|
212
|
+
|
|
213
|
+
class CeleryTaskDetector(EntrypointDetector):
|
|
214
|
+
def detect(self, source: str, file_path: str) -> list[Entrypoint]:
|
|
215
|
+
# Find @app.task decorators, return Entrypoint objects
|
|
216
|
+
...
|
|
204
217
|
```
|
|
205
218
|
|
|
206
|
-
|
|
207
|
-
- `detect_entrypoints(functions, classes, ...)` → list of `Entrypoint`
|
|
208
|
-
- `detect_global_handlers(...)` → list of `GlobalHandler`
|
|
219
|
+
### AI-Friendly Design
|
|
209
220
|
|
|
210
|
-
|
|
221
|
+
The protocol is intentionally simple for LLMs. Give your agent:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
Read bubble/protocols.py and bubble/integrations/flask/detector.py.
|
|
225
|
+
Implement a detector for [YOUR FRAMEWORK] that finds [PATTERNS].
|
|
226
|
+
Put it in .flow/detectors/[framework].py
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
See **[docs/EXTENDING.md](docs/EXTENDING.md)** for complete examples including Celery, Django REST Framework, and custom decorator patterns.
|
|
211
230
|
|
|
212
231
|
## Configuration
|
|
213
232
|
|
|
214
|
-
|
|
233
|
+
Bubble can be configured via `.flow/config.yaml`:
|
|
215
234
|
|
|
216
235
|
```yaml
|
|
217
236
|
resolution_mode: default # "strict", "default", or "aggressive"
|
|
@@ -222,13 +241,15 @@ exclude:
|
|
|
222
241
|
|
|
223
242
|
### Exception Stubs
|
|
224
243
|
|
|
225
|
-
|
|
244
|
+
Bubble includes built-in stubs for common libraries (requests, sqlalchemy, httpx, redis, boto3). These declare what exceptions external library functions can raise.
|
|
226
245
|
|
|
227
246
|
Add custom stubs in `.flow/stubs/`:
|
|
228
247
|
|
|
229
248
|
```yaml
|
|
230
249
|
# .flow/stubs/mylib.yaml
|
|
231
|
-
mylib
|
|
250
|
+
module: mylib
|
|
251
|
+
|
|
252
|
+
functions:
|
|
232
253
|
do_thing:
|
|
233
254
|
- MyLibError
|
|
234
255
|
- TimeoutError
|
|
@@ -253,10 +274,10 @@ Manage stubs with `bubble stubs list` and `bubble stubs validate`.
|
|
|
253
274
|
## Development
|
|
254
275
|
|
|
255
276
|
```bash
|
|
256
|
-
git clone https://github.com/ianm199/
|
|
257
|
-
cd
|
|
258
|
-
pip install -e ".[dev]"
|
|
259
|
-
pytest
|
|
277
|
+
git clone https://github.com/ianm199/bubble-analysis
|
|
278
|
+
cd bubble-analysis
|
|
279
|
+
uv pip install -e ".[dev]"
|
|
280
|
+
uv run pytest
|
|
260
281
|
```
|
|
261
282
|
|
|
262
283
|
## License
|