navvi 2.0.0

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,652 @@
1
+ """
2
+ Navvi API Server — FastAPI REST endpoints for browser automation.
3
+
4
+ Runs inside the container. Controls Firefox via:
5
+ - xdotool (OS-level mouse/keyboard — isTrusted: true events)
6
+ - scrot (screenshots)
7
+ - marionette.py (navigate, getURL, getTitle, executeJS via Firefox Marionette)
8
+ """
9
+
10
+ import argparse
11
+ import asyncio
12
+ import base64
13
+ import os
14
+ import re
15
+ import shlex
16
+ import subprocess
17
+ import tempfile
18
+
19
+ from fastapi import FastAPI, HTTPException
20
+ from fastapi.responses import JSONResponse
21
+ from pydantic import BaseModel
22
+ from typing import Optional
23
+
24
+ from marionette import Marionette, MarionetteError
25
+
26
+ app = FastAPI(title="Navvi Server", version="2.0.0")
27
+
28
+ # --- Globals ---
29
+
30
+ marionette: Optional[Marionette] = None
31
+ display: str = ":1"
32
+
33
+
34
+ # --- Pydantic models ---
35
+
36
+ class NavigateRequest(BaseModel):
37
+ url: str
38
+
39
+ class ClickRequest(BaseModel):
40
+ x: int
41
+ y: int
42
+
43
+ class TypeRequest(BaseModel):
44
+ text: str
45
+ delay: int = 12 # ms between chars
46
+
47
+ class KeyRequest(BaseModel):
48
+ key: str
49
+
50
+ class MouseRequest(BaseModel):
51
+ x: int
52
+ y: int
53
+
54
+ class DragRequest(BaseModel):
55
+ x1: int
56
+ y1: int
57
+ x2: int
58
+ y2: int
59
+ steps: int = 20
60
+ duration: float = 0.3 # seconds for the full drag
61
+
62
+ class ScrollRequest(BaseModel):
63
+ direction: str = "down" # up, down, left, right
64
+ amount: int = 3
65
+
66
+ class ExecuteJSRequest(BaseModel):
67
+ script: str
68
+ args: list = []
69
+
70
+ class FindRequest(BaseModel):
71
+ selector: str
72
+ all: bool = False # return all matches vs just the first
73
+
74
+ class CredsAutofillRequest(BaseModel):
75
+ entry: str # gopass entry path, e.g. "navvi/default/tuta"
76
+ username_selector: str = "input[type=email], input[type=text], input[name*=user i], input[name*=email i], input[name*=login i]"
77
+ password_selector: str = "input[type=password]"
78
+
79
+ class CredsGetRequest(BaseModel):
80
+ entry: str
81
+ field: str # e.g. "username", "url", "email" — NOT "password"
82
+
83
+
84
+ # --- Helpers ---
85
+
86
+ def run_xdotool(args: str, timeout: float = 5.0) -> str:
87
+ """Run an xdotool command and return stdout."""
88
+ env = os.environ.copy()
89
+ env["DISPLAY"] = display
90
+ result = subprocess.run(
91
+ f"xdotool {args}",
92
+ shell=True,
93
+ capture_output=True,
94
+ text=True,
95
+ timeout=timeout,
96
+ env=env,
97
+ )
98
+ if result.returncode != 0 and result.stderr:
99
+ raise RuntimeError(f"xdotool error: {result.stderr.strip()}")
100
+ return result.stdout.strip()
101
+
102
+
103
+ def get_marionette() -> Marionette:
104
+ """Get or reconnect the Marionette client."""
105
+ global marionette
106
+ if marionette is None:
107
+ marionette = Marionette()
108
+ marionette.connect()
109
+ marionette.new_session()
110
+ return marionette
111
+
112
+
113
+ def reconnect_marionette() -> Marionette:
114
+ """Force reconnect (e.g. after Firefox restart)."""
115
+ global marionette
116
+ if marionette:
117
+ marionette.close()
118
+ marionette = None
119
+ return get_marionette()
120
+
121
+
122
+ # xdotool key name mapping (browser key names → xdotool names)
123
+ KEY_MAP = {
124
+ "Enter": "Return",
125
+ "Backspace": "BackSpace",
126
+ "ArrowUp": "Up",
127
+ "ArrowDown": "Down",
128
+ "ArrowLeft": "Left",
129
+ "ArrowRight": "Right",
130
+ "Escape": "Escape",
131
+ "Tab": "Tab",
132
+ "Delete": "Delete",
133
+ "Home": "Home",
134
+ "End": "End",
135
+ "PageUp": "Prior",
136
+ "PageDown": "Next",
137
+ "Space": "space",
138
+ " ": "space",
139
+ }
140
+
141
+
142
+ # --- Endpoints ---
143
+
144
+ @app.get("/health")
145
+ async def health():
146
+ """Check Firefox + Xvfb are alive."""
147
+ checks = {"xvfb": False, "firefox": False, "marionette": False}
148
+
149
+ # Check Xvfb
150
+ try:
151
+ result = subprocess.run(
152
+ ["xdpyinfo", "-display", display],
153
+ capture_output=True, timeout=3
154
+ )
155
+ checks["xvfb"] = result.returncode == 0
156
+ except (subprocess.TimeoutExpired, FileNotFoundError):
157
+ # xdpyinfo may not be installed; check if Xvfb process exists
158
+ try:
159
+ subprocess.run(
160
+ ["pgrep", "-f", f"Xvfb {display}"],
161
+ capture_output=True, timeout=3
162
+ )
163
+ checks["xvfb"] = True
164
+ except Exception:
165
+ pass
166
+
167
+ # Check Firefox process
168
+ try:
169
+ result = subprocess.run(
170
+ ["pgrep", "-f", "firefox"],
171
+ capture_output=True, timeout=3
172
+ )
173
+ checks["firefox"] = result.returncode == 0
174
+ except Exception:
175
+ pass
176
+
177
+ # Check Marionette connection
178
+ try:
179
+ m = get_marionette()
180
+ m.get_url()
181
+ checks["marionette"] = True
182
+ except Exception:
183
+ pass
184
+
185
+ ok = all(checks.values())
186
+ return {"ok": ok, **checks}
187
+
188
+
189
+ @app.post("/navigate")
190
+ async def navigate(req: NavigateRequest):
191
+ """Navigate to a URL via Marionette."""
192
+ try:
193
+ m = get_marionette()
194
+ m.navigate(req.url)
195
+ # Give the page a moment to start loading
196
+ await asyncio.sleep(1.0)
197
+ url = m.get_url()
198
+ title = m.get_title()
199
+ return {"ok": True, "url": url, "title": title}
200
+ except MarionetteError as e:
201
+ raise HTTPException(status_code=500, detail=str(e))
202
+
203
+
204
+ @app.get("/url")
205
+ async def get_url():
206
+ """Get current page URL."""
207
+ try:
208
+ m = get_marionette()
209
+ return {"url": m.get_url()}
210
+ except MarionetteError as e:
211
+ raise HTTPException(status_code=500, detail=str(e))
212
+
213
+
214
+ @app.get("/title")
215
+ async def get_title():
216
+ """Get current page title."""
217
+ try:
218
+ m = get_marionette()
219
+ return {"title": m.get_title()}
220
+ except MarionetteError as e:
221
+ raise HTTPException(status_code=500, detail=str(e))
222
+
223
+
224
+ @app.post("/click")
225
+ async def click(req: ClickRequest):
226
+ """Click at (x, y) using xdotool."""
227
+ run_xdotool(f"mousemove --sync {req.x} {req.y}")
228
+ await asyncio.sleep(0.05)
229
+ run_xdotool("click 1")
230
+ return {"ok": True, "x": req.x, "y": req.y}
231
+
232
+
233
+ @app.post("/type")
234
+ async def type_text(req: TypeRequest):
235
+ """Type text using xdotool, chunked at 50 chars."""
236
+ text = req.text
237
+ chunk_size = 50
238
+ for i in range(0, len(text), chunk_size):
239
+ chunk = text[i:i + chunk_size]
240
+ # Escape for shell safety
241
+ safe_chunk = shlex.quote(chunk)
242
+ run_xdotool(f"type --delay {req.delay} -- {safe_chunk}", timeout=30.0)
243
+ return {"ok": True, "length": len(text)}
244
+
245
+
246
+ @app.post("/key")
247
+ async def press_key(req: KeyRequest):
248
+ """Press a key using xdotool."""
249
+ key = KEY_MAP.get(req.key, req.key)
250
+ run_xdotool(f"key {shlex.quote(key)}")
251
+ return {"ok": True, "key": key}
252
+
253
+
254
+ @app.post("/mousedown")
255
+ async def mousedown(req: MouseRequest):
256
+ """Move to (x, y) and press mouse button down."""
257
+ run_xdotool(f"mousemove --sync {req.x} {req.y}")
258
+ await asyncio.sleep(0.05)
259
+ run_xdotool("mousedown 1")
260
+ return {"ok": True, "x": req.x, "y": req.y}
261
+
262
+
263
+ @app.post("/mouseup")
264
+ async def mouseup(req: MouseRequest):
265
+ """Move to (x, y) and release mouse button."""
266
+ run_xdotool(f"mousemove --sync {req.x} {req.y}")
267
+ await asyncio.sleep(0.05)
268
+ run_xdotool("mouseup 1")
269
+ return {"ok": True, "x": req.x, "y": req.y}
270
+
271
+
272
+ @app.post("/mousemove")
273
+ async def mousemove(req: MouseRequest):
274
+ """Move mouse to (x, y)."""
275
+ run_xdotool(f"mousemove --sync {req.x} {req.y}")
276
+ return {"ok": True, "x": req.x, "y": req.y}
277
+
278
+
279
+ @app.post("/drag")
280
+ async def drag(req: DragRequest):
281
+ """Drag from (x1,y1) to (x2,y2) with interpolated mouse moves."""
282
+ steps = max(req.steps, 2)
283
+ step_delay = req.duration / steps
284
+
285
+ # Move to start and press
286
+ run_xdotool(f"mousemove --sync {req.x1} {req.y1}")
287
+ await asyncio.sleep(0.05)
288
+ run_xdotool("mousedown 1")
289
+ await asyncio.sleep(0.05)
290
+
291
+ # Interpolate path
292
+ for i in range(1, steps + 1):
293
+ t = i / steps
294
+ cx = int(req.x1 + (req.x2 - req.x1) * t)
295
+ cy = int(req.y1 + (req.y2 - req.y1) * t)
296
+ run_xdotool(f"mousemove --sync {cx} {cy}")
297
+ await asyncio.sleep(step_delay)
298
+
299
+ # Release
300
+ run_xdotool("mouseup 1")
301
+ return {"ok": True, "from": [req.x1, req.y1], "to": [req.x2, req.y2]}
302
+
303
+
304
+ @app.post("/scroll")
305
+ async def scroll(req: ScrollRequest):
306
+ """Scroll using xdotool button clicks (4=up, 5=down, 6=left, 7=right)."""
307
+ button_map = {"up": 4, "down": 5, "left": 6, "right": 7}
308
+ button = button_map.get(req.direction)
309
+ if button is None:
310
+ raise HTTPException(
311
+ status_code=400,
312
+ detail=f"Invalid direction: {req.direction}. Use up/down/left/right."
313
+ )
314
+ run_xdotool(f"click --repeat {req.amount} {button}")
315
+ return {"ok": True, "direction": req.direction, "amount": req.amount}
316
+
317
+
318
+ @app.get("/screenshot")
319
+ async def screenshot():
320
+ """Take a screenshot with scrot and return base64 PNG."""
321
+ env = os.environ.copy()
322
+ env["DISPLAY"] = display
323
+ tmp_path = os.path.join(tempfile.gettempdir(), "navvi-shot.png")
324
+
325
+ result = subprocess.run(
326
+ ["scrot", "-o", "-p", tmp_path],
327
+ capture_output=True, timeout=5, env=env
328
+ )
329
+ if result.returncode != 0:
330
+ raise HTTPException(
331
+ status_code=500,
332
+ detail=f"scrot failed: {result.stderr.decode().strip()}"
333
+ )
334
+
335
+ with open(tmp_path, "rb") as f:
336
+ img_data = f.read()
337
+
338
+ b64 = base64.b64encode(img_data).decode("ascii")
339
+ return {"ok": True, "base64": b64, "size": len(img_data)}
340
+
341
+
342
+ @app.get("/cursor")
343
+ async def cursor():
344
+ """Get current mouse position."""
345
+ output = run_xdotool("getmouselocation --shell")
346
+ # Parse: X=123\nY=456\nSCREEN=0\nWINDOW=...
347
+ pos = {}
348
+ for line in output.splitlines():
349
+ if "=" in line:
350
+ k, v = line.split("=", 1)
351
+ pos[k.lower()] = int(v) if v.isdigit() else v
352
+ return {"ok": True, "x": pos.get("x", 0), "y": pos.get("y", 0)}
353
+
354
+
355
+ @app.post("/execute")
356
+ async def execute_js(req: ExecuteJSRequest):
357
+ """Execute JavaScript in Firefox via Marionette."""
358
+ try:
359
+ m = get_marionette()
360
+ result = m.execute_script(req.script, req.args)
361
+ return {"ok": True, "value": result}
362
+ except MarionetteError as e:
363
+ raise HTTPException(status_code=500, detail=str(e))
364
+
365
+
366
+ def get_viewport_offset():
367
+ """Get the pixel offset from screen origin to Firefox's content viewport.
368
+
369
+ JS getBoundingClientRect() returns coords relative to the viewport.
370
+ xdotool works with absolute screen coords. The difference is the
371
+ browser chrome (tab bar, address bar, notification banners).
372
+
373
+ Firefox exposes this via mozInnerScreenX/Y — the screen position
374
+ of the top-left corner of the viewport.
375
+ """
376
+ try:
377
+ m = get_marionette()
378
+ result = m.execute_script(
379
+ "return { x: window.mozInnerScreenX || 0, y: window.mozInnerScreenY || 0 }"
380
+ )
381
+ return (int(result.get("x", 0)), int(result.get("y", 0)))
382
+ except Exception:
383
+ return (0, 0)
384
+
385
+
386
+ @app.get("/viewport")
387
+ async def viewport():
388
+ """Get viewport offset — the translation between JS coordinates and screen coordinates.
389
+
390
+ Returns the pixel offset from screen origin to the browser content area.
391
+ Add these values to any getBoundingClientRect() coordinates before
392
+ passing them to click/mousedown/etc.
393
+ """
394
+ offset_x, offset_y = get_viewport_offset()
395
+ try:
396
+ m = get_marionette()
397
+ dims = m.execute_script(
398
+ "return { innerWidth: window.innerWidth, innerHeight: window.innerHeight }"
399
+ )
400
+ except Exception:
401
+ dims = {}
402
+ return {
403
+ "ok": True,
404
+ "offset_x": offset_x,
405
+ "offset_y": offset_y,
406
+ "viewport_width": dims.get("innerWidth", 0),
407
+ "viewport_height": dims.get("innerHeight", 0),
408
+ }
409
+
410
+
411
+ @app.post("/find")
412
+ async def find_element(req: FindRequest):
413
+ """Find element(s) by CSS selector and return screen-ready coordinates.
414
+
415
+ Unlike raw executeJS + getBoundingClientRect(), this endpoint
416
+ auto-translates viewport coordinates to screen coordinates by adding
417
+ the browser chrome offset. The returned x/y can be passed directly
418
+ to /click, /mousedown, etc.
419
+ """
420
+ try:
421
+ m = get_marionette()
422
+ offset_x, offset_y = get_viewport_offset()
423
+
424
+ if req.all:
425
+ script = """
426
+ const els = document.querySelectorAll(arguments[0]);
427
+ return Array.from(els).slice(0, 50).map(el => {
428
+ const r = el.getBoundingClientRect();
429
+ return {
430
+ tag: el.tagName,
431
+ id: el.id || '',
432
+ name: el.name || el.getAttribute('name') || '',
433
+ type: el.type || '',
434
+ role: el.getAttribute('role') || '',
435
+ text: (el.textContent || '').trim().slice(0, 80),
436
+ value: (el.value || '').slice(0, 80),
437
+ placeholder: el.placeholder || '',
438
+ ariaLabel: el.getAttribute('aria-label') || '',
439
+ visible: r.width > 0 && r.height > 0,
440
+ vx: Math.round(r.x + r.width / 2),
441
+ vy: Math.round(r.y + r.height / 2),
442
+ width: Math.round(r.width),
443
+ height: Math.round(r.height),
444
+ };
445
+ });
446
+ """
447
+ elements = m.execute_script(script, [req.selector])
448
+ if not elements:
449
+ return {"ok": True, "found": False, "elements": []}
450
+ # Apply screen offset
451
+ for el in elements:
452
+ el["x"] = el.pop("vx") + offset_x
453
+ el["y"] = el.pop("vy") + offset_y
454
+ return {"ok": True, "found": True, "count": len(elements), "elements": elements}
455
+ else:
456
+ script = """
457
+ const el = document.querySelector(arguments[0]);
458
+ if (!el) return null;
459
+ const r = el.getBoundingClientRect();
460
+ return {
461
+ tag: el.tagName,
462
+ id: el.id || '',
463
+ name: el.name || el.getAttribute('name') || '',
464
+ type: el.type || '',
465
+ role: el.getAttribute('role') || '',
466
+ text: (el.textContent || '').trim().slice(0, 80),
467
+ value: (el.value || '').slice(0, 80),
468
+ placeholder: el.placeholder || '',
469
+ ariaLabel: el.getAttribute('aria-label') || '',
470
+ visible: r.width > 0 && r.height > 0,
471
+ vx: Math.round(r.x + r.width / 2),
472
+ vy: Math.round(r.y + r.height / 2),
473
+ width: Math.round(r.width),
474
+ height: Math.round(r.height),
475
+ };
476
+ """
477
+ el = m.execute_script(script, [req.selector])
478
+ if not el:
479
+ return {"ok": True, "found": False}
480
+ el["x"] = el.pop("vx") + offset_x
481
+ el["y"] = el.pop("vy") + offset_y
482
+ return {"ok": True, "found": True, **el}
483
+ except MarionetteError as e:
484
+ raise HTTPException(status_code=500, detail=str(e))
485
+
486
+
487
+ # --- Credential management (gopass) ---
488
+
489
+ def run_gopass(args: str, timeout: float = 5.0) -> str:
490
+ """Run a gopass command and return stdout."""
491
+ result = subprocess.run(
492
+ f"gopass {args}",
493
+ shell=True,
494
+ capture_output=True,
495
+ text=True,
496
+ timeout=timeout,
497
+ )
498
+ if result.returncode != 0:
499
+ raise RuntimeError(result.stderr.strip() or f"gopass failed: {result.returncode}")
500
+ return result.stdout.strip()
501
+
502
+
503
+ @app.get("/creds/list")
504
+ async def creds_list():
505
+ """List all credential entries (names only, no secrets)."""
506
+ try:
507
+ output = run_gopass("ls --flat")
508
+ entries = [e for e in output.splitlines() if e.strip()]
509
+ return {"ok": True, "entries": entries, "count": len(entries)}
510
+ except RuntimeError as e:
511
+ raise HTTPException(status_code=500, detail=str(e))
512
+
513
+
514
+ @app.post("/creds/get")
515
+ async def creds_get(req: CredsGetRequest):
516
+ """Get a specific non-secret field from a gopass entry.
517
+
518
+ Returns metadata fields like username, url, email.
519
+ Refuses to return the password field — use /creds/autofill instead.
520
+ """
521
+ blocked = {"password", "pass", "secret", "token", "key", "recovery"}
522
+ if req.field.lower() in blocked:
523
+ raise HTTPException(
524
+ status_code=403,
525
+ detail=f"Field '{req.field}' is a secret — use /creds/autofill to fill it into the browser without exposing it."
526
+ )
527
+ try:
528
+ value = run_gopass(f"show {shlex.quote(req.entry)} {shlex.quote(req.field)}")
529
+ return {"ok": True, "entry": req.entry, "field": req.field, "value": value}
530
+ except RuntimeError as e:
531
+ raise HTTPException(status_code=500, detail=str(e))
532
+
533
+
534
+ @app.post("/creds/autofill")
535
+ async def creds_autofill(req: CredsAutofillRequest):
536
+ """Autofill a login form using gopass credentials.
537
+
538
+ Reads username and password from gopass, finds the form fields
539
+ via CSS selectors, and types them using xdotool. The password
540
+ NEVER appears in the API response — it goes directly from
541
+ gopass → xdotool → browser.
542
+ """
543
+ try:
544
+ # Read credentials from gopass (server-side only, never returned)
545
+ # Try email first (most login forms want full email), fall back to username
546
+ username = ""
547
+ for field in ["email", "username", "login", "user"]:
548
+ try:
549
+ username = run_gopass(f"show {shlex.quote(req.entry)} {field}")
550
+ if username:
551
+ break
552
+ except RuntimeError:
553
+ continue
554
+ password = run_gopass(f"show -o {shlex.quote(req.entry)}")
555
+ except RuntimeError as e:
556
+ raise HTTPException(status_code=500, detail=f"gopass error: {e}")
557
+
558
+ if not username or not password:
559
+ raise HTTPException(status_code=404, detail=f"Entry '{req.entry}' missing username/email or password")
560
+
561
+ # Find form fields
562
+ try:
563
+ m = get_marionette()
564
+ offset_x, offset_y = get_viewport_offset()
565
+
566
+ find_script = """
567
+ const el = document.querySelector(arguments[0]);
568
+ if (!el) return null;
569
+ const r = el.getBoundingClientRect();
570
+ return {
571
+ x: Math.round(r.x + r.width / 2),
572
+ y: Math.round(r.y + r.height / 2),
573
+ visible: r.width > 0 && r.height > 0,
574
+ };
575
+ """
576
+ username_el = m.execute_script(find_script, [req.username_selector])
577
+ password_el = m.execute_script(find_script, [req.password_selector])
578
+
579
+ if not username_el or not username_el.get("visible"):
580
+ raise HTTPException(status_code=404, detail=f"Username field not found: {req.username_selector}")
581
+ if not password_el or not password_el.get("visible"):
582
+ raise HTTPException(status_code=404, detail=f"Password field not found: {req.password_selector}")
583
+
584
+ # Apply viewport offset
585
+ ux, uy = username_el["x"] + offset_x, username_el["y"] + offset_y
586
+ px, py = password_el["x"] + offset_x, password_el["y"] + offset_y
587
+ except MarionetteError as e:
588
+ raise HTTPException(status_code=500, detail=f"Browser error: {e}")
589
+
590
+ # Fill username
591
+ run_xdotool(f"mousemove --sync {ux} {uy}")
592
+ await asyncio.sleep(0.05)
593
+ run_xdotool("click 1")
594
+ await asyncio.sleep(0.1)
595
+ run_xdotool("key ctrl+a")
596
+ await asyncio.sleep(0.05)
597
+ safe_user = shlex.quote(username)
598
+ run_xdotool(f"type --delay 15 -- {safe_user}", timeout=15.0)
599
+
600
+ await asyncio.sleep(0.3)
601
+
602
+ # Fill password (never logged, never returned)
603
+ run_xdotool(f"mousemove --sync {px} {py}")
604
+ await asyncio.sleep(0.05)
605
+ run_xdotool("click 1")
606
+ await asyncio.sleep(0.1)
607
+ run_xdotool("key ctrl+a")
608
+ await asyncio.sleep(0.05)
609
+ safe_pass = shlex.quote(password)
610
+ run_xdotool(f"type --delay 15 -- {safe_pass}", timeout=15.0)
611
+
612
+ # Scrub password from memory
613
+ del password
614
+ del safe_pass
615
+
616
+ return {
617
+ "ok": True,
618
+ "entry": req.entry,
619
+ "username_filled": True,
620
+ "password_filled": True,
621
+ "username_at": [ux, uy],
622
+ "password_at": [px, py],
623
+ "note": "Password was typed directly into the browser — it never appeared in this response."
624
+ }
625
+
626
+
627
+ # --- Startup ---
628
+
629
+ def main():
630
+ import uvicorn
631
+
632
+ parser = argparse.ArgumentParser(description="Navvi API Server")
633
+ parser.add_argument("--port", type=int, default=8024)
634
+ parser.add_argument("--display", type=str, default=":1")
635
+ args = parser.parse_args()
636
+
637
+ global display
638
+ display = args.display
639
+
640
+ # Connect to Marionette at startup
641
+ try:
642
+ get_marionette()
643
+ print(f"[navvi-server] Marionette connected")
644
+ except Exception as e:
645
+ print(f"[navvi-server] WARNING: Marionette not ready yet: {e}")
646
+ print("[navvi-server] Will retry on first request")
647
+
648
+ uvicorn.run(app, host="0.0.0.0", port=args.port, log_level="warning")
649
+
650
+
651
+ if __name__ == "__main__":
652
+ main()
@@ -0,0 +1,2 @@
1
+ fastapi==0.115.0
2
+ uvicorn==0.30.6