bubble-analysis 0.2.0__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of bubble-analysis might be problematic. Click here for more details.

bubble/detectors.py CHANGED
@@ -13,6 +13,7 @@ from bubble.integrations.cli_scripts.detector import (
13
13
  from bubble.integrations.django.detector import (
14
14
  DjangoExceptionHandlerVisitor,
15
15
  DjangoViewVisitor,
16
+ detect_django_entrypoints,
16
17
  )
17
18
  from bubble.integrations.django.semantics import (
18
19
  EXCEPTION_RESPONSES as DJANGO_EXCEPTION_RESPONSES,
@@ -26,7 +27,9 @@ from bubble.integrations.fastapi.semantics import (
26
27
  )
27
28
  from bubble.integrations.flask.detector import (
28
29
  FlaskErrorHandlerVisitor,
30
+ FlaskRESTfulVisitor,
29
31
  FlaskRouteVisitor,
32
+ detect_flask_entrypoints,
30
33
  )
31
34
  from bubble.integrations.flask.semantics import (
32
35
  EXCEPTION_RESPONSES as FLASK_EXCEPTION_RESPONSES,
@@ -53,13 +56,13 @@ FRAMEWORK_EXCEPTION_RESPONSES: dict[str, dict[str, str]] = {
53
56
  def detect_entrypoints(source: str, file_path: str) -> list[Entrypoint]:
54
57
  """Detect entrypoints in a Python source file (HTTP routes and CLI scripts).
55
58
 
56
- Uses the generic detector with Flask, FastAPI, and Django configurations,
57
- plus CLI script detection.
59
+ Uses framework-specific detectors for Flask and Django (with HTTP method detection),
60
+ the generic detector for FastAPI, plus CLI script detection.
58
61
  """
59
62
  entrypoints: list[Entrypoint] = []
60
- entrypoints.extend(generic_detect_entrypoints(source, file_path, FLASK_CONFIG))
63
+ entrypoints.extend(detect_flask_entrypoints(source, file_path))
61
64
  entrypoints.extend(generic_detect_entrypoints(source, file_path, FASTAPI_CONFIG))
62
- entrypoints.extend(generic_detect_entrypoints(source, file_path, DJANGO_CONFIG))
65
+ entrypoints.extend(detect_django_entrypoints(source, file_path))
63
66
  entrypoints.extend(detect_cli_entrypoints(source, file_path))
64
67
  return entrypoints
65
68
 
bubble/extractor.py CHANGED
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
13
13
  pass
14
14
 
15
15
  from bubble.detectors import detect_entrypoints, detect_global_handlers
16
+ from bubble.integrations.flask import correlate_flask_restful_entrypoints
16
17
  from bubble.enums import ResolutionKind
17
18
  from bubble.loader import load_detectors
18
19
  from bubble.models import (
@@ -440,13 +441,19 @@ class CodeExtractor(cst.CSTVisitor):
440
441
  return True
441
442
 
442
443
  def _get_current_qualified_name(self) -> str:
443
- """Get the fully qualified name of the current context."""
444
- parts = [self.relative_path]
444
+ """Get the fully qualified name of the current context.
445
+
446
+ Returns format: file.py::Class.method (matches canonical format from CLAUDE.md)
447
+ """
445
448
  if self._class_stack:
446
- parts.append(".".join(self._class_stack))
447
- if self._function_stack:
448
- parts.append(self._function_stack[-1])
449
- return "::".join(parts) if len(parts) > 1 else parts[0]
449
+ if self._function_stack:
450
+ qualified = ".".join(self._class_stack + [self._function_stack[-1]])
451
+ else:
452
+ qualified = ".".join(self._class_stack)
453
+ return f"{self.relative_path}::{qualified}"
454
+ elif self._function_stack:
455
+ return f"{self.relative_path}::{self._function_stack[-1]}"
456
+ return self.relative_path
450
457
 
451
458
  def _extract_depends_calls(
452
459
  self,
@@ -615,13 +622,14 @@ def extract_from_file(file_path: Path, relative_path: str | None = None) -> File
615
622
  result.return_types = extractor.return_types
616
623
  result.detected_frameworks = extractor.detected_frameworks
617
624
 
625
+ detection_path = relative_path if relative_path else str(file_path)
618
626
  try:
619
- result.entrypoints = detect_entrypoints(source, str(file_path))
627
+ result.entrypoints = detect_entrypoints(source, detection_path)
620
628
  except Exception:
621
629
  pass
622
630
 
623
631
  try:
624
- result.global_handlers = detect_global_handlers(source, str(file_path))
632
+ result.global_handlers = detect_global_handlers(source, detection_path)
625
633
  except Exception:
626
634
  pass
627
635
 
@@ -664,6 +672,10 @@ def _inject_drf_dispatch_calls(model: ProgramModel) -> None:
664
672
  When a DRF view class is detected as an entrypoint, this creates CallSite
665
673
  entries from the view class to each HTTP method handler (get, post, etc.)
666
674
  that exists on the class.
675
+
676
+ Handles both:
677
+ - Class-level entrypoints: "UserAPIView" -> creates edges to get, post, etc.
678
+ - Method-level entrypoints: "UserAPIView.get" -> creates edge to that method
667
679
  """
668
680
  drf_view_entrypoints = [
669
681
  ep
@@ -671,18 +683,32 @@ def _inject_drf_dispatch_calls(model: ProgramModel) -> None:
671
683
  if ep.metadata.get("framework") == "django" and ep.metadata.get("view_type") == "class"
672
684
  ]
673
685
 
686
+ seen_edges: set[tuple[str, str]] = set()
687
+
674
688
  for entrypoint in drf_view_entrypoints:
675
- view_class = entrypoint.function
689
+ view_function = entrypoint.function
676
690
  view_file = entrypoint.file
677
691
  view_line = entrypoint.line
678
692
 
693
+ if "." in view_function:
694
+ view_class, method_name = view_function.rsplit(".", 1)
695
+ methods_to_inject = [method_name] if method_name in DRF_DISPATCH_METHODS else []
696
+ else:
697
+ view_class = view_function
698
+ methods_to_inject = list(DRF_DISPATCH_METHODS)
699
+
679
700
  for _func_key, func_def in model.functions.items():
680
701
  if not func_def.is_method:
681
702
  continue
682
703
  if func_def.class_name != view_class:
683
704
  continue
684
- if func_def.name not in DRF_DISPATCH_METHODS:
705
+ if func_def.name not in methods_to_inject:
706
+ continue
707
+
708
+ edge_key = (view_class, func_def.name)
709
+ if edge_key in seen_edges:
685
710
  continue
711
+ seen_edges.add(edge_key)
686
712
 
687
713
  relative_file = view_file
688
714
  if "/" in relative_file or "\\" in relative_file:
@@ -824,6 +850,7 @@ def extract_from_directory(
824
850
  if cache:
825
851
  cache.close()
826
852
 
853
+ model.entrypoints = correlate_flask_restful_entrypoints(model.entrypoints)
827
854
  _inject_drf_dispatch_calls(model)
828
855
 
829
856
  return model
bubble/formatters.py CHANGED
@@ -694,7 +694,13 @@ def catches(
694
694
 
695
695
  total = len(result.local_catches) + len(result.global_handlers)
696
696
  if total == 0:
697
- console.print(f"[yellow]No catch sites found for {result.exception_type}[/yellow]")
697
+ if result.raise_site_count == 0:
698
+ console.print(
699
+ f"[yellow]No catch sites found for {result.exception_type}[/yellow]\n"
700
+ f"[dim] (this exception is not raised anywhere in the codebase)[/dim]"
701
+ )
702
+ else:
703
+ console.print(f"[yellow]No catch sites found for {result.exception_type}[/yellow]")
698
704
  return
699
705
 
700
706
  subclass_note = " (and subclasses)" if result.include_subclasses else ""
@@ -40,8 +40,28 @@ def _get_django_entrypoints_and_handlers(model: ProgramModel) -> tuple[list, lis
40
40
  return entrypoints, handlers
41
41
 
42
42
 
43
+ def _filter_entrypoints(
44
+ entrypoints: list, filter_arg: str | None, directory: Path
45
+ ) -> list:
46
+ """Filter entrypoints by file path or route path."""
47
+ if not filter_arg:
48
+ return entrypoints
49
+
50
+ if filter_arg.startswith("/"):
51
+ return [e for e in entrypoints if e.metadata.get("http_path") == filter_arg]
52
+
53
+ filter_path = Path(filter_arg)
54
+ if not filter_path.is_absolute():
55
+ filter_path = directory / filter_path
56
+
57
+ return [e for e in entrypoints if Path(directory / e.file).resolve() == filter_path.resolve()]
58
+
59
+
43
60
  @app.command()
44
61
  def audit(
62
+ filter_arg: Annotated[
63
+ str | None, typer.Argument(help="Filter by file path or route (e.g., /users)")
64
+ ] = None,
45
65
  directory: Annotated[
46
66
  Path, typer.Option("--directory", "-d", help="Directory to analyze")
47
67
  ] = Path("."),
@@ -50,22 +70,35 @@ def audit(
50
70
  ) -> None:
51
71
  """Check Django views for escaping exceptions.
52
72
 
53
- Scans every Django view (APIView, ViewSet, @api_view), reports which have uncaught exceptions.
73
+ Scans Django views (APIView, ViewSet, @api_view) and reports which have uncaught exceptions.
54
74
 
55
- Example:
56
- flow django audit
57
- flow django audit -d /path/to/project
75
+ Examples:
76
+ bubble django audit # All views
77
+ bubble django audit /users # Views matching /users
78
+ bubble django audit views/users.py # Views in specific file
58
79
  """
59
80
  directory = directory.resolve()
60
81
  config = load_config(directory)
61
82
  model = _build_model(directory, use_cache=not no_cache)
62
83
  entrypoints, handlers = _get_django_entrypoints_and_handlers(model)
84
+ entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
85
+
86
+ if filter_arg and not entrypoints:
87
+ if filter_arg.startswith("/"):
88
+ console.print(f"[yellow]No Django views found matching {filter_arg}[/yellow]")
89
+ else:
90
+ console.print(f"[yellow]No Django views found in {filter_arg}[/yellow]")
91
+ return
92
+
63
93
  result = audit_integration(model, integration, entrypoints, handlers, config=config)
64
94
  formatters.audit(result, output_format, directory, console)
65
95
 
66
96
 
67
97
  @app.command(name="entrypoints")
68
98
  def list_views(
99
+ filter_arg: Annotated[
100
+ str | None, typer.Argument(help="Filter by file path or route (e.g., /users)")
101
+ ] = None,
69
102
  directory: Annotated[
70
103
  Path, typer.Option("--directory", "-d", help="Directory to analyze")
71
104
  ] = Path("."),
@@ -74,12 +107,15 @@ def list_views(
74
107
  ) -> None:
75
108
  """List Django views (APIView, ViewSet, @api_view).
76
109
 
77
- Example:
78
- flow django entrypoints
110
+ Examples:
111
+ bubble django entrypoints # All views
112
+ bubble django entrypoints /users # Views matching /users
113
+ bubble django entrypoints views/users.py # Views in specific file
79
114
  """
80
115
  directory = directory.resolve()
81
116
  model = _build_model(directory, use_cache=not no_cache)
82
117
  entrypoints, _ = _get_django_entrypoints_and_handlers(model)
118
+ entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
83
119
  result = list_integration_entrypoints(integration, entrypoints)
84
120
  formatters.entrypoints(result, output_format, directory, console)
85
121
 
@@ -23,6 +23,17 @@ DRF_BASE_CLASSES = {
23
23
  "RetrieveUpdateDestroyAPIView",
24
24
  }
25
25
 
26
+ DRF_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
27
+ DRF_ACTION_METHODS = {"list", "create", "retrieve", "update", "partial_update", "destroy"}
28
+ DRF_METHOD_TO_HTTP = {
29
+ "list": "GET",
30
+ "create": "POST",
31
+ "retrieve": "GET",
32
+ "update": "PUT",
33
+ "partial_update": "PATCH",
34
+ "destroy": "DELETE",
35
+ }
36
+
26
37
  DRF_GENERICS_QUALIFIERS = {
27
38
  "generics",
28
39
  "rest_framework.generics",
@@ -51,31 +62,65 @@ class DjangoViewVisitor(cst.CSTVisitor):
51
62
  self._in_class: str | None = None
52
63
  self._class_is_view = False
53
64
  self._class_line: int = 0
65
+ self._class_methods: dict[str, int] = {}
54
66
 
55
67
  def visit_ClassDef(self, node: cst.ClassDef) -> bool:
56
68
  self._in_class = node.name.value
57
69
  self._class_is_view = self._is_view_class(node)
70
+ self._class_methods = {}
58
71
  if self._class_is_view:
59
72
  pos = self.get_metadata(PositionProvider, node)
60
73
  self._class_line = pos.start.line
61
- self.entrypoints.append(
62
- Entrypoint(
63
- file=self.file_path,
64
- function=node.name.value,
65
- line=self._class_line,
66
- kind="http_route",
67
- metadata={
68
- "framework": "django",
69
- "view_type": "class",
70
- },
71
- )
72
- )
74
+ return True
75
+
76
+ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
77
+ if self._class_is_view and self._in_class:
78
+ method_name = node.name.value.lower()
79
+ if method_name in DRF_HTTP_METHODS or method_name in DRF_ACTION_METHODS:
80
+ pos = self.get_metadata(PositionProvider, node)
81
+ self._class_methods[method_name] = pos.start.line
73
82
  return True
74
83
 
75
84
  def leave_ClassDef(self, node: cst.ClassDef) -> None:
85
+ if self._class_is_view and self._in_class:
86
+ class_name = self._in_class
87
+ if self._class_methods:
88
+ for method_name, line in self._class_methods.items():
89
+ http_method = DRF_METHOD_TO_HTTP.get(method_name, method_name.upper())
90
+ self.entrypoints.append(
91
+ Entrypoint(
92
+ file=self.file_path,
93
+ function=f"{class_name}.{method_name}",
94
+ line=line,
95
+ kind="http_route",
96
+ metadata={
97
+ "framework": "django",
98
+ "view_type": "class",
99
+ "http_method": http_method,
100
+ "http_path": f"<drf:{class_name}>",
101
+ },
102
+ )
103
+ )
104
+ else:
105
+ self.entrypoints.append(
106
+ Entrypoint(
107
+ file=self.file_path,
108
+ function=class_name,
109
+ line=self._class_line,
110
+ kind="http_route",
111
+ metadata={
112
+ "framework": "django",
113
+ "view_type": "class",
114
+ "http_method": "ANY",
115
+ "http_path": f"<drf:{class_name}>",
116
+ },
117
+ )
118
+ )
119
+
76
120
  self._in_class = None
77
121
  self._class_is_view = False
78
122
  self._class_line = 0
123
+ self._class_methods = {}
79
124
 
80
125
  def _is_view_class(self, node: cst.ClassDef) -> bool:
81
126
  """Check if a class inherits from a Django/DRF view base class."""
@@ -40,8 +40,28 @@ def _get_fastapi_entrypoints_and_handlers(model: ProgramModel) -> tuple[list, li
40
40
  return entrypoints, handlers
41
41
 
42
42
 
43
+ def _filter_entrypoints(
44
+ entrypoints: list, filter_arg: str | None, directory: Path
45
+ ) -> list:
46
+ """Filter entrypoints by file path or route path."""
47
+ if not filter_arg:
48
+ return entrypoints
49
+
50
+ if filter_arg.startswith("/"):
51
+ return [e for e in entrypoints if e.metadata.get("http_path") == filter_arg]
52
+
53
+ filter_path = Path(filter_arg)
54
+ if not filter_path.is_absolute():
55
+ filter_path = directory / filter_path
56
+
57
+ return [e for e in entrypoints if Path(directory / e.file).resolve() == filter_path.resolve()]
58
+
59
+
43
60
  @app.command()
44
61
  def audit(
62
+ filter_arg: Annotated[
63
+ str | None, typer.Argument(help="Filter by file path or route (e.g., /users)")
64
+ ] = None,
45
65
  directory: Annotated[
46
66
  Path, typer.Option("--directory", "-d", help="Directory to analyze")
47
67
  ] = Path("."),
@@ -50,21 +70,34 @@ def audit(
50
70
  ) -> None:
51
71
  """Check FastAPI routes for escaping exceptions.
52
72
 
53
- Scans every FastAPI HTTP route, reports which have uncaught exceptions.
73
+ Scans FastAPI HTTP routes and reports which have uncaught exceptions.
54
74
 
55
- Example:
56
- flow fastapi audit
57
- flow fastapi audit -d /path/to/project
75
+ Examples:
76
+ bubble fastapi audit # All routes
77
+ bubble fastapi audit /users # Routes matching /users
78
+ bubble fastapi audit routers/users.py # Routes in specific file
58
79
  """
59
80
  directory = directory.resolve()
60
81
  model = _build_model(directory, use_cache=not no_cache)
61
82
  entrypoints, handlers = _get_fastapi_entrypoints_and_handlers(model)
83
+ entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
84
+
85
+ if filter_arg and not entrypoints:
86
+ if filter_arg.startswith("/"):
87
+ console.print(f"[yellow]No FastAPI routes found matching {filter_arg}[/yellow]")
88
+ else:
89
+ console.print(f"[yellow]No FastAPI routes found in {filter_arg}[/yellow]")
90
+ return
91
+
62
92
  result = audit_integration(model, integration, entrypoints, handlers)
63
93
  formatters.audit(result, OutputFormat(output_format), directory, console)
64
94
 
65
95
 
66
96
  @app.command(name="entrypoints")
67
97
  def list_routes(
98
+ filter_arg: Annotated[
99
+ str | None, typer.Argument(help="Filter by file path or route (e.g., /users)")
100
+ ] = None,
68
101
  directory: Annotated[
69
102
  Path, typer.Option("--directory", "-d", help="Directory to analyze")
70
103
  ] = Path("."),
@@ -73,12 +106,15 @@ def list_routes(
73
106
  ) -> None:
74
107
  """List FastAPI HTTP routes.
75
108
 
76
- Example:
77
- flow fastapi entrypoints
109
+ Examples:
110
+ bubble fastapi entrypoints # All routes
111
+ bubble fastapi entrypoints /users # Routes matching /users
112
+ bubble fastapi entrypoints routers/users.py # Routes in specific file
78
113
  """
79
114
  directory = directory.resolve()
80
115
  model = _build_model(directory, use_cache=not no_cache)
81
116
  entrypoints, _ = _get_fastapi_entrypoints_and_handlers(model)
117
+ entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
82
118
  result = list_integration_entrypoints(integration, entrypoints)
83
119
  formatters.entrypoints(result, OutputFormat(output_format), directory, console)
84
120
 
@@ -5,7 +5,9 @@ import typer
5
5
  from bubble.integrations.base import Entrypoint, GlobalHandler
6
6
  from bubble.integrations.flask.detector import (
7
7
  FlaskErrorHandlerVisitor,
8
+ FlaskRESTfulVisitor,
8
9
  FlaskRouteVisitor,
10
+ correlate_flask_restful_entrypoints,
9
11
  detect_flask_entrypoints,
10
12
  detect_flask_global_handlers,
11
13
  )
@@ -50,8 +52,10 @@ class FlaskIntegration:
50
52
  __all__ = [
51
53
  "FlaskIntegration",
52
54
  "FlaskRouteVisitor",
55
+ "FlaskRESTfulVisitor",
53
56
  "FlaskErrorHandlerVisitor",
54
57
  "EXCEPTION_RESPONSES",
58
+ "correlate_flask_restful_entrypoints",
55
59
  "detect_flask_entrypoints",
56
60
  "detect_flask_global_handlers",
57
61
  ]
@@ -40,8 +40,28 @@ def _get_flask_entrypoints_and_handlers(model: ProgramModel) -> tuple[list, list
40
40
  return entrypoints, handlers
41
41
 
42
42
 
43
+ def _filter_entrypoints(
44
+ entrypoints: list, filter_arg: str | None, directory: Path
45
+ ) -> list:
46
+ """Filter entrypoints by file path or route path."""
47
+ if not filter_arg:
48
+ return entrypoints
49
+
50
+ if filter_arg.startswith("/"):
51
+ return [e for e in entrypoints if e.metadata.get("http_path") == filter_arg]
52
+
53
+ filter_path = Path(filter_arg)
54
+ if not filter_path.is_absolute():
55
+ filter_path = directory / filter_path
56
+
57
+ return [e for e in entrypoints if Path(directory / e.file).resolve() == filter_path.resolve()]
58
+
59
+
43
60
  @app.command()
44
61
  def audit(
62
+ filter_arg: Annotated[
63
+ str | None, typer.Argument(help="Filter by file path or route (e.g., /balance)")
64
+ ] = None,
45
65
  directory: Annotated[
46
66
  Path, typer.Option("--directory", "-d", help="Directory to analyze")
47
67
  ] = Path("."),
@@ -50,21 +70,34 @@ def audit(
50
70
  ) -> None:
51
71
  """Check Flask routes for escaping exceptions.
52
72
 
53
- Scans every Flask HTTP route, reports which have uncaught exceptions.
73
+ Scans Flask HTTP routes and reports which have uncaught exceptions.
54
74
 
55
- Example:
56
- flow flask audit
57
- flow flask audit -d /path/to/project
75
+ Examples:
76
+ bubble flask audit # All routes
77
+ bubble flask audit /balance # Routes matching /balance
78
+ bubble flask audit blueprints/api.py # Routes in specific file
58
79
  """
59
80
  directory = directory.resolve()
60
81
  model = _build_model(directory, use_cache=not no_cache)
61
82
  entrypoints, handlers = _get_flask_entrypoints_and_handlers(model)
83
+ entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
84
+
85
+ if filter_arg and not entrypoints:
86
+ if filter_arg.startswith("/"):
87
+ console.print(f"[yellow]No Flask routes found matching {filter_arg}[/yellow]")
88
+ else:
89
+ console.print(f"[yellow]No Flask routes found in {filter_arg}[/yellow]")
90
+ return
91
+
62
92
  result = audit_integration(model, integration, entrypoints, handlers)
63
93
  formatters.audit(result, OutputFormat(output_format), directory, console)
64
94
 
65
95
 
66
96
  @app.command(name="entrypoints")
67
97
  def list_routes(
98
+ filter_arg: Annotated[
99
+ str | None, typer.Argument(help="Filter by file path or route (e.g., /balance)")
100
+ ] = None,
68
101
  directory: Annotated[
69
102
  Path, typer.Option("--directory", "-d", help="Directory to analyze")
70
103
  ] = Path("."),
@@ -73,12 +106,15 @@ def list_routes(
73
106
  ) -> None:
74
107
  """List Flask HTTP routes.
75
108
 
76
- Example:
77
- flow flask entrypoints
109
+ Examples:
110
+ bubble flask entrypoints # All routes
111
+ bubble flask entrypoints /users # Routes matching /users
112
+ bubble flask entrypoints blueprints/api.py # Routes in specific file
78
113
  """
79
114
  directory = directory.resolve()
80
115
  model = _build_model(directory, use_cache=not no_cache)
81
116
  entrypoints, _ = _get_flask_entrypoints_and_handlers(model)
117
+ entrypoints = _filter_entrypoints(entrypoints, filter_arg, directory)
82
118
  result = list_integration_entrypoints(integration, entrypoints)
83
119
  formatters.entrypoints(result, OutputFormat(output_format), directory, console)
84
120
 
@@ -6,6 +6,8 @@ from libcst.metadata import MetadataWrapper, PositionProvider
6
6
  from bubble.enums import EntrypointKind, Framework
7
7
  from bubble.integrations.base import Entrypoint, GlobalHandler
8
8
 
9
+ HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
10
+
9
11
 
10
12
  class FlaskRouteVisitor(cst.CSTVisitor):
11
13
  """
@@ -157,21 +159,185 @@ class FlaskErrorHandlerVisitor(cst.CSTVisitor):
157
159
  return ""
158
160
 
159
161
 
162
+ class FlaskRESTfulVisitor(cst.CSTVisitor):
163
+ """
164
+ Detects Flask-RESTful Resource classes and add_resource() registrations.
165
+
166
+ Supports:
167
+ - api.add_resource(ResourceClass, "/path")
168
+ - api.add_resource(ResourceClass, "/path1", "/path2")
169
+ - Custom methods like api.add_org_resource()
170
+ """
171
+
172
+ METADATA_DEPENDENCIES = (PositionProvider,)
173
+
174
+ ADD_RESOURCE_METHODS = {"add_resource", "add_org_resource"}
175
+
176
+ def __init__(self, file_path: str) -> None:
177
+ self.file_path = file_path
178
+ self.entrypoints: list[Entrypoint] = []
179
+ self.resource_classes: dict[str, dict[str, int]] = {}
180
+ self.resource_registrations: list[tuple[str, list[str], int]] = []
181
+
182
+ def visit_ClassDef(self, node: cst.ClassDef) -> bool:
183
+ methods_found: dict[str, int] = {}
184
+ for item in node.body.body:
185
+ if isinstance(item, cst.FunctionDef):
186
+ method_name = item.name.value.lower()
187
+ if method_name in HTTP_METHODS and not self._has_route_decorator(item):
188
+ pos = self.get_metadata(PositionProvider, item)
189
+ methods_found[method_name.upper()] = pos.start.line
190
+
191
+ if methods_found:
192
+ self.resource_classes[node.name.value] = methods_found
193
+
194
+ return True
195
+
196
+ def _has_route_decorator(self, node: cst.FunctionDef) -> bool:
197
+ """Check if a function has a route decorator like @expose, @route."""
198
+ for decorator in node.decorators:
199
+ dec = decorator.decorator
200
+ if isinstance(dec, cst.Call):
201
+ if isinstance(dec.func, cst.Attribute):
202
+ if dec.func.attr.value in ("route", "expose"):
203
+ return True
204
+ elif isinstance(dec.func, cst.Name):
205
+ if dec.func.value in ("route", "expose"):
206
+ return True
207
+ elif isinstance(dec, cst.Attribute):
208
+ if dec.attr.value in ("route", "expose"):
209
+ return True
210
+ elif isinstance(dec, cst.Name):
211
+ if dec.value in ("route", "expose"):
212
+ return True
213
+ return False
214
+
215
+ def visit_Call(self, node: cst.Call) -> bool:
216
+ if not isinstance(node.func, cst.Attribute):
217
+ return True
218
+
219
+ method_name = node.func.attr.value
220
+ if method_name not in self.ADD_RESOURCE_METHODS:
221
+ return True
222
+
223
+ if len(node.args) < 2:
224
+ return True
225
+
226
+ first_arg = node.args[0].value
227
+ resource_name = self._get_name_from_expr(first_arg)
228
+ if not resource_name:
229
+ return True
230
+
231
+ urls: list[str] = []
232
+ for arg in node.args[1:]:
233
+ if arg.keyword is not None:
234
+ continue
235
+ url = self._extract_string(arg.value)
236
+ if url:
237
+ urls.append(url)
238
+
239
+ if urls:
240
+ pos = self.get_metadata(PositionProvider, node)
241
+ self.resource_registrations.append((resource_name, urls, pos.start.line))
242
+
243
+ return True
244
+
245
+ def leave_Module(self, original_node: cst.Module) -> None:
246
+ registered_classes: set[str] = set()
247
+
248
+ for resource_name, urls, reg_line in self.resource_registrations:
249
+ registered_classes.add(resource_name)
250
+ methods = self.resource_classes.get(resource_name, {})
251
+ if not methods:
252
+ methods = {"GET": reg_line}
253
+
254
+ for url in urls:
255
+ for method, method_line in methods.items():
256
+ self.entrypoints.append(
257
+ Entrypoint(
258
+ file=self.file_path,
259
+ function=f"{resource_name}.{method.lower()}",
260
+ line=method_line,
261
+ kind=EntrypointKind.HTTP_ROUTE,
262
+ metadata={
263
+ "http_method": method,
264
+ "http_path": url,
265
+ "framework": Framework.FLASK,
266
+ "flask_restful": True,
267
+ },
268
+ )
269
+ )
270
+
271
+ for class_name, methods in self.resource_classes.items():
272
+ if class_name in registered_classes:
273
+ continue
274
+
275
+ for method, method_line in methods.items():
276
+ self.entrypoints.append(
277
+ Entrypoint(
278
+ file=self.file_path,
279
+ function=f"{class_name}.{method.lower()}",
280
+ line=method_line,
281
+ kind=EntrypointKind.HTTP_ROUTE,
282
+ metadata={
283
+ "http_method": method,
284
+ "http_path": f"<flask-restful:{class_name}>",
285
+ "framework": Framework.FLASK,
286
+ "flask_restful": True,
287
+ },
288
+ )
289
+ )
290
+
291
+ def _get_name_from_expr(self, expr: cst.BaseExpression) -> str:
292
+ if isinstance(expr, cst.Name):
293
+ return expr.value
294
+ elif isinstance(expr, cst.Attribute):
295
+ return expr.attr.value
296
+ return ""
297
+
298
+ def _extract_string(self, node: cst.BaseExpression) -> str | None:
299
+ if isinstance(node, cst.SimpleString):
300
+ return node.evaluated_value
301
+ elif isinstance(node, cst.ConcatenatedString):
302
+ parts = []
303
+ for part in (node.left, node.right):
304
+ if isinstance(part, cst.SimpleString):
305
+ val = part.evaluated_value
306
+ if val:
307
+ parts.append(val)
308
+ return "".join(parts) if parts else None
309
+ return None
310
+
311
+
160
312
  def detect_flask_entrypoints(source: str, file_path: str) -> list[Entrypoint]:
161
- """Detect Flask route entrypoints in a Python source file."""
313
+ """Detect Flask route entrypoints in a Python source file.
314
+
315
+ Detects both decorator-based routes (@app.route) and
316
+ Flask-RESTful call-based routes (api.add_resource).
317
+ """
162
318
  try:
163
319
  module = cst.parse_module(source)
164
320
  except Exception:
165
321
  return []
166
322
 
323
+ entrypoints: list[Entrypoint] = []
167
324
  wrapper = MetadataWrapper(module)
168
- visitor = FlaskRouteVisitor(file_path)
169
325
 
326
+ route_visitor = FlaskRouteVisitor(file_path)
170
327
  try:
171
- wrapper.visit(visitor)
172
- return visitor.entrypoints
328
+ wrapper.visit(route_visitor)
329
+ entrypoints.extend(route_visitor.entrypoints)
173
330
  except Exception:
174
- return []
331
+ pass
332
+
333
+ restful_visitor = FlaskRESTfulVisitor(file_path)
334
+ try:
335
+ wrapper.visit(restful_visitor)
336
+ entrypoints.extend(restful_visitor.entrypoints)
337
+ except Exception:
338
+ pass
339
+
340
+ return entrypoints
175
341
 
176
342
 
177
343
  def detect_flask_global_handlers(source: str, file_path: str) -> list[GlobalHandler]:
@@ -189,3 +355,75 @@ def detect_flask_global_handlers(source: str, file_path: str) -> list[GlobalHand
189
355
  return visitor.handlers
190
356
  except Exception:
191
357
  return []
358
+
359
+
360
+ def correlate_flask_restful_entrypoints(entrypoints: list[Entrypoint]) -> list[Entrypoint]:
361
+ """Correlate Flask-RESTful entrypoints across files.
362
+
363
+ Flask-RESTful apps often define Resource classes in one file and register
364
+ them with api.add_resource() in another. This function merges information
365
+ from both sources:
366
+ - Class definitions have correct methods but placeholder paths
367
+ - Registrations have correct paths but fallback methods
368
+
369
+ Same-file cases (already fully resolved) are passed through unchanged.
370
+ Only placeholder entries trigger cross-file correlation.
371
+
372
+ Returns a new list with correlated entrypoints.
373
+ """
374
+ placeholder_classes: dict[str, list[Entrypoint]] = {}
375
+ real_path_entries: dict[str, list[Entrypoint]] = {}
376
+ non_flask_restful: list[Entrypoint] = []
377
+
378
+ for ep in entrypoints:
379
+ if not ep.metadata.get("flask_restful"):
380
+ non_flask_restful.append(ep)
381
+ continue
382
+
383
+ path = ep.metadata.get("http_path", "")
384
+
385
+ if path.startswith("<flask-restful:") and path.endswith(">"):
386
+ class_name = path[15:-1]
387
+ if class_name not in placeholder_classes:
388
+ placeholder_classes[class_name] = []
389
+ placeholder_classes[class_name].append(ep)
390
+ else:
391
+ parts = ep.function.rsplit(".", 1)
392
+ if len(parts) == 2:
393
+ class_name = parts[0]
394
+ if class_name not in real_path_entries:
395
+ real_path_entries[class_name] = []
396
+ real_path_entries[class_name].append(ep)
397
+ else:
398
+ non_flask_restful.append(ep)
399
+
400
+ result: list[Entrypoint] = list(non_flask_restful)
401
+
402
+ for class_name, class_eps in placeholder_classes.items():
403
+ if class_name in real_path_entries:
404
+ reg_eps = real_path_entries[class_name]
405
+ paths_from_registrations = list({ep.metadata.get("http_path") for ep in reg_eps})
406
+
407
+ for class_ep in class_eps:
408
+ for reg_path in paths_from_registrations:
409
+ result.append(
410
+ Entrypoint(
411
+ file=class_ep.file,
412
+ function=class_ep.function,
413
+ line=class_ep.line,
414
+ kind=class_ep.kind,
415
+ metadata={
416
+ **class_ep.metadata,
417
+ "http_path": reg_path,
418
+ },
419
+ )
420
+ )
421
+
422
+ del real_path_entries[class_name]
423
+ else:
424
+ result.extend(class_eps)
425
+
426
+ for class_name, eps in real_path_entries.items():
427
+ result.extend(eps)
428
+
429
+ return result
@@ -53,6 +53,13 @@ def audit(
53
53
  ]
54
54
  for exc_type, raises_list in issue.caught_by_generic.items()
55
55
  },
56
+ "caught_by_remote": {
57
+ exc_type: [
58
+ {"file": r.file, "line": r.line, "function": r.function}
59
+ for r in raises_list
60
+ ]
61
+ for exc_type, raises_list in issue.caught_by_remote.items()
62
+ },
56
63
  }
57
64
  for issue in result.issues
58
65
  ],
@@ -104,6 +111,17 @@ def audit(
104
111
  if len(raise_sites) > 2:
105
112
  console.print(f" [dim]...and {len(raise_sites) - 2} more[/dim]")
106
113
 
114
+ for exc_type, raise_sites in issue.caught_by_remote.items():
115
+ exc_simple = exc_type.split(".")[-1]
116
+ for rs in raise_sites[:2]:
117
+ rel = _rel_path(rs.file, directory)
118
+ console.print(
119
+ f" [blue]{exc_simple}[/blue] [dim]({rel}:{rs.line}) "
120
+ f"- caught by handler in different file[/dim]"
121
+ )
122
+ if len(raise_sites) > 2:
123
+ console.print(f" [dim]...and {len(raise_sites) - 2} more[/dim]")
124
+
107
125
  console.print()
108
126
  else:
109
127
  console.print("[green bold]All entrypoints have specific exception handlers[/green bold]\n")
@@ -22,11 +22,19 @@ class IntegrationData:
22
22
 
23
23
  @dataclass
24
24
  class AuditIssue:
25
- """An entrypoint with uncaught or poorly-handled exceptions."""
25
+ """An entrypoint with uncaught or poorly-handled exceptions.
26
+
27
+ Fields:
28
+ - uncaught: Exceptions with no handler at all
29
+ - caught_by_generic: Caught by generic handler (@errorhandler(Exception))
30
+ - caught_by_remote: Caught by a handler in a different file (may indicate missing local handler)
31
+ - caught: Caught by a handler in the same file
32
+ """
26
33
 
27
34
  entrypoint: Entrypoint
28
35
  uncaught: dict[str, list["RaiseSite"]]
29
36
  caught_by_generic: dict[str, list["RaiseSite"]]
37
+ caught_by_remote: dict[str, list["RaiseSite"]]
30
38
  caught: dict[str, list["RaiseSite"]]
31
39
 
32
40
 
@@ -51,6 +51,7 @@ def _compute_exception_flow_for_integration(
51
51
  forward_graph: dict[str, set[str]] | None = None,
52
52
  name_to_qualified: dict[str, list[str]] | None = None,
53
53
  config: FlowConfig | None = None,
54
+ entrypoint_file: str | None = None,
54
55
  ) -> ExceptionFlow:
55
56
  """Compute exception flow for a function with integration-specific handling.
56
57
 
@@ -134,9 +135,17 @@ def _compute_exception_flow_for_integration(
134
135
  flow.caught_by_generic[exc_type] = []
135
136
  flow.caught_by_generic[exc_type].extend(raise_sites)
136
137
  else:
137
- if exc_type not in flow.caught_by_global:
138
- flow.caught_by_global[exc_type] = []
139
- flow.caught_by_global[exc_type].extend(raise_sites)
138
+ is_same_file = (
139
+ entrypoint_file is not None and caught_by_handler.file == entrypoint_file
140
+ )
141
+ if is_same_file:
142
+ if exc_type not in flow.caught_by_global:
143
+ flow.caught_by_global[exc_type] = []
144
+ flow.caught_by_global[exc_type].extend(raise_sites)
145
+ else:
146
+ if exc_type not in flow.caught_by_remote_global:
147
+ flow.caught_by_remote_global[exc_type] = []
148
+ flow.caught_by_remote_global[exc_type].extend(raise_sites)
140
149
  continue
141
150
 
142
151
  framework_response = integration.get_exception_response(exc_type)
@@ -218,19 +227,24 @@ def audit_integration(
218
227
  forward_graph,
219
228
  name_to_qualified,
220
229
  config,
230
+ entrypoint_file=entrypoint.file,
221
231
  )
222
232
 
223
233
  real_uncaught = {k: v for k, v in flow.uncaught.items() if k not in reraise_patterns}
224
234
  real_generic = {
225
235
  k: v for k, v in flow.caught_by_generic.items() if k not in reraise_patterns
226
236
  }
237
+ real_remote = {
238
+ k: v for k, v in flow.caught_by_remote_global.items() if k not in reraise_patterns
239
+ }
227
240
 
228
- if real_uncaught or real_generic:
241
+ if real_uncaught or real_generic or real_remote:
229
242
  issues.append(
230
243
  AuditIssue(
231
244
  entrypoint=entrypoint,
232
245
  uncaught=real_uncaught,
233
246
  caught_by_generic=real_generic,
247
+ caught_by_remote=real_remote,
234
248
  caught=flow.caught_by_global,
235
249
  )
236
250
  )
@@ -399,10 +413,14 @@ def _trace_to_entrypoints(
399
413
  return
400
414
  visited.add(current)
401
415
 
402
- current_simple = current.split("::")[-1] if "::" in current else current
403
- current_simple = current_simple.split(".")[-1]
416
+ current_qualified = current.split("::")[-1] if "::" in current else current
417
+ current_simple = current_qualified.split(".")[-1]
404
418
 
405
- if current in entrypoint_functions or current_simple in entrypoint_functions:
419
+ if (
420
+ current in entrypoint_functions
421
+ or current_qualified in entrypoint_functions
422
+ or current_simple in entrypoint_functions
423
+ ):
406
424
  paths.append(list(path))
407
425
  return
408
426
 
@@ -411,13 +429,11 @@ def _trace_to_entrypoints(
411
429
  if len(paths) >= max_paths:
412
430
  return
413
431
  if reachable_from_entrypoints is not None:
414
- caller_simple = (
415
- caller.split("::")[-1].split(".")[-1]
416
- if "::" in caller
417
- else caller.split(".")[-1]
418
- )
432
+ caller_qualified = caller.split("::")[-1] if "::" in caller else caller
433
+ caller_simple = caller_qualified.split(".")[-1]
419
434
  if (
420
435
  caller not in reachable_from_entrypoints
436
+ and caller_qualified not in reachable_from_entrypoints
421
437
  and caller_simple not in reachable_from_entrypoints
422
438
  ):
423
439
  continue
@@ -460,7 +476,9 @@ def trace_routes_to_exception(
460
476
  endpoint = path[-1]
461
477
  entrypoints_reached.add(endpoint)
462
478
  if "::" in endpoint:
463
- entrypoints_reached.add(endpoint.split("::")[-1].split(".")[-1])
479
+ qualified_part = endpoint.split("::")[-1]
480
+ entrypoints_reached.add(qualified_part)
481
+ entrypoints_reached.add(qualified_part.split(".")[-1])
464
482
 
465
483
  matching_entrypoints = [e for e in entrypoints if e.function in entrypoints_reached]
466
484
 
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,6 +71,34 @@ class PropagationResult:
61
71
  )
62
72
 
63
73
 
74
+ def _normalize_callee_to_file_format(
75
+ callee_qualified: str, model: ProgramModel
76
+ ) -> str:
77
+ """Normalize a module-dot callee to file::function format.
78
+
79
+ Converts: blueprints.orchestrators.CallbackOrchestrators.BalanceCallbackOrchestrator.run
80
+ To: blueprints/orchestrators/CallbackOrchestrators.py::BalanceCallbackOrchestrator.run
81
+ """
82
+ if "::" in callee_qualified:
83
+ return callee_qualified
84
+
85
+ parts = callee_qualified.split(".")
86
+ for i in range(len(parts) - 1, 0, -1):
87
+ module_path = "/".join(parts[:i]) + ".py"
88
+ func_name = ".".join(parts[i:])
89
+ candidate_key = f"{module_path}::{func_name}"
90
+
91
+ if candidate_key in model.functions:
92
+ return candidate_key
93
+
94
+ for key in model.functions:
95
+ if key.endswith(f"::{func_name}") or key.endswith(f":{func_name}"):
96
+ if module_path.replace("/", ".").replace(".py", "") in key.replace("/", "."):
97
+ return f"{key.split(':')[0]}::{func_name}"
98
+
99
+ return callee_qualified
100
+
101
+
64
102
  def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
65
103
  """Build a map from caller to callees."""
66
104
  graph: dict[str, set[str]] = {}
@@ -69,6 +107,9 @@ def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
69
107
  caller = call_site.caller_qualified or f"{call_site.file}::{call_site.caller_function}"
70
108
  callee = call_site.callee_qualified or call_site.callee_name
71
109
 
110
+ if call_site.callee_qualified and "::" not in call_site.callee_qualified:
111
+ callee = _normalize_callee_to_file_format(call_site.callee_qualified, model)
112
+
72
113
  if caller not in graph:
73
114
  graph[caller] = set()
74
115
  graph[caller].add(callee)
@@ -218,6 +259,23 @@ def _build_call_site_lookup(
218
259
  return lookup
219
260
 
220
261
 
262
+ def _build_is_method_lookup(model: ProgramModel) -> dict[tuple[str, str], bool]:
263
+ """Build a lookup for is_method_call by (caller, callee) pairs.
264
+
265
+ This is always needed for correct fallback resolution, even when
266
+ skip_evidence=True. When a call is in the form obj.method(), we
267
+ should prefer matching methods over standalone functions.
268
+ """
269
+ lookup: dict[tuple[str, str], bool] = {}
270
+ for cs in model.call_sites:
271
+ caller = cs.caller_qualified or f"{cs.file}::{cs.caller_function}"
272
+ callee = cs.callee_qualified or cs.callee_name
273
+ key = (caller, callee)
274
+ if key not in lookup:
275
+ lookup[key] = cs.is_method_call
276
+ return lookup
277
+
278
+
221
279
  def _build_raise_site_lookup(
222
280
  model: ProgramModel,
223
281
  ) -> dict[tuple[str, str, int], RaiseSite]:
@@ -278,9 +336,7 @@ def _scoped_fallback_lookup(
278
336
  candidates = name_to_qualified.get(fallback_key, [])
279
337
 
280
338
  if not candidates:
281
- result = ([], "none")
282
- _fallback_cache[cache_key] = result
283
- return result
339
+ return ([], "none")
284
340
 
285
341
  same_file = [c for c in candidates if c.startswith(f"{caller_file}::")]
286
342
  if same_file:
@@ -347,6 +403,7 @@ def propagate_exceptions(
347
403
  catches_by_function = compute_catches_by_function(model)
348
404
  forward_graph = build_forward_call_graph(model)
349
405
  call_site_lookup = _build_call_site_lookup(model) if not skip_evidence else {}
406
+ is_method_lookup = _build_is_method_lookup(model)
350
407
 
351
408
  propagated: dict[str, set[str]] = {}
352
409
  propagated_evidence: dict[str, dict[tuple[str, str, int], PropagatedRaise]] = {}
@@ -418,7 +475,7 @@ def propagate_exceptions(
418
475
  if "::" in expanded_callee
419
476
  else expanded_callee.split(".")[-1]
420
477
  )
421
- is_method = call_site.is_method_call if call_site else False
478
+ is_method = is_method_lookup.get((caller, callee), False)
422
479
  caller_file = caller.split("::")[0] if "::" in caller else caller
423
480
  import_map = model.import_maps.get(caller_file, {})
424
481
 
bubble/queries.py CHANGED
@@ -7,7 +7,7 @@ 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,
@@ -320,28 +320,110 @@ def find_escapes(
320
320
  )
321
321
 
322
322
 
323
+ def _compute_reverse_reachability(
324
+ raise_sites: list[RaiseSite],
325
+ qualified_graph: dict[str, set[str]],
326
+ name_graph: dict[str, set[str]],
327
+ ) -> set[str]:
328
+ """Compute all functions that can transitively call the raise site functions."""
329
+ reachable: set[str] = set()
330
+
331
+ for raise_site in raise_sites:
332
+ func_key = f"{raise_site.file}::{raise_site.function}"
333
+ worklist = [func_key]
334
+ visited: set[str] = set()
335
+
336
+ while worklist:
337
+ current = worklist.pop()
338
+ if current in visited:
339
+ continue
340
+ visited.add(current)
341
+ reachable.add(current)
342
+
343
+ simple_name = (
344
+ current.split("::")[-1].split(".")[-1]
345
+ if "::" in current
346
+ else current.split(".")[-1]
347
+ )
348
+ reachable.add(simple_name)
349
+
350
+ for caller in qualified_graph.get(current, set()):
351
+ if caller not in visited:
352
+ worklist.append(caller)
353
+
354
+ for caller in name_graph.get(simple_name, set()):
355
+ if caller not in visited:
356
+ worklist.append(caller)
357
+
358
+ return reachable
359
+
360
+
361
+ def _catch_site_catches_exception(
362
+ catch_site: CatchSite,
363
+ types_to_find: set[str],
364
+ hierarchy: ClassHierarchy,
365
+ ) -> bool:
366
+ """Check if a catch site would catch the given exception type."""
367
+ if catch_site.has_bare_except:
368
+ return True
369
+
370
+ for caught_type in catch_site.caught_types:
371
+ caught_simple = caught_type.split(".")[-1]
372
+
373
+ if caught_type in types_to_find or caught_simple in types_to_find:
374
+ return True
375
+
376
+ if caught_simple in ("Exception", "BaseException"):
377
+ return True
378
+
379
+ for t in types_to_find:
380
+ t_simple = t.split(".")[-1]
381
+ if hierarchy.is_subclass_of(t_simple, caught_simple):
382
+ return True
383
+
384
+ return False
385
+
386
+
323
387
  def find_catches(
324
388
  model: ProgramModel,
325
389
  exception_type: str,
326
390
  include_subclasses: bool = False,
327
391
  ) -> CatchesResult:
328
- """Find all places where an exception is caught."""
392
+ """Find catch sites that are in the call path from where the exception is raised."""
329
393
  types_to_find: set[str] = {exception_type}
330
394
  if include_subclasses:
331
395
  subclasses = model.exception_hierarchy.get_subclasses(exception_type)
332
396
  types_to_find.update(subclasses)
333
397
 
334
- matching_catches = []
398
+ raises_result = find_raises(model, exception_type, include_subclasses)
399
+
400
+ if not raises_result.matches:
401
+ return CatchesResult(
402
+ exception_type=exception_type,
403
+ include_subclasses=include_subclasses,
404
+ types_searched=types_to_find,
405
+ local_catches=[],
406
+ global_handlers=[],
407
+ raise_site_count=0,
408
+ )
409
+
410
+ qualified_graph, name_graph = build_reverse_call_graph(model)
411
+ reachable_functions = _compute_reverse_reachability(
412
+ raises_result.matches, qualified_graph, name_graph
413
+ )
414
+
415
+ matching_catches: list[CatchSite] = []
335
416
  for catch_site in model.catch_sites:
336
- for caught_type in catch_site.caught_types:
337
- caught_simple = caught_type.split(".")[-1]
338
- if caught_type in types_to_find or caught_simple in types_to_find:
339
- matching_catches.append(catch_site)
340
- break
341
- for t in types_to_find:
342
- if model.exception_hierarchy.is_subclass_of(t, caught_simple):
343
- matching_catches.append(catch_site)
344
- break
417
+ catch_func_key = f"{catch_site.file}::{catch_site.function}"
418
+ catch_func_simple = catch_site.function.split(".")[-1]
419
+
420
+ if catch_func_key not in reachable_functions and catch_func_simple not in reachable_functions:
421
+ continue
422
+
423
+ if _catch_site_catches_exception(
424
+ catch_site, types_to_find, model.exception_hierarchy
425
+ ):
426
+ matching_catches.append(catch_site)
345
427
 
346
428
  global_handlers = [
347
429
  h
@@ -360,6 +442,7 @@ def find_catches(
360
442
  types_searched=types_to_find,
361
443
  local_catches=matching_catches,
362
444
  global_handlers=global_handlers,
445
+ raise_site_count=len(raises_result.matches),
363
446
  )
364
447
 
365
448
 
bubble/results.py CHANGED
@@ -118,6 +118,7 @@ class CatchesResult:
118
118
  types_searched: set[str]
119
119
  local_catches: list[CatchSite]
120
120
  global_handlers: list[GlobalHandler]
121
+ raise_site_count: int = 0
121
122
 
122
123
 
123
124
  @dataclass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bubble-analysis
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -2,45 +2,45 @@ bubble/__init__.py,sha256=1xAFxCucbe8Id65q6ReTBn1ipRs5Yn8sRjkREJEu1U8,83
2
2
  bubble/cache.py,sha256=-cqvtjmc8w_7ozJSXtqxxI1eKaYryuHeb5Vd9mk1kko,7418
3
3
  bubble/cli.py,sha256=76Yj7mvczncJEX7ajtiOUjl8-R10n6bUWQBoSigxhnc,17293
4
4
  bubble/config.py,sha256=SUwXeHKRkuHOl06JDcoTAe1o5wNIW7kIcBSCxIns3j4,1668
5
- bubble/detectors.py,sha256=YtRbTECmQvs07375vDhh3XlpVGWCTtH1S_Pj2QcBNCA,3146
5
+ bubble/detectors.py,sha256=5MgziIsOGOoUHPQZznN7pb72sAkKd4yXVE5LR8kfeKI,3245
6
6
  bubble/enums.py,sha256=GuPbw2nr076fcnBQpOlKLrEc7rrf2Vnk8XjT2O37CfQ,1516
7
- bubble/extractor.py,sha256=ji8EG88bzkpZ1nbRA-PxGHWOxEXU8STUG_Hye52rRsU,29453
8
- bubble/formatters.py,sha256=x2jGJsGU9RsF9evs55B3NWitw66ePFzwsVc-K1t7Qiw,34031
7
+ bubble/extractor.py,sha256=IdwJMXdVBBowYfGgNES-RFI7y_mzTrqzWnKT4fqovpg,30615
8
+ bubble/formatters.py,sha256=tjcG2EfTNws953vzFc-uDf1XDh1XdgK91rySTkTctZw,34306
9
9
  bubble/loader.py,sha256=-KepjgS9EVk0JjCyGrX2xr6ihbbWyGDrVE61IhlFEJE,4217
10
10
  bubble/models.py,sha256=_v4k3Wf2ZBQC6pZkBLU_cbcm0PldrwQ-CsryD5TbiAE,12315
11
- bubble/propagation.py,sha256=DzADHu-ka7bkiDUamwvCpEipk7V_38lo47eRX2dOw0Y,27880
11
+ bubble/propagation.py,sha256=V9iw1DiNbIhixAgcs1RLHOB-C-quDa4wrlCIJ5nQ3Xs,30293
12
12
  bubble/protocols.py,sha256=ER05yyvNDAcOpbgo3SFDHcccn2tFbp7SFGGVq9-kG48,3443
13
- bubble/queries.py,sha256=cSStEuxZsrKJyyUicxIBc1QbmxqFuoFYtQVJsOBcyvc,20381
14
- bubble/results.py,sha256=lAipfj04uTS-QeKhRcq5un3xf-sOoQUV2NxsJ-2a72A,4130
13
+ bubble/queries.py,sha256=WvxBRep_E_lUGTfpSmDQK6_NZs3QP-MduH5P6OwxW_Q,22961
14
+ bubble/results.py,sha256=xTI4a5re_e9OCvu9jkvf811P3weI-fsOPeDfRzxocnQ,4160
15
15
  bubble/stubs.py,sha256=jfM_Ji4ZrTM4UWrfB-lZtzHXbqbx4uFDF_kQOZtPvUA,2786
16
16
  bubble/timing.py,sha256=COoo50IfYhyvC5xXWS2L8vj9ZOoSk_SyC9v0Kt_rt6M,3828
17
17
  bubble/integrations/__init__.py,sha256=v5nUEGJJgJzpSljakQdAR2qKpheAugprBqrYb1GF7xc,2602
18
18
  bubble/integrations/base.py,sha256=veDox95mLw5MOTb0_9XogahEf-1GKyV1K0G7MnV-R-I,2853
19
- bubble/integrations/formatters.py,sha256=It1Ma4jcL5taVU63dBdN_yvLI98AuVtVXPeUsMzZTaY,10041
20
- bubble/integrations/models.py,sha256=2dioXNhpdLnz9er2xycDeVH23H45CZv-Xf-BE1uEV8Q,1605
21
- bubble/integrations/queries.py,sha256=I_ydltY4dDYybLfkfKZj4jBo2XatQlmFcqtQkCCw9II,16786
19
+ bubble/integrations/formatters.py,sha256=-zd-hO5W7haWfQejkbXXsjyuSQ2JrEiJN4zJoyyGVVk,10950
20
+ bubble/integrations/models.py,sha256=Ufg8YnzMUIOXnvAwDIvzYali9GD2pL0Gujl-TIftLlk,1954
21
+ bubble/integrations/queries.py,sha256=gs8sgGRWINjowV1aI6ZVe2zJZI3Tk_5YUQzwG7UFzUk,17736
22
22
  bubble/integrations/cli_scripts/__init__.py,sha256=4YEoXXapVwz6tbnhTAcpLkC8C9lST7TsQ0XvKbeBG2E,1320
23
23
  bubble/integrations/cli_scripts/cli.py,sha256=Kjw02axBGNPpsesgSb9V3DdtG8s-iCcYTJZnaT6KBPA,3930
24
24
  bubble/integrations/cli_scripts/detector.py,sha256=mvev_ApKRv0i5WZ3NMbp9HWHA5sndLMpLhgsd5svc7A,4499
25
25
  bubble/integrations/django/__init__.py,sha256=KsIzlw8gcMLDEfM54azxciWJaLJrzUyHQbYxs6HuwQU,2037
26
- bubble/integrations/django/cli.py,sha256=pmg2yGKNUBkWd_jZpgdMMzuswH3HMyZEkzIrV4Q1ix4,4140
27
- bubble/integrations/django/detector.py,sha256=hxXsZk14WYlSpWmaD9TWPa4LeV2DM_T3GmOVbeADvrA,11370
26
+ bubble/integrations/django/cli.py,sha256=y_di9LBTVlEWlJ58Lp62hNFo6yXiIrIenNE6mTQOnwA,5714
27
+ bubble/integrations/django/detector.py,sha256=wZ6MNUBQ7c2SNRDJt-iAEsAYvqQW-KTXyAogsyM2r58,13327
28
28
  bubble/integrations/django/semantics.py,sha256=qdO6UC2U7brzc8SRrARLU9ivOGEQ59pN_TMXFpugYDk,1761
29
29
  bubble/integrations/fastapi/__init__.py,sha256=aIV3eWhX9Z_8od6e_EaTU9LCS2BvezgWlQasWTXTjKU,1867
30
- bubble/integrations/fastapi/cli.py,sha256=odJwIehPKSLfUqxkLi8SWwmWmV02mSCyd2OiZssh4L8,4129
30
+ bubble/integrations/fastapi/cli.py,sha256=HB5_3mDcJVkWOK40gHJBS0WV9gBPk1Hl-pCCBHrlqBQ,5710
31
31
  bubble/integrations/fastapi/detector.py,sha256=6LoCdomsHA33wPRpXz6tWCzSFKTmul9NOoChAFSjaWo,5767
32
32
  bubble/integrations/fastapi/semantics.py,sha256=xtHs8HUFMzpRMvE1a4uqCflWiGY1-5_v3ACCz0BBnUg,499
33
- bubble/integrations/flask/__init__.py,sha256=IaWU0TxLicbLsk0KdyYL8-VAjaujdZpjE6tvD5iu1Ws,1823
34
- bubble/integrations/flask/cli.py,sha256=Iz0J9C2Kb4zg3P30sI1iJ0Ey320lpMLN74MeRw0K2F8,4083
35
- bubble/integrations/flask/detector.py,sha256=3RUYmzDRbYFttrcVVwu5xGQk7BRekfYvk0AzNfCAJ0M,6511
33
+ bubble/integrations/flask/__init__.py,sha256=1fmMcxLOzIjU3aToF1vGnxeo-UwAVaKlPopnV2KPgnw,1959
34
+ bubble/integrations/flask/cli.py,sha256=Xkmg_QgLz6kMU7MS1staARBl4tCr0efvd7fzoZqgJtw,5672
35
+ bubble/integrations/flask/detector.py,sha256=gVg6pUUC3e3NPps2ZlLZfpGDO9Sn32AtJy171oSKSHE,15447
36
36
  bubble/integrations/flask/semantics.py,sha256=gf95XfpaztI1ASQfen38wyjbbLVIH_H2Xc6YwScCBV4,675
37
37
  bubble/integrations/generic/__init__.py,sha256=daDOwBkgmyA6mHOi6JbznZfSkIJx_6TMoy30wrKOPHE,321
38
38
  bubble/integrations/generic/config.py,sha256=61mKh9xRKPPo0sfI_SFQnIvoEMaUNFrbE21215LM1gc,3236
39
39
  bubble/integrations/generic/detector.py,sha256=Z0PSRLVF96CJ-FN473M8Txj28etMeEJS0fmaPV1Ev6M,12460
40
40
  bubble/integrations/generic/frameworks.py,sha256=s8fpdPD_71ZJvIbPv4VVvplLnp7cAb5W418fg5hu0uM,4060
41
- bubble_analysis-0.2.0.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
42
- bubble_analysis-0.2.0.dist-info/METADATA,sha256=XmSVfFbo8r4ts0Ak92w94EGI2OqLiDDaaZPcREp01P0,9040
43
- bubble_analysis-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
44
- bubble_analysis-0.2.0.dist-info/entry_points.txt,sha256=IsRFTKJQI7FCzniBdLSC4lebLcfyAvPnI9x1gpYFL3c,42
45
- bubble_analysis-0.2.0.dist-info/top_level.txt,sha256=PI9j20Ka7CoFSwoBiRcLAg9xm-BhUQujkDOn8ykeIjU,7
46
- bubble_analysis-0.2.0.dist-info/RECORD,,
41
+ bubble_analysis-0.3.0.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
42
+ bubble_analysis-0.3.0.dist-info/METADATA,sha256=C7qAa7iPLYcxyvrGXXT-2stTVm0nzM6pL9FeXmQWQlk,9040
43
+ bubble_analysis-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
44
+ bubble_analysis-0.3.0.dist-info/entry_points.txt,sha256=IsRFTKJQI7FCzniBdLSC4lebLcfyAvPnI9x1gpYFL3c,42
45
+ bubble_analysis-0.3.0.dist-info/top_level.txt,sha256=PI9j20Ka7CoFSwoBiRcLAg9xm-BhUQujkDOn8ykeIjU,7
46
+ bubble_analysis-0.3.0.dist-info/RECORD,,