llama-deploy-appserver 0.3.0a8__py3-none-any.whl → 0.3.0a10__py3-none-any.whl

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.
@@ -13,6 +13,10 @@ import uvicorn
13
13
  from fastapi import FastAPI
14
14
  from fastapi.middleware.cors import CORSMiddleware
15
15
  from fastapi.openapi.utils import get_openapi
16
+ from llama_deploy.appserver.configure_logging import (
17
+ add_log_middleware,
18
+ setup_logging,
19
+ )
16
20
  from llama_deploy.appserver.deployment_config_parser import (
17
21
  get_deployment_config,
18
22
  )
@@ -41,15 +45,21 @@ from starlette.schemas import SchemaGenerator
41
45
  from workflows.server import WorkflowServer
42
46
 
43
47
  from .deployment import Deployment
48
+ from .interrupts import shutdown_event
44
49
  from .process_utils import run_process
45
50
  from .routers import health_router
46
51
  from .stats import apiserver_state
47
52
 
48
53
  logger = logging.getLogger("uvicorn.info")
49
54
 
55
+ # Auto-configure logging on import when requested (e.g., uvicorn reload workers)
56
+ if os.getenv("LLAMA_DEPLOY_AUTO_LOGGING", "0") == "1":
57
+ setup_logging(os.getenv("LOG_LEVEL", "INFO"))
58
+
50
59
 
51
60
  @asynccontextmanager
52
61
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]:
62
+ shutdown_event.clear()
53
63
  apiserver_state.state("starting")
54
64
  config = get_deployment_config()
55
65
 
@@ -137,6 +147,7 @@ def _setup_openapi(name: str, app: FastAPI, server: WorkflowServer) -> None:
137
147
  app = FastAPI(lifespan=lifespan)
138
148
  Instrumentator().instrument(app).expose(app)
139
149
 
150
+
140
151
  # Configure CORS middleware if the environment variable is set
141
152
  if not os.environ.get("DISABLE_CORS", False):
142
153
  app.add_middleware(
@@ -148,6 +159,7 @@ if not os.environ.get("DISABLE_CORS", False):
148
159
  )
149
160
 
150
161
  app.include_router(health_router)
162
+ add_log_middleware(app)
151
163
 
152
164
 
153
165
  def open_browser_async(host: str, port: int) -> None:
@@ -181,6 +193,7 @@ def start_server(
181
193
  cwd: Path | None = None,
182
194
  deployment_file: Path | None = None,
183
195
  open_browser: bool = False,
196
+ configure_logging: bool = True,
184
197
  ) -> None:
185
198
  # Configure via environment so uvicorn reload workers inherit the values
186
199
  configure_settings(
@@ -199,12 +212,19 @@ def start_server(
199
212
  try:
200
213
  if open_browser:
201
214
  open_browser_async(settings.host, settings.port)
202
-
215
+ # Ensure reload workers configure logging on import
216
+ os.environ["LLAMA_DEPLOY_AUTO_LOGGING"] = "1"
217
+ # Configure logging for the launcher process as well
218
+ if configure_logging:
219
+ setup_logging(os.getenv("LOG_LEVEL", "INFO"))
203
220
  uvicorn.run(
204
221
  "llama_deploy.appserver.app:app",
205
222
  host=settings.host,
206
223
  port=settings.port,
207
224
  reload=reload,
225
+ timeout_graceful_shutdown=60,
226
+ access_log=False,
227
+ log_config=None,
208
228
  )
209
229
  finally:
210
230
  if ui_process is not None:
@@ -219,6 +239,8 @@ def start_server_in_target_venv(
219
239
  open_browser: bool = False,
220
240
  port: int | None = None,
221
241
  ui_port: int | None = None,
242
+ log_level: str | None = None,
243
+ log_format: str | None = None,
222
244
  ) -> None:
223
245
  # Ensure settings reflect the intended working directory before computing paths
224
246
 
@@ -246,6 +268,10 @@ def start_server_in_target_venv(
246
268
  env["LLAMA_DEPLOY_APISERVER_PORT"] = str(port)
247
269
  if ui_port:
248
270
  env["LLAMA_DEPLOY_APISERVER_PROXY_UI_PORT"] = str(ui_port)
271
+ if log_level:
272
+ env["LOG_LEVEL"] = log_level
273
+ if log_format:
274
+ env["LOG_FORMAT"] = log_format
249
275
 
250
276
  ret = run_process(
251
277
  args,
@@ -0,0 +1,189 @@
1
+ import logging
2
+ import logging.config
3
+ import os
4
+ import time
5
+ from contextlib import asynccontextmanager
6
+ from contextvars import ContextVar
7
+ from typing import Any, AsyncGenerator, Awaitable, Callable
8
+
9
+ import structlog
10
+ from fastapi import FastAPI, Request, Response
11
+ from llama_deploy.appserver.correlation_id import (
12
+ create_correlation_id,
13
+ get_correlation_id,
14
+ set_correlation_id,
15
+ )
16
+ from llama_deploy.appserver.process_utils import should_use_color
17
+
18
+ access_logger = logging.getLogger("app.access")
19
+
20
+
21
+ def _get_or_create_correlation_id(request: Request) -> str:
22
+ return request.headers.get("X-Request-ID", create_correlation_id())
23
+
24
+
25
+ def add_log_middleware(app: FastAPI):
26
+ @app.middleware("http")
27
+ async def add_log_id(
28
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
29
+ ):
30
+ set_correlation_id(_get_or_create_correlation_id(request))
31
+ return await call_next(request)
32
+
33
+ @app.middleware("http")
34
+ async def access_log_middleware(
35
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
36
+ ):
37
+ if _is_proxy_request(request):
38
+ return await call_next(request)
39
+ start = time.perf_counter()
40
+ response = await call_next(request)
41
+ dur_ms = (time.perf_counter() - start) * 1000
42
+ qp = str(request.query_params)
43
+ if qp:
44
+ qp = f"?{qp}"
45
+ access_logger.info(
46
+ f"{request.method} {request.url.path}{qp}",
47
+ extra={
48
+ "duration_ms": round(dur_ms, 2),
49
+ "status_code": response.status_code,
50
+ },
51
+ )
52
+ return response
53
+
54
+
55
+ def _add_request_id(_: Any, __: str, event_dict: dict[str, Any]) -> dict[str, Any]:
56
+ req_id = get_correlation_id()
57
+ if req_id and "request_id" not in event_dict:
58
+ event_dict["request_id"] = req_id
59
+ return event_dict
60
+
61
+
62
+ def _drop_uvicorn_color_message(
63
+ _: Any, __: str, event_dict: dict[str, Any]
64
+ ) -> dict[str, Any]:
65
+ # Uvicorn injects an ANSI-colored duplicate of the message under this key
66
+ event_dict.pop("color_message", None)
67
+ return event_dict
68
+
69
+
70
+ def setup_logging(level: str = "INFO") -> None:
71
+ """
72
+ Configure console logging via structlog with a compact, dev-friendly format.
73
+ Includes request_id and respects logging.extra.
74
+ """
75
+ # Choose renderer and timestamp format based on LOG_FORMAT
76
+ log_format = os.getenv("LOG_FORMAT", "console").lower()
77
+ is_console = log_format == "console"
78
+
79
+ if log_format == "json":
80
+ renderer = structlog.processors.JSONRenderer()
81
+ timestamper = structlog.processors.TimeStamper(fmt="iso", key="timestamp")
82
+ else:
83
+ renderer = structlog.dev.ConsoleRenderer(colors=should_use_color())
84
+ timestamper = structlog.processors.TimeStamper(fmt="%H:%M:%S", key="timestamp")
85
+
86
+ pre_chain = [
87
+ structlog.contextvars.merge_contextvars,
88
+ structlog.stdlib.add_logger_name,
89
+ structlog.stdlib.add_log_level,
90
+ timestamper,
91
+ _add_request_id,
92
+ ]
93
+
94
+ # Ensure stdlib logs (foreign to structlog) also include `extra={...}` fields
95
+ # and that exceptions/stack info are rendered nicely (esp. for JSON format)
96
+ foreign_pre_chain = [
97
+ *pre_chain,
98
+ structlog.stdlib.ExtraAdder(),
99
+ *( # otherwise ConsoleRenderer will render nice rich stack traces
100
+ [
101
+ structlog.processors.StackInfoRenderer(),
102
+ structlog.processors.format_exc_info,
103
+ ]
104
+ if not is_console
105
+ else []
106
+ ),
107
+ _drop_uvicorn_color_message,
108
+ ]
109
+
110
+ structlog.configure(
111
+ processors=[
112
+ *pre_chain,
113
+ structlog.stdlib.PositionalArgumentsFormatter(),
114
+ structlog.stdlib.ExtraAdder(),
115
+ structlog.processors.StackInfoRenderer(),
116
+ structlog.processors.format_exc_info,
117
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
118
+ ],
119
+ logger_factory=structlog.stdlib.LoggerFactory(),
120
+ cache_logger_on_first_use=True,
121
+ )
122
+
123
+ handler = {
124
+ "class": "logging.StreamHandler",
125
+ "level": level,
126
+ "formatter": "console",
127
+ "stream": "ext://sys.stdout",
128
+ }
129
+
130
+ logging.config.dictConfig(
131
+ {
132
+ "version": 1,
133
+ "disable_existing_loggers": False,
134
+ "formatters": {
135
+ "console": {
136
+ "()": structlog.stdlib.ProcessorFormatter,
137
+ # With Rich, let it handle the final formatting; otherwise use our renderer
138
+ "processor": renderer,
139
+ "foreign_pre_chain": foreign_pre_chain,
140
+ }
141
+ },
142
+ "handlers": {"console": handler, "default": handler},
143
+ "root": {
144
+ "handlers": ["console"],
145
+ "level": level,
146
+ },
147
+ "loggers": {
148
+ "uvicorn.access": { # disable access logging, we have our own access log
149
+ "level": "WARNING",
150
+ "handlers": ["console"],
151
+ "propagate": False,
152
+ },
153
+ },
154
+ }
155
+ )
156
+
157
+ # Reduce noise from httpx globally, with fine-grained suppression controlled per-request
158
+ logging.getLogger("httpx").addFilter(_HttpxProxyNoiseFilter())
159
+
160
+
161
+ #####################################################################################
162
+ ### Proxying through the fastapi server in dev mode is noisy, various suppressions
163
+ ###
164
+ def _is_proxy_request(request: Request) -> bool:
165
+ parts = request.url.path.split("/")
166
+ return len(parts) >= 4 and parts[1] == "deployments" and parts[3] == "ui"
167
+
168
+
169
+ _suppress_httpx_logging: ContextVar[bool] = ContextVar(
170
+ "suppress_httpx_logging", default=False
171
+ )
172
+
173
+
174
+ class _HttpxProxyNoiseFilter(logging.Filter):
175
+ def filter(self, record: logging.LogRecord) -> bool:
176
+ """Return False to drop httpx info/debug logs when suppression is active."""
177
+ try:
178
+ if record.name.startswith("httpx") and record.levelno <= logging.INFO:
179
+ return not _suppress_httpx_logging.get()
180
+ except Exception:
181
+ return True
182
+ return True
183
+
184
+
185
+ @asynccontextmanager
186
+ async def suppress_httpx_logs() -> AsyncGenerator[None, None]:
187
+ _suppress_httpx_logging.set(True)
188
+ yield
189
+ _suppress_httpx_logging.set(False)
@@ -0,0 +1,24 @@
1
+ import random
2
+ import string
3
+ from contextvars import ContextVar
4
+
5
+ correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="")
6
+
7
+
8
+ def get_correlation_id() -> str:
9
+ return correlation_id_var.get()
10
+
11
+
12
+ def set_correlation_id(correlation_id: str) -> None:
13
+ correlation_id_var.set(correlation_id)
14
+
15
+
16
+ def create_correlation_id() -> str:
17
+ return random_alphanumeric_string(8)
18
+
19
+
20
+ _alphanumeric_chars = string.ascii_letters + string.digits
21
+
22
+
23
+ def random_alphanumeric_string(length: int) -> str:
24
+ return "".join(random.choices(_alphanumeric_chars, k=length))
@@ -0,0 +1,55 @@
1
+ import asyncio
2
+ import signal
3
+ from asyncio import Event
4
+ from contextlib import suppress
5
+ from typing import Awaitable, TypeVar
6
+
7
+ shutdown_event = Event()
8
+
9
+
10
+ def setup_interrupts() -> None:
11
+ loop = asyncio.get_running_loop()
12
+ for sig in (signal.SIGINT, signal.SIGTERM):
13
+ loop.add_signal_handler(sig, shutdown_event.set)
14
+
15
+
16
+ class OperationAborted(Exception):
17
+ """Raised when an operation is aborted due to shutdown/interrupt."""
18
+
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ async def wait_or_abort(
24
+ awaitable: Awaitable[T], shutdown_event: asyncio.Event = shutdown_event
25
+ ) -> T:
26
+ """Await an operation, aborting early if shutdown is requested.
27
+
28
+ If the shutdown event is set before the awaitable completes, cancel the
29
+ awaitable and raise OperationAborted. Otherwise, return the awaitable's result.
30
+ """
31
+ event = shutdown_event
32
+ if event.is_set():
33
+ raise OperationAborted()
34
+
35
+ op_task = asyncio.create_task(awaitable)
36
+ stop_task = asyncio.create_task(event.wait())
37
+ try:
38
+ done, _ = await asyncio.wait(
39
+ {op_task, stop_task}, return_when=asyncio.FIRST_COMPLETED
40
+ )
41
+ if stop_task in done:
42
+ op_task.cancel()
43
+ with suppress(asyncio.CancelledError):
44
+ await op_task
45
+ raise OperationAborted()
46
+ # Operation finished first
47
+ stop_task.cancel()
48
+ with suppress(asyncio.CancelledError):
49
+ await stop_task
50
+ return await op_task
51
+ finally:
52
+ # Ensure no leaked tasks if an exception propagates
53
+ for t in (op_task, stop_task):
54
+ if not t.done():
55
+ t.cancel()
@@ -103,7 +103,11 @@ def _should_use_pty(use_tty: bool | None) -> bool:
103
103
  return False
104
104
  if use_tty is None:
105
105
  return sys.stdout.isatty()
106
- return use_tty and sys.stdout.isatty()
106
+ return use_tty and sys.stdout.isatty() and not os.environ.get("NO_COLOR")
107
+
108
+
109
+ def should_use_color() -> bool:
110
+ return _should_use_pty(None)
107
111
 
108
112
 
109
113
  def _spawn_process(
File without changes
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import logging
3
+ from contextlib import suppress
3
4
  from typing import List
4
5
 
5
6
  import httpx
@@ -13,51 +14,63 @@ from fastapi import (
13
14
  )
14
15
  from fastapi.responses import StreamingResponse
15
16
  from fastapi.staticfiles import StaticFiles
17
+ from llama_deploy.appserver.configure_logging import suppress_httpx_logs
18
+ from llama_deploy.appserver.interrupts import (
19
+ OperationAborted,
20
+ shutdown_event,
21
+ wait_or_abort,
22
+ )
16
23
  from llama_deploy.appserver.settings import ApiserverSettings
17
24
  from llama_deploy.core.deployment_config import DeploymentConfig
18
- from starlette.background import BackgroundTask
19
25
 
20
26
  logger = logging.getLogger(__name__)
21
27
 
22
28
 
23
29
  async def _ws_proxy(ws: WebSocket, upstream_url: str) -> None:
24
30
  """Proxy WebSocket connection to upstream server."""
25
- await ws.accept()
31
+ if shutdown_event.is_set():
32
+ await ws.close()
33
+ return
34
+
35
+ # Defer accept until after upstream connects so we can mirror the selected subprotocol
26
36
 
27
37
  # Forward most headers except WebSocket-specific ones
38
+ header_prefix_blacklist = ["sec-websocket-"]
28
39
  header_blacklist = {
29
40
  "host",
30
41
  "connection",
31
42
  "upgrade",
32
- "sec-websocket-key",
33
- "sec-websocket-version",
34
- "sec-websocket-extensions",
35
43
  }
36
- hdrs = [(k, v) for k, v in ws.headers.items() if k.lower() not in header_blacklist]
44
+ hdrs = []
45
+ for k, v in ws.headers.items():
46
+ if k.lower() not in header_blacklist:
47
+ for prefix in header_prefix_blacklist:
48
+ if k.lower().startswith(prefix):
49
+ break
50
+ else:
51
+ hdrs.append((k, v))
37
52
 
38
53
  try:
39
54
  # Parse subprotocols if present
40
- subprotocols: List[websockets.Subprotocol] | None = None
41
- if "sec-websocket-protocol" in ws.headers:
42
- # Parse comma-separated subprotocols
43
- subprotocols = [
44
- websockets.Subprotocol(p.strip())
45
- for p in ws.headers["sec-websocket-protocol"].split(",")
46
- ]
47
-
48
- # Open upstream WebSocket connection
55
+ subprotocols: List[str] | None = None
56
+ requested = ws.headers.get("sec-websocket-protocol")
57
+ if requested:
58
+ # Parse comma-separated subprotocols (as plain strings)
59
+ subprotocols = [p.strip() for p in requested.split(",")]
60
+
61
+ # Open upstream WebSocket connection, offering the same subprotocols
49
62
  async with websockets.connect(
50
63
  upstream_url,
51
64
  additional_headers=hdrs,
52
65
  subprotocols=subprotocols,
53
- open_timeout=None,
54
- ping_interval=None,
66
+ open_timeout=5,
55
67
  ) as upstream:
68
+ await ws.accept(subprotocol=upstream.subprotocol)
56
69
 
57
70
  async def client_to_upstream() -> None:
58
71
  try:
59
72
  while True:
60
- msg = await ws.receive()
73
+ msg = await wait_or_abort(ws.receive(), shutdown_event)
61
74
  if msg["type"] == "websocket.receive":
62
75
  if "text" in msg:
63
76
  await upstream.send(msg["text"])
@@ -65,26 +78,49 @@ async def _ws_proxy(ws: WebSocket, upstream_url: str) -> None:
65
78
  await upstream.send(msg["bytes"])
66
79
  elif msg["type"] == "websocket.disconnect":
67
80
  break
68
- except Exception as e:
69
- logger.debug(f"Client to upstream connection ended: {e}")
81
+ except OperationAborted:
82
+ pass
83
+ except Exception:
84
+ pass
70
85
 
71
86
  async def upstream_to_client() -> None:
72
87
  try:
73
- async for message in upstream:
88
+ while True:
89
+ message = await wait_or_abort(upstream.recv(), shutdown_event)
74
90
  if isinstance(message, str):
75
91
  await ws.send_text(message)
76
92
  else:
77
93
  await ws.send_bytes(message)
78
- except Exception as e:
79
- logger.debug(f"Upstream to client connection ended: {e}")
80
-
81
- # Pump both directions concurrently
82
- await asyncio.gather(
83
- client_to_upstream(), upstream_to_client(), return_exceptions=True
94
+ except OperationAborted:
95
+ pass
96
+ except Exception:
97
+ pass
98
+
99
+ # Pump both directions concurrently, cancel the peer when one side closes
100
+ t1 = asyncio.create_task(client_to_upstream())
101
+ t2 = asyncio.create_task(upstream_to_client())
102
+ _, pending = await asyncio.wait(
103
+ {t1, t2}, return_when=asyncio.FIRST_COMPLETED
84
104
  )
105
+ for task in pending:
106
+ task.cancel()
107
+ with suppress(asyncio.CancelledError):
108
+ await task
109
+
110
+ # On shutdown, proactively close both sides to break any remaining waits
111
+ if shutdown_event.is_set():
112
+ with suppress(Exception):
113
+ await ws.close()
114
+ with suppress(Exception):
115
+ await upstream.close()
85
116
 
86
117
  except Exception as e:
87
118
  logger.error(f"WebSocket proxy error: {e}")
119
+ # Accept then close so clients (and TestClient) don't error on enter
120
+ with suppress(Exception):
121
+ await ws.accept()
122
+ with suppress(Exception):
123
+ await ws.close()
88
124
  finally:
89
125
  try:
90
126
  await ws.close()
@@ -105,7 +141,7 @@ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
105
141
  path: str | None = None,
106
142
  ) -> None:
107
143
  # Build the upstream WebSocket URL using FastAPI's extracted path parameter
108
- slash_path = f"/{path}" if path else ""
144
+ slash_path = f"/{path}" if path is not None else ""
109
145
  upstream_path = f"/deployments/{name}/ui{slash_path}"
110
146
 
111
147
  # Convert to WebSocket URL
@@ -113,8 +149,6 @@ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
113
149
  if websocket.url.query:
114
150
  upstream_url += f"?{websocket.url.query}"
115
151
 
116
- logger.debug(f"Proxying WebSocket {websocket.url} -> {upstream_url}")
117
-
118
152
  await _ws_proxy(websocket, upstream_url)
119
153
 
120
154
  @deployment_router.api_route(
@@ -167,22 +201,28 @@ def create_ui_proxy_router(name: str, port: int) -> APIRouter:
167
201
  headers=headers,
168
202
  content=request.stream(), # stream uploads
169
203
  )
170
- upstream = await client.send(req, stream=True)
204
+ async with suppress_httpx_logs():
205
+ upstream = await client.send(req, stream=True)
171
206
 
172
207
  resp_headers = {
173
208
  k: v for k, v in upstream.headers.items() if k.lower() not in hop_by_hop
174
209
  }
175
210
 
176
- # Close client when upstream response is done
177
- async def cleanup() -> None:
178
- await upstream.aclose()
179
- await client.aclose()
211
+ # Stream downloads and ensure cleanup in the generator's finally block
212
+ async def upstream_body():
213
+ try:
214
+ async for chunk in upstream.aiter_raw():
215
+ yield chunk
216
+ finally:
217
+ try:
218
+ await upstream.aclose()
219
+ finally:
220
+ await client.aclose()
180
221
 
181
222
  return StreamingResponse(
182
- upstream.aiter_raw(), # stream downloads
223
+ upstream_body(),
183
224
  status_code=upstream.status_code,
184
225
  headers=resp_headers,
185
- background=BackgroundTask(cleanup), # tidy up when finished
186
226
  )
187
227
 
188
228
  except httpx.ConnectError:
@@ -1,3 +1,4 @@
1
+ import configparser
1
2
  import functools
2
3
  import importlib
3
4
  import logging
@@ -5,8 +6,10 @@ import os
5
6
  import socket
6
7
  import subprocess
7
8
  import sys
9
+ from dataclasses import dataclass
8
10
  from importlib.metadata import version as pkg_version
9
11
  from pathlib import Path
12
+ from textwrap import dedent
10
13
 
11
14
  from dotenv import dotenv_values
12
15
  from llama_deploy.appserver.deployment_config_parser import (
@@ -104,7 +107,7 @@ def inject_appserver_into_target(
104
107
  path = settings.resolved_config_parent
105
108
  logger.info(f"Installing ensuring venv at {path} and adding appserver to it")
106
109
  _ensure_uv_available()
107
- _add_appserver_if_missing(path, source_root, sdists=sdists)
110
+ _install_and_add_appserver_if_missing(path, source_root, sdists=sdists)
108
111
 
109
112
 
110
113
  def _get_installed_version_within_target(path: Path) -> Version | None:
@@ -115,9 +118,16 @@ def _get_installed_version_within_target(path: Path) -> Version | None:
115
118
  "run",
116
119
  "python",
117
120
  "-c",
118
- """from importlib.metadata import version; print(version("llama-deploy-appserver"))""",
121
+ dedent("""
122
+ from importlib.metadata import version
123
+ try:
124
+ print(version("llama-deploy-appserver"))
125
+ except Exception:
126
+ pass
127
+ """),
119
128
  ],
120
129
  cwd=path,
130
+ stderr=subprocess.DEVNULL,
121
131
  )
122
132
  try:
123
133
  return Version(result.decode("utf-8").strip())
@@ -142,14 +152,14 @@ def _is_missing_or_outdated(path: Path) -> Version | None:
142
152
  return None
143
153
 
144
154
 
145
- def _add_appserver_if_missing(
155
+ def _install_and_add_appserver_if_missing(
146
156
  path: Path,
147
157
  source_root: Path,
148
158
  save_version: bool = False,
149
159
  sdists: list[Path] | None = None,
150
160
  ) -> None:
151
161
  """
152
- Add the appserver to the venv if it's not already there.
162
+ Ensure venv, install project deps, and add the appserver to the venv if it's missing or outdated
153
163
  """
154
164
 
155
165
  if not (source_root / path / "pyproject.toml").exists():
@@ -158,7 +168,10 @@ def _add_appserver_if_missing(
158
168
  )
159
169
  return
160
170
 
161
- def run_uv(cmd: str, args: list[str]):
171
+ def run_uv(cmd: str, args: list[str] = [], extra_env: dict[str, str] | None = None):
172
+ env = os.environ.copy()
173
+ if extra_env:
174
+ env.update(extra_env)
162
175
  run_process(
163
176
  ["uv", cmd] + args,
164
177
  cwd=source_root / path,
@@ -166,6 +179,7 @@ def _add_appserver_if_missing(
166
179
  color_code="36",
167
180
  use_tty=False,
168
181
  line_transform=_exclude_venv_warning,
182
+ env=env,
169
183
  )
170
184
 
171
185
  def ensure_venv(path: Path, force: bool = False) -> Path:
@@ -174,14 +188,30 @@ def _add_appserver_if_missing(
174
188
  run_uv("venv", [str(venv_path)])
175
189
  return venv_path
176
190
 
191
+ editable = are_we_editable_mode()
192
+ venv_path = ensure_venv(path, force=editable)
193
+ run_uv(
194
+ "sync",
195
+ ["--no-dev", "--inexact"],
196
+ extra_env={"UV_PROJECT_ENVIRONMENT": str(venv_path)},
197
+ )
198
+
177
199
  if sdists:
178
200
  run_uv(
179
201
  "pip",
180
202
  ["install"]
181
203
  + [str(s.absolute()) for s in sdists]
182
- + ["--prefix", str(ensure_venv(path))],
204
+ + ["--prefix", str(venv_path)],
183
205
  )
184
206
  elif are_we_editable_mode():
207
+ same_python_version = _same_python_version(venv_path)
208
+ if not same_python_version.is_same:
209
+ logger.error(
210
+ f"Python version mismatch. Current: {same_python_version.current_version} != Project: {same_python_version.target_version}. During development, the target environment must be running the same Python version, otherwise the appserver cannot be installed."
211
+ )
212
+ raise RuntimeError(
213
+ f"Python version mismatch. Current: {same_python_version.current_version} != Project: {same_python_version.target_version}"
214
+ )
185
215
  pyproject = _find_development_pyproject()
186
216
  if pyproject is None:
187
217
  raise RuntimeError("No pyproject.toml found in llama-deploy-appserver")
@@ -195,7 +225,7 @@ def _add_appserver_if_missing(
195
225
  "llama-deploy-appserver",
196
226
  target,
197
227
  "--prefix",
198
- str(ensure_venv(path, force=True)),
228
+ str(venv_path),
199
229
  ],
200
230
  )
201
231
 
@@ -211,7 +241,7 @@ def _add_appserver_if_missing(
211
241
  "install",
212
242
  f"llama-deploy-appserver=={version}",
213
243
  "--prefix",
214
- str(ensure_venv(path)),
244
+ str(venv_path),
215
245
  ],
216
246
  )
217
247
 
@@ -262,16 +292,53 @@ def _ensure_uv_available() -> None:
262
292
  raise RuntimeError(msg)
263
293
 
264
294
 
295
+ @dataclass
296
+ class SamePythonVersionResult:
297
+ is_same: bool
298
+ current_version: str
299
+ target_version: str | None
300
+
301
+
302
+ def _same_python_version(venv_path: Path) -> SamePythonVersionResult:
303
+ current_version = f"{sys.version_info.major}.{sys.version_info.minor}"
304
+ target_version = None
305
+ cfg = venv_path / "pyvenv.cfg"
306
+ if cfg.exists():
307
+ parser = configparser.ConfigParser()
308
+ parser.read_string("[venv]\n" + cfg.read_text())
309
+ ver_str = parser["venv"].get("version_info", "").strip()
310
+ if ver_str:
311
+ try:
312
+ v = Version(ver_str)
313
+ target_version = f"{v.major}.{v.minor}"
314
+ except InvalidVersion:
315
+ pass
316
+ return SamePythonVersionResult(
317
+ is_same=current_version == target_version,
318
+ current_version=current_version,
319
+ target_version=target_version,
320
+ )
321
+
322
+
265
323
  def install_ui(config: DeploymentConfig, config_parent: Path) -> None:
266
324
  if config.ui is None:
267
325
  return
268
326
  package_manager = config.ui.package_manager
269
- run_process(
270
- [package_manager, "install"],
271
- cwd=config_parent / config.ui.directory,
272
- prefix=f"[{package_manager} install]",
273
- color_code="33",
274
- )
327
+ try:
328
+ run_process(
329
+ [package_manager, "install"],
330
+ cwd=config_parent / config.ui.directory,
331
+ prefix=f"[{package_manager} install]",
332
+ color_code="33",
333
+ # auto download the package manager
334
+ env={**os.environ.copy(), "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0"},
335
+ )
336
+ except BaseException as e:
337
+ if "No such file or directory" in str(e):
338
+ raise RuntimeError(
339
+ f"Package manager {package_manager} not found. Please download and enable corepack, or install the package manager manually."
340
+ )
341
+ raise e
275
342
 
276
343
 
277
344
  def _ui_env(config: DeploymentConfig, settings: ApiserverSettings) -> dict[str, str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: llama-deploy-appserver
3
- Version: 0.3.0a8
3
+ Version: 0.3.0a10
4
4
  Summary: Application server components for LlamaDeploy
5
5
  Author: Massimiliano Pippi
6
6
  Author-email: Massimiliano Pippi <mpippi@gmail.com>
@@ -10,10 +10,12 @@ Requires-Dist: pydantic-settings>=2.10.1
10
10
  Requires-Dist: uvicorn>=0.24.0
11
11
  Requires-Dist: fastapi>=0.100.0
12
12
  Requires-Dist: websockets>=12.0
13
- Requires-Dist: llama-deploy-core>=0.3.0a8,<0.4.0
13
+ Requires-Dist: llama-deploy-core>=0.3.0a10,<0.4.0
14
14
  Requires-Dist: httpx>=0.28.1
15
15
  Requires-Dist: prometheus-fastapi-instrumentator>=7.1.0
16
16
  Requires-Dist: packaging>=25.0
17
+ Requires-Dist: structlog>=25.4.0
18
+ Requires-Dist: rich>=14.1.0
17
19
  Requires-Python: >=3.12, <4
18
20
  Description-Content-Type: text/markdown
19
21
 
@@ -0,0 +1,21 @@
1
+ llama_deploy/appserver/__init__.py,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
2
+ llama_deploy/appserver/app.py,sha256=5892f34498944e5cee5c8ffe1424f540310b313981732cacb40d4de5c5e91d0d,9486
3
+ llama_deploy/appserver/bootstrap.py,sha256=fa32be007f18b4b3af92c878bac417416c9afb09b1beddf51b5cd73115e6b7c6,2453
4
+ llama_deploy/appserver/configure_logging.py,sha256=194dd1ebed3c1d9065d9174f7828d557a577eaac8fb0443b3102430b1f578c19,6329
5
+ llama_deploy/appserver/correlation_id.py,sha256=8ac5bc6160c707b93a9fb818b64dd369a4ef7a53f9f91a6b3d90c4cf446f7327,572
6
+ llama_deploy/appserver/deployment.py,sha256=1a7c75d12abbf7c93d1c2ab791cedfe5431a36a6f7a0d3642d487f8b6336206d,2950
7
+ llama_deploy/appserver/deployment_config_parser.py,sha256=e2b6c483203d96ab795c4e55df15c694c20458d5a03fab89c2b71e481291a2d3,510
8
+ llama_deploy/appserver/interrupts.py,sha256=14f262a0cedc00bb3aecd3d6c14c41ba0e88e7d2a6df02cd35b5bea1940822a2,1622
9
+ llama_deploy/appserver/process_utils.py,sha256=befee4918c6cf72082dca8bf807afb61ad3d6c83f01bc0c007594b47930570d8,6056
10
+ llama_deploy/appserver/py.typed,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
11
+ llama_deploy/appserver/routers/__init__.py,sha256=ee2d14ebf4b067c844947ed1cc98186456e8bfa4919282722eaaf8cca345a138,214
12
+ llama_deploy/appserver/routers/deployments.py,sha256=510b6f22118256ce9b8ba6a116ecd21f5d5e052a3a300ce60e0ce0afe135b9e3,7257
13
+ llama_deploy/appserver/routers/status.py,sha256=eead8e0aebbc7e5e3ca8f0c00d0c1b6df1d6cde7844edfbe9350bf64ab85006f,257
14
+ llama_deploy/appserver/routers/ui_proxy.py,sha256=f63c36c201070594a4011320192d724b1c534d0ec655c49ba65c4e9911dbdd97,8633
15
+ llama_deploy/appserver/settings.py,sha256=7f1f481216b29614a94783c81cb49f0790d66e9e0cacef407da4ed3c8fcbbeeb,3484
16
+ llama_deploy/appserver/stats.py,sha256=1f3989f6705a6de3e4d61ee8cdd189fbe04a2c53ec5e720b2e5168acc331427f,691
17
+ llama_deploy/appserver/types.py,sha256=4edc991aafb6b8497f068d12387455df292da3ff8440223637641ab1632553ec,2133
18
+ llama_deploy/appserver/workflow_loader.py,sha256=88510be8bd74159a3397a0136198b51e56741a7fca7554ad0ee5a42df00f7b23,13853
19
+ llama_deploy_appserver-0.3.0a10.dist-info/WHEEL,sha256=66530aef82d5020ef5af27ae0123c71abb9261377c5bc519376c671346b12918,79
20
+ llama_deploy_appserver-0.3.0a10.dist-info/METADATA,sha256=8cdb485dda545d9283c568cb74069471388086edcc5fa866778736b40c5f03e2,848
21
+ llama_deploy_appserver-0.3.0a10.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- import uvicorn
2
-
3
- from .settings import settings
4
-
5
- if __name__ == "__main__":
6
- uvicorn.run(
7
- "llama_deploy.appserver.app:app",
8
- host=settings.host,
9
- port=settings.port,
10
- )
@@ -1,18 +0,0 @@
1
- llama_deploy/appserver/__init__.py,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
2
- llama_deploy/appserver/__main__.py,sha256=32eff329cadb4f883c9df3a1b2bcb908d5adde765e7c7e761d25b7df4827b9ca,196
3
- llama_deploy/appserver/app.py,sha256=e127cfde4204e84a8f00289a624ee35f3b16ea6686b6935b873da6ba4020df5d,8527
4
- llama_deploy/appserver/bootstrap.py,sha256=fa32be007f18b4b3af92c878bac417416c9afb09b1beddf51b5cd73115e6b7c6,2453
5
- llama_deploy/appserver/deployment.py,sha256=1a7c75d12abbf7c93d1c2ab791cedfe5431a36a6f7a0d3642d487f8b6336206d,2950
6
- llama_deploy/appserver/deployment_config_parser.py,sha256=e2b6c483203d96ab795c4e55df15c694c20458d5a03fab89c2b71e481291a2d3,510
7
- llama_deploy/appserver/process_utils.py,sha256=22ca4db8f5df489fdfcc1859ad47674c0a77a03e1de56966bf936c3b256dd73f,5954
8
- llama_deploy/appserver/routers/__init__.py,sha256=ee2d14ebf4b067c844947ed1cc98186456e8bfa4919282722eaaf8cca345a138,214
9
- llama_deploy/appserver/routers/deployments.py,sha256=510b6f22118256ce9b8ba6a116ecd21f5d5e052a3a300ce60e0ce0afe135b9e3,7257
10
- llama_deploy/appserver/routers/status.py,sha256=eead8e0aebbc7e5e3ca8f0c00d0c1b6df1d6cde7844edfbe9350bf64ab85006f,257
11
- llama_deploy/appserver/routers/ui_proxy.py,sha256=5742f6d5d8cc6cd9a180d579a98e165f709e3db80f6413d1c127d4f7263147fa,7169
12
- llama_deploy/appserver/settings.py,sha256=7f1f481216b29614a94783c81cb49f0790d66e9e0cacef407da4ed3c8fcbbeeb,3484
13
- llama_deploy/appserver/stats.py,sha256=1f3989f6705a6de3e4d61ee8cdd189fbe04a2c53ec5e720b2e5168acc331427f,691
14
- llama_deploy/appserver/types.py,sha256=4edc991aafb6b8497f068d12387455df292da3ff8440223637641ab1632553ec,2133
15
- llama_deploy/appserver/workflow_loader.py,sha256=fbd98790524104014a0c329d368f48c3072207f80a008201c76d67993b3a65dc,11221
16
- llama_deploy_appserver-0.3.0a8.dist-info/WHEEL,sha256=66530aef82d5020ef5af27ae0123c71abb9261377c5bc519376c671346b12918,79
17
- llama_deploy_appserver-0.3.0a8.dist-info/METADATA,sha256=d0b6504ea82cf6761f24eba33bb211966f7a5769fb9b40ca148740a79a2ce519,785
18
- llama_deploy_appserver-0.3.0a8.dist-info/RECORD,,