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.
- botversion_sdk-2.1.0/PKG-INFO +33 -0
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/botversion_sdk/__init__.py +1 -1
- botversion_sdk-2.1.0/botversion_sdk/bin/__init__.py +0 -0
- botversion_sdk-2.1.0/botversion_sdk/bin/scan_endpoints.py +59 -0
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/botversion_sdk/client.py +1 -1
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/botversion_sdk/scanner.py +621 -1
- botversion_sdk-2.1.0/botversion_sdk.egg-info/PKG-INFO +33 -0
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/botversion_sdk.egg-info/SOURCES.txt +5 -1
- botversion_sdk-2.1.0/botversion_sdk.egg-info/entry_points.txt +2 -0
- botversion_sdk-2.1.0/botversion_sdk.egg-info/requires.txt +11 -0
- botversion_sdk-2.1.0/pyproject.toml +3 -0
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/setup.py +14 -4
- botversion_sdk-2.0.0/PKG-INFO +0 -13
- botversion_sdk-2.0.0/botversion_sdk.egg-info/PKG-INFO +0 -13
- botversion_sdk-2.0.0/pyproject.toml +0 -11
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/botversion_sdk/interceptor.py +0 -0
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/botversion_sdk.egg-info/dependency_links.txt +0 -0
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/botversion_sdk.egg-info/top_level.txt +0 -0
- {botversion_sdk-2.0.0 → botversion_sdk-2.1.0}/setup.cfg +0 -0
|
@@ -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/
|
|
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
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="botversion-sdk",
|
|
5
|
-
version="2.
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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"],
|
botversion_sdk-2.0.0/PKG-INFO
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|