vlmparse 0.1.8__py3-none-any.whl → 0.1.10__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.
@@ -0,0 +1,489 @@
1
+ import getpass
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import tempfile
6
+ import threading
7
+ import time
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ import docker
13
+ from loguru import logger
14
+
15
+ if TYPE_CHECKING:
16
+ from .base_server import BaseServerConfig
17
+
18
+
19
+ def _sanitize_compose_project_name(name: str, fallback: str = "vlmparse") -> str:
20
+ """Return a Docker Compose-compatible project name.
21
+
22
+ Compose requires only lowercase letters, numbers, hyphens, and underscores,
23
+ and the name must start with a letter or number.
24
+ """
25
+
26
+ if not name:
27
+ return fallback
28
+
29
+ sanitized = re.sub(r"[^a-z0-9_-]+", "-", name.lower())
30
+ sanitized = re.sub(r"^[^a-z0-9]+", "", sanitized)
31
+ sanitized = sanitized.strip("-_")
32
+
33
+ return sanitized or fallback
34
+
35
+
36
+ def _build_compose_override_yaml(config: "BaseServerConfig") -> str | None:
37
+ services_overrides: dict[str, dict] = {}
38
+
39
+ service_names = config.compose_services or [config.server_service]
40
+
41
+ # Labels for model/uri inference (match docker_server behavior)
42
+ uri = f"http://localhost:{config.docker_port}{config.get_base_url_suffix()}"
43
+ if config.gpu_device_ids is None:
44
+ gpu_label = "0"
45
+ elif len(config.gpu_device_ids) == 0 or (
46
+ len(config.gpu_device_ids) == 1 and config.gpu_device_ids[0] == ""
47
+ ):
48
+ gpu_label = "cpu"
49
+ else:
50
+ gpu_label = ",".join(config.gpu_device_ids)
51
+ if config.server_service:
52
+ services_overrides.setdefault(config.server_service, {}).setdefault(
53
+ "labels", {}
54
+ ).update(
55
+ {
56
+ "vlmparse_model_name": config.model_name,
57
+ "vlmparse_uri": uri,
58
+ "vlmparse_gpus": gpu_label,
59
+ "vlmparse_deployment": "docker_compose",
60
+ "vlmparse_compose_project": str(config.compose_project_name or ""),
61
+ "vlmparse_compose_file": str(config.compose_file or ""),
62
+ "vlmparse_compose_server_service": str(config.server_service or ""),
63
+ }
64
+ )
65
+
66
+ # Port override for the server service
67
+ if config.server_service:
68
+ services_overrides.setdefault(config.server_service, {})["ports"] = [
69
+ f"{config.docker_port}:{config.container_port}"
70
+ ]
71
+
72
+ # Environment overrides
73
+ environment = config.get_environment()
74
+ if environment:
75
+ env_targets = (
76
+ config.environment_services
77
+ if config.environment_services is not None
78
+ else [config.server_service]
79
+ )
80
+ for service in env_targets:
81
+ services_overrides.setdefault(service, {})["environment"] = environment
82
+
83
+ # GPU overrides
84
+ if config.gpu_device_ids is not None:
85
+ gpu_targets = (
86
+ config.gpu_service_names
87
+ if config.gpu_service_names is not None
88
+ else service_names
89
+ )
90
+
91
+ if len(config.gpu_device_ids) == 0 or (
92
+ len(config.gpu_device_ids) == 1 and config.gpu_device_ids[0] == ""
93
+ ):
94
+ devices_value = []
95
+ else:
96
+ devices_value = [
97
+ {
98
+ "driver": "nvidia",
99
+ "device_ids": config.gpu_device_ids,
100
+ "capabilities": ["gpu"],
101
+ }
102
+ ]
103
+
104
+ for service in gpu_targets:
105
+ services_overrides.setdefault(service, {}).setdefault("deploy", {})
106
+ services_overrides[service]["deploy"].setdefault("resources", {})
107
+ services_overrides[service]["deploy"]["resources"].setdefault(
108
+ "reservations", {}
109
+ )
110
+ services_overrides[service]["deploy"]["resources"]["reservations"][
111
+ "devices"
112
+ ] = devices_value
113
+
114
+ if not services_overrides:
115
+ return None
116
+
117
+ # Manual YAML rendering for the limited structure we need.
118
+ lines = ["services:"]
119
+ for service, overrides in services_overrides.items():
120
+ lines.append(f" {service}:")
121
+
122
+ if "ports" in overrides:
123
+ # Compose override files normally merge/append list fields (like ports).
124
+ # Use !override so we replace the base compose list instead of adding
125
+ # extra published ports.
126
+ lines.append(" ports: !override")
127
+ for port in overrides["ports"]:
128
+ lines.append(f' - "{port}"')
129
+
130
+ if "environment" in overrides:
131
+ lines.append(" environment:")
132
+ for key, value in overrides["environment"].items():
133
+ lines.append(f' {key}: "{value}"')
134
+
135
+ if "labels" in overrides:
136
+ lines.append(" labels:")
137
+ for key, value in overrides["labels"].items():
138
+ lines.append(f' {key}: "{value}"')
139
+
140
+ if "deploy" in overrides:
141
+ lines.append(" deploy:")
142
+ lines.append(" resources:")
143
+ lines.append(" reservations:")
144
+ # Same story as ports: ensure we replace the base list.
145
+ lines.append(" devices: !override")
146
+ devices = overrides["deploy"]["resources"]["reservations"].get("devices")
147
+ if not devices:
148
+ lines.append(" []")
149
+ else:
150
+ for device in devices:
151
+ lines.append(" - driver: nvidia")
152
+ lines.append(" device_ids:")
153
+ for device_id in device.get("device_ids", []):
154
+ lines.append(f' - "{device_id}"')
155
+ lines.append(" capabilities:")
156
+ for cap in device.get("capabilities", []):
157
+ lines.append(f" - {cap}")
158
+
159
+ return "\n".join(lines) + "\n"
160
+
161
+
162
+ def _get_compose_container(
163
+ client: docker.DockerClient, project_name: str, service: str
164
+ ):
165
+ containers = client.containers.list(
166
+ all=True,
167
+ filters={
168
+ "label": [
169
+ f"com.docker.compose.project={project_name}",
170
+ f"com.docker.compose.service={service}",
171
+ ]
172
+ },
173
+ )
174
+ if not containers:
175
+ return None
176
+ return containers[0]
177
+
178
+
179
+ def _start_compose_logs_stream(
180
+ compose_cmd: list[str],
181
+ *,
182
+ env: dict | None,
183
+ model_name: str,
184
+ services: list[str] | None = None,
185
+ tail: int = 200,
186
+ ) -> tuple[subprocess.Popen[str], threading.Event, threading.Thread] | None:
187
+ """Stream `docker compose logs -f` output into loguru.
188
+
189
+ This is useful for compose stacks where the actual failure happens in a
190
+ dependency service (db, redis, etc). Output is forwarded line-by-line so it
191
+ shows up similarly to the single-container `docker_server` startup logs.
192
+
193
+ Returns a (process, stop_event, thread) triple, or None if the stream can't
194
+ be started.
195
+ """
196
+
197
+ cmd = list(compose_cmd) + [
198
+ "logs",
199
+ "--no-color",
200
+ "--follow",
201
+ "--tail",
202
+ str(tail),
203
+ ]
204
+ if services:
205
+ cmd.extend([s for s in services if s])
206
+
207
+ try:
208
+ proc: subprocess.Popen[str] = subprocess.Popen(
209
+ cmd,
210
+ stdout=subprocess.PIPE,
211
+ stderr=subprocess.STDOUT,
212
+ text=True,
213
+ env=env,
214
+ bufsize=1,
215
+ )
216
+ except Exception as e:
217
+ logger.debug(f"Failed to start docker compose log stream: {e}")
218
+ return None
219
+
220
+ stop_event = threading.Event()
221
+
222
+ def _pump_logs() -> None:
223
+ try:
224
+ if proc.stdout is None:
225
+ return
226
+ for line in proc.stdout:
227
+ if stop_event.is_set():
228
+ break
229
+ line = line.rstrip("\n")
230
+ if line.strip():
231
+ # Compose already prefixes with service name; keep a stable
232
+ # model prefix so users can correlate when multiplexed.
233
+ logger.info(f"[{model_name}] {line}")
234
+ except Exception as e:
235
+ logger.debug(f"Compose log stream stopped: {e}")
236
+ finally:
237
+ try:
238
+ if proc.stdout is not None:
239
+ proc.stdout.close()
240
+ except Exception:
241
+ pass
242
+
243
+ thread = threading.Thread(target=_pump_logs, name="compose-logs", daemon=True)
244
+ thread.start()
245
+ return proc, stop_event, thread
246
+
247
+
248
+ def _stop_compose_logs_stream(
249
+ stream: tuple[subprocess.Popen[str], threading.Event, threading.Thread] | None,
250
+ *,
251
+ timeout: float = 2.0,
252
+ ) -> None:
253
+ if stream is None:
254
+ return
255
+
256
+ proc, stop_event, thread = stream
257
+ stop_event.set()
258
+ try:
259
+ proc.terminate()
260
+ except Exception:
261
+ pass
262
+ try:
263
+ proc.wait(timeout=timeout)
264
+ except Exception:
265
+ try:
266
+ proc.kill()
267
+ except Exception:
268
+ pass
269
+ thread.join(timeout=timeout)
270
+
271
+
272
+ @contextmanager
273
+ def docker_compose_server(
274
+ config: "BaseServerConfig",
275
+ timeout: int = 1000,
276
+ cleanup: bool = True,
277
+ ):
278
+ """Generic context manager for Docker Compose server deployment.
279
+
280
+ Args:
281
+ config: DockerComposeServerConfig
282
+ timeout: Timeout in seconds to wait for server to be ready
283
+ cleanup: If True, stop and remove containers on exit. If False, leave running
284
+
285
+ Yields:
286
+ tuple: (base_url, container) - The base URL of the server and the container object
287
+ """
288
+
289
+ compose_file = Path(config.compose_file).expanduser().resolve()
290
+ if not compose_file.exists():
291
+ raise FileNotFoundError(f"Compose file not found at {compose_file}")
292
+
293
+ project_name = _sanitize_compose_project_name(
294
+ config.compose_project_name
295
+ or f"vlmparse-{config.model_name.replace('/', '-')}-{getpass.getuser()}"
296
+ )
297
+
298
+ # Persist resolved name so it can be attached as a label via the override YAML.
299
+ # This allows `vlmparse stop` to bring down the full compose stack.
300
+ config.compose_project_name = project_name
301
+
302
+ base_cmd = [
303
+ "docker",
304
+ "compose",
305
+ "-f",
306
+ str(compose_file),
307
+ "--project-name",
308
+ project_name,
309
+ ]
310
+
311
+ client = docker.from_env()
312
+ container = None
313
+
314
+ override_content = _build_compose_override_yaml(config)
315
+ compose_env = config.get_compose_env()
316
+ env = None
317
+ if compose_env:
318
+ env = os.environ.copy()
319
+ env.update(compose_env)
320
+
321
+ with tempfile.TemporaryDirectory() as temp_dir:
322
+ override_file = None
323
+ if override_content:
324
+ override_file = Path(temp_dir) / "compose.override.yaml"
325
+ override_file.write_text(override_content)
326
+
327
+ compose_cmd = list(base_cmd)
328
+ if override_file is not None:
329
+ compose_cmd.extend(["-f", str(override_file)])
330
+
331
+ logs_stream = None
332
+
333
+ try:
334
+ logger.info(
335
+ f"Starting Docker Compose for {config.model_name} on port {config.docker_port}"
336
+ )
337
+
338
+ try:
339
+ subprocess.run(
340
+ compose_cmd + ["up", "-d"],
341
+ check=True,
342
+ capture_output=True,
343
+ text=True,
344
+ env=env,
345
+ )
346
+ except subprocess.CalledProcessError as e:
347
+ if e.stdout:
348
+ logger.error(
349
+ f"Docker Compose stdout for {config.model_name}:\n{e.stdout}"
350
+ )
351
+ if e.stderr:
352
+ logger.error(
353
+ f"Docker Compose stderr for {config.model_name}:\n{e.stderr}"
354
+ )
355
+
356
+ # Best-effort diagnostics: container status and last logs.
357
+ # This is especially helpful when dependencies crash quickly (e.g. exit 137).
358
+ try:
359
+ ps_res = subprocess.run(
360
+ compose_cmd + ["ps"],
361
+ check=False,
362
+ capture_output=True,
363
+ text=True,
364
+ env=env,
365
+ )
366
+ if ps_res.stdout:
367
+ logger.error(
368
+ f"Docker Compose ps for {config.model_name}:\n{ps_res.stdout}"
369
+ )
370
+ if ps_res.stderr:
371
+ logger.error(
372
+ f"Docker Compose ps stderr for {config.model_name}:\n{ps_res.stderr}"
373
+ )
374
+
375
+ logs_res = subprocess.run(
376
+ compose_cmd + ["logs", "--no-color", "--tail", "200"],
377
+ check=False,
378
+ capture_output=True,
379
+ text=True,
380
+ env=env,
381
+ )
382
+ if logs_res.stdout:
383
+ logger.error(
384
+ f"Docker Compose logs (tail=200) for {config.model_name}:\n{logs_res.stdout}"
385
+ )
386
+ if logs_res.stderr:
387
+ logger.error(
388
+ f"Docker Compose logs stderr for {config.model_name}:\n{logs_res.stderr}"
389
+ )
390
+ except Exception as diag_error:
391
+ logger.warning(
392
+ f"Failed to collect docker compose diagnostics: {diag_error}"
393
+ )
394
+ raise
395
+
396
+ logger.info("Compose stack started, waiting for server to be ready...")
397
+
398
+ # Stream logs for the whole compose stack (or requested services).
399
+ # This mirrors the visibility you get with `docker_server` while
400
+ # being much more useful for multi-service compose deployments.
401
+ service_names = (
402
+ config.compose_services
403
+ if config.compose_services
404
+ else ([config.server_service] if config.server_service else None)
405
+ )
406
+ logs_stream = _start_compose_logs_stream(
407
+ compose_cmd,
408
+ env=env,
409
+ model_name=config.model_name,
410
+ services=service_names,
411
+ tail=200,
412
+ )
413
+
414
+ start_time = time.time()
415
+ server_ready = False
416
+ last_log_position = 0
417
+
418
+ while time.time() - start_time < timeout:
419
+ container = _get_compose_container(
420
+ client, project_name, config.server_service
421
+ )
422
+
423
+ if container is None:
424
+ time.sleep(2)
425
+ continue
426
+
427
+ try:
428
+ container.reload()
429
+ except docker.errors.NotFound as e:
430
+ logger.error("Container stopped unexpectedly during startup")
431
+ raise RuntimeError(
432
+ "Container crashed during initialization. Check Docker logs for details."
433
+ ) from e
434
+
435
+ if container.status == "running":
436
+ all_logs = container.logs().decode("utf-8")
437
+ # Keep the old log-position tracker for readiness checks.
438
+ # Actual log visibility is handled by the compose log stream.
439
+ if len(all_logs) > last_log_position:
440
+ last_log_position = len(all_logs)
441
+
442
+ for indicator in config.server_ready_indicators:
443
+ if indicator in all_logs:
444
+ server_ready = True
445
+ break
446
+
447
+ if not server_ready:
448
+ health = container.attrs.get("State", {}).get("Health", {})
449
+ if health.get("Status") == "healthy":
450
+ server_ready = True
451
+
452
+ if server_ready:
453
+ logger.info(
454
+ f"Server ready indicator found for service '{config.server_service}'"
455
+ )
456
+ break
457
+
458
+ time.sleep(2)
459
+
460
+ if not server_ready:
461
+ raise TimeoutError(
462
+ f"Server did not become ready within {timeout} seconds"
463
+ )
464
+
465
+ base_url = (
466
+ f"http://localhost:{config.docker_port}{config.get_base_url_suffix()}"
467
+ )
468
+ logger.info(f"{config.model_name} server ready at {base_url}")
469
+
470
+ # Deployment phase is over: stop log streaming so we don't
471
+ # continuously forward runtime logs (caller can still fetch logs
472
+ # explicitly via Docker / CLI if needed).
473
+ _stop_compose_logs_stream(logs_stream)
474
+ logs_stream = None
475
+
476
+ yield base_url, container
477
+
478
+ finally:
479
+ _stop_compose_logs_stream(logs_stream)
480
+ if cleanup:
481
+ logger.info("Stopping Docker Compose stack")
482
+ subprocess.run(
483
+ compose_cmd + ["down"],
484
+ check=False,
485
+ capture_output=True,
486
+ text=True,
487
+ env=env,
488
+ )
489
+ logger.info("Compose stack stopped")
@@ -0,0 +1,39 @@
1
+ from pydantic import Field
2
+
3
+ from .base_server import BaseServer, BaseServerConfig
4
+ from .docker_compose_deployment import docker_compose_server
5
+
6
+
7
+ class DockerComposeServerConfig(BaseServerConfig):
8
+ """Configuration for deploying a Docker Compose server.
9
+
10
+ Inherits from BaseServerConfig which provides common server configuration.
11
+ """
12
+
13
+ compose_file: str
14
+ server_service: str
15
+ compose_services: list[str] = Field(default_factory=list)
16
+ compose_project_name: str | None = None
17
+ compose_env: dict[str, str] = Field(default_factory=dict)
18
+ gpu_service_names: list[str] | None = None
19
+ environment_services: list[str] | None = None
20
+
21
+ @property
22
+ def client_config(self):
23
+ """Override in subclasses to return appropriate client config."""
24
+ raise NotImplementedError
25
+
26
+ def get_server(self, auto_stop: bool = True):
27
+ return ComposeServer(config=self, auto_stop=auto_stop)
28
+
29
+ def get_compose_env(self) -> dict | None:
30
+ """Environment variables for docker compose command substitution."""
31
+ return self.compose_env if self.compose_env else None
32
+
33
+
34
+ class ComposeServer(BaseServer):
35
+ """Manages Docker Compose server lifecycle with start/stop methods."""
36
+
37
+ def _create_server_context(self):
38
+ """Create the Docker Compose server context."""
39
+ return docker_compose_server(config=self.config, cleanup=self.auto_stop)