botversion-sdk 1.1.7__tar.gz → 2.0.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: botversion-sdk
3
- Version: 1.1.7
3
+ Version: 2.0.0
4
4
  Summary: BotVersion AI Agent SDK for FastAPI, Flask, and Django
5
5
  Home-page: https://github.com/botversion/botversion-sdk-python
6
6
  Author: BotVersion
@@ -5,7 +5,7 @@ import builtins
5
5
  import os
6
6
 
7
7
  from .client import BotVersionClient
8
- from .scanner import scan_routes, scan_frontend_routes
8
+ from .scanner import scan_routes
9
9
  from .interceptor import (
10
10
  attach_fastapi_interceptor,
11
11
  attach_flask_interceptor,
@@ -20,12 +20,64 @@ from .interceptor import (
20
20
  attach_cherrypy_interceptor,
21
21
  )
22
22
 
23
+ def _classify_installation(framework):
24
+ """
25
+ Python/PHP SDKs only ever run on a backend server — there is no such
26
+ thing as a Python or PHP frontend project. So unlike the JS SDK, this
27
+ is a simple two-way classification: either a supported backend
28
+ framework was detected, or it wasn't.
29
+ """
30
+ if framework:
31
+ return "backend-only"
32
+ return "unknown"
33
+
23
34
  _initialized = False
24
35
  _client = None
25
36
  _options = {}
26
37
  _app = None
27
38
 
28
39
 
40
+ def _run_full_scan(app, framework, cwd, client, debug=False):
41
+ """
42
+ Runs a full scan (backend endpoints + frontend routes) and reports
43
+ results to the platform. This used to run automatically on server
44
+ start / first request. Now it only runs when the BotVersion dashboard's
45
+ "Scan" button sends a request to this SDK's scan-trigger endpoint
46
+ (see interceptor.py). This fixes scans not happening on redeploys
47
+ (e.g. Vercel/serverless) since there's no "restart" event to hook into.
48
+ """
49
+ result = {
50
+ "endpoint_count": 0,
51
+ "route_count": 0,
52
+ "project_type": None,
53
+ "detected_backend": framework,
54
+ "detected_frontend": None,
55
+ "sdk_language": "python",
56
+ }
57
+
58
+ try:
59
+ endpoints = []
60
+ if app is not None:
61
+ endpoints = scan_routes(app, framework)
62
+ elif framework == "django":
63
+ endpoints = scan_routes(None, "django")
64
+
65
+ result["endpoint_count"] = len(endpoints)
66
+
67
+ if endpoints:
68
+ client.register_endpoints_now(endpoints)
69
+
70
+ except Exception:
71
+ if debug:
72
+ import traceback
73
+ traceback.print_exc()
74
+
75
+ result["detected_frontend"] = None
76
+ result["project_type"] = _classify_installation(framework)
77
+
78
+ return result
79
+
80
+
29
81
  def init(app=None, api_key=None, **options):
30
82
  """
31
83
  Initialize the BotVersion SDK.
@@ -52,10 +104,16 @@ def init(app=None, api_key=None, **options):
52
104
  # Re-attach interceptor after hot reload
53
105
  framework = _detect_framework(app)
54
106
  if framework and _client:
107
+ _cwd = _options.get("cwd", os.getcwd())
108
+ _debug = _options.get("debug", False)
55
109
  interceptor_options = {
56
110
  "exclude": _options.get("exclude", []),
57
111
  "api_prefix": _options.get("api_prefix", None),
58
- "debug": _options.get("debug", False),
112
+ "debug": _debug,
113
+ "scan_secret": _options.get("api_key"),
114
+ "on_scan_requested": lambda: _run_full_scan(
115
+ app, framework, _cwd, _client, _debug
116
+ ),
59
117
  }
60
118
  if framework == "fastapi":
61
119
  attach_fastapi_interceptor(app, _client, interceptor_options)
@@ -94,6 +152,8 @@ def init(app=None, api_key=None, **options):
94
152
 
95
153
  if not framework:
96
154
  _initialized = False
155
+ if debug:
156
+ print("[botversion] No supported framework detected. SDK not initialized.")
97
157
  return
98
158
 
99
159
  _client = BotVersionClient({
@@ -108,10 +168,16 @@ def init(app=None, api_key=None, **options):
108
168
  builtins._botversion_client = _client
109
169
  builtins._botversion_options = _options
110
170
 
171
+ cwd = options.get("cwd", os.getcwd())
172
+
111
173
  interceptor_options = {
112
174
  "exclude": options.get("exclude", []),
113
175
  "api_prefix": options.get("api_prefix", None),
114
176
  "debug": debug,
177
+ "scan_secret": api_key,
178
+ "on_scan_requested": lambda: _run_full_scan(
179
+ app, framework, cwd, _client, debug
180
+ ),
115
181
  }
116
182
 
117
183
  # ── Attach runtime interceptor ───────────────────────────────────────────
@@ -141,85 +207,6 @@ def init(app=None, api_key=None, **options):
141
207
  else:
142
208
  return
143
209
 
144
- # ── Static scan (delayed 500ms — let app finish registering routes) ──────
145
- def _run_scan():
146
- try:
147
- endpoints = []
148
-
149
- if app is not None:
150
- endpoints = scan_routes(app, framework)
151
-
152
- elif framework == "django":
153
- endpoints = scan_routes(None, "django")
154
-
155
- # For any other framework with no app object, skip backend scan
156
- # but still continue to frontend scan below
157
-
158
- if endpoints:
159
- _client.register_endpoints_now(endpoints)
160
-
161
- except Exception as e:
162
- if debug:
163
- import traceback
164
- traceback.print_exc()
165
-
166
- cwd = options.get("cwd", os.getcwd())
167
- route_patterns = scan_frontend_routes(cwd)
168
- if route_patterns:
169
- _client.register_route_patterns(route_patterns)
170
-
171
- if framework == "flask":
172
- with app.app_context():
173
- t = threading.Thread(target=_run_scan, daemon=False)
174
- t.start()
175
- t.join(timeout=15)
176
- app._botversion_scanned = True
177
-
178
- @app.after_request
179
- def _botversion_first_scan(response):
180
- if not getattr(app, '_botversion_scanned', False):
181
- app._botversion_scanned = True
182
- t = threading.Thread(target=_run_scan, daemon=False)
183
- t.start()
184
- t.join(timeout=15)
185
- return response
186
-
187
- elif framework == "fastapi":
188
- @app.on_event("startup")
189
- async def _botversion_startup_scan():
190
- if not getattr(app, '_botversion_scanned', False):
191
- app._botversion_scanned = True
192
- t = threading.Thread(target=_run_scan, daemon=False)
193
- t.start()
194
- t.join(timeout=15)
195
-
196
- @app.middleware("http")
197
- async def _botversion_first_scan(request, call_next):
198
- if not getattr(app, '_botversion_scanned', False):
199
- app._botversion_scanned = True
200
- t = threading.Thread(target=_run_scan, daemon=False)
201
- t.start()
202
- t.join(timeout=15)
203
- return await call_next(request)
204
-
205
- elif framework == "django":
206
- try:
207
- from django.apps import apps
208
- if apps.ready:
209
- t = threading.Thread(target=_run_scan, daemon=False)
210
- t.start()
211
- t.join(timeout=15)
212
- except Exception:
213
- t = threading.Timer(3.0, _run_scan)
214
- t.daemon = False
215
- t.start()
216
-
217
- elif framework in ("starlette", "sanic", "falcon", "bottle",
218
- "aiohttp", "tornado", "pyramid", "cherrypy"):
219
- t = threading.Timer(3.0, _run_scan)
220
- t.daemon = False
221
- t.start()
222
-
223
210
 
224
211
  def get_endpoints():
225
212
  """Get all registered endpoints for this workspace."""
@@ -89,13 +89,14 @@ class BotVersionClient:
89
89
 
90
90
  # ── Register frontend route patterns ─────────────────────────────────────────
91
91
 
92
- def register_route_patterns(self, patterns):
93
- if not patterns:
92
+ def register_route_patterns(self, patterns, classification=None):
93
+ if not patterns and not classification:
94
94
  return
95
95
  try:
96
96
  self._post("/api/sdk/register-route-patterns", {
97
97
  "workspaceKey": self.api_key,
98
- "patterns": patterns,
98
+ "patterns": patterns or [],
99
+ "classification": classification,
99
100
  })
100
101
  except Exception as e:
101
102
  if self.debug:
@@ -121,6 +122,7 @@ class BotVersionClient:
121
122
  headers={
122
123
  "Content-Type": "application/json",
123
124
  "Content-Length": str(len(body)),
125
+ "X-BotVersion-SDK": "1.0.0-python",
124
126
  },
125
127
  )
126
128
 
@@ -150,6 +152,9 @@ class BotVersionClient:
150
152
  req = urllib.request.Request(
151
153
  url,
152
154
  method="GET",
155
+ headers={
156
+ "X-BotVersion-SDK": "1.0.0-python",
157
+ },
153
158
  )
154
159
 
155
160
  try:
@@ -212,11 +212,34 @@ def attach_fastapi_interceptor(app, client, options):
212
212
  from starlette.requests import Request
213
213
  import json as _json
214
214
 
215
+ from starlette.responses import JSONResponse
216
+
215
217
  class BotVersionMiddleware(BaseHTTPMiddleware):
216
218
  async def dispatch(self, request: Request, call_next):
217
219
  path = request.url.path
218
220
  method = request.method.upper()
219
221
 
222
+ # ── Scan trigger from BotVersion dashboard ───────────────
223
+ if path == "/__botversion/scan" and method == "POST":
224
+ provided_key = request.headers.get("x-botversion-scan-key", "")
225
+ if (
226
+ options.get("scan_secret")
227
+ and provided_key == options["scan_secret"]
228
+ and callable(options.get("on_scan_requested"))
229
+ ):
230
+ try:
231
+ result = options["on_scan_requested"]()
232
+ return JSONResponse(
233
+ {"success": True, "result": result}, status_code=200
234
+ )
235
+ except Exception as e:
236
+ return JSONResponse(
237
+ {"success": False, "error": str(e)}, status_code=500
238
+ )
239
+ return JSONResponse(
240
+ {"success": False, "error": "Unauthorized"}, status_code=401
241
+ )
242
+
220
243
  response = await call_next(request)
221
244
 
222
245
  if not should_ignore(path, options.get("exclude")):
@@ -259,6 +282,21 @@ def attach_flask_interceptor(app, client, options):
259
282
  path = flask_request.path
260
283
  method = flask_request.method.upper()
261
284
 
285
+ # ── Scan trigger from BotVersion dashboard ───────────────────
286
+ if path == "/__botversion/scan" and method == "POST":
287
+ provided_key = flask_request.headers.get("x-botversion-scan-key", "")
288
+ if (
289
+ options.get("scan_secret")
290
+ and provided_key == options["scan_secret"]
291
+ and callable(options.get("on_scan_requested"))
292
+ ):
293
+ try:
294
+ result = options["on_scan_requested"]()
295
+ return {"success": True, "result": result}, 200
296
+ except Exception as e:
297
+ return {"success": False, "error": str(e)}, 500
298
+ return {"success": False, "error": "Unauthorized"}, 401
299
+
262
300
  if should_ignore(path, options.get("exclude")):
263
301
  return
264
302
  if options.get("api_prefix") and not path.startswith(options["api_prefix"]):
@@ -296,6 +334,23 @@ class BotVersionDjangoMiddleware:
296
334
  def __call__(self, request):
297
335
  path = request.path
298
336
  method = request.method.upper()
337
+ options = self.__class__._options
338
+
339
+ # ── Scan trigger from BotVersion dashboard ───────────────────────
340
+ if path == "/__botversion/scan" and method == "POST":
341
+ provided_key = request.META.get("HTTP_X_BOTVERSION_SCAN_KEY", "")
342
+ from django.http import JsonResponse
343
+ if (
344
+ options.get("scan_secret")
345
+ and provided_key == options["scan_secret"]
346
+ and callable(options.get("on_scan_requested"))
347
+ ):
348
+ try:
349
+ result = options["on_scan_requested"]()
350
+ return JsonResponse({"success": True, "result": result}, status=200)
351
+ except Exception as e:
352
+ return JsonResponse({"success": False, "error": str(e)}, status=500)
353
+ return JsonResponse({"success": False, "error": "Unauthorized"}, status=401)
299
354
 
300
355
  if not should_ignore(path, self.__class__._options.get("exclude")):
301
356
  if not self.__class__._options.get("api_prefix") or path.startswith(self.__class__._options["api_prefix"]):
@@ -353,11 +408,34 @@ def attach_starlette_interceptor(app, client, options):
353
408
  from starlette.requests import Request
354
409
  import json as _json
355
410
 
411
+ from starlette.responses import JSONResponse
412
+
356
413
  class BotVersionStarletteMiddleware(BaseHTTPMiddleware):
357
414
  async def dispatch(self, request: Request, call_next):
358
415
  path = request.url.path
359
416
  method = request.method.upper()
360
417
 
418
+ # ── Scan trigger from BotVersion dashboard ───────────────
419
+ if path == "/__botversion/scan" and method == "POST":
420
+ provided_key = request.headers.get("x-botversion-scan-key", "")
421
+ if (
422
+ options.get("scan_secret")
423
+ and provided_key == options["scan_secret"]
424
+ and callable(options.get("on_scan_requested"))
425
+ ):
426
+ try:
427
+ result = options["on_scan_requested"]()
428
+ return JSONResponse(
429
+ {"success": True, "result": result}, status_code=200
430
+ )
431
+ except Exception as e:
432
+ return JSONResponse(
433
+ {"success": False, "error": str(e)}, status_code=500
434
+ )
435
+ return JSONResponse(
436
+ {"success": False, "error": "Unauthorized"}, status_code=401
437
+ )
438
+
361
439
  response = await call_next(request)
362
440
 
363
441
  if not should_ignore(path, options.get("exclude")):
@@ -395,6 +473,22 @@ def attach_sanic_interceptor(app, client, options):
395
473
  path = request.path
396
474
  method = request.method.upper()
397
475
 
476
+ # ── Scan trigger from BotVersion dashboard ───────────────────
477
+ if path == "/__botversion/scan" and method == "POST":
478
+ from sanic.response import json as sanic_json
479
+ provided_key = request.headers.get("x-botversion-scan-key", "")
480
+ if (
481
+ options.get("scan_secret")
482
+ and provided_key == options["scan_secret"]
483
+ and callable(options.get("on_scan_requested"))
484
+ ):
485
+ try:
486
+ result = options["on_scan_requested"]()
487
+ return sanic_json({"success": True, "result": result}, status=200)
488
+ except Exception as e:
489
+ return sanic_json({"success": False, "error": str(e)}, status=500)
490
+ return sanic_json({"success": False, "error": "Unauthorized"}, status=401)
491
+
398
492
  if should_ignore(path, options.get("exclude")):
399
493
  return
400
494
  if options.get("api_prefix") and not path.startswith(options["api_prefix"]):
@@ -428,6 +522,27 @@ def attach_falcon_interceptor(app, client, options):
428
522
  path = req.path
429
523
  method = req.method.upper()
430
524
 
525
+ # ── Scan trigger from BotVersion dashboard ───────────────
526
+ if path == "/__botversion/scan" and method == "POST":
527
+ provided_key = req.get_header("x-botversion-scan-key") or ""
528
+ if (
529
+ options.get("scan_secret")
530
+ and provided_key == options["scan_secret"]
531
+ and callable(options.get("on_scan_requested"))
532
+ ):
533
+ try:
534
+ result = options["on_scan_requested"]()
535
+ resp.media = {"success": True, "result": result}
536
+ resp.status = 200
537
+ except Exception as e:
538
+ resp.media = {"success": False, "error": str(e)}
539
+ resp.status = 500
540
+ else:
541
+ resp.media = {"success": False, "error": "Unauthorized"}
542
+ resp.status = 401
543
+ resp.complete = True
544
+ return
545
+
431
546
  if should_ignore(path, options.get("exclude")):
432
547
  return
433
548
  if options.get("api_prefix") and not path.startswith(options["api_prefix"]):
@@ -460,12 +575,41 @@ def attach_falcon_interceptor(app, client, options):
460
575
 
461
576
  def attach_bottle_interceptor(app, client, options):
462
577
  try:
463
- from bottle import request as bottle_request
578
+ from bottle import request as bottle_request, HTTPResponse
464
579
 
465
580
  def botversion_bottle_interceptor():
466
581
  path = bottle_request.path
467
582
  method = bottle_request.method.upper()
468
583
 
584
+ # ── Scan trigger from BotVersion dashboard ────────────────────
585
+ if path == "/__botversion/scan" and method == "POST":
586
+ provided_key = bottle_request.headers.get("x-botversion-scan-key", "")
587
+ if (
588
+ options.get("scan_secret")
589
+ and provided_key == options["scan_secret"]
590
+ and callable(options.get("on_scan_requested"))
591
+ ):
592
+ try:
593
+ result = options["on_scan_requested"]()
594
+ raise HTTPResponse(
595
+ body=json.dumps({"success": True, "result": result}),
596
+ status=200,
597
+ headers={"Content-Type": "application/json"},
598
+ )
599
+ except HTTPResponse:
600
+ raise
601
+ except Exception as e:
602
+ raise HTTPResponse(
603
+ body=json.dumps({"success": False, "error": str(e)}),
604
+ status=500,
605
+ headers={"Content-Type": "application/json"},
606
+ )
607
+ raise HTTPResponse(
608
+ body=json.dumps({"success": False, "error": "Unauthorized"}),
609
+ status=401,
610
+ headers={"Content-Type": "application/json"},
611
+ )
612
+
469
613
  if should_ignore(path, options.get("exclude")):
470
614
  return
471
615
  if options.get("api_prefix") and not path.startswith(options["api_prefix"]):
@@ -503,6 +647,28 @@ def attach_aiohttp_interceptor(app, client, options):
503
647
  path = request.path
504
648
  method = request.method.upper()
505
649
 
650
+ # ── Scan trigger from BotVersion dashboard ───────────────────
651
+ if path == "/__botversion/scan" and method == "POST":
652
+ from aiohttp import web
653
+ provided_key = request.headers.get("x-botversion-scan-key", "")
654
+ if (
655
+ options.get("scan_secret")
656
+ and provided_key == options["scan_secret"]
657
+ and callable(options.get("on_scan_requested"))
658
+ ):
659
+ try:
660
+ result = options["on_scan_requested"]()
661
+ return web.json_response(
662
+ {"success": True, "result": result}, status=200
663
+ )
664
+ except Exception as e:
665
+ return web.json_response(
666
+ {"success": False, "error": str(e)}, status=500
667
+ )
668
+ return web.json_response(
669
+ {"success": False, "error": "Unauthorized"}, status=401
670
+ )
671
+
506
672
  response = await handler(request)
507
673
 
508
674
  if not should_ignore(path, options.get("exclude")):
@@ -548,6 +714,41 @@ def attach_tornado_interceptor(app, client, options):
548
714
  import tornado.web
549
715
  import json as _json
550
716
 
717
+ original_prepare = tornado.web.RequestHandler.prepare
718
+
719
+ def patched_prepare(self):
720
+ try:
721
+ path = self.request.path
722
+ method = self.request.method.upper()
723
+
724
+ # ── Scan trigger from BotVersion dashboard ───────────────
725
+ if path == "/__botversion/scan" and method == "POST":
726
+ provided_key = self.request.headers.get("x-botversion-scan-key", "")
727
+ if (
728
+ options.get("scan_secret")
729
+ and provided_key == options["scan_secret"]
730
+ and callable(options.get("on_scan_requested"))
731
+ ):
732
+ try:
733
+ result = options["on_scan_requested"]()
734
+ self.set_status(200)
735
+ self.set_header("Content-Type", "application/json")
736
+ self.finish(_json.dumps({"success": True, "result": result}))
737
+ except Exception as e:
738
+ self.set_status(500)
739
+ self.set_header("Content-Type", "application/json")
740
+ self.finish(_json.dumps({"success": False, "error": str(e)}))
741
+ else:
742
+ self.set_status(401)
743
+ self.set_header("Content-Type", "application/json")
744
+ self.finish(_json.dumps({"success": False, "error": "Unauthorized"}))
745
+ return
746
+ except Exception:
747
+ pass
748
+ return original_prepare(self)
749
+
750
+ tornado.web.RequestHandler.prepare = patched_prepare
751
+
551
752
  original_finish = tornado.web.RequestHandler.finish
552
753
 
553
754
  def patched_finish(self, chunk=None):
@@ -592,6 +793,25 @@ _pyramid_options = {}
592
793
 
593
794
  def botversion_pyramid_tween_factory(handler, registry):
594
795
  def botversion_tween(request):
796
+ path = request.path
797
+ method = request.method.upper()
798
+
799
+ # ── Scan trigger from BotVersion dashboard ────────────────────────
800
+ if path == "/__botversion/scan" and method == "POST":
801
+ from pyramid.response import Response
802
+ provided_key = request.headers.get("x-botversion-scan-key", "")
803
+ if (
804
+ _pyramid_options.get("scan_secret")
805
+ and provided_key == _pyramid_options["scan_secret"]
806
+ and callable(_pyramid_options.get("on_scan_requested"))
807
+ ):
808
+ try:
809
+ result = _pyramid_options["on_scan_requested"]()
810
+ return Response(json_body={"success": True, "result": result}, status=200)
811
+ except Exception as e:
812
+ return Response(json_body={"success": False, "error": str(e)}, status=500)
813
+ return Response(json_body={"success": False, "error": "Unauthorized"}, status=401)
814
+
595
815
  response = handler(request)
596
816
  try:
597
817
  import json as _json
@@ -645,6 +865,30 @@ def attach_cherrypy_interceptor(app, client, options):
645
865
  path = request.path_info
646
866
  method = request.method.upper()
647
867
 
868
+ # ── Scan trigger from BotVersion dashboard ────────────────────
869
+ if path == "/__botversion/scan" and method == "POST":
870
+ provided_key = request.headers.get("x-botversion-scan-key", "")
871
+ if (
872
+ options.get("scan_secret")
873
+ and provided_key == options["scan_secret"]
874
+ and callable(options.get("on_scan_requested"))
875
+ ):
876
+ try:
877
+ result = options["on_scan_requested"]()
878
+ cherrypy.response.status = 200
879
+ body = _json.dumps({"success": True, "result": result})
880
+ except Exception as e:
881
+ cherrypy.response.status = 500
882
+ body = _json.dumps({"success": False, "error": str(e)})
883
+ else:
884
+ cherrypy.response.status = 401
885
+ body = _json.dumps({"success": False, "error": "Unauthorized"})
886
+
887
+ cherrypy.response.headers["Content-Type"] = "application/json"
888
+ cherrypy.response.body = body.encode("utf-8")
889
+ cherrypy.request.handler = None
890
+ return
891
+
648
892
  if should_ignore(path, options.get("exclude")):
649
893
  return
650
894
  if options.get("api_prefix") and not path.startswith(options["api_prefix"]):