botversion-sdk 2.0.0__tar.gz → 2.1.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.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: botversion-sdk
3
+ Version: 2.1.0
4
+ Summary: BotVersion SDK — automatically discover and register your API endpoints
5
+ Home-page: https://github.com/botversion/botversion-sdk-python
6
+ Author: BotVersion
7
+ Author-email: support@botversion.com
8
+ Keywords: botversion,api,sdk,fastapi,flask,django
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.7
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: python-dotenv>=1.0.0
17
+ Provides-Extra: fastapi
18
+ Requires-Dist: fastapi; extra == "fastapi"
19
+ Requires-Dist: starlette; extra == "fastapi"
20
+ Provides-Extra: flask
21
+ Requires-Dist: flask; extra == "flask"
22
+ Provides-Extra: django
23
+ Requires-Dist: django; extra == "django"
24
+ Dynamic: author
25
+ Dynamic: author-email
26
+ Dynamic: classifier
27
+ Dynamic: description-content-type
28
+ Dynamic: home-page
29
+ Dynamic: keywords
30
+ Dynamic: provides-extra
31
+ Dynamic: requires-dist
32
+ Dynamic: requires-python
33
+ Dynamic: summary
@@ -158,7 +158,7 @@ def init(app=None, api_key=None, **options):
158
158
 
