debug-agent-py 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ """Database inspector: SQLAlchemy engines, connection pools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ from debug_agent.tool_registry import debug_tool, ToolParam
9
+
10
+
11
+ def _find_sqlalchemy_engines() -> list:
12
+ """Find SQLAlchemy Engine instances in loaded modules."""
13
+ engines = []
14
+ seen_ids = set()
15
+
16
+ # Try to find via SQLAlchemy's own registries first
17
+ try:
18
+ from sqlalchemy import inspect as sa_inspect
19
+ from sqlalchemy.engine import Engine
20
+ except ImportError:
21
+ return engines
22
+
23
+ for mod in list(sys.modules.values()):
24
+ if mod is None or mod.__name__.startswith("debug_agent"):
25
+ continue
26
+ for attr_name in dir(mod):
27
+ if attr_name.startswith("_"):
28
+ continue
29
+ try:
30
+ obj = getattr(mod, attr_name)
31
+ if isinstance(obj, Engine) and id(obj) not in seen_ids:
32
+ seen_ids.add(id(obj))
33
+ engines.append(obj)
34
+ except Exception:
35
+ continue
36
+
37
+ return engines
38
+
39
+
40
+ @debug_tool(
41
+ "get_sqlalchemy_engines",
42
+ "Find SQLAlchemy engines and their connection pool status",
43
+ )
44
+ def get_sqlalchemy_engines() -> dict:
45
+ try:
46
+ from sqlalchemy import create_engine, inspect as sa_inspect
47
+ from sqlalchemy.pool import QueuePool, NullPool, StaticPool
48
+ except ImportError:
49
+ return {"error": "SQLAlchemy is not installed"}
50
+
51
+ engines = _find_sqlalchemy_engines()
52
+ if not engines:
53
+ return {"message": "No SQLAlchemy engines found in loaded modules"}
54
+
55
+ result = []
56
+ for engine in engines:
57
+ pool = engine.pool
58
+ pool_info: dict = {
59
+ "pool_type": type(pool).__name__,
60
+ }
61
+
62
+ if isinstance(pool, QueuePool):
63
+ pool_info["size"] = pool.size()
64
+ pool_info["checked_in"] = pool.checkedin()
65
+ pool_info["checked_out"] = pool.checkedout()
66
+ pool_info["overflow"] = pool.overflow()
67
+ pool_info["checked_out_total"] = pool.status()
68
+
69
+ url = str(engine.url)
70
+ # Mask password in URL
71
+ if "" in url:
72
+ parts = url.split("@")
73
+ if len(parts) >= 2:
74
+ cred = parts[0].split(":")
75
+ if len(cred) >= 3:
76
+ cred[-1] = "***"
77
+ parts[0] = ":".join(cred)
78
+ url = "@".join(parts)
79
+
80
+ result.append({
81
+ "url": url,
82
+ "dialect": engine.dialect.name,
83
+ "driver": engine.driver,
84
+ "pool": pool_info,
85
+ })
86
+
87
+ return {"engine_count": len(result), "engines": result}
88
+
89
+
90
+ @debug_tool(
91
+ "get_db_connections",
92
+ "Inspect database connection pools and active connections if available",
93
+ )
94
+ def get_db_connections() -> dict:
95
+ try:
96
+ from sqlalchemy.pool import QueuePool, SingletonThreadPool, StaticPool
97
+ except ImportError:
98
+ return {"error": "SQLAlchemy is not installed"}
99
+
100
+ engines = _find_sqlalchemy_engines()
101
+ if not engines:
102
+ return {"message": "No SQLAlchemy engines found"}
103
+
104
+ connections = []
105
+ for engine in engines:
106
+ pool = engine.pool
107
+ info: dict = {
108
+ "dialect": engine.dialect.name,
109
+ "pool_type": type(pool).__name__,
110
+ }
111
+
112
+ if hasattr(pool, "size"):
113
+ info["pool_size"] = pool.size()
114
+ if hasattr(pool, "checkedin"):
115
+ info["checked_in"] = pool.checkedin()
116
+ if hasattr(pool, "checkedout"):
117
+ info["checked_out"] = pool.checkedout()
118
+ if hasattr(pool, "overflow"):
119
+ info["overflow"] = pool.overflow()
120
+
121
+ # Try to get pool status string
122
+ try:
123
+ info["pool_status"] = pool.status()
124
+ except Exception:
125
+ pass
126
+
127
+ connections.append(info)
128
+
129
+ return {"total_engines": len(connections), "connection_pools": connections}
@@ -0,0 +1,133 @@
1
+ """Framework inspector: routes, middleware, dependency injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ from debug_agent.tool_registry import debug_tool, ToolParam
9
+
10
+
11
+ def _find_app():
12
+ """Find the main web framework app instance."""
13
+ # Try FastAPI / Starlette
14
+ try:
15
+ import fastapi
16
+ for obj in _walk_module_objects():
17
+ if isinstance(obj, fastapi.FastAPI):
18
+ return ("fastapi", obj)
19
+ except ImportError:
20
+ pass
21
+
22
+ # Try Flask
23
+ try:
24
+ import flask
25
+ for obj in _walk_module_objects():
26
+ if isinstance(obj, flask.Flask):
27
+ return ("flask", obj)
28
+ except ImportError:
29
+ pass
30
+
31
+ return (None, None)
32
+
33
+
34
+ def _walk_module_objects():
35
+ """Walk loaded modules looking for app instances."""
36
+ seen = set()
37
+ for mod in list(sys.modules.values()):
38
+ if mod is None or mod.__name__.startswith("debug_agent"):
39
+ continue
40
+ for attr_name in dir(mod):
41
+ if attr_name.startswith("_"):
42
+ continue
43
+ try:
44
+ obj = getattr(mod, attr_name)
45
+ if id(obj) not in seen:
46
+ seen.add(id(obj))
47
+ yield obj
48
+ except Exception:
49
+ continue
50
+
51
+
52
+ @debug_tool("get_routes", "List all registered web routes/endpoints with methods and paths")
53
+ def get_routes() -> dict:
54
+ ftype, app = _find_app()
55
+ if not app:
56
+ return {"error": "No supported web framework app found"}
57
+
58
+ routes = []
59
+ if ftype == "fastapi":
60
+ for route in app.routes:
61
+ if hasattr(route, "methods") and hasattr(route, "path"):
62
+ routes.append({
63
+ "path": route.path,
64
+ "methods": list(route.methods or set()),
65
+ "name": getattr(route, "name", ""),
66
+ "endpoint": getattr(route, "endpoint", "").__name__ if hasattr(getattr(route, "endpoint", ""), "__name__") else "",
67
+ })
68
+ elif ftype == "flask":
69
+ for rule in app.url_map.iter_rules():
70
+ routes.append({
71
+ "path": str(rule),
72
+ "methods": sorted(rule.methods - {"HEAD", "OPTIONS"}),
73
+ "endpoint": rule.endpoint,
74
+ })
75
+
76
+ return {"framework": ftype, "route_count": len(routes), "routes": routes}
77
+
78
+
79
+ @debug_tool("get_middleware", "List registered middleware (FastAPI/Starlette/FastAPI)")
80
+ def get_middleware() -> dict:
81
+ ftype, app = _find_app()
82
+ if not app:
83
+ return {"error": "No supported web framework app found"}
84
+
85
+ middlewares = []
86
+ if ftype == "fastapi":
87
+ try:
88
+ mw_stack = app.user_middleware
89
+ middlewares = [str(m) for m in mw_stack]
90
+ except Exception:
91
+ pass
92
+ elif ftype == "flask":
93
+ middlewares = [] # Flask doesn't have middleware in the same way
94
+
95
+ return {"framework": ftype, "middlewares": middlewares}
96
+
97
+
98
+ @debug_tool("get_installed_packages", "List installed Python packages")
99
+ def get_installed_packages(prefix: str = ToolParam("Filter by prefix (empty = all)", required=False)) -> dict:
100
+ try:
101
+ import pkg_resources
102
+ pkgs = sorted(
103
+ [(p.project_name, p.version) for p in pkg_resources.working_set],
104
+ key=lambda x: x[0].lower(),
105
+ )
106
+ if prefix:
107
+ pkgs = [(n, v) for n, v in pkgs if n.lower().startswith(prefix.lower())]
108
+ return {"total": len(pkgs), "packages": [{"name": n, "version": v} for n, v in pkgs]}
109
+ except ImportError:
110
+ from importlib.metadata import distributions
111
+ pkgs = sorted(
112
+ [(d.metadata["Name"], d.version) for d in distributions()],
113
+ key=lambda x: x[0].lower(),
114
+ )
115
+ if prefix:
116
+ pkgs = [(n, v) for n, v in pkgs if n.lower().startswith(prefix.lower())]
117
+ return {"total": len(pkgs), "packages": [{"name": n, "version": v} for n, v in pkgs]}
118
+
119
+
120
+ @debug_tool("get_environment_variables", "List environment variables (filtered)")
121
+ def get_environment_variables(prefix: str = ToolParam("Filter by prefix (e.g. 'PATH')", required=False)) -> dict:
122
+ import os
123
+ env = dict(os.environ)
124
+ if prefix:
125
+ env = {k: v for k, v in env.items() if k.upper().startswith(prefix.upper())}
126
+ # Mask potential secrets
127
+ masked = {}
128
+ for k, v in env.items():
129
+ if any(s in k.upper() for s in ["KEY", "SECRET", "PASSWORD", "TOKEN", "CREDENTIAL"]):
130
+ masked[k] = "***masked***"
131
+ else:
132
+ masked[k] = v
133
+ return {"variables": masked, "count": len(masked)}
@@ -0,0 +1,93 @@
1
+ """HTTP request tracker: in-memory ring buffer for recent requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import threading
7
+ from collections import deque
8
+
9
+ from debug_agent.tool_registry import debug_tool, ToolParam
10
+
11
+
12
+ # Global ring buffer for request tracking
13
+ _buffer_lock = threading.Lock()
14
+ _request_buffer: deque = deque(maxlen=500)
15
+
16
+
17
+ def record_request(method: str, path: str, status: int, duration_ms: float, client: str = ""):
18
+ """Record an HTTP request. Call this from your middleware."""
19
+ with _buffer_lock:
20
+ _request_buffer.append({
21
+ "timestamp": time.time(),
22
+ "method": method,
23
+ "path": path,
24
+ "status": status,
25
+ "duration_ms": round(duration_ms, 2),
26
+ "client": client,
27
+ })
28
+
29
+
30
+ def _get_all() -> list[dict]:
31
+ with _buffer_lock:
32
+ return list(_request_buffer)
33
+
34
+
35
+ @debug_tool("get_recent_requests", "Get recent HTTP requests from the in-memory ring buffer")
36
+ def get_recent_requests(limit: int = ToolParam("Max results to return", required=False)) -> dict:
37
+ reqs = _get_all()
38
+ if limit:
39
+ reqs = reqs[-limit:]
40
+ return {"total": len(_request_buffer), "requests": list(reversed(reqs))}
41
+
42
+
43
+ @debug_tool("get_slow_requests", "Get slowest HTTP requests sorted by duration")
44
+ def get_slow_requests(threshold_ms: float = ToolParam("Minimum duration in ms", required=False)) -> dict:
45
+ reqs = _get_all()
46
+ if threshold_ms:
47
+ reqs = [r for r in reqs if r["duration_ms"] >= threshold_ms]
48
+ reqs.sort(key=lambda x: -x["duration_ms"])
49
+ return {"count": len(reqs), "requests": reqs[:20]}
50
+
51
+
52
+ @debug_tool("get_error_requests", "Get all error requests (4xx/5xx status codes)")
53
+ def get_error_requests() -> dict:
54
+ reqs = [r for r in _get_all() if r["status"] >= 400]
55
+ reqs.sort(key=lambda x: -x["duration_ms"])
56
+ return {"count": len(reqs), "requests": reqs}
57
+
58
+
59
+ @debug_tool("get_request_stats", "Get HTTP request statistics: count, P50/P95/P99 latency, error rate")
60
+ def get_request_stats() -> dict:
61
+ import math
62
+
63
+ reqs = _get_all()
64
+ if not reqs:
65
+ return {"message": "No requests recorded yet"}
66
+
67
+ durations = sorted([r["duration_ms"] for r in reqs])
68
+ n = len(durations)
69
+
70
+ def percentile(p: float) -> float:
71
+ idx = min(int(math.ceil(p * n)) - 1, n - 1)
72
+ return round(durations[idx], 2)
73
+
74
+ errors = sum(1 for r in reqs if r["status"] >= 400)
75
+
76
+ # Group by path
77
+ by_path: dict[str, int] = {}
78
+ for r in reqs:
79
+ by_path[r["path"]] = by_path.get(r["path"], 0) + 1
80
+
81
+ return {
82
+ "total_requests": n,
83
+ "error_count": errors,
84
+ "error_rate": f"{errors / n * 100:.1f}%",
85
+ "latency_ms": {
86
+ "min": durations[0],
87
+ "p50": percentile(0.5),
88
+ "p95": percentile(0.95),
89
+ "p99": percentile(0.99),
90
+ "max": durations[-1],
91
+ },
92
+ "top_paths": dict(sorted(by_path.items(), key=lambda x: -x[1])[:10]),
93
+ }
@@ -0,0 +1,117 @@
1
+ """Memory profiler inspector: tracemalloc, object counts, GC details, reference cycles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gc
6
+ import tracemalloc
7
+ from collections import Counter
8
+ from typing import Any
9
+
10
+ from debug_agent.tool_registry import debug_tool, ToolParam
11
+
12
+
13
+ @debug_tool(
14
+ "get_tracemalloc_stats",
15
+ "Get Python tracemalloc statistics: top allocations by file/line, traced memory",
16
+ )
17
+ def get_tracemalloc_stats(limit: int = ToolParam("Max number of top allocations to return", required=False)) -> dict:
18
+ if not tracemalloc.is_tracing():
19
+ try:
20
+ tracemalloc.start(10)
21
+ except RuntimeError:
22
+ return {"error": "tracemalloc could not be started"}
23
+
24
+ snapshot = tracemalloc.take_snapshot()
25
+ top = snapshot.statistics("lineno")
26
+ current, peak = tracemalloc.get_traced_memory()
27
+
28
+ allocations = []
29
+ n = limit if limit and limit > 0 else 20
30
+ for stat in top[:n]:
31
+ frame = stat.traceback[0]
32
+ allocations.append({
33
+ "file": frame.filename,
34
+ "line": frame.lineno,
35
+ "size_kb": round(stat.size / 1024, 2),
36
+ "count": stat.count,
37
+ })
38
+
39
+ return {
40
+ "traced_memory_mb": round(current / 1024 / 1024, 2),
41
+ "peak_memory_mb": round(peak / 1024 / 1024, 2),
42
+ "top_allocations": allocations,
43
+ "total_traced_blocks": sum(s.count for s in top),
44
+ }
45
+
46
+
47
+ @debug_tool(
48
+ "get_object_counts",
49
+ "Count live Python objects by type via gc.get_objects() summary",
50
+ )
51
+ def get_object_counts(limit: int = ToolParam("Max number of type entries to return", required=False)) -> dict:
52
+ all_objects = gc.get_objects()
53
+ type_counter: Counter = Counter()
54
+ for obj in all_objects:
55
+ type_counter[type(obj).__name__] += 1
56
+
57
+ n = limit if limit and limit > 0 else 20
58
+ top_types = type_counter.most_common(n)
59
+ return {
60
+ "total_objects": len(all_objects),
61
+ "unique_types": len(type_counter),
62
+ "top_types": {t: c for t, c in top_types},
63
+ }
64
+
65
+
66
+ @debug_tool(
67
+ "get_gc_stats",
68
+ "Get garbage collector statistics: collections, collected, and uncollectable counts per generation",
69
+ )
70
+ def get_gc_stats() -> dict:
71
+ stats = gc.get_stats()
72
+ threshold = gc.get_threshold()
73
+ return {
74
+ "generations": [
75
+ {
76
+ "generation": i,
77
+ "collections": s["collections"],
78
+ "collected": s["collected"],
79
+ "uncollectable": s["uncollectable"],
80
+ }
81
+ for i, s in enumerate(stats)
82
+ ],
83
+ "thresholds": {"gen0": threshold[0], "gen1": threshold[1], "gen2": threshold[2]},
84
+ "current_counts": {"gen0": gc.get_count()[0], "gen1": gc.get_count()[1], "gen2": gc.get_count()[2]},
85
+ "garbage_list_size": len(gc.garbage),
86
+ "debug_flags": gc.get_debug(),
87
+ }
88
+
89
+
90
+ @debug_tool(
91
+ "get_ref_cycles",
92
+ "Count reference cycles detected by the garbage collector",
93
+ )
94
+ def get_ref_cycles() -> dict:
95
+ # Save current debug flags
96
+ old_flags = gc.get_debug()
97
+ gc.set_debug(gc.DEBUG_SAVEALL)
98
+
99
+ before = len(gc.get_objects())
100
+ collected = gc.collect()
101
+ after = len(gc.get_objects())
102
+
103
+ # Analyze what was collected (stored in gc.garbage when DEBUG_SAVEALL)
104
+ cycle_types: Counter = Counter()
105
+ for obj in gc.garbage:
106
+ cycle_types[type(obj).__name__] += 1
107
+
108
+ # Restore
109
+ gc.set_debug(old_flags)
110
+ gc.garbage.clear()
111
+
112
+ return {
113
+ "objects_collected": collected,
114
+ "objects_freed": before - after,
115
+ "reference_cycle_types": dict(cycle_types.most_common(15)),
116
+ "total_cycles_found": sum(cycle_types.values()),
117
+ }
@@ -0,0 +1,129 @@
1
+ """Module inspector: loaded modules, import stats, module details."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ from debug_agent.tool_registry import debug_tool, ToolParam
9
+
10
+
11
+ @debug_tool(
12
+ "get_loaded_modules",
13
+ "List loaded Python modules (sys.modules) with their versions",
14
+ )
15
+ def get_loaded_modules(prefix: str = ToolParam("Filter by module name prefix (empty = all)", required=False)) -> dict:
16
+ results = []
17
+ for name in sorted(sys.modules.keys()):
18
+ if prefix and not name.lower().startswith(prefix.lower()):
19
+ continue
20
+ mod = sys.modules.get(name)
21
+ if mod is None:
22
+ results.append({"name": name, "version": None, "loaded": False})
23
+ continue
24
+
25
+ version = getattr(mod, "__version__", None)
26
+ if not version:
27
+ # Try importlib.metadata for installed packages
28
+ try:
29
+ from importlib.metadata import version as get_version
30
+ # Handle submodules by taking the top-level package
31
+ top_pkg = name.split(".")[0]
32
+ version = get_version(top_pkg)
33
+ except Exception:
34
+ version = None
35
+
36
+ results.append({"name": name, "version": version, "file": getattr(mod, "__file__", None)})
37
+
38
+ return {"total": len(results), "modules": results}
39
+
40
+
41
+ @debug_tool(
42
+ "get_import_stats",
43
+ "Get module import statistics: total count, largest modules by file size",
44
+ )
45
+ def get_import_stats() -> dict:
46
+ import os
47
+
48
+ module_sizes = []
49
+ total = 0
50
+ builtin_count = 0
51
+
52
+ for name, mod in sys.modules.items():
53
+ if mod is None:
54
+ continue
55
+ total += 1
56
+ filepath = getattr(mod, "__file__", None)
57
+ if filepath and os.path.isfile(filepath):
58
+ try:
59
+ size = os.path.getsize(filepath)
60
+ module_sizes.append((name, size, filepath))
61
+ except OSError:
62
+ pass
63
+ else:
64
+ builtin_count += 1
65
+
66
+ module_sizes.sort(key=lambda x: -x[1])
67
+ largest = [
68
+ {"name": name, "size_kb": round(size / 1024, 2), "file": filepath}
69
+ for name, size, filepath in module_sizes[:20]
70
+ ]
71
+
72
+ return {
73
+ "total_modules": total,
74
+ "builtin_or_builtin_like": builtin_count,
75
+ "with_file_path": len(module_sizes),
76
+ "largest_modules": largest,
77
+ }
78
+
79
+
80
+ @debug_tool(
81
+ "get_module_detail",
82
+ "Get detailed information about a specific loaded module",
83
+ )
84
+ def get_module_detail(module_name: str = ToolParam("The module name to inspect (e.g. 'flask', 'debug_agent.engine')")) -> dict:
85
+ mod = sys.modules.get(module_name)
86
+ if mod is None:
87
+ return {"error": f"Module '{module_name}' is not loaded"}
88
+
89
+ import os
90
+ filepath = getattr(mod, "__file__", None)
91
+ file_size = None
92
+ if filepath and os.path.isfile(filepath):
93
+ try:
94
+ file_size = os.path.getsize(filepath)
95
+ except OSError:
96
+ pass
97
+
98
+ version = getattr(mod, "__version__", None)
99
+ if not version:
100
+ try:
101
+ from importlib.metadata import version as get_version
102
+ version = get_version(module_name.split(".")[0])
103
+ except Exception:
104
+ pass
105
+
106
+ # Get public attributes
107
+ public_attrs = []
108
+ for attr in sorted(dir(mod)):
109
+ if attr.startswith("_"):
110
+ continue
111
+ try:
112
+ obj = getattr(mod, attr)
113
+ public_attrs.append({
114
+ "name": attr,
115
+ "type": type(obj).__name__,
116
+ })
117
+ except Exception:
118
+ continue
119
+
120
+ return {
121
+ "name": module_name,
122
+ "file": filepath,
123
+ "file_size_bytes": file_size,
124
+ "version": version,
125
+ "package": getattr(mod, "__package__", None),
126
+ "spec": str(getattr(mod, "__spec__", None)),
127
+ "public_attribute_count": len(public_attrs),
128
+ "public_attributes": public_attrs[:50],
129
+ }