aeonic-agentguard-sdk-python 0.1.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.
@@ -0,0 +1,468 @@
1
+ """
2
+ Route Introspection - Discover and register agent routes at SDK initialization.
3
+
4
+ This module implements deferred route introspection for FastAPI and Django,
5
+ allowing the SDK to discover all agent routes before any requests are made.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Optional
10
+
11
+ from aeonic.core.types import AgentContext
12
+
13
+ logger = logging.getLogger("aeonic")
14
+
15
+
16
+ def introspect_routes(app: Any) -> None:
17
+ """
18
+ Introspect routes from FastAPI or Django app and register agents.
19
+
20
+ This function:
21
+ - Detects the framework automatically
22
+ - Walks the route/URL pattern registry
23
+ - Matches routes to agent metadata
24
+ - Registers all discovered agents
25
+
26
+ Args:
27
+ app: FastAPI or Django app instance (None for Django auto-detection)
28
+ """
29
+ try:
30
+ framework = _detect_framework(app)
31
+
32
+ if framework == "fastapi":
33
+ if app is None:
34
+ logger.warning("[Aeonic] FastAPI introspection requires app instance")
35
+ return
36
+ _introspect_fastapi_routes(app)
37
+ elif framework == "django":
38
+ # Django can introspect even without app (uses get_resolver())
39
+ _introspect_django_routes(app)
40
+ else:
41
+ # Try Django as fallback if app is None
42
+ if app is None:
43
+ try:
44
+ import django # noqa: F401
45
+
46
+ _introspect_django_routes(None)
47
+ except ImportError:
48
+ logger.warning("[Aeonic] Unknown framework - cannot introspect routes")
49
+ else:
50
+ logger.warning("[Aeonic] Unknown framework - cannot introspect routes")
51
+
52
+ except Exception as e:
53
+ logger.warning(f"[Aeonic] Route introspection failed: {e}")
54
+ import traceback
55
+
56
+ logger.error(f"[Aeonic] Traceback: {traceback.format_exc()}")
57
+
58
+
59
+ def _detect_framework(app: Any) -> Optional[str]:
60
+ """
61
+ Detect framework type from app instance.
62
+
63
+ Args:
64
+ app: Application instance
65
+
66
+ Returns:
67
+ "fastapi", "django", or None if unknown
68
+ """
69
+ # FastAPI detection
70
+ if hasattr(app, "routes") or hasattr(app, "router"):
71
+ return "fastapi"
72
+
73
+ # Django detection
74
+ if hasattr(app, "url_patterns") or hasattr(app, "urlpatterns"):
75
+ return "django"
76
+
77
+ # Try to detect Django by checking if it's a module with get_resolver
78
+ try:
79
+ from django.urls import get_resolver # noqa: F401
80
+
81
+ # If app is a Django URLConf module
82
+ if hasattr(app, "__name__"):
83
+ return "django"
84
+ except ImportError:
85
+ pass
86
+
87
+ return None
88
+
89
+
90
+ def _introspect_fastapi_routes(app: Any) -> None:
91
+ """
92
+ Introspect FastAPI routes and register agents.
93
+
94
+ FastAPI stores routes in app.routes or app.router.routes.
95
+ Each route has: path, methods, endpoint (handler function), and dependencies.
96
+
97
+ For FastAPI, we need to check the route's dependencies list to find with_agent() calls.
98
+
99
+ Args:
100
+ app: FastAPI app instance
101
+ """
102
+ try:
103
+ from aeonic.adapters.fastapi import get_agent_metadata
104
+ from aeonic.core.agent_registry import register_agent
105
+ from aeonic.core.init import get_config
106
+
107
+ # Get routes
108
+ routes = getattr(app, "routes", None)
109
+ if routes is None and hasattr(app, "router"):
110
+ routes = getattr(app.router, "routes", [])
111
+
112
+ if not routes:
113
+ logger.warning("[Aeonic] No routes found - routes may not have been added yet.")
114
+ logger.warning(
115
+ "[Aeonic] ⚠️ Ensure routes are defined before init() runs, or increase introspection_delay_ms."
116
+ )
117
+ logger.info(
118
+ "[Aeonic] ℹ️ Agents will be tracked at runtime on first request (fallback mode)."
119
+ )
120
+ return
121
+
122
+ config = get_config()
123
+ service_name = config.get("service_name")
124
+ agent_count = 0
125
+
126
+ for route in routes:
127
+ # Skip non-APIRoute routes (like Mount, etc.)
128
+ if not hasattr(route, "endpoint"):
129
+ continue
130
+
131
+ # Skip FastAPI auto-generated routes by checking route type
132
+ route_type = type(route).__name__
133
+ if route_type not in ["APIRoute", "APIRouter"]:
134
+ # Skip Mount, StaticFiles, etc.
135
+ continue
136
+
137
+ # Get route path first - FastAPI routes have 'path' attribute
138
+ route_path = getattr(route, "path", None)
139
+ if route_path is None:
140
+ # Try alternative attribute names
141
+ route_path = getattr(route, "path_regex", None)
142
+ if route_path:
143
+ # Convert regex pattern to path (remove ^$ and regex syntax)
144
+ import re
145
+
146
+ route_path = re.sub(r"^\^|\$$", "", str(route_path))
147
+ route_path = re.sub(r"\\", "", route_path)
148
+ else:
149
+ # Try to get from path_operation
150
+ if hasattr(route, "path_operation"):
151
+ route_path = getattr(route.path_operation, "path", None)
152
+ if route_path is None:
153
+ route_path = ""
154
+
155
+ # Check if this route has dependencies
156
+ agent_context: Optional[AgentContext] = None
157
+
158
+ # Try to get dependencies from the route
159
+ if hasattr(route, "dependant") and hasattr(route.dependant, "dependencies"):
160
+ # Check each dependency for agent metadata
161
+ for i, dep in enumerate(route.dependant.dependencies):
162
+ if hasattr(dep, "call"):
163
+ agent_context = get_agent_metadata(dep.call)
164
+ if agent_context:
165
+ break
166
+ else:
167
+ # Fallback: try to find metadata by checking closure
168
+ # FastAPI might wrap the dependency, so function IDs don't match
169
+ if hasattr(dep.call, "__closure__") and dep.call.__closure__:
170
+ for cell_idx, cell in enumerate(dep.call.__closure__):
171
+ try:
172
+ value = cell.cell_contents
173
+ # Check if closure contains our agent context (with source key)
174
+ if isinstance(value, dict):
175
+ if value.get("source") == "manual":
176
+ agent_context = value # type: ignore[assignment]
177
+ break
178
+ # Also check for agent dict by keys (name, type, model)
179
+ # The closure might have the original agent dict without 'source'
180
+ if (
181
+ "name" in value
182
+ and "type" in value
183
+ and "model" in value
184
+ ):
185
+ # This looks like an agent context - add source and use it
186
+ agent_context = {
187
+ **value,
188
+ "source": "manual",
189
+ } # type: ignore[assignment]
190
+ break
191
+ # Check if closure contains a function with our metadata
192
+ if callable(value) and hasattr(
193
+ value, "__aeonic_metadata__"
194
+ ):
195
+ agent_context = getattr(value, "__aeonic_metadata__") # type: ignore[assignment]
196
+ break
197
+ except (ValueError, AttributeError) as e:
198
+ logger.error(
199
+ f"[Aeonic] Could not inspect closure cell {cell_idx}: {e}"
200
+ )
201
+ continue
202
+ if agent_context:
203
+ break
204
+
205
+ # Also try checking the endpoint directly (in case it's decorated)
206
+ if not agent_context:
207
+ agent_context = get_agent_metadata(route.endpoint)
208
+ # if not agent_context:
209
+ # logger.warning("[Aeonic] ✗ No metadata found in endpoint")
210
+
211
+ if not agent_context:
212
+ # No agent metadata found - skip this route
213
+ # Only routes with with_agent() should be registered
214
+ # logger.warning(
215
+ # f"[Aeonic] Skipping route {route_path} - no agent metadata found (route does not use with_agent())"
216
+ # )
217
+ continue
218
+
219
+ # Filter out FastAPI auto-generated routes
220
+ if route_path in ["/openapi.json", "/docs", "/redoc", "/openapi.yaml"]:
221
+ logger.warning(f"[Aeonic] Skipping auto-generated FastAPI route: {route_path}")
222
+ continue
223
+
224
+ # Get route methods (FastAPI routes have a methods attribute)
225
+ methods = getattr(route, "methods", None)
226
+ if methods is None:
227
+ # Try to get from path_operation
228
+ if hasattr(route, "path_operation"):
229
+ methods = getattr(route.path_operation, "methods", None)
230
+ if methods is None:
231
+ methods = {"GET"} # Default
232
+
233
+ # Convert methods to list if it's a set
234
+ if isinstance(methods, set):
235
+ methods = list(methods)
236
+ elif not isinstance(methods, (list, tuple)):
237
+ methods = [methods] if methods else ["GET"]
238
+
239
+ # Register agent for each HTTP method
240
+ for method in methods:
241
+ # Convert to string and uppercase
242
+ method = str(method).upper()
243
+
244
+ # Skip HEAD and OPTIONS if they're auto-generated
245
+ if method in ["HEAD", "OPTIONS"]:
246
+ continue
247
+
248
+ register_agent(
249
+ name=agent_context.get("name", ""),
250
+ agent_type=agent_context.get("type"),
251
+ model=agent_context.get("model"),
252
+ route_path=route_path,
253
+ http_method=method,
254
+ framework="fastapi",
255
+ service_name=service_name,
256
+ )
257
+
258
+ agent_count += 1
259
+
260
+ if agent_count == 0:
261
+ logger.warning("[Aeonic] No agent routes found during introspection")
262
+
263
+ except Exception as e:
264
+ logger.warning(f"[Aeonic] FastAPI introspection failed: {e}")
265
+
266
+
267
+ def _introspect_django_routes(app: Any) -> None:
268
+ """
269
+ Introspect Django URL patterns and register agents.
270
+
271
+ Django stores URL patterns in urlpatterns, accessible via get_resolver().
272
+ We need to walk the pattern tree recursively to handle include() patterns.
273
+
274
+ Args:
275
+ app: Django app instance (or URLConf module, or None for auto-detection)
276
+ """
277
+ try:
278
+ from django.urls import get_resolver
279
+
280
+ from aeonic.adapters.django import get_agent_metadata
281
+ from aeonic.core.agent_registry import register_agent
282
+ from aeonic.core.init import get_config
283
+
284
+ resolver = get_resolver()
285
+ config = get_config()
286
+ service_name = config.get("service_name")
287
+
288
+ agent_count = [0] # Use list to allow mutation in nested function
289
+
290
+ def walk_url_patterns(patterns: Any, prefix: str = "") -> None:
291
+ """Recursively walk URL patterns."""
292
+ for pattern in patterns:
293
+ try:
294
+ # Handle nested patterns (include())
295
+ if hasattr(pattern, "url_patterns"):
296
+ # Get the pattern prefix
297
+ pattern_str = str(pattern.pattern) if hasattr(pattern, "pattern") else ""
298
+ walk_url_patterns(pattern.url_patterns, prefix + pattern_str)
299
+ else:
300
+ # Individual route
301
+ pattern_str = str(pattern.pattern) if hasattr(pattern, "pattern") else ""
302
+ path = prefix + pattern_str
303
+
304
+ # Get the view callback
305
+ if not hasattr(pattern, "callback") or pattern.callback is None:
306
+ continue
307
+
308
+ # Try to get agent metadata
309
+ agent_context = get_agent_metadata(pattern.callback)
310
+ if not agent_context:
311
+ continue
312
+
313
+ # Django doesn't store HTTP methods in URL patterns
314
+ # We need to check the view's allowed methods
315
+ methods = _get_view_methods(pattern.callback)
316
+
317
+ # If no methods found, skip this view (methods must be explicitly defined)
318
+ if not methods:
319
+ continue
320
+
321
+ for method in methods:
322
+ register_agent(
323
+ name=agent_context.get("name", ""),
324
+ agent_type=agent_context.get("type"),
325
+ model=agent_context.get("model"),
326
+ route_path=(f"/{path}" if not path.startswith("/") else path),
327
+ http_method=method,
328
+ framework="django",
329
+ service_name=service_name,
330
+ )
331
+
332
+ agent_count[0] += 1
333
+
334
+ except Exception as e:
335
+ # Skip problematic patterns
336
+ logger.error(f"[Aeonic] Skipped pattern: {e}")
337
+ continue
338
+
339
+ # Start walking from root patterns
340
+ url_patterns = resolver.url_patterns
341
+ if not url_patterns:
342
+ logger.warning("[Aeonic] No URL patterns found")
343
+ return
344
+
345
+ walk_url_patterns(url_patterns)
346
+
347
+ if agent_count[0] == 0:
348
+ logger.warning("[Aeonic] No agent routes found during introspection")
349
+
350
+ except ImportError:
351
+ logger.error("[Aeonic] Django not installed - cannot introspect Django routes")
352
+ except Exception as e:
353
+ logger.error(f"[Aeonic] Django introspection failed: {e}")
354
+
355
+
356
+ def _get_view_methods(view_func: Any) -> list:
357
+ """
358
+ Get HTTP methods supported by a Django view.
359
+
360
+ Args:
361
+ view_func: Django view function or class
362
+
363
+ Returns:
364
+ List of HTTP methods (e.g., ["GET", "POST"])
365
+ Returns empty list if methods cannot be determined (only registers explicitly defined methods)
366
+ """
367
+ try:
368
+ # Function-based view - check if it has http_method_names attribute
369
+ # (This is set by decorators like @require_http_methods)
370
+ if hasattr(view_func, "http_method_names"):
371
+ methods = [
372
+ m.upper()
373
+ for m in view_func.http_method_names
374
+ if m.lower() not in ["head", "options"]
375
+ ]
376
+ if methods:
377
+ return methods
378
+
379
+ # Class-based view - check the view class
380
+ if hasattr(view_func, "view_class"):
381
+ view_class = view_func.view_class
382
+ if hasattr(view_class, "http_method_names"):
383
+ methods = [
384
+ m.upper()
385
+ for m in view_class.http_method_names
386
+ if m.lower() not in ["head", "options"]
387
+ ]
388
+ if methods:
389
+ return methods
390
+
391
+ # Check for as_view (CBV)
392
+ if hasattr(view_func, "cls"):
393
+ cls = view_func.cls
394
+ if hasattr(cls, "http_method_names"):
395
+ methods = [
396
+ m.upper() for m in cls.http_method_names if m.lower() not in ["head", "options"]
397
+ ]
398
+ if methods:
399
+ return methods
400
+
401
+ # Try to check if view has method restrictions via decorators
402
+ # Check for @require_http_methods or similar decorators
403
+ if hasattr(view_func, "__wrapped__"):
404
+ # Check the wrapped function (might have method restrictions)
405
+ wrapped = view_func.__wrapped__
406
+ if hasattr(wrapped, "http_method_names"):
407
+ methods = [
408
+ m.upper()
409
+ for m in wrapped.http_method_names
410
+ if m.lower() not in ["head", "options"]
411
+ ]
412
+ if methods:
413
+ return methods
414
+
415
+ # For function-based views without explicit method restrictions,
416
+ # try to infer methods from the view's code
417
+ # Check if view uses request.POST, request.body (POST) or request.GET (GET)
418
+ try:
419
+ import inspect
420
+
421
+ # Get the actual function (unwrap decorators)
422
+ actual_func = view_func
423
+ while hasattr(actual_func, "__wrapped__"):
424
+ actual_func = actual_func.__wrapped__
425
+
426
+ # Get source code
427
+ try:
428
+ source = inspect.getsource(actual_func)
429
+ methods = []
430
+
431
+ # Check for POST indicators
432
+ if (
433
+ "request.POST" in source
434
+ or "request.body" in source
435
+ or "request.method" in source
436
+ and "POST" in source
437
+ ):
438
+ methods.append("POST")
439
+
440
+ # Check for GET indicators
441
+ if "request.GET" in source or (
442
+ "request.method" in source and "GET" in source and "POST" not in source
443
+ ):
444
+ # Only add GET if POST wasn't found (to avoid both)
445
+ if "POST" not in methods:
446
+ methods.append("GET")
447
+
448
+ if methods:
449
+ return [m.upper() for m in methods]
450
+ except (OSError, TypeError):
451
+ # Can't get source code (might be C extension or other)
452
+ pass
453
+ except Exception:
454
+ pass
455
+
456
+ # If we still can't determine, return empty list
457
+ # Users should use @require_http_methods(['POST']) to explicitly define methods
458
+ logger.warning(
459
+ f"[Aeonic] Could not determine HTTP methods for view {getattr(view_func, '__name__', 'unknown')}. "
460
+ f"Use @require_http_methods(['POST']) decorator to explicitly define supported methods. "
461
+ f"Skipping method registration for this view."
462
+ )
463
+ return []
464
+
465
+ except Exception as e:
466
+ logger.error(f"[Aeonic] Error determining view methods: {e}")
467
+ # Return empty list - be conservative
468
+ return []