ls-algorithm-plugin-sdk 0.2.5__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,52 @@
1
+ from .algorithm import Algorithm
2
+ from .context import (
3
+ DatasetProgress,
4
+ EpisodeProgress,
5
+ ExecutionContext,
6
+ ProgressSnapshot
7
+ )
8
+ from .deployment import DeploymentConfig
9
+ from .errors import (
10
+ AlgorithmLoadError,
11
+ AlgorithmPluginError,
12
+ ExecutionCancelled,
13
+ InvalidAlgorithmResult,
14
+ InvalidRequest
15
+ )
16
+ from .loader import load_algorithm
17
+ from .models import (
18
+ AlgorithmInput,
19
+ AlgorithmMetadata,
20
+ AlgorithmRequest,
21
+ AlgorithmResult,
22
+ DatasetResult
23
+ )
24
+ from .registration import PluginRegistrationAgent, RegistrationSettings
25
+ from .release import ReleaseManifest
26
+ from .runner import AlgorithmRunner
27
+ from .service import ExecutionManager
28
+
29
+ __all__ = [
30
+ "Algorithm",
31
+ "AlgorithmInput",
32
+ "AlgorithmLoadError",
33
+ "AlgorithmMetadata",
34
+ "AlgorithmPluginError",
35
+ "AlgorithmRequest",
36
+ "AlgorithmResult",
37
+ "AlgorithmRunner",
38
+ "DeploymentConfig",
39
+ "DatasetProgress",
40
+ "DatasetResult",
41
+ "EpisodeProgress",
42
+ "ExecutionCancelled",
43
+ "ExecutionContext",
44
+ "ExecutionManager",
45
+ "InvalidAlgorithmResult",
46
+ "InvalidRequest",
47
+ "PluginRegistrationAgent",
48
+ "ProgressSnapshot",
49
+ "RegistrationSettings",
50
+ "ReleaseManifest",
51
+ "load_algorithm",
52
+ ]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from .context import ExecutionContext
6
+ from .models import AlgorithmMetadata, AlgorithmRequest, AlgorithmResult
7
+
8
+
9
+ class Algorithm(ABC):
10
+ """The only runtime contract an algorithm package must implement."""
11
+
12
+ @classmethod
13
+ @abstractmethod
14
+ def metadata(cls) -> AlgorithmMetadata:
15
+ """Describe the algorithm without loading models or datasets."""
16
+
17
+ def startup(self) -> None:
18
+ """Initialize process-level resources before the first execution."""
19
+
20
+ @abstractmethod
21
+ def execute(
22
+ self,
23
+ request: AlgorithmRequest,
24
+ context: ExecutionContext,
25
+ ) -> AlgorithmResult:
26
+ """Run all datasets and return their final results.
27
+
28
+ Algorithms own dataset/GPU scheduling and optional merging. Concurrent
29
+ workers report live progress through ``context.report_progress()``.
30
+ """
31
+
32
+ def shutdown(self) -> None:
33
+ """Release process-level resources during service shutdown."""
@@ -0,0 +1,542 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from dataclasses import replace
6
+ import logging
7
+ import os
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ from contextlib import ExitStack, contextmanager, redirect_stderr, redirect_stdout
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .context import ExecutionContext, ProgressSnapshot
18
+ from .deployment import DEFAULT_CONFIG_NAME, DeploymentConfig, repository_root
19
+ from .errors import ExecutionCancelled
20
+ from .loader import load_algorithm
21
+ from .models import AlgorithmRequest
22
+ from .registration import PluginRegistrationAgent
23
+ from .release import ReleaseManifest
24
+ from .runner import AlgorithmRunner
25
+ from .service import (
26
+ MAX_ATTEMPTS,
27
+ RETRY_JITTER_RATIO,
28
+ RETRY_SECONDS,
29
+ ExecutionManager,
30
+ run_server
31
+ )
32
+
33
+
34
+ def _env_bool(name: str, default: bool = False) -> bool:
35
+ value = os.getenv(name)
36
+ if value is None:
37
+ return default
38
+ return value.strip().lower() in {"1", "true", "yes", "on"}
39
+
40
+
41
+ def _json_object(value: str) -> dict[str, Any]:
42
+ try:
43
+ parsed = json.loads(value)
44
+ except json.JSONDecodeError as exc:
45
+ raise argparse.ArgumentTypeError(f"invalid JSON: {exc}") from exc
46
+ if not isinstance(parsed, dict):
47
+ raise argparse.ArgumentTypeError("value must be a JSON object")
48
+ return parsed
49
+
50
+
51
+ def _gpu_ids(value: str) -> list[int]:
52
+ if not value.strip():
53
+ return []
54
+ try:
55
+ return [int(item.strip()) for item in value.split(",")]
56
+ except ValueError as exc:
57
+ raise argparse.ArgumentTypeError(
58
+ "GPU IDs must be comma-separated integers"
59
+ ) from exc
60
+
61
+
62
+ def _environment(value: str) -> tuple[str, str]:
63
+ name, separator, setting = value.partition("=")
64
+ if not separator or not name or not name.replace("_", "").isalnum():
65
+ raise argparse.ArgumentTypeError("environment must use NAME=VALUE")
66
+ if name.startswith("LDP_") or name.startswith("ALGORITHM_"):
67
+ raise argparse.ArgumentTypeError(
68
+ "SDK-managed LDP_/ALGORITHM_ settings must use dedicated options"
69
+ )
70
+ return name, setting
71
+
72
+
73
+ def _add_serve_options(parser: argparse.ArgumentParser) -> None:
74
+ parser.add_argument("algorithm", help="entry-point name or package.module:AlgorithmClass")
75
+ parser.add_argument("--host", default=os.getenv("ALGORITHM_HOST", "0.0.0.0"))
76
+ parser.add_argument(
77
+ "--port",
78
+ type=int,
79
+ default=int(os.getenv("ALGORITHM_PORT", os.getenv("LDP_PLUGIN_PORT", "9000"))),
80
+ )
81
+ parser.add_argument(
82
+ "--max-concurrent-executions",
83
+ type=int,
84
+ default=int(
85
+ os.getenv(
86
+ "ALGORITHM_MAX_CONCURRENT_EXECUTIONS",
87
+ os.getenv("LDP_PLUGIN_MAX_CONCURRENCY", "1"),
88
+ )
89
+ ),
90
+ )
91
+ parser.add_argument("--scratch-dir", default=os.getenv("ALGORITHM_SCRATCH_DIR"))
92
+ parser.add_argument(
93
+ "--gpu-ids", type=_gpu_ids, default=None,
94
+ help="GPU IDs injected into every service execution",
95
+ )
96
+ parser.add_argument(
97
+ "--max-attempts",
98
+ type=int,
99
+ default=int(os.getenv("ALGORITHM_MAX_ATTEMPTS", str(MAX_ATTEMPTS))),
100
+ )
101
+ parser.add_argument(
102
+ "--retry-seconds",
103
+ type=float,
104
+ default=float(os.getenv("ALGORITHM_RETRY_SECONDS", str(RETRY_SECONDS))),
105
+ )
106
+ parser.add_argument(
107
+ "--retry-jitter-ratio",
108
+ type=float,
109
+ default=float(
110
+ os.getenv("ALGORITHM_RETRY_JITTER_RATIO", str(RETRY_JITTER_RATIO))
111
+ ),
112
+ )
113
+ parser.add_argument(
114
+ "--webui",
115
+ action=argparse.BooleanOptionalAction,
116
+ default=_env_bool("ALGORITHM_WEBUI"),
117
+ help="serve the SDK processing UI at /ui/",
118
+ )
119
+
120
+
121
+ def build_parser() -> argparse.ArgumentParser:
122
+ parser = argparse.ArgumentParser(prog="algorithm-plugin")
123
+ subparsers = parser.add_subparsers(dest="command", required=True)
124
+
125
+ run_parser = subparsers.add_parser("run", help="run datasets once")
126
+ run_parser.add_argument("algorithm", help="entry-point name or package.module:AlgorithmClass")
127
+ run_parser.add_argument("--input", action="append", type=_json_object, default=[])
128
+ run_parser.add_argument("--merge", action="store_true", default=None)
129
+ run_parser.add_argument("--gpu-ids", type=_gpu_ids)
130
+ run_parser.add_argument("--parameters", type=_json_object)
131
+ run_parser.add_argument("--request-file", type=Path)
132
+ run_parser.add_argument("--scratch-dir")
133
+ run_parser.add_argument("--algorithm-log", type=Path)
134
+
135
+ serve_parser = subparsers.add_parser("serve", help="run the generated HTTP service")
136
+ _add_serve_options(serve_parser)
137
+
138
+ config_parser = subparsers.add_parser(
139
+ "config", help="generate deployment config from the current repository"
140
+ )
141
+ config_parser.add_argument("algorithm", nargs="?", help="normally auto-detected")
142
+ config_parser.add_argument("--output", type=Path, default=Path(DEFAULT_CONFIG_NAME))
143
+ config_parser.add_argument("--repository", type=Path, default=Path.cwd())
144
+ config_parser.add_argument("--host", default="0.0.0.0")
145
+ config_parser.add_argument("--port", type=int, default=9000)
146
+ config_parser.add_argument(
147
+ "--webui", action=argparse.BooleanOptionalAction, default=True
148
+ )
149
+ config_parser.add_argument("--max-concurrency", type=int, default=1)
150
+ config_parser.add_argument("--scratch-dir")
151
+ config_parser.add_argument(
152
+ "--gpu-ids", type=_gpu_ids, default=[],
153
+ help="GPU IDs injected into every service execution",
154
+ )
155
+ config_parser.add_argument("--service-token")
156
+ config_parser.add_argument("--compute-url", default=os.getenv("LDP_COMPUTE_URL"))
157
+ config_parser.add_argument("--api-key", default=os.getenv("LDP_INTERNAL_API_KEY"))
158
+ config_parser.add_argument("--public-url", default=os.getenv("LDP_PLUGIN_PUBLIC_URL"))
159
+ config_parser.add_argument("--input-root", default=os.getenv("LDP_JOB_INPUT_ROOT"))
160
+ config_parser.add_argument(
161
+ "--workspace-root", default=os.getenv("LDP_JOB_WORKSPACE_ROOT")
162
+ )
163
+ config_parser.add_argument("--instance-key", default=os.getenv("LDP_PLUGIN_INSTANCE_KEY"))
164
+ config_parser.add_argument("--cluster", default=os.getenv("LDP_CLUSTER"))
165
+ config_parser.add_argument("--namespace", default=os.getenv("POD_NAMESPACE"))
166
+ config_parser.add_argument("--node-name", default=os.getenv("NODE_NAME"))
167
+ config_parser.add_argument(
168
+ "--local",
169
+ action="store_true",
170
+ help="generate a local config without compute registration",
171
+ )
172
+ config_parser.add_argument(
173
+ "--env", action="append", type=_environment, default=[], metavar="NAME=VALUE"
174
+ )
175
+
176
+ start_parser = subparsers.add_parser("start", help="start the configured service")
177
+ start_parser.add_argument("--config", type=Path, default=Path(DEFAULT_CONFIG_NAME))
178
+ start_parser.add_argument(
179
+ "--foreground", action="store_true", help="stay in foreground for systemd"
180
+ )
181
+
182
+ stop_parser = subparsers.add_parser("stop", help="stop the configured service")
183
+ stop_parser.add_argument("--config", type=Path, default=Path(DEFAULT_CONFIG_NAME))
184
+ stop_parser.add_argument("--timeout", type=float, default=30.0)
185
+ return parser
186
+
187
+
188
+ def _request_from_args(args: argparse.Namespace) -> AlgorithmRequest:
189
+ values: dict[str, Any] = {}
190
+ if args.request_file is not None:
191
+ try:
192
+ values = json.loads(args.request_file.read_text(encoding="utf-8"))
193
+ except (OSError, json.JSONDecodeError) as exc:
194
+ raise ValueError(f"cannot read request file: {exc}") from exc
195
+ if not isinstance(values, dict):
196
+ raise ValueError("request file must contain a JSON object")
197
+ if args.input:
198
+ values["input"] = args.input
199
+ if args.merge is not None:
200
+ values["merge"] = args.merge
201
+ if args.gpu_ids is not None:
202
+ values["gpuIds"] = args.gpu_ids
203
+ if args.parameters is not None:
204
+ values["parameters"] = args.parameters
205
+ request = AlgorithmRequest.from_dict(values)
206
+ if args.gpu_ids is not None:
207
+ request = replace(request, gpu_ids=args.gpu_ids)
208
+ return request
209
+
210
+
211
+ class TerminalProgress:
212
+ def __init__(self, stream: Any = None) -> None:
213
+ self.stream = stream or sys.stderr
214
+ self.enabled = bool(getattr(self.stream, "isatty", lambda: False)())
215
+ self._line_count = 0
216
+ self._lock = threading.Lock()
217
+
218
+ def update(self, snapshot: ProgressSnapshot) -> None:
219
+ if not self.enabled:
220
+ return
221
+ lines = []
222
+ for dataset, progress in snapshot.datasets.items():
223
+ lines.append(self._format_line(dataset, progress))
224
+ lines.extend(
225
+ self._format_line(f" {episode}", episode_progress)
226
+ for episode, episode_progress in progress.episodes.items()
227
+ )
228
+ with self._lock:
229
+ if self._line_count:
230
+ self.stream.write(f"\x1b[{self._line_count}F")
231
+ for line in lines:
232
+ self.stream.write(f"\x1b[2K{line}\n")
233
+ self.stream.flush()
234
+ self._line_count = len(lines)
235
+
236
+ def close(self) -> None:
237
+ if self.enabled:
238
+ self.stream.flush()
239
+
240
+ @staticmethod
241
+ def _format_line(dataset: str, progress: Any) -> str:
242
+ name = dataset.rstrip("/") or dataset
243
+ percent = " --%" if progress.percent is None else f"{progress.percent:5.1f}%"
244
+ filled = 0 if progress.percent is None else round(progress.percent / 5)
245
+ bar = f"[{'#' * filled}{'-' * (20 - filled)}]"
246
+ message = f" {progress.message}" if progress.message else ""
247
+ return f"{name:24.24} {bar} {percent} {progress.stage:16} {progress.status:9}{message}"
248
+
249
+
250
+ @contextmanager
251
+ def _cancel_on_interrupt(context: ExecutionContext, stream: Any):
252
+ interrupted = False
253
+ previous_handler = signal.getsignal(signal.SIGINT)
254
+
255
+ def handle_interrupt(_signum: int, _frame: Any) -> None:
256
+ nonlocal interrupted
257
+ if interrupted:
258
+ raise KeyboardInterrupt
259
+ interrupted = True
260
+ context.cancel("interrupted by user")
261
+ stream.write("\nCancellation requested; waiting for the algorithm to stop.\n")
262
+ stream.flush()
263
+
264
+ signal.signal(signal.SIGINT, handle_interrupt)
265
+ try:
266
+ yield
267
+ finally:
268
+ signal.signal(signal.SIGINT, previous_handler)
269
+
270
+
271
+ def _run(args: argparse.Namespace) -> int:
272
+ request = _request_from_args(args)
273
+ progress = TerminalProgress()
274
+ context = ExecutionContext(
275
+ f"cli-{id(args)}",
276
+ [item.dataset for item in request.inputs],
277
+ scratch_dir=args.scratch_dir,
278
+ progress_callback=progress.update,
279
+ )
280
+ try:
281
+ with ExitStack() as stack:
282
+ if args.algorithm_log is not None:
283
+ args.algorithm_log.parent.mkdir(parents=True, exist_ok=True)
284
+ stream = stack.enter_context(
285
+ args.algorithm_log.open("w", encoding="utf-8", buffering=1)
286
+ )
287
+ stack.enter_context(redirect_stdout(stream))
288
+ stack.enter_context(redirect_stderr(stream))
289
+ runner = AlgorithmRunner(load_algorithm(args.algorithm))
290
+ try:
291
+ with _cancel_on_interrupt(context, progress.stream):
292
+ result = runner.run(request, context=context)
293
+ except ExecutionCancelled as exc:
294
+ print(f"CANCELLED: {exc}", file=progress.stream)
295
+ return 130
296
+ finally:
297
+ runner.close()
298
+ finally:
299
+ progress.close()
300
+ print(json.dumps({"type": "result", **result.to_dict()}, ensure_ascii=False))
301
+ return 0 if result.status == "succeeded" else 1
302
+
303
+
304
+ def _serve(args: argparse.Namespace) -> int:
305
+ algorithm = load_algorithm(args.algorithm)
306
+
307
+ def create_runner() -> AlgorithmRunner:
308
+ return AlgorithmRunner(load_algorithm(args.algorithm))
309
+
310
+ release = ReleaseManifest.discover(algorithm, start=Path.cwd(), required=False)
311
+ manager = ExecutionManager(
312
+ AlgorithmRunner(algorithm),
313
+ max_concurrent_executions=args.max_concurrent_executions,
314
+ scratch_dir=args.scratch_dir,
315
+ gpu_ids=args.gpu_ids,
316
+ runner_factory=create_runner,
317
+ max_attempts=args.max_attempts,
318
+ retry_seconds=args.retry_seconds,
319
+ retry_jitter_ratio=args.retry_jitter_ratio,
320
+ release_manifest=release,
321
+ )
322
+ registration = PluginRegistrationAgent.from_env(manager.manifest, manager.heartbeat)
323
+ if registration is not None and release is None:
324
+ raise RuntimeError(
325
+ "compute registration requires release-manifest.json in the Algorithm repository"
326
+ )
327
+ run_server(
328
+ manager,
329
+ host=args.host,
330
+ port=args.port,
331
+ webui=args.webui,
332
+ token=os.getenv("LDP_PLUGIN_TOKEN") or None,
333
+ lifecycle=registration,
334
+ )
335
+ return 0
336
+
337
+
338
+ def _config(args: argparse.Namespace) -> int:
339
+ registration = None
340
+ if not args.local:
341
+ supplied = {
342
+ "computeUrl": args.compute_url,
343
+ "apiKey": args.api_key,
344
+ "publicUrl": args.public_url,
345
+ "inputRoot": args.input_root,
346
+ "workspaceRoot": args.workspace_root,
347
+ "cluster": args.cluster,
348
+ "namespace": args.namespace,
349
+ "nodeName": args.node_name,
350
+ }
351
+ missing = [name for name, value in supplied.items() if not str(value or "").strip()]
352
+ if missing:
353
+ raise ValueError(
354
+ "deployment registration requires "
355
+ + ", ".join(missing)
356
+ + "; use --local to disable registration"
357
+ )
358
+ registration = {name: str(value) for name, value in supplied.items()}
359
+ if args.instance_key:
360
+ registration["instanceKey"] = args.instance_key
361
+ root = repository_root(args.repository)
362
+ output = args.output if args.output.is_absolute() else root / args.output
363
+ config = DeploymentConfig.generate(
364
+ output=output,
365
+ root=root,
366
+ algorithm=args.algorithm,
367
+ host=args.host,
368
+ port=args.port,
369
+ webui=args.webui,
370
+ max_concurrency=args.max_concurrency,
371
+ scratch_dir=args.scratch_dir,
372
+ token=args.service_token,
373
+ gpu_ids=args.gpu_ids,
374
+ registration=registration,
375
+ environment=dict(args.env),
376
+ )
377
+ config.save()
378
+ identity = config.payload["algorithm"]
379
+ print(
380
+ f"Generated {config.path} for {identity['type']}@{identity['version']} "
381
+ f"({identity['implementationDigest']})"
382
+ )
383
+ return 0
384
+
385
+
386
+ def _proc_start_time(pid: int) -> str | None:
387
+ try:
388
+ fields = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()
389
+ except OSError:
390
+ return None
391
+ return fields[21] if len(fields) > 21 else None
392
+
393
+
394
+ def _read_process_state(config: DeploymentConfig) -> dict[str, Any] | None:
395
+ try:
396
+ state = json.loads(config.pid_file.read_text(encoding="utf-8"))
397
+ except FileNotFoundError:
398
+ return None
399
+ except (OSError, json.JSONDecodeError) as exc:
400
+ raise RuntimeError(f"cannot read PID state {config.pid_file}: {exc}") from exc
401
+ if not isinstance(state, dict) or not isinstance(state.get("pid"), int):
402
+ raise RuntimeError(f"invalid PID state: {config.pid_file}")
403
+ return state
404
+
405
+
406
+ def _is_managed_process(config: DeploymentConfig, state: dict[str, Any]) -> bool:
407
+ pid = int(state["pid"])
408
+ if _proc_start_time(pid) != str(state.get("startTime")):
409
+ return False
410
+ try:
411
+ command = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode()
412
+ except OSError:
413
+ return False
414
+ return (
415
+ "algorithm_plugin_sdk.cli" in command
416
+ and " serve " in f" {command} "
417
+ and config.algorithm_reference in command
418
+ )
419
+
420
+
421
+ def _start(args: argparse.Namespace) -> int:
422
+ config = DeploymentConfig.load(args.config)
423
+ config.save() # Persist any auto-refreshed release identity.
424
+ service = config.payload["service"]
425
+ command = [
426
+ sys.executable,
427
+ "-m",
428
+ "algorithm_plugin_sdk.cli",
429
+ "serve",
430
+ config.algorithm_reference,
431
+ "--host",
432
+ str(service["host"]),
433
+ "--port",
434
+ str(service["port"]),
435
+ "--max-concurrent-executions",
436
+ str(service["maxConcurrency"]),
437
+ ]
438
+ command.append("--webui" if service.get("webui") else "--no-webui")
439
+ if service.get("scratchDir"):
440
+ command.extend(("--scratch-dir", str(service["scratchDir"])))
441
+ if "gpuIds" in service:
442
+ command.extend(("--gpu-ids", ",".join(str(value) for value in service["gpuIds"])))
443
+
444
+ if args.foreground:
445
+ os.chdir(config.repository)
446
+ os.execve(sys.executable, command, config.process_environment())
447
+
448
+ existing = _read_process_state(config)
449
+ if existing and _is_managed_process(config, existing):
450
+ print(f"Algorithm service is already running (pid {existing['pid']})")
451
+ return 0
452
+ if existing:
453
+ config.pid_file.unlink(missing_ok=True)
454
+
455
+ config.log_file.parent.mkdir(parents=True, exist_ok=True)
456
+ with config.log_file.open("ab", buffering=0) as log:
457
+ process = subprocess.Popen(
458
+ command,
459
+ cwd=config.repository,
460
+ env=config.process_environment(),
461
+ stdin=subprocess.DEVNULL,
462
+ stdout=log,
463
+ stderr=subprocess.STDOUT,
464
+ start_new_session=True,
465
+ )
466
+ time.sleep(0.25)
467
+ status = process.poll()
468
+ if status is not None:
469
+ raise RuntimeError(
470
+ f"algorithm service exited with status {status}; see {config.log_file}"
471
+ )
472
+ start_time = _proc_start_time(process.pid)
473
+ if start_time is None:
474
+ raise RuntimeError("algorithm service disappeared during startup")
475
+ config.pid_file.write_text(
476
+ json.dumps(
477
+ {
478
+ "pid": process.pid,
479
+ "startTime": start_time,
480
+ "algorithm": config.algorithm_reference,
481
+ "config": str(config.path),
482
+ },
483
+ indent=2,
484
+ )
485
+ + "\n",
486
+ encoding="utf-8",
487
+ )
488
+ print(f"Started algorithm service (pid {process.pid}); log: {config.log_file}")
489
+ return 0
490
+
491
+
492
+ def _stop(args: argparse.Namespace) -> int:
493
+ if args.timeout < 0:
494
+ raise ValueError("timeout cannot be negative")
495
+ config = DeploymentConfig.load(args.config, refresh=False)
496
+ state = _read_process_state(config)
497
+ if state is None:
498
+ print("Algorithm service is not running")
499
+ return 0
500
+ if not _is_managed_process(config, state):
501
+ config.pid_file.unlink(missing_ok=True)
502
+ print("Algorithm service is not running; removed stale PID state")
503
+ return 0
504
+ pid = int(state["pid"])
505
+ os.kill(pid, signal.SIGTERM)
506
+ deadline = time.monotonic() + args.timeout
507
+ while _is_managed_process(config, state) and time.monotonic() < deadline:
508
+ time.sleep(0.1)
509
+ if _is_managed_process(config, state):
510
+ os.kill(pid, signal.SIGKILL)
511
+ while _is_managed_process(config, state):
512
+ time.sleep(0.05)
513
+ config.pid_file.unlink(missing_ok=True)
514
+ print(f"Stopped algorithm service (pid {pid})")
515
+ return 0
516
+
517
+
518
+ def main(argv: list[str] | None = None) -> int:
519
+ logging.basicConfig(
520
+ level=logging.INFO,
521
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
522
+ )
523
+ parser = build_parser()
524
+ args = parser.parse_args(argv)
525
+ handlers = {
526
+ "run": _run,
527
+ "serve": _serve,
528
+ "config": _config,
529
+ "start": _start,
530
+ "stop": _stop,
531
+ }
532
+ try:
533
+ return handlers[args.command](args)
534
+ except KeyboardInterrupt:
535
+ return 130
536
+ except Exception as exc:
537
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
538
+ return 2
539
+
540
+
541
+ if __name__ == "__main__":
542
+ raise SystemExit(main())