llama-deploy-appserver 0.3.23__tar.gz → 0.3.25__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.
Files changed (25) hide show
  1. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/PKG-INFO +5 -4
  2. llama_deploy_appserver-0.3.25/pyproject.toml +48 -0
  3. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/app.py +95 -12
  4. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/bootstrap.py +1 -1
  5. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/configure_logging.py +3 -3
  6. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/deployment.py +9 -12
  7. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/interrupts.py +3 -3
  8. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/process_utils.py +7 -4
  9. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/routers/ui_proxy.py +9 -6
  10. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/workflow_loader.py +41 -26
  11. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/workflow_store/agent_data_store.py +14 -6
  12. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/workflow_store/keyed_lock.py +4 -3
  13. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/workflow_store/lru_cache.py +4 -3
  14. llama_deploy_appserver-0.3.23/pyproject.toml +0 -48
  15. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/README.md +0 -0
  16. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/__init__.py +0 -0
  17. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/correlation_id.py +0 -0
  18. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/deployment_config_parser.py +0 -0
  19. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/py.typed +0 -0
  20. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/routers/__init__.py +0 -0
  21. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/routers/deployments.py +0 -0
  22. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/routers/status.py +0 -0
  23. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/settings.py +0 -0
  24. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/stats.py +0 -0
  25. {llama_deploy_appserver-0.3.23 → llama_deploy_appserver-0.3.25}/src/llama_deploy/appserver/types.py +0 -0
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: llama-deploy-appserver
3
- Version: 0.3.23
3
+ Version: 0.3.25
4
4
  Summary: Application server components for LlamaDeploy
5
5
  Author: Massimiliano Pippi, Adrian Lyjak
6
6
  Author-email: Massimiliano Pippi <mpippi@gmail.com>, Adrian Lyjak <adrianlyjak@gmail.com>
7
7
  License: MIT
8
- Requires-Dist: llama-index-workflows[server]>=2.9.1
8
+ Requires-Dist: llama-index-workflows[server]>=2.11.3
9
9
  Requires-Dist: pydantic-settings>=2.10.1
10
10
  Requires-Dist: fastapi>=0.100.0
11
11
  Requires-Dist: websockets>=12.0
12
- Requires-Dist: llama-deploy-core>=0.3.23,<0.4.0
12
+ Requires-Dist: llama-deploy-core>=0.3.25,<0.4.0
13
13
  Requires-Dist: httpx>=0.24.0,<1.0.0
14
14
  Requires-Dist: prometheus-fastapi-instrumentator>=7.1.0
15
15
  Requires-Dist: packaging>=25.0
@@ -19,7 +19,8 @@ Requires-Dist: pyyaml>=6.0.2
19
19
  Requires-Dist: llama-cloud-services>=0.6.60
20
20
  Requires-Dist: watchfiles>=1.1.0
21
21
  Requires-Dist: uvicorn>=0.35.0
22
- Requires-Python: >=3.11, <4
22
+ Requires-Dist: typing-extensions>=4.15.0 ; python_full_version < '3.12'
23
+ Requires-Python: >=3.10, <4
23
24
  Description-Content-Type: text/markdown
24
25
 
25
26
  # llama-deploy-appserver
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.7.20,<0.8.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [dependency-groups]
6
+ dev = [
7
+ "pytest>=8.4.1",
8
+ "pytest-asyncio>=0.25.3",
9
+ "respx>=0.22.0",
10
+ "pytest-xdist>=3.8.0",
11
+ "ty>=0.0.1a19",
12
+ "ruff>=0.12.9"
13
+ ]
14
+
15
+ [project]
16
+ name = "llama-deploy-appserver"
17
+ version = "0.3.25"
18
+ description = "Application server components for LlamaDeploy"
19
+ readme = "README.md"
20
+ license = {text = "MIT"}
21
+ authors = [
22
+ {name = "Massimiliano Pippi", email = "mpippi@gmail.com"},
23
+ {name = "Adrian Lyjak", email = "adrianlyjak@gmail.com"}
24
+ ]
25
+ requires-python = ">=3.10, <4"
26
+ dependencies = [
27
+ "llama-index-workflows[server]>=2.11.3",
28
+ "pydantic-settings>=2.10.1",
29
+ "fastapi>=0.100.0",
30
+ "websockets>=12.0",
31
+ "llama-deploy-core>=0.3.25,<0.4.0",
32
+ "httpx>=0.24.0,<1.0.0",
33
+ "prometheus-fastapi-instrumentator>=7.1.0",
34
+ "packaging>=25.0",
35
+ "structlog>=25.4.0",
36
+ "rich>=14.1.0",
37
+ "pyyaml>=6.0.2",
38
+ "llama-cloud-services>=0.6.60",
39
+ "watchfiles>=1.1.0",
40
+ "uvicorn>=0.35.0",
41
+ "typing-extensions>=4.15.0 ; python_full_version < '3.12'"
42
+ ]
43
+
44
+ [tool.uv.build-backend]
45
+ module-name = "llama_deploy.appserver"
46
+
47
+ [tool.uv.sources]
48
+ llama-deploy-core = {workspace = true}
@@ -1,4 +1,5 @@
1
1
  import argparse
2
+ import json
2
3
  import logging
3
4
  import os
4
5
  import threading
@@ -122,7 +123,7 @@ def _setup_openapi(name: str, app: FastAPI, server: WorkflowServer) -> None:
122
123
 
123
124
  schema["paths"] = new_paths
124
125
 
125
- def custom_openapi():
126
+ def custom_openapi() -> dict[str, object]:
126
127
  return schema
127
128
 
128
129
  app.openapi = custom_openapi # ty: ignore[invalid-assignment] - doesn't like us overwriting the method
@@ -139,16 +140,22 @@ app = FastAPI(
139
140
  Instrumentator().instrument(app).expose(app, include_in_schema=False)
140
141
 
141
142
 
142
- # Configure CORS middleware if the environment variable is set
143
- if not os.environ.get("DISABLE_CORS", False):
143
+ def _configure_cors(app: FastAPI) -> None:
144
+ """Attach CORS middleware in a way that keeps type-checkers happy."""
145
+ # Use a cast here because ty's view of Starlette's middleware factory
146
+ # protocol is stricter than FastAPI's runtime expectations.
144
147
  app.add_middleware(
145
- CORSMiddleware,
148
+ cast(Any, CORSMiddleware),
146
149
  allow_origins=["*"], # Allows all origins
147
150
  allow_credentials=True,
148
151
  allow_methods=["GET", "POST"],
149
152
  allow_headers=["Content-Type", "Authorization"],
150
153
  )
151
154
 
155
+
156
+ if not os.environ.get("DISABLE_CORS", False):
157
+ _configure_cors(app)
158
+
152
159
  app.include_router(health_router)
153
160
  add_log_middleware(app)
154
161
 
@@ -280,16 +287,13 @@ def start_server_in_target_venv(
280
287
  if log_format:
281
288
  env["LOG_FORMAT"] = log_format
282
289
 
283
- ret = run_process(
290
+ run_process(
284
291
  args,
285
292
  cwd=path,
286
293
  env=env,
287
294
  line_transform=_exclude_venv_warning,
288
295
  )
289
296
 
290
- if ret != 0:
291
- raise SystemExit(ret)
292
-
293
297
 
294
298
  def start_preflight_in_target_venv(
295
299
  cwd: Path | None = None,
@@ -297,7 +301,7 @@ def start_preflight_in_target_venv(
297
301
  ) -> None:
298
302
  """
299
303
  Run preflight validation inside the target project's virtual environment using uv.
300
- Mirrors the venv targetting and invocation strategy used by start_server_in_target_venv.
304
+ Mirrors the venv targeting and invocation strategy used by start_server_in_target_venv.
301
305
  """
302
306
  configure_settings(
303
307
  app_root=cwd,
@@ -317,14 +321,51 @@ def start_preflight_in_target_venv(
317
321
  if deployment_file:
318
322
  args.extend(["--deployment-file", str(deployment_file)])
319
323
 
320
- ret = run_process(
324
+ run_process(
325
+ args,
326
+ cwd=path,
327
+ env=os.environ.copy(),
328
+ line_transform=_exclude_venv_warning,
329
+ )
330
+ # Note: run_process doesn't return exit code; process runs to completion or raises
331
+
332
+
333
+ def start_export_json_graph_in_target_venv(
334
+ cwd: Path | None = None,
335
+ deployment_file: Path | None = None,
336
+ output: Path | None = None,
337
+ ) -> None:
338
+ """
339
+ Run workflow graph export inside the target project's virtual environment using uv.
340
+ Mirrors the venv targeting and invocation strategy used by start_preflight_in_target_venv.
341
+ """
342
+
343
+ configure_settings(
344
+ app_root=cwd,
345
+ deployment_file_path=deployment_file or Path(DEFAULT_DEPLOYMENT_FILE_PATH),
346
+ )
347
+ base_dir = cwd or Path.cwd()
348
+ path = settings.resolved_config_parent.relative_to(base_dir)
349
+ args = [
350
+ "uv",
351
+ "run",
352
+ "--no-progress",
353
+ "python",
354
+ "-m",
355
+ "llama_deploy.appserver.app",
356
+ "--export-json-graph",
357
+ ]
358
+ if deployment_file:
359
+ args.extend(["--deployment-file", str(deployment_file)])
360
+ if output is not None:
361
+ args.extend(["--export-output", str(output)])
362
+
363
+ run_process(
321
364
  args,
322
365
  cwd=path,
323
366
  env=os.environ.copy(),
324
367
  line_transform=_exclude_venv_warning,
325
368
  )
326
- if ret is not None and ret != 0:
327
- raise SystemExit(ret)
328
369
 
329
370
 
330
371
  class PreflightValidationError(Exception):
@@ -374,6 +415,40 @@ def preflight_validate(
374
415
  raise PreflightValidationError(errors)
375
416
 
376
417
 
418
+ def export_json_graph(
419
+ cwd: Path | None = None,
420
+ deployment_file: Path | None = None,
421
+ output: Path | None = None,
422
+ ) -> None:
423
+ """
424
+ Export a JSON representation of the registered workflows' graph.
425
+
426
+ This follows the same initialization path as preflight validation and writes
427
+ a workflows.json-style structure compatible with the CLI expectations.
428
+ """
429
+ from workflows.representation_utils import extract_workflow_structure
430
+
431
+ configure_settings(
432
+ app_root=cwd,
433
+ deployment_file_path=deployment_file or Path(DEFAULT_DEPLOYMENT_FILE_PATH),
434
+ )
435
+ cfg = get_deployment_config()
436
+ load_environment_variables(cfg, settings.resolved_config_parent)
437
+
438
+ workflows = load_workflows(cfg)
439
+
440
+ graph: dict[str, dict[str, Any]] = {}
441
+ for name, workflow in workflows.items():
442
+ wf_repr_dict = (
443
+ extract_workflow_structure(workflow).to_response_model().model_dump()
444
+ )
445
+ graph[name] = wf_repr_dict
446
+
447
+ output_path = output or (Path.cwd() / "workflows.json")
448
+ with output_path.open("w", encoding="utf-8") as f:
449
+ json.dump(graph, f, indent=2)
450
+
451
+
377
452
  if __name__ == "__main__":
378
453
  parser = argparse.ArgumentParser()
379
454
  parser.add_argument("--proxy-ui", action="store_true")
@@ -381,10 +456,18 @@ if __name__ == "__main__":
381
456
  parser.add_argument("--deployment-file", type=Path)
382
457
  parser.add_argument("--open-browser", action="store_true")
383
458
  parser.add_argument("--preflight", action="store_true")
459
+ parser.add_argument("--export-json-graph", action="store_true")
460
+ parser.add_argument("--export-output", type=Path)
384
461
 
385
462
  args = parser.parse_args()
386
463
  if args.preflight:
387
464
  preflight_validate(cwd=Path.cwd(), deployment_file=args.deployment_file)
465
+ elif args.export_json_graph:
466
+ export_json_graph(
467
+ cwd=Path.cwd(),
468
+ deployment_file=args.deployment_file,
469
+ output=args.export_output,
470
+ )
388
471
  else:
389
472
  start_server(
390
473
  proxy_ui=args.proxy_ui,
@@ -27,7 +27,7 @@ from llama_deploy.core.git.git_util import (
27
27
 
28
28
  def bootstrap_app_from_repo(
29
29
  target_dir: str = "/opt/app",
30
- ):
30
+ ) -> None:
31
31
  bootstrap_settings = BootstrapSettings()
32
32
  # Needs the github url+auth, and the deployment file path
33
33
  # clones the repo to a standard directory
@@ -22,18 +22,18 @@ def _get_or_create_correlation_id(request: Request) -> str:
22
22
  return request.headers.get("X-Request-ID", create_correlation_id())
23
23
 
24
24
 
25
- def add_log_middleware(app: FastAPI):
25
+ def add_log_middleware(app: FastAPI) -> None:
26
26
  @app.middleware("http")
27
27
  async def add_log_id(
28
28
  request: Request, call_next: Callable[[Request], Awaitable[Response]]
29
- ):
29
+ ) -> Response:
30
30
  set_correlation_id(_get_or_create_correlation_id(request))
31
31
  return await call_next(request)
32
32
 
33
33
  @app.middleware("http")
34
34
  async def access_log_middleware(
35
35
  request: Request, call_next: Callable[[Request], Awaitable[Response]]
36
- ):
36
+ ) -> Response:
37
37
  if _is_proxy_request(request):
38
38
  return await call_next(request)
39
39
  start = time.perf_counter()
@@ -18,7 +18,7 @@ from starlette.responses import HTMLResponse
18
18
  from starlette.routing import Route
19
19
  from workflows import Context, Workflow
20
20
  from workflows.handler import WorkflowHandler
21
- from workflows.server import SqliteWorkflowStore, WorkflowServer
21
+ from workflows.server import AbstractWorkflowStore, SqliteWorkflowStore, WorkflowServer
22
22
  from workflows.server.memory_workflow_store import MemoryWorkflowStore
23
23
 
24
24
  logger = logging.getLogger()
@@ -40,7 +40,7 @@ class Deployment:
40
40
  local: Whether the deployment is local. If true, sources won't be synced
41
41
  """
42
42
 
43
- self._default_service: str | None = workflows.get(DEFAULT_SERVICE_ID)
43
+ self._default_service: Workflow | None = workflows.get(DEFAULT_SERVICE_ID)
44
44
  self._service_tasks: list[asyncio.Task] = []
45
45
  # Ready to load services
46
46
  self._workflow_services: dict[str, Workflow] = workflows
@@ -50,13 +50,9 @@ class Deployment:
50
50
 
51
51
  @property
52
52
  def default_service(self) -> Workflow | None:
53
+ """Return the default workflow, if any."""
53
54
  return self._default_service
54
55
 
55
- @property
56
- def name(self) -> str:
57
- """Returns the name of this deployment."""
58
- return self._name
59
-
60
56
  @property
61
57
  def service_names(self) -> list[str]:
62
58
  """Returns the list of service names in this deployment."""
@@ -66,7 +62,7 @@ class Deployment:
66
62
  self, service_id: str, session_id: str | None = None, **run_kwargs: dict
67
63
  ) -> Any:
68
64
  workflow = self._workflow_services[service_id]
69
- if session_id:
65
+ if session_id is not None:
70
66
  context = self._contexts[session_id]
71
67
  return await workflow.run(context=context, **run_kwargs)
72
68
 
@@ -79,7 +75,7 @@ class Deployment:
79
75
  self, service_id: str, session_id: str | None = None, **run_kwargs: dict
80
76
  ) -> Tuple[str, str]:
81
77
  workflow = self._workflow_services[service_id]
82
- if session_id:
78
+ if session_id is not None:
83
79
  context = self._contexts[session_id]
84
80
  handler = workflow.run(context=context, **run_kwargs)
85
81
  else:
@@ -90,12 +86,13 @@ class Deployment:
90
86
  handler_id = generate_id()
91
87
  self._handlers[handler_id] = handler
92
88
  self._handler_inputs[handler_id] = json.dumps(run_kwargs)
89
+ assert session_id is not None
93
90
  return handler_id, session_id
94
91
 
95
92
  def create_workflow_server(
96
93
  self, deployment_config: DeploymentConfig, settings: ApiserverSettings
97
94
  ) -> WorkflowServer:
98
- persistence = MemoryWorkflowStore()
95
+ persistence: AbstractWorkflowStore = MemoryWorkflowStore()
99
96
  if settings.persistence == "local":
100
97
  logger.info("Using local sqlite persistence for workflows")
101
98
  persistence = SqliteWorkflowStore(
@@ -137,8 +134,8 @@ class Deployment:
137
134
  "/debugger/index.html?api=" + quote_plus("/deployments/" + config.name)
138
135
  )
139
136
 
140
- @app.get("/debugger/index.html", include_in_schema=False)
141
- def serve_debugger(api: str | None = None):
137
+ @app.get("/debugger/index.html", include_in_schema=False, response_model=None)
138
+ def serve_debugger(api: str | None = None) -> RedirectResponse | HTMLResponse:
142
139
  if not api:
143
140
  return RedirectResponse(
144
141
  "/debugger/index.html?api="
@@ -2,7 +2,7 @@ import asyncio
2
2
  import signal
3
3
  from asyncio import Event
4
4
  from contextlib import suppress
5
- from typing import Awaitable, TypeVar
5
+ from typing import Any, Coroutine, TypeVar
6
6
 
7
7
  shutdown_event = Event()
8
8
 
@@ -21,7 +21,7 @@ T = TypeVar("T")
21
21
 
22
22
 
23
23
  async def wait_or_abort(
24
- awaitable: Awaitable[T], shutdown_event: asyncio.Event = shutdown_event
24
+ awaitable: Coroutine[Any, Any, T], shutdown_event: asyncio.Event = shutdown_event
25
25
  ) -> T:
26
26
  """Await an operation, aborting early if shutdown is requested.
27
27
 
@@ -32,7 +32,7 @@ async def wait_or_abort(
32
32
  if event.is_set():
33
33
  raise OperationAborted()
34
34
 
35
- op_task = asyncio.create_task(awaitable)
35
+ op_task: asyncio.Task[T] = asyncio.create_task(awaitable)
36
36
  stop_task = asyncio.create_task(event.wait())
37
37
  try:
38
38
  done, _ = await asyncio.wait(
@@ -112,7 +112,7 @@ def should_use_color() -> bool:
112
112
 
113
113
  @dataclass
114
114
  class SpawnProcessResult:
115
- process: subprocess.Popen
115
+ process: subprocess.Popen[str] | subprocess.Popen[bytes]
116
116
  sources: list[Tuple[int | TextIO, TextIO]]
117
117
  cleanup: Callable[[], None]
118
118
 
@@ -124,6 +124,7 @@ def _spawn_process(
124
124
  env: dict[str, str] | None,
125
125
  use_pty: bool,
126
126
  ) -> SpawnProcessResult:
127
+ process: subprocess.Popen[str] | subprocess.Popen[bytes]
127
128
  if use_pty:
128
129
  import pty
129
130
 
@@ -164,7 +165,7 @@ def _spawn_process(
164
165
  shell=use_shell,
165
166
  )
166
167
 
167
- def cleanup() -> None:
168
+ def cleanup_non_pty() -> None:
168
169
  return None
169
170
 
170
171
  assert process.stdout is not None and process.stderr is not None
@@ -172,7 +173,7 @@ def _spawn_process(
172
173
  (cast(int | TextIO, process.stdout), cast(TextIO, sys.stdout)),
173
174
  (cast(int | TextIO, process.stderr), cast(TextIO, sys.stderr)),
174
175
  ]
175
- return SpawnProcessResult(process, sources, cleanup)
176
+ return SpawnProcessResult(process, sources, cleanup_non_pty)
176
177
 
177
178
 
178
179
  def _stream_source(
@@ -212,7 +213,9 @@ def _stream_source(
212
213
  def _log_command(cmd: list[str], transform: Callable[[str], str | None] | None) -> None:
213
214
  cmd_str = "> " + " ".join(cmd)
214
215
  if transform:
215
- cmd_str = transform(cmd_str)
216
+ transformed = transform(cmd_str)
217
+ if transformed is not None:
218
+ cmd_str = transformed
216
219
  sys.stderr.write(cmd_str + "\n")
217
220
 
218
221
 
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  import logging
3
+ from collections.abc import AsyncGenerator, Sequence
3
4
  from contextlib import suppress
4
- from typing import List
5
5
 
6
6
  import httpx
7
7
  import websockets
@@ -23,6 +23,7 @@ from llama_deploy.appserver.interrupts import (
23
23
  from llama_deploy.appserver.settings import ApiserverSettings
24
24
  from llama_deploy.core.client.ssl_util import get_httpx_verify_param
25
25
  from llama_deploy.core.deployment_config import DeploymentConfig
26
+ from websockets.typing import Subprotocol
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
@@ -53,11 +54,12 @@ async def _ws_proxy(ws: WebSocket, upstream_url: str) -> None:
53
54
 
54
55
  try:
55
56
  # Parse subprotocols if present
56
- subprotocols: List[str] | None = None
57
+ subprotocols: Sequence[Subprotocol] | None = None
57
58
  requested = ws.headers.get("sec-websocket-protocol")
58
59
  if requested:
59
60
  # Parse comma-separated subprotocols (as plain strings)
60
- subprotocols = [p.strip() for p in requested.split(",")]
61
+ parsed = [p.strip() for p in requested.split(",")]
62
+ subprotocols = [Subprotocol(p) for p in parsed if p]
61
63
 
62
64
  # Open upstream WebSocket connection, offering the same subprotocols
63
65
  async with websockets.connect(
@@ -210,7 +212,7 @@ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
210
212
  }
211
213
 
212
214
  # Stream downloads and ensure cleanup in the generator's finally block
213
- async def upstream_body():
215
+ async def upstream_body() -> AsyncGenerator[bytes, None]:
214
216
  try:
215
217
  async for chunk in upstream.aiter_raw():
216
218
  yield chunk
@@ -240,9 +242,10 @@ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
240
242
  def mount_static_files(
241
243
  app: FastAPI, config: DeploymentConfig, settings: ApiserverSettings
242
244
  ) -> None:
243
- path = settings.app_root / config.build_output_path()
244
- if not path:
245
+ build_output = config.build_output_path()
246
+ if build_output is None:
245
247
  return
248
+ path = settings.app_root / build_output
246
249
 
247
250
  if not path.exists():
248
251
  return
@@ -12,11 +12,9 @@ from pathlib import Path
12
12
  from textwrap import dedent
13
13
 
14
14
  from dotenv import dotenv_values
15
- from llama_deploy.appserver.deployment_config_parser import (
16
- DeploymentConfig,
17
- )
18
15
  from llama_deploy.appserver.process_utils import run_process, spawn_process
19
16
  from llama_deploy.appserver.settings import ApiserverSettings, settings
17
+ from llama_deploy.core.deployment_config import DeploymentConfig
20
18
  from llama_deploy.core.ui_build import ui_build_output_path
21
19
  from packaging.version import InvalidVersion, Version
22
20
  from workflows import Workflow
@@ -106,7 +104,8 @@ def parse_environment_variables(
106
104
  for env_file in config.env_files or []:
107
105
  env_file_path = source_root / env_file
108
106
  values = dotenv_values(env_file_path)
109
- env_vars.update(**values)
107
+ str_values = {k: v for k, v in values.items() if isinstance(v, str)}
108
+ env_vars.update(str_values)
110
109
  return env_vars
111
110
 
112
111
 
@@ -190,6 +189,34 @@ def _is_missing_or_outdated(path: Path) -> Version | None:
190
189
  return None
191
190
 
192
191
 
192
+ def run_uv(
193
+ source_root: Path,
194
+ path: Path,
195
+ cmd: str,
196
+ args: list[str] = [],
197
+ extra_env: dict[str, str] | None = None,
198
+ ) -> None:
199
+ env = os.environ.copy()
200
+ if extra_env:
201
+ env.update(extra_env)
202
+ run_process(
203
+ ["uv", cmd] + args,
204
+ cwd=source_root / path,
205
+ prefix=f"[uv {cmd}]",
206
+ color_code="36",
207
+ use_tty=False,
208
+ line_transform=_exclude_venv_warning,
209
+ env=env,
210
+ )
211
+
212
+
213
+ def ensure_venv(source_root: Path, path: Path, force: bool = False) -> Path:
214
+ venv_path = source_root / path / ".venv"
215
+ if force or not venv_path.exists():
216
+ run_uv(source_root, path, "venv", [str(venv_path)])
217
+ return venv_path
218
+
219
+
193
220
  def _install_and_add_appserver_if_missing(
194
221
  path: Path,
195
222
  source_root: Path,
@@ -206,29 +233,11 @@ def _install_and_add_appserver_if_missing(
206
233
  )
207
234
  return
208
235
 
209
- def run_uv(cmd: str, args: list[str] = [], extra_env: dict[str, str] | None = None):
210
- env = os.environ.copy()
211
- if extra_env:
212
- env.update(extra_env)
213
- run_process(
214
- ["uv", cmd] + args,
215
- cwd=source_root / path,
216
- prefix=f"[uv {cmd}]",
217
- color_code="36",
218
- use_tty=False,
219
- line_transform=_exclude_venv_warning,
220
- env=env,
221
- )
222
-
223
- def ensure_venv(path: Path, force: bool = False) -> Path:
224
- venv_path = source_root / path / ".venv"
225
- if force or not venv_path.exists():
226
- run_uv("venv", [str(venv_path)])
227
- return venv_path
228
-
229
236
  editable = are_we_editable_mode()
230
- venv_path = ensure_venv(path, force=editable)
237
+ venv_path = ensure_venv(source_root, path, force=editable)
231
238
  run_uv(
239
+ source_root,
240
+ path,
232
241
  "sync",
233
242
  ["--no-dev", "--inexact"],
234
243
  extra_env={"UV_PROJECT_ENVIRONMENT": str(venv_path)},
@@ -236,6 +245,8 @@ def _install_and_add_appserver_if_missing(
236
245
 
237
246
  if sdists:
238
247
  run_uv(
248
+ source_root,
249
+ path,
239
250
  "pip",
240
251
  ["install"]
241
252
  + [str(s.absolute()) for s in sdists]
@@ -258,6 +269,8 @@ def _install_and_add_appserver_if_missing(
258
269
  target = f"file://{str(rel)}"
259
270
 
260
271
  run_uv(
272
+ source_root,
273
+ path,
261
274
  "pip",
262
275
  [
263
276
  "install",
@@ -273,9 +286,11 @@ def _install_and_add_appserver_if_missing(
273
286
  version = _is_missing_or_outdated(path)
274
287
  if version is not None:
275
288
  if save_version:
276
- run_uv("add", [f"llama-deploy-appserver>={version}"])
289
+ run_uv(source_root, path, "add", [f"llama-deploy-appserver>={version}"])
277
290
  else:
278
291
  run_uv(
292
+ source_root,
293
+ path,
279
294
  "pip",
280
295
  [
281
296
  "install",
@@ -1,19 +1,24 @@
1
1
  import asyncio
2
2
  import logging
3
3
  import os
4
- from typing import Any, List
4
+ import sys
5
+ from typing import Any, List, cast
5
6
 
6
7
  from llama_cloud.client import AsyncLlamaCloud, httpx
7
8
  from llama_cloud_services.beta.agent_data import AsyncAgentDataClient
8
9
  from llama_deploy.appserver.settings import ApiserverSettings
9
10
  from llama_deploy.core.client.ssl_util import get_httpx_verify_param
10
11
  from llama_deploy.core.deployment_config import DeploymentConfig
11
- from typing_extensions import override
12
12
  from workflows.server import AbstractWorkflowStore, HandlerQuery, PersistentHandler
13
13
 
14
14
  from .keyed_lock import AsyncKeyedLock
15
15
  from .lru_cache import LRUCache
16
16
 
17
+ if sys.version_info <= (3, 11):
18
+ from typing_extensions import override
19
+ else:
20
+ from typing import override
21
+
17
22
  logger = logging.getLogger(__name__)
18
23
 
19
24
 
@@ -77,8 +82,8 @@ class AgentDataStore(AbstractWorkflowStore):
77
82
  )
78
83
 
79
84
  @override
80
- async def delete(self, handler: HandlerQuery) -> int:
81
- filters = self._build_filters(handler)
85
+ async def delete(self, query: HandlerQuery) -> int:
86
+ filters = self._build_filters(query)
82
87
  results = await self.client.search(filter=filters, page_size=1000)
83
88
  await asyncio.gather(
84
89
  *[self.client.delete_item(item_id=x.id) for x in results.items if x.id]
@@ -89,18 +94,21 @@ class AgentDataStore(AbstractWorkflowStore):
89
94
  cached_id = self.cache.get(handler.handler_id)
90
95
  if cached_id is not None:
91
96
  return cached_id
97
+ search_filter = {"handler_id": {"eq": handler.handler_id}}
92
98
  results = await self.client.search(
93
- filter={"handler_id": {"eq": handler.handler_id}},
99
+ filter=cast(Any, search_filter),
94
100
  page_size=1,
95
101
  )
96
102
  if not results.items:
97
103
  return None
98
104
  id = results.items[0].id
105
+ if id is None:
106
+ return None
99
107
  self.cache.set(handler.handler_id, id)
100
108
  return id
101
109
 
102
110
  def _build_filters(self, query: HandlerQuery) -> dict[str, Any]:
103
- filters = {}
111
+ filters: dict[str, Any] = {}
104
112
  if query.handler_id_in is not None:
105
113
  filters["handler_id"] = {
106
114
  "includes": query.handler_id_in,
@@ -1,16 +1,17 @@
1
1
  import asyncio
2
2
  from collections import Counter
3
+ from collections.abc import AsyncIterator
3
4
  from contextlib import asynccontextmanager
4
5
 
5
6
 
6
7
  class AsyncKeyedLock:
7
- def __init__(self):
8
+ def __init__(self) -> None:
8
9
  self._locks: dict[str, asyncio.Lock] = {}
9
- self._refcnt = Counter()
10
+ self._refcnt: Counter[str] = Counter()
10
11
  self._registry_lock = asyncio.Lock() # protects _locks/_refcnt
11
12
 
12
13
  @asynccontextmanager
13
- async def acquire(self, key: str):
14
+ async def acquire(self, key: str) -> AsyncIterator[None]:
14
15
  async with self._registry_lock:
15
16
  lock = self._locks.get(key)
16
17
  if lock is None:
@@ -1,4 +1,5 @@
1
1
  from collections import OrderedDict
2
+ from collections.abc import Iterator
2
3
  from typing import Generic, TypeVar, overload
3
4
 
4
5
  K = TypeVar("K")
@@ -21,7 +22,7 @@ class LRUCache(Generic[K, V]):
21
22
  return default
22
23
  return self[key]
23
24
 
24
- def set(self, key: K, value: V):
25
+ def set(self, key: K, value: V) -> None:
25
26
  if key in self._store:
26
27
  # remove old so we can push to end
27
28
  self._store.pop(key)
@@ -41,11 +42,11 @@ class LRUCache(Generic[K, V]):
41
42
  self._store[key] = value
42
43
  return value
43
44
 
44
- def __setitem__(self, key: K, value: V):
45
+ def __setitem__(self, key: K, value: V) -> None:
45
46
  self.set(key, value)
46
47
 
47
48
  def __len__(self) -> int:
48
49
  return len(self._store)
49
50
 
50
- def __iter__(self):
51
+ def __iter__(self) -> Iterator[K]:
51
52
  return iter(self._store)
@@ -1,48 +0,0 @@
1
- [project]
2
- name = "llama-deploy-appserver"
3
- version = "0.3.23"
4
- description = "Application server components for LlamaDeploy"
5
- readme = "README.md"
6
- license = { text = "MIT" }
7
- authors = [
8
- { name = "Massimiliano Pippi", email = "mpippi@gmail.com" },
9
- { name = "Adrian Lyjak", email = "adrianlyjak@gmail.com" },
10
- ]
11
- requires-python = ">=3.11, <4"
12
- dependencies = [
13
- "llama-index-workflows[server]>=2.9.1",
14
- "pydantic-settings>=2.10.1",
15
- "fastapi>=0.100.0",
16
- "websockets>=12.0",
17
- "llama-deploy-core>=0.3.23,<0.4.0",
18
- "httpx>=0.24.0,<1.0.0",
19
- "prometheus-fastapi-instrumentator>=7.1.0",
20
- "packaging>=25.0",
21
- "structlog>=25.4.0",
22
- "rich>=14.1.0",
23
- "pyyaml>=6.0.2",
24
- "llama-cloud-services>=0.6.60",
25
- "watchfiles>=1.1.0",
26
- "uvicorn>=0.35.0",
27
- ]
28
-
29
- [build-system]
30
- requires = ["uv_build>=0.7.20,<0.8.0"]
31
- build-backend = "uv_build"
32
-
33
- [tool.uv.build-backend]
34
- module-name = "llama_deploy.appserver"
35
-
36
-
37
- [tool.uv.sources]
38
- llama-deploy-core = { workspace = true }
39
-
40
- [dependency-groups]
41
- dev = [
42
- "pytest>=8.4.1",
43
- "pytest-asyncio>=0.25.3",
44
- "respx>=0.22.0",
45
- "pytest-xdist>=3.8.0",
46
- "ty>=0.0.1a19",
47
- "ruff>=0.12.9",
48
- ]