159
159
  _client = BotVersionClient({
160
160
  "api_key": api_key,
161
- "platform_url": options.get("platform_url", "https://botversion.com"),
161
+ "platform_url": options.get("platform_url", "https://console.botversion.com"),
162
162
  "debug": debug,
163
163
  "timeout": options.get("timeout", 30),
164
164
  "flush_delay": options.get("flush_delay", 3),
File without changes
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Build-time backend endpoint scanner. Run this as part of your deploy
4
+ pipeline (Procfile 'release' step, build.sh, or Dockerfile RUN step) so
5
+ endpoints are reported even on serverless platforms where the live
6
+ interceptor's scan-trigger route may not be reachable.
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ from botversion_sdk.client import BotVersionClient
12
+ from botversion_sdk.scanner import scan_routes_static, detect_framework_from_deps
13
+
14
+
15
+ def _load_env_files(cwd):
16
+ try:
17
+ from dotenv import load_dotenv
18
+ except ImportError:
19
+ return
20
+ for filename in (".env", ".env.local"):
21
+ filepath = os.path.join(cwd, filename)
22
+ if os.path.exists(filepath):
23
+ load_dotenv(filepath, override=False)
24
+
25
+
26
+ def main():
27
+ cwd = os.getcwd()
28
+ _load_env_files(cwd)
29
+
30
+ api_key = os.environ.get("BOTVERSION_API_KEY")
31
+ platform_url = os.environ.get("BOTVERSION_PLATFORM_URL", "https://console.botversion.com")
32
+
33
+ if not api_key:
34
+ print("[botversion] BOTVERSION_API_KEY environment variable is not set. Skipping backend endpoint scan.")
35
+ sys.exit(0)
36
+
37
+ framework = detect_framework_from_deps(cwd)
38
+ if not framework:
39
+ print("[botversion] No supported backend framework detected. Skipping backend endpoint scan.")
40
+ sys.exit(0)
41
+
42
+ endpoints = scan_routes_static(cwd, framework)
43
+
44
+ if not endpoints:
45
+ print(f"[botversion] No backend endpoints found via static scan. detected_framework={framework}")
46
+ sys.exit(0)
47
+
48
+ client = BotVersionClient({"api_key": api_key, "platform_url": platform_url})
49
+ try:
50
+ client.register_endpoints_now(endpoints)
51
+ print(f"[botversion] Reported {len(endpoints)} backend endpoints. detected_framework={framework}")
52
+ except Exception as e:
53
+ print(f"[botversion] Failed to report backend endpoints: {e}")
54
+
55
+ sys.exit(0)
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
@@ -11,7 +11,7 @@ class BotVersionClient:
11
11
 
12
12
  def __init__(self, options):
13
13
  self.api_key = options["api_key"]
14
- platform_url = options.get("platform_url", "https://botversion.com")
14
+ platform_url = options.get("platform_url", "https://console.botversion.com")
15
15
 
16
16
  self.platform_url = platform_url
17
17
  self.debug = options.get("debug", False)
@@ -31,6 +31,10 @@ def scan_routes(app, framework):
31
31
  elif framework == "cherrypy":
32
32
  result = scan_cherrypy_routes(app)
33
33
 
34
+ for endpoint in result:
35
+ if "routeParamMap" not in endpoint:
36
+ endpoint["routeParamMap"] = build_route_param_map(endpoint.get("path", ""))
37
+
34
38
  return result
35
39
 
36
40
 
@@ -737,6 +741,37 @@ def build_param_schema(params):
737
741
  properties = {p: {"type": "string"} for p in params}
738
742
  return {"type": "object", "properties": properties}
739
743
 
744
+ def build_route_param_map(route_path):
745
+ """
746
+ Gives route params more descriptive names based on their parent
747
+ segment, mirroring the JS SDK's buildRouteParamMap().
748
+ e.g. /projects/:id -> {"id": "projectId"}
749
+ """
750
+ param_map = {}
751
+ parts = [p for p in route_path.split("/") if p]
752
+
753
+ for i, part in enumerate(parts):
754
+ match = re.match(r"^:([a-zA-Z_][a-zA-Z0-9_]*)$", part)
755
+ if not match:
756
+ continue
757
+ param_name = match.group(1)
758
+
759
+ if param_name not in ("id", "slug", "param"):
760
+ param_map[param_name] = param_name
761
+ continue
762
+
763
+ parent_segment = parts[i - 1] if i > 0 else None
764
+ if parent_segment:
765
+ clean_parent = parent_segment.lstrip(":")
766
+ singular = re.sub(r"ies$", "y", clean_parent)
767
+ if singular == clean_parent:
768
+ singular = re.sub(r"s$", "", clean_parent)
769
+ param_map[param_name] = singular + "Id"
770
+ else:
771
+ param_map[param_name] = "id"
772
+
773
+ return param_map
774
+
740
775
 
741
776
  def generate_description(method, path, handler_name=None):
742
777
  """
@@ -1297,4 +1332,589 @@ def scan_cherrypy_routes(app):
1297
1332
  except Exception:
1298
1333
  pass
1299
1334
 
1300
- return endpoints
1335
+ return endpoints
1336
+
1337
+ # ── Static (build-time) scanning — no live app object required ──────────────
1338
+ # Used by the CLI (bin/scan_endpoints.py) since there's no running server
1339
+ # at build time. Parses .py files directly instead of introspecting a live
1340
+ # app instance.
1341
+
1342
+ import os
1343
+
1344
+ SKIP_DIRS = {"node_modules", ".git", "venv", "env", ".venv", "__pycache__",
1345
+ "migrations", "dist", "build", ".tox", "site-packages"}
1346
+
1347
+
1348
+ def scan_routes_static(cwd, framework):
1349
+ if framework in ("fastapi", "starlette", "sanic"):
1350
+ result = _scan_decorator_style_static(cwd)
1351
+ elif framework == "flask":
1352
+ result = _scan_flask_static(cwd)
1353
+ elif framework == "django":
1354
+ result = _scan_django_static(cwd)
1355
+ elif framework == "falcon":
1356
+ result = _scan_falcon_static(cwd)
1357
+ elif framework == "bottle":
1358
+ result = _scan_bottle_static(cwd)
1359
+ elif framework == "aiohttp":
1360
+ result = _scan_aiohttp_static(cwd)
1361
+ elif framework == "tornado":
1362
+ result = _scan_tornado_static(cwd)
1363
+ elif framework == "pyramid":
1364
+ result = _scan_pyramid_static(cwd)
1365
+ else:
1366
+ # CherryPy's routing is a runtime object tree discovered by
1367
+ # walking live class instances — it can't be approximated
1368
+ # reliably via static text scanning, so it's not supported for
1369
+ # build-time/serverless scans. The live interceptor still works
1370
+ # fine for CherryPy on always-on servers.
1371
+ result = []
1372
+
1373
+ for endpoint in result:
1374
+ if "routeParamMap" not in endpoint:
1375
+ endpoint["routeParamMap"] = build_route_param_map(endpoint.get("path", ""))
1376
+
1377
+ return result
1378
+
1379
+
1380
+ def _walk_py_files(cwd):
1381
+ for root, dirs, files in os.walk(cwd):
1382
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
1383
+ for f in files:
1384
+ if f.endswith(".py"):
1385
+ yield os.path.join(root, f)
1386
+
1387
+
1388
+ def _scan_decorator_style_static(cwd):
1389
+ """
1390
+ Handles FastAPI / Starlette / Sanic-style decorators:
1391
+ @app.get("/path")
1392
+ @router.post("/path")
1393
+ """
1394
+ endpoints = []
1395
+ seen = set()
1396
+
1397
+ pattern = re.compile(
1398
+ r"@(?:\w+)\.(get|post|put|delete|patch)\s*\(\s*[\"']([^\"']+)[\"']",
1399
+ re.IGNORECASE,
1400
+ )
1401
+
1402
+ for filepath in _walk_py_files(cwd):
1403
+ try:
1404
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1405
+ content = fh.read()
1406
+ except Exception:
1407
+ continue
1408
+
1409
+ for match in pattern.finditer(content):
1410
+ method = match.group(1).upper()
1411
+ path = match.group(2)
1412
+ normalized = re.sub(r"\{([^}]+)\}", r":\1", path)
1413
+ key = f"{method}:{normalized}"
1414
+ if key in seen:
1415
+ continue
1416
+ seen.add(key)
1417
+
1418
+ body_fields = _extract_body_fields_static(content) if method in ("POST", "PUT", "PATCH") else None
1419
+
1420
+ endpoints.append({
1421
+ "method": method,
1422
+ "path": normalized,
1423
+ "description": generate_description(method, normalized, None),
1424
+ "requestBody": body_fields,
1425
+ "detectedBy": "static-scan-file",
1426
+ })
1427
+
1428
+ return endpoints
1429
+
1430
+
1431
+ def _find_matching_paren(text, open_idx):
1432
+ """
1433
+ Given the index of an opening '(' in text, return the index of its
1434
+ matching closing ')'. Used to bound a single decorator's argument
1435
+ list precisely, instead of using a regex that can drift across
1436
+ unrelated decorators later in the file.
1437
+ """
1438
+ depth = 0
1439
+ i = open_idx
1440
+ n = len(text)
1441
+ while i < n:
1442
+ c = text[i]
1443
+ if c == "(":
1444
+ depth += 1
1445
+ elif c == ")":
1446
+ depth -= 1
1447
+ if depth == 0:
1448
+ return i
1449
+ i += 1
1450
+ return None
1451
+
1452
+
1453
+ def _scan_flask_static(cwd):
1454
+ """
1455
+ Handles Flask-style decorators:
1456
+ @app.route("/path", methods=["POST"])
1457
+ @bp.route("/path") (Blueprint, defaults to GET)
1458
+
1459
+ Each decorator's argument list is isolated using paren-matching
1460
+ (see _find_matching_paren) rather than a DOTALL regex. The previous
1461
+ DOTALL approach could match "methods=[...]" belonging to a LATER,
1462
+ unrelated @app.route(...) decorator further down the file, which
1463
+ both mis-tagged the earlier route's methods and caused the later
1464
+ route to be skipped entirely.
1465
+ """
1466
+ endpoints = []
1467
+ seen = set()
1468
+
1469
+ decorator_start = re.compile(r"@(?:\w+)\.route\s*\(", re.IGNORECASE)
1470
+
1471
+ for filepath in _walk_py_files(cwd):
1472
+ try:
1473
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1474
+ content = fh.read()
1475
+ except Exception:
1476
+ continue
1477
+
1478
+ for start_match in decorator_start.finditer(content):
1479
+ open_paren_idx = start_match.end() - 1 # index of the '(' itself
1480
+ close_paren_idx = _find_matching_paren(content, open_paren_idx)
1481
+ if close_paren_idx is None:
1482
+ continue
1483
+
1484
+ # Only look inside THIS decorator's own argument list
1485
+ args_text = content[open_paren_idx + 1:close_paren_idx]
1486
+
1487
+ path_match = re.search(r"[\"']([^\"']+)[\"']", args_text)
1488
+ if not path_match:
1489
+ continue
1490
+ path = path_match.group(1)
1491
+
1492
+ methods_match = re.search(r"methods\s*=\s*\[([^\]]*)\]", args_text)
1493
+ if methods_match:
1494
+ methods = [
1495
+ m.strip().strip("'\"").upper()
1496
+ for m in methods_match.group(1).split(",")
1497
+ if m.strip()
1498
+ ]
1499
+ if not methods:
1500
+ methods = ["GET"]
1501
+ else:
1502
+ methods = ["GET"]
1503
+
1504
+ normalized = normalize_flask_path(path)
1505
+
1506
+ for method in methods:
1507
+ key = f"{method}:{normalized}"
1508
+ if key in seen:
1509
+ continue
1510
+ seen.add(key)
1511
+
1512
+ body_fields = _extract_body_fields_static(content) if method in ("POST", "PUT", "PATCH") else None
1513
+
1514
+ endpoints.append({
1515
+ "method": method,
1516
+ "path": normalized,
1517
+ "description": generate_description(method, normalized, None),
1518
+ "requestBody": body_fields,
1519
+ "detectedBy": "static-scan-file",
1520
+ })
1521
+
1522
+ return endpoints
1523
+
1524
+
1525
+ def _scan_django_static(cwd):
1526
+ """
1527
+ Handles Django urls.py:
1528
+ path("users/<int:id>/", views.user_detail)
1529
+ re_path(r"^users/(?P<id>\\d+)/$", views.user_detail)
1530
+ """
1531
+ endpoints = []
1532
+ seen = set()
1533
+
1534
+ pattern = re.compile(
1535
+ r"(?:path|re_path)\s*\(\s*r?[\"']([^\"']+)[\"']",
1536
+ re.IGNORECASE,
1537
+ )
1538
+
1539
+ for filepath in _walk_py_files(cwd):
1540
+ if "urls.py" not in filepath:
1541
+ continue
1542
+ try:
1543
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1544
+ content = fh.read()
1545
+ except Exception:
1546
+ continue
1547
+
1548
+ for match in pattern.finditer(content):
1549
+ raw_path = match.group(1)
1550
+ normalized = _django_pattern_to_path(raw_path)
1551
+
1552
+ # Static scan can't reliably detect the view's supported HTTP
1553
+ # methods without importing it — default to GET + POST as the
1554
+ # common case (consistent with function-based view fallback
1555
+ # in _detect_django_methods).
1556
+ for method in ["GET", "POST"]:
1557
+ key = f"{method}:{normalized}"
1558
+ if key in seen:
1559
+ continue
1560
+ seen.add(key)
1561
+
1562
+ endpoints.append({
1563
+ "method": method,
1564
+ "path": normalized,
1565
+ "description": generate_description(method, normalized, None),
1566
+ "requestBody": None,
1567
+ "detectedBy": "static-scan-file",
1568
+ })
1569
+
1570
+ return endpoints
1571
+
1572
+ def _scan_falcon_static(cwd):
1573
+ """
1574
+ Handles Falcon-style route registration:
1575
+ app.add_route('/path', SomeResource())
1576
+ Falcon resources define on_get/on_post/etc as class methods, so we
1577
+ can't perfectly link one add_route() call to its resource's methods
1578
+ without executing the code. As a best-effort approximation, we scan
1579
+ the whole file for any on_<method> definitions and apply that method
1580
+ set to every add_route() path found in the same file.
1581
+ """
1582
+ endpoints = []
1583
+ seen = set()
1584
+
1585
+ route_pattern = re.compile(r"add_route\s*\(\s*[\"']([^\"']+)[\"']", re.IGNORECASE)
1586
+ method_def_pattern = re.compile(r"def\s+on_(get|post|put|delete|patch)\b", re.IGNORECASE)
1587
+
1588
+ for filepath in _walk_py_files(cwd):
1589
+ try:
1590
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1591
+ content = fh.read()
1592
+ except Exception:
1593
+ continue
1594
+
1595
+ route_matches = list(route_pattern.finditer(content))
1596
+ if not route_matches:
1597
+ continue
1598
+
1599
+ methods_in_file = sorted({m.group(1).upper() for m in method_def_pattern.finditer(content)})
1600
+ if not methods_in_file:
1601
+ methods_in_file = ["GET"]
1602
+
1603
+ for match in route_matches:
1604
+ path = match.group(1)
1605
+ normalized = re.sub(r"\{([^:}]+)(?::[^}]+)?\}", r":\1", path)
1606
+
1607
+ for method in methods_in_file:
1608
+ key = f"{method}:{normalized}"
1609
+ if key in seen:
1610
+ continue
1611
+ seen.add(key)
1612
+
1613
+ body_fields = _extract_body_fields_static(content) if method in ("POST", "PUT", "PATCH") else None
1614
+
1615
+ endpoints.append({
1616
+ "method": method,
1617
+ "path": normalized,
1618
+ "description": generate_description(method, normalized, None),
1619
+ "requestBody": body_fields,
1620
+ "detectedBy": "static-scan-file",
1621
+ })
1622
+
1623
+ return endpoints
1624
+
1625
+
1626
+ def _scan_bottle_static(cwd):
1627
+ """
1628
+ Handles Bottle-style routes:
1629
+ @app.route('/path', method='POST')
1630
+ @app.get('/path') / @app.post('/path') (shorthand decorators)
1631
+ """
1632
+ endpoints = []
1633
+ seen = set()
1634
+
1635
+ shorthand_pattern = re.compile(
1636
+ r"@(?:\w+)\.(get|post|put|delete|patch)\s*\(\s*[\"']([^\"']+)[\"']",
1637
+ re.IGNORECASE,
1638
+ )
1639
+ route_decorator_start = re.compile(r"@(?:\w+)\.route\s*\(", re.IGNORECASE)
1640
+
1641
+ for filepath in _walk_py_files(cwd):
1642
+ try:
1643
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1644
+ content = fh.read()
1645
+ except Exception:
1646
+ continue
1647
+
1648
+ for match in shorthand_pattern.finditer(content):
1649
+ method = match.group(1).upper()
1650
+ path = match.group(2)
1651
+ normalized = re.sub(r"<([^:>]+)(?::[^>]+)?>", r":\1", path)
1652
+
1653
+ key = f"{method}:{normalized}"
1654
+ if key in seen:
1655
+ continue
1656
+ seen.add(key)
1657
+
1658
+ body_fields = _extract_body_fields_static(content) if method in ("POST", "PUT", "PATCH") else None
1659
+
1660
+ endpoints.append({
1661
+ "method": method,
1662
+ "path": normalized,
1663
+ "description": generate_description(method, normalized, None),
1664
+ "requestBody": body_fields,
1665
+ "detectedBy": "static-scan-file",
1666
+ })
1667
+
1668
+ for start_match in route_decorator_start.finditer(content):
1669
+ open_idx = start_match.end() - 1
1670
+ close_idx = _find_matching_paren(content, open_idx)
1671
+ if close_idx is None:
1672
+ continue
1673
+ args_text = content[open_idx + 1:close_idx]
1674
+
1675
+ path_match = re.search(r"[\"']([^\"']+)[\"']", args_text)
1676
+ if not path_match:
1677
+ continue
1678
+ path = path_match.group(1)
1679
+
1680
+ method_match = re.search(r"method\s*=\s*[\"'](\w+)[\"']", args_text)
1681
+ method = method_match.group(1).upper() if method_match else "GET"
1682
+
1683
+ normalized = re.sub(r"<([^:>]+)(?::[^>]+)?>", r":\1", path)
1684
+
1685
+ key = f"{method}:{normalized}"
1686
+ if key in seen:
1687
+ continue
1688
+ seen.add(key)
1689
+
1690
+ body_fields = _extract_body_fields_static(content) if method in ("POST", "PUT", "PATCH") else None
1691
+
1692
+ endpoints.append({
1693
+ "method": method,
1694
+ "path": normalized,
1695
+ "description": generate_description(method, normalized, None),
1696
+ "requestBody": body_fields,
1697
+ "detectedBy": "static-scan-file",
1698
+ })
1699
+
1700
+ return endpoints
1701
+
1702
+
1703
+ def _scan_aiohttp_static(cwd):
1704
+ """
1705
+ Handles aiohttp-style route registration:
1706
+ app.router.add_get('/path', handler)
1707
+ app.router.add_post('/path', handler)
1708
+ app.router.add_route('POST', '/path', handler)
1709
+ """
1710
+ endpoints = []
1711
+ seen = set()
1712
+
1713
+ shorthand_pattern = re.compile(
1714
+ r"add_(get|post|put|delete|patch)\s*\(\s*[\"']([^\"']+)[\"']",
1715
+ re.IGNORECASE,
1716
+ )
1717
+ generic_pattern = re.compile(
1718
+ r"add_route\s*\(\s*[\"'](\w+)[\"']\s*,\s*[\"']([^\"']+)[\"']",
1719
+ re.IGNORECASE,
1720
+ )
1721
+
1722
+ for filepath in _walk_py_files(cwd):
1723
+ try:
1724
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1725
+ content = fh.read()
1726
+ except Exception:
1727
+ continue
1728
+
1729
+ for pattern in (shorthand_pattern, generic_pattern):
1730
+ for match in pattern.finditer(content):
1731
+ method = match.group(1).upper()
1732
+ path = match.group(2)
1733
+ normalized = re.sub(r"\{([^:}]+)(?::[^}]+)?\}", r":\1", path)
1734
+
1735
+ key = f"{method}:{normalized}"
1736
+ if key in seen:
1737
+ continue
1738
+ seen.add(key)
1739
+
1740
+ body_fields = _extract_body_fields_static(content) if method in ("POST", "PUT", "PATCH") else None
1741
+
1742
+ endpoints.append({
1743
+ "method": method,
1744
+ "path": normalized,
1745
+ "description": generate_description(method, normalized, None),
1746
+ "requestBody": body_fields,
1747
+ "detectedBy": "static-scan-file",
1748
+ })
1749
+
1750
+ return endpoints
1751
+
1752
+
1753
+ def _scan_tornado_static(cwd):
1754
+ """
1755
+ Handles Tornado-style URL specs:
1756
+ (r"/path", SomeHandler)
1757
+ Tornado handler methods (get/post/etc) live on the handler class, so
1758
+ like Falcon, we approximate by scanning the whole file for any
1759
+ get/post/put/delete/patch method definitions and applying that set
1760
+ to every URL spec found in the same file. Only runs on files that
1761
+ look like Tornado app files, to avoid matching unrelated tuples.
1762
+ """
1763
+ endpoints = []
1764
+ seen = set()
1765
+
1766
+ url_spec_pattern = re.compile(r"\(\s*r?[\"']([^\"']+)[\"']\s*,\s*(\w+)\s*\)")
1767
+ method_def_pattern = re.compile(r"def\s+(get|post|put|delete|patch)\s*\(\s*self", re.IGNORECASE)
1768
+
1769
+ for filepath in _walk_py_files(cwd):
1770
+ try:
1771
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1772
+ content = fh.read()
1773
+ except Exception:
1774
+ continue
1775
+
1776
+ is_tornado_file = "tornado" in content.lower() and (
1777
+ "Application(" in content or "URLSpec" in content
1778
+ )
1779
+ if not is_tornado_file:
1780
+ continue
1781
+
1782
+ url_matches = list(url_spec_pattern.finditer(content))
1783
+ if not url_matches:
1784
+ continue
1785
+
1786
+ methods_in_file = sorted({m.group(1).upper() for m in method_def_pattern.finditer(content)})
1787
+ if not methods_in_file:
1788
+ methods_in_file = ["GET"]
1789
+
1790
+ for match in url_matches:
1791
+ path = match.group(1)
1792
+ normalized = re.sub(r"\(\?P<([^>]+)>[^)]+\)", r":\1", path)
1793
+ normalized = re.sub(r"[\\^$]", "", normalized)
1794
+ if not normalized.startswith("/"):
1795
+ normalized = "/" + normalized
1796
+
1797
+ for method in methods_in_file:
1798
+ key = f"{method}:{normalized}"
1799
+ if key in seen:
1800
+ continue
1801
+ seen.add(key)
1802
+
1803
+ body_fields = _extract_body_fields_static(content) if method in ("POST", "PUT", "PATCH") else None
1804
+
1805
+ endpoints.append({
1806
+ "method": method,
1807
+ "path": normalized,
1808
+ "description": generate_description(method, normalized, None),
1809
+ "requestBody": body_fields,
1810
+ "detectedBy": "static-scan-file",
1811
+ })
1812
+
1813
+ return endpoints
1814
+
1815
+
1816
+ def _scan_pyramid_static(cwd):
1817
+ """
1818
+ Handles Pyramid-style route registration:
1819
+ config.add_route('name', '/path')
1820
+ Static scan can't reliably determine which HTTP methods a route's
1821
+ view supports without executing the config, so — consistent with
1822
+ the Django static scanner's fallback — we default to GET + POST.
1823
+ """
1824
+ endpoints = []
1825
+ seen = set()
1826
+
1827
+ pattern = re.compile(r"add_route\s*\(\s*[\"'][^\"']+[\"']\s*,\s*[\"']([^\"']+)[\"']")
1828
+
1829
+ for filepath in _walk_py_files(cwd):
1830
+ try:
1831
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1832
+ content = fh.read()
1833
+ except Exception:
1834
+ continue
1835
+
1836
+ for match in pattern.finditer(content):
1837
+ path = match.group(1)
1838
+ normalized = re.sub(r"\{([^:}]+)(?::[^}]+)?\}", r":\1", path)
1839
+ if not normalized.startswith("/"):
1840
+ normalized = "/" + normalized
1841
+
1842
+ for method in ("GET", "POST"):
1843
+ key = f"{method}:{normalized}"
1844
+ if key in seen:
1845
+ continue
1846
+ seen.add(key)
1847
+
1848
+ endpoints.append({
1849
+ "method": method,
1850
+ "path": normalized,
1851
+ "description": generate_description(method, normalized, None),
1852
+ "requestBody": None,
1853
+ "detectedBy": "static-scan-file",
1854
+ })
1855
+
1856
+ return endpoints
1857
+
1858
+
1859
+ def _extract_body_fields_static(content):
1860
+ """
1861
+ Best-effort body field extraction from source text — mirrors the JS
1862
+ static file scanner's approach. Looks for common request-body access
1863
+ patterns without needing to import/execute the file.
1864
+ """
1865
+ fields = set()
1866
+
1867
+ for m in re.finditer(r"request\.json(?:\.get\(|\[)\s*['\"]([a-zA-Z_][a-zA-Z0-9_]*)['\"]", content):
1868
+ fields.add(m.group(1))
1869
+ for m in re.finditer(r"request\.data(?:\.get\(|\[)\s*['\"]([a-zA-Z_][a-zA-Z0-9_]*)['\"]", content):
1870
+ fields.add(m.group(1))
1871
+ for m in re.finditer(r"request\.form(?:\.get\(|\[)\s*['\"]([a-zA-Z_][a-zA-Z0-9_]*)['\"]", content):
1872
+ fields.add(m.group(1))
1873
+
1874
+ if not fields:
1875
+ return None
1876
+
1877
+ properties = {f: {"type": infer_field_type(f, content)} for f in fields}
1878
+ return {"type": "object", "properties": properties}
1879
+
1880
+
1881
+ def detect_framework_from_deps(cwd):
1882
+ """
1883
+ Detects the backend framework from dependency declarations, since
1884
+ Python has no single 'package.json' convention. Checks requirements.txt,
1885
+ pyproject.toml, and Pipfile, in that order.
1886
+ """
1887
+ FRAMEWORK_PACKAGES = [
1888
+ ("fastapi", "fastapi"),
1889
+ ("flask", "flask"),
1890
+ ("django", "django"),
1891
+ ("starlette", "starlette"),
1892
+ ("sanic", "sanic"),
1893
+ ("falcon", "falcon"),
1894
+ ("bottle", "bottle"),
1895
+ ("aiohttp", "aiohttp"),
1896
+ ("tornado", "tornado"),
1897
+ ("pyramid", "pyramid"),
1898
+ ("cherrypy", "cherrypy"),
1899
+ ]
1900
+
1901
+ candidates = [
1902
+ os.path.join(cwd, "requirements.txt"),
1903
+ os.path.join(cwd, "pyproject.toml"),
1904
+ os.path.join(cwd, "Pipfile"),
1905
+ ]
1906
+
1907
+ combined = ""
1908
+ for filepath in candidates:
1909
+ if os.path.exists(filepath):
1910
+ try:
1911
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
1912
+ combined += fh.read().lower() + "\n"
1913
+ except Exception:
1914
+ continue
1915
+
1916
+ for pkg_name, framework in FRAMEWORK_PACKAGES:
1917
+ if pkg_name in combined:
1918
+ return framework
1919
+
1920
+ return None
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: botversion-sdk
3
+ Version: 2.1.0
4
+ Summary: BotVersion SDK — automatically discover and register your API endpoints
5
+ Home-page: https://github.com/botversion/botversion-sdk-python
6
+ Author: BotVersion
7
+ Author-email: support@botversion.com
8
+ Keywords: botversion,api,sdk,fastapi,flask,django
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.7
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: python-dotenv>=1.0.0
17
+ Provides-Extra: fastapi
18
+ Requires-Dist: fastapi; extra == "fastapi"
19
+ Requires-Dist: starlette; extra == "fastapi"
20
+ Provides-Extra: flask
21
+ Requires-Dist: flask; extra == "flask"
22
+ Provides-Extra: django
23
+ Requires-Dist: django; extra == "django"
24
+ Dynamic: author
25
+ Dynamic: author-email
26
+ Dynamic: classifier
27
+ Dynamic: description-content-type
28
+ Dynamic: home-page
29
+ Dynamic: keywords
30
+ Dynamic: provides-extra
31
+ Dynamic: requires-dist
32
+ Dynamic: requires-python
33
+ Dynamic: summary
@@ -7,4 +7,8 @@ botversion_sdk/scanner.py
7
7
  botversion_sdk.egg-info/PKG-INFO
8
8
  botversion_sdk.egg-info/SOURCES.txt
9
9
  botversion_sdk.egg-info/dependency_links.txt
10
- botversion_sdk.egg-info/top_level.txt
10
+ botversion_sdk.egg-info/entry_points.txt
11
+ botversion_sdk.egg-info/requires.txt
12
+ botversion_sdk.egg-info/top_level.txt
13
+ botversion_sdk/bin/__init__.py
14
+ botversion_sdk/bin/scan_endpoints.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ botversion-scan-endpoints = botversion_sdk.bin.scan_endpoints:main
@@ -0,0 +1,11 @@
1
+ python-dotenv>=1.0.0
2
+
3
+ [django]
4
+ django
5
+
6
+ [fastapi]
7
+ fastapi
8
+ starlette
9
+
10
+ [flask]
11
+ flask
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="botversion-sdk",
5
- version="2.0.0",
5
+ version="2.1.0",
6
6
  description="BotVersion SDK — automatically discover and register your API endpoints",
7
7
  long_description=open("README.md").read() if __import__("os").path.exists("README.md") else "",
8
8
  long_description_content_type="text/markdown",
@@ -12,9 +12,19 @@ setup(
12
12
  packages=find_packages(),
13
13
  python_requires=">=3.7",
14
14
 
15
- # No required dependencies — works with stdlib only.
16
- # FastAPI, Flask, Django are optional — detected at runtime.
17
- install_requires=[],
15
+ entry_points={
16
+ "console_scripts": [
17
+ "botversion-scan-endpoints=botversion_sdk.bin.scan_endpoints:main",
18
+ ],
19
+ },
20
+
21
+ # python-dotenv is the only hard dependency, used to auto-load .env /
22
+ # .env.local for the build-time CLI scanner (see bin/scan_endpoints.py).
23
+ # FastAPI, Flask, Django, etc. are optional — detected at runtime, not
24
+ # required to install the SDK itself.
25
+ install_requires=[
26
+ "python-dotenv>=1.0.0",
27
+ ],
18
28
 
19
29
  extras_require={
20
30
  "fastapi": ["fastapi", "starlette"],
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: botversion-sdk
3
- Version: 2.0.0
4
- Summary: BotVersion AI Agent SDK for FastAPI, Flask, and Django
5
- Home-page: https://github.com/botversion/botversion-sdk-python
6
- Author: BotVersion
7
- Author-email: Saurav Dhakal <sauravdhakal828@gmail.com>
8
- Requires-Python: >=3.7
9
- Description-Content-Type: text/markdown
10
- Dynamic: author
11
- Dynamic: description-content-type
12
- Dynamic: home-page
13
- Dynamic: requires-python
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: botversion-sdk
3
- Version: 2.0.0
4
- Summary: BotVersion AI Agent SDK for FastAPI, Flask, and Django
5
- Home-page: https://github.com/botversion/botversion-sdk-python
6
- Author: BotVersion
7
- Author-email: Saurav Dhakal <sauravdhakal828@gmail.com>
8
- Requires-Python: >=3.7
9
- Description-Content-Type: text/markdown
10
- Dynamic: author
11
- Dynamic: description-content-type
12
- Dynamic: home-page
13
- Dynamic: requires-python
@@ -1,11 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "botversion-sdk"
7
- version = "2.0.0"
8
- description = "BotVersion AI Agent SDK for FastAPI, Flask, and Django"
9
- authors = [{name = "Saurav Dhakal", email = "sauravdhakal828@gmail.com"}]
10
- requires-python = ">=3.8"
11
- dependencies = []
File without changes