llama-deploy-appserver 0.3.0a8__tar.gz → 0.3.0a10__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.
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/PKG-INFO +4 -2
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/pyproject.toml +4 -2
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/app.py +27 -1
- llama_deploy_appserver-0.3.0a10/src/llama_deploy/appserver/configure_logging.py +189 -0
- llama_deploy_appserver-0.3.0a10/src/llama_deploy/appserver/correlation_id.py +24 -0
- llama_deploy_appserver-0.3.0a10/src/llama_deploy/appserver/interrupts.py +55 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/process_utils.py +5 -1
- llama_deploy_appserver-0.3.0a10/src/llama_deploy/appserver/py.typed +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/routers/ui_proxy.py +77 -37
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/workflow_loader.py +81 -14
- llama_deploy_appserver-0.3.0a8/src/llama_deploy/appserver/__main__.py +0 -10
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/README.md +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/__init__.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/bootstrap.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/deployment.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/deployment_config_parser.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/routers/__init__.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/routers/deployments.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/routers/status.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/settings.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/stats.py +0 -0
- {llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/types.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: llama-deploy-appserver
|
3
|
-
Version: 0.3.
|
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.
|
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
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "llama-deploy-appserver"
|
3
|
-
version = "0.3.
|
3
|
+
version = "0.3.0a10"
|
4
4
|
description = "Application server components for LlamaDeploy"
|
5
5
|
readme = "README.md"
|
6
6
|
license = { text = "MIT" }
|
@@ -14,10 +14,12 @@ dependencies = [
|
|
14
14
|
"uvicorn>=0.24.0",
|
15
15
|
"fastapi>=0.100.0",
|
16
16
|
"websockets>=12.0",
|
17
|
-
"llama-deploy-core>=0.3.
|
17
|
+
"llama-deploy-core>=0.3.0a10,<0.4.0",
|
18
18
|
"httpx>=0.28.1",
|
19
19
|
"prometheus-fastapi-instrumentator>=7.1.0",
|
20
20
|
"packaging>=25.0",
|
21
|
+
"structlog>=25.4.0",
|
22
|
+
"rich>=14.1.0",
|
21
23
|
]
|
22
24
|
|
23
25
|
[build-system]
|
{llama_deploy_appserver-0.3.0a8 → llama_deploy_appserver-0.3.0a10}/src/llama_deploy/appserver/app.py
RENAMED
@@ -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
|
-
|
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 = [
|
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[
|
41
|
-
|
42
|
-
|
43
|
-
subprotocols
|
44
|
-
|
45
|
-
|
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=
|
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
|
69
|
-
|
81
|
+
except OperationAborted:
|
82
|
+
pass
|
83
|
+
except Exception:
|
84
|
+
pass
|
70
85
|
|
71
86
|
async def upstream_to_client() -> None:
|
72
87
|
try:
|
73
|
-
|
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
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
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
|
-
|
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
|
-
#
|
177
|
-
async def
|
178
|
-
|
179
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
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(
|
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(
|
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(
|
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
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
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]:
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|