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.
Files changed (46) hide show
  1. bubble/__init__.py +3 -0
  2. bubble/cache.py +207 -0
  3. bubble/cli.py +470 -0
  4. bubble/config.py +52 -0
  5. bubble/detectors.py +90 -0
  6. bubble/enums.py +65 -0
  7. bubble/extractor.py +829 -0
  8. bubble/formatters.py +887 -0
  9. bubble/integrations/__init__.py +92 -0
  10. bubble/integrations/base.py +98 -0
  11. bubble/integrations/cli_scripts/__init__.py +49 -0
  12. bubble/integrations/cli_scripts/cli.py +108 -0
  13. bubble/integrations/cli_scripts/detector.py +149 -0
  14. bubble/integrations/django/__init__.py +63 -0
  15. bubble/integrations/django/cli.py +111 -0
  16. bubble/integrations/django/detector.py +331 -0
  17. bubble/integrations/django/semantics.py +40 -0
  18. bubble/integrations/fastapi/__init__.py +57 -0
  19. bubble/integrations/fastapi/cli.py +110 -0
  20. bubble/integrations/fastapi/detector.py +176 -0
  21. bubble/integrations/fastapi/semantics.py +14 -0
  22. bubble/integrations/flask/__init__.py +57 -0
  23. bubble/integrations/flask/cli.py +110 -0
  24. bubble/integrations/flask/detector.py +191 -0
  25. bubble/integrations/flask/semantics.py +19 -0
  26. bubble/integrations/formatters.py +268 -0
  27. bubble/integrations/generic/__init__.py +13 -0
  28. bubble/integrations/generic/config.py +106 -0
  29. bubble/integrations/generic/detector.py +346 -0
  30. bubble/integrations/generic/frameworks.py +145 -0
  31. bubble/integrations/models.py +68 -0
  32. bubble/integrations/queries.py +481 -0
  33. bubble/loader.py +118 -0
  34. bubble/models.py +397 -0
  35. bubble/propagation.py +737 -0
  36. bubble/protocols.py +104 -0
  37. bubble/queries.py +627 -0
  38. bubble/results.py +211 -0
  39. bubble/stubs.py +89 -0
  40. bubble/timing.py +144 -0
  41. bubble_analysis-0.2.0.dist-info/METADATA +264 -0
  42. bubble_analysis-0.2.0.dist-info/RECORD +46 -0
  43. bubble_analysis-0.2.0.dist-info/WHEEL +5 -0
  44. bubble_analysis-0.2.0.dist-info/entry_points.txt +2 -0
  45. bubble_analysis-0.2.0.dist-info/licenses/LICENSE +21 -0
  46. 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