ls-algorithm-plugin-sdk 0.3.1__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/PKG-INFO +1 -1
  2. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/pyproject.toml +1 -1
  3. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/configure.py +44 -5
  4. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/run.py +5 -0
  5. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/serve.py +22 -2
  6. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/deployment.py +19 -0
  7. ls_algorithm_plugin_sdk-0.3.4/src/algorithm_plugin_sdk/gpu_isolation.py +27 -0
  8. ls_algorithm_plugin_sdk-0.3.4/src/algorithm_plugin_sdk/log_manager.py +125 -0
  9. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/service.py +158 -8
  10. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +1 -1
  11. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/SOURCES.txt +4 -0
  12. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_configure.py +42 -2
  13. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_deployment.py +9 -0
  14. ls_algorithm_plugin_sdk-0.3.4/tests/test_gpu_isolation.py +38 -0
  15. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_runner.py +26 -0
  16. ls_algorithm_plugin_sdk-0.3.4/tests/test_serve.py +73 -0
  17. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_service.py +192 -0
  18. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/README.md +0 -0
  19. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/setup.cfg +0 -0
  20. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/__init__.py +0 -0
  21. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/algorithm.py +0 -0
  22. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli.py +0 -0
  23. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/__init__.py +0 -0
  24. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/parsing.py +0 -0
  25. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/context.py +0 -0
  26. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/errors.py +0 -0
  27. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/__init__.py +0 -0
  28. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/example_algorithm.py +0 -0
  29. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/simulated_algorithm.py +0 -0
  30. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/loader.py +0 -0
  31. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/models.py +0 -0
  32. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/registration.py +0 -0
  33. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/release.py +0 -0
  34. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/runner.py +0 -0
  35. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/__init__.py +0 -0
  36. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/app.css +0 -0
  37. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/app.js +0 -0
  38. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/index.html +0 -0
  39. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui_app.py +0 -0
  40. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/dependency_links.txt +0 -0
  41. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/entry_points.txt +0 -0
  42. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/requires.txt +0 -0
  43. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/top_level.txt +0 -0
  44. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_cli.py +0 -0
  45. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_models.py +0 -0
  46. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_registration.py +0 -0
  47. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_release.py +0 -0
  48. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_sdk_automation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ls-algorithm-plugin-sdk
3
- Version: 0.3.1
3
+ Version: 0.3.4
4
4
  Summary: Protocol-independent runtime SDK for dataset algorithms
5
5
  Author: Ling Robotics
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ls-algorithm-plugin-sdk"
7
- version = "0.3.1"
7
+ version = "0.3.4"
8
8
  description = "Protocol-independent runtime SDK for dataset algorithms"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -16,7 +16,7 @@ from ..deployment import (
16
16
  repository_root,
17
17
  stable_instance_key,
18
18
  )
19
- from .parsing import environment, gpu_ids
19
+ from .parsing import environment, gpu_ids, json_object
20
20
 
21
21
 
22
22
  SYSTEMD_UNIT_DIR = Path("/etc/systemd/system")
@@ -56,14 +56,34 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
56
56
  action=argparse.BooleanOptionalAction,
57
57
  default=True,
58
58
  )
59
- parser.add_argument("--max-concurrency", type=int, default=1)
59
+ parser.add_argument(
60
+ "--max-concurrency",
61
+ type=int,
62
+ help="maximum concurrent executions (default: GPU count, or 1)",
63
+ )
60
64
  parser.add_argument("--scratch-dir")
65
+ parser.add_argument(
66
+ "--local-scratch",
67
+ action="store_true",
68
+ help="override request scratchRoot with <repository>/.scratch",
69
+ )
70
+ parser.add_argument(
71
+ "--local-output",
72
+ action="store_true",
73
+ help="override request output paths with <repository>/.output/<job-id>",
74
+ )
61
75
  parser.add_argument(
62
76
  "--gpu-ids",
63
77
  type=gpu_ids,
64
78
  default=[],
65
79
  help="GPU IDs injected into every service execution",
66
80
  )
81
+ parser.add_argument(
82
+ "--default-parameters",
83
+ type=json_object,
84
+ default={},
85
+ help="JSON object used to fill missing request parameters",
86
+ )
67
87
  parser.add_argument("--service-token")
68
88
  parser.add_argument(
69
89
  "--compute-url",
@@ -139,10 +159,18 @@ def render_systemd_unit(
139
159
  repository: Path,
140
160
  config_path: Path,
141
161
  service_name: str,
162
+ gpu_ids: list[int] | None = None,
142
163
  ) -> str:
143
164
  root = repository.resolve()
165
+ working_directory = root / "jobs"
144
166
  launcher = root / ".venv" / "bin" / "algorithm-plugin"
145
167
  config = config_path.resolve()
168
+ environment = ["Environment=PYTHONUNBUFFERED=1"]
169
+ if gpu_ids:
170
+ environment.append(
171
+ "Environment=CUDA_VISIBLE_DEVICES="
172
+ + ",".join(str(gpu_id) for gpu_id in gpu_ids)
173
+ )
146
174
  return "\n".join(
147
175
  (
148
176
  "[Unit]",
@@ -153,12 +181,13 @@ def render_systemd_unit(
153
181
  "[Service]",
154
182
  "Type=simple",
155
183
  "User=root",
156
- f"WorkingDirectory={_escape_systemd_path(root)}",
184
+ f"WorkingDirectory={_escape_systemd_path(working_directory)}",
157
185
  (
158
186
  f"ExecStart={_quote_systemd_path(launcher)} "
159
187
  f"serve --config {_quote_systemd_path(config)}"
160
188
  ),
161
- "Environment=PYTHONUNBUFFERED=1",
189
+ *environment,
190
+ "LimitNOFILE=524288",
162
191
  "Restart=on-failure",
163
192
  "RestartSec=5s",
164
193
  "",
@@ -376,6 +405,11 @@ def generate_config(
376
405
  args: argparse.Namespace,
377
406
  repository: Path,
378
407
  ) -> DeploymentConfig:
408
+ max_concurrency = (
409
+ args.max_concurrency
410
+ if args.max_concurrency is not None
411
+ else max(len(args.gpu_ids), 1)
412
+ )
379
413
  config = DeploymentConfig.generate(
380
414
  output=args.config_output,
381
415
  root=repository,
@@ -383,12 +417,15 @@ def generate_config(
383
417
  host=args.host,
384
418
  port=args.port,
385
419
  webui=args.webui,
386
- max_concurrency=args.max_concurrency,
420
+ max_concurrency=max_concurrency,
387
421
  scratch_dir=args.scratch_dir,
422
+ local_scratch=args.local_scratch,
423
+ local_output=args.local_output,
388
424
  token=args.service_token,
389
425
  registration=registration_from_args(args, repository),
390
426
  gpu_ids=args.gpu_ids,
391
427
  environment=dict(args.env),
428
+ default_parameters=args.default_parameters,
392
429
  )
393
430
  config.save()
394
431
  return config
@@ -407,6 +444,7 @@ def execute(args: argparse.Namespace) -> int:
407
444
  repository=repository,
408
445
  config_path=config_path,
409
446
  service_name=service_name,
447
+ gpu_ids=args.gpu_ids,
410
448
  )
411
449
 
412
450
  inspect_systemd_unit(
@@ -414,6 +452,7 @@ def execute(args: argparse.Namespace) -> int:
414
452
  content=unit,
415
453
  force=args.force,
416
454
  )
455
+ (repository / "jobs").mkdir(exist_ok=True)
417
456
  config = generate_config(args, repository)
418
457
  unit_path, changed = write_systemd_unit(
419
458
  service_name=service_name,
@@ -17,6 +17,7 @@ from typing import Any, Iterator
17
17
 
18
18
  from ..context import ExecutionContext, ProgressSnapshot
19
19
  from ..errors import ExecutionCancelled
20
+ from ..gpu_isolation import apply_gpu_isolation
20
21
  from ..loader import load_algorithm
21
22
  from ..models import AlgorithmRequest
22
23
  from ..runner import AlgorithmRunner
@@ -125,6 +126,10 @@ def cancel_on_interrupt(
125
126
 
126
127
  def execute(args: argparse.Namespace) -> int:
127
128
  request = request_from_args(args)
129
+ request = replace(
130
+ request,
131
+ gpu_ids=apply_gpu_isolation(request.gpu_ids),
132
+ )
128
133
  progress = TerminalProgress()
129
134
  context = ExecutionContext(
130
135
  f"cli-{id(args)}",
@@ -5,6 +5,7 @@ import os
5
5
  from pathlib import Path
6
6
 
7
7
  from ..deployment import DEFAULT_CONFIG_NAME, DeploymentConfig
8
+ from ..gpu_isolation import apply_gpu_isolation
8
9
  from ..loader import load_algorithm
9
10
  from ..registration import PluginRegistrationAgent
10
11
  from ..release import ReleaseManifest
@@ -78,15 +79,22 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
78
79
 
79
80
 
80
81
  def execute(args: argparse.Namespace) -> int:
82
+ config: DeploymentConfig | None = None
83
+ service: dict[str, object] | None = None
84
+ local_scratch = False
85
+ local_output = False
81
86
  if args.config:
82
- config = DeploymentConfig.load(args.config)
83
- config.save()
87
+ # Apply the process mask before refreshing/loading the algorithm. A
88
+ # repository module may import CUDA during module import.
89
+ config = DeploymentConfig.load(args.config, refresh=False)
84
90
  service = config.payload["service"]
85
91
  args.algorithm = config.algorithm_reference
86
92
  args.host = str(service["host"])
87
93
  args.port = int(service["port"])
88
94
  args.max_concurrent_executions = int(service["maxConcurrency"])
89
95
  args.scratch_dir = service.get("scratchDir")
96
+ local_scratch = bool(service.get("localScratch", False))
97
+ local_output = bool(service.get("localOutput", False))
90
98
  args.gpu_ids = list(service.get("gpuIds", []))
91
99
  args.webui = bool(service.get("webui"))
92
100
  values = config.process_environment()
@@ -96,6 +104,11 @@ def execute(args: argparse.Namespace) -> int:
96
104
  elif not args.algorithm:
97
105
  raise ValueError("algorithm is required unless --config is provided")
98
106
 
107
+ args.gpu_ids = apply_gpu_isolation(args.gpu_ids)
108
+ if config is not None:
109
+ config.refresh()
110
+ config.save()
111
+
99
112
  algorithm_reference = str(args.algorithm)
100
113
  algorithm = load_algorithm(algorithm_reference)
101
114
 
@@ -105,14 +118,21 @@ def execute(args: argparse.Namespace) -> int:
105
118
  release = ReleaseManifest.discover(algorithm, start=Path.cwd(), required=False)
106
119
  manager = ExecutionManager(
107
120
  AlgorithmRunner(algorithm),
121
+ default_parameters=(
122
+ service.get("defaultParameters", {})
123
+ if service is not None else None
124
+ ),
108
125
  max_concurrent_executions=args.max_concurrent_executions,
109
126
  scratch_dir=args.scratch_dir,
127
+ local_scratch=local_scratch,
128
+ local_output=local_output,
110
129
  gpu_ids=args.gpu_ids,
111
130
  runner_factory=create_runner,
112
131
  max_attempts=args.max_attempts,
113
132
  retry_seconds=args.retry_seconds,
114
133
  retry_jitter_ratio=args.retry_jitter_ratio,
115
134
  release_manifest=release,
135
+ repository=(config.repository if config is not None else Path.cwd()),
116
136
  )
117
137
  registration = PluginRegistrationAgent.from_env(manager.manifest, manager.heartbeat)
118
138
  if registration is not None and release is None:
@@ -145,6 +145,9 @@ class DeploymentConfig:
145
145
  registration: dict[str, str] | None,
146
146
  gpu_ids: list[int] | None = None,
147
147
  environment: dict[str, str] | None = None,
148
+ default_parameters: dict[str, Any] | None = None,
149
+ local_scratch: bool = False,
150
+ local_output: bool = False,
148
151
  ) -> "DeploymentConfig":
149
152
  repository = repository_root(root)
150
153
  _make_repository_importable(repository)
@@ -188,8 +191,11 @@ class DeploymentConfig:
188
191
  "webui": webui,
189
192
  "maxConcurrency": max_concurrency,
190
193
  "scratchDir": _absolute(scratch_dir, repository),
194
+ "localScratch": local_scratch,
195
+ "localOutput": local_output,
191
196
  "gpuIds": configured_gpu_ids,
192
197
  "token": token or None,
198
+ "defaultParameters": dict(default_parameters or {}),
193
199
  },
194
200
  "registration": registration,
195
201
  "environment": dict(sorted((environment or {}).items())),
@@ -266,6 +272,9 @@ class DeploymentConfig:
266
272
  raise RuntimeError("service port must be in [1, 65535]")
267
273
  if concurrency < 1:
268
274
  raise RuntimeError("service maxConcurrency must be positive")
275
+ for name in ("localScratch", "localOutput"):
276
+ if not isinstance(service.get(name, False), bool):
277
+ raise RuntimeError(f"service {name} must be a boolean")
269
278
  gpu_ids = service.get("gpuIds")
270
279
  if not isinstance(gpu_ids, list) or any(
271
280
  isinstance(gpu_id, bool) or not isinstance(gpu_id, int) or gpu_id < 0
@@ -273,6 +282,16 @@ class DeploymentConfig:
273
282
  ) or len(gpu_ids) != len(set(gpu_ids)):
274
283
  raise RuntimeError("service gpuIds must be unique non-negative integers")
275
284
 
285
+ default_parameters = service.get("defaultParameters", {})
286
+ if not isinstance(default_parameters, dict):
287
+ raise RuntimeError("defaultParameters must be an object")
288
+ if any(not isinstance(key, str) for key in default_parameters):
289
+ raise RuntimeError("defaultParameters keys must be strings")
290
+ try:
291
+ json.dumps(default_parameters)
292
+ except (TypeError, ValueError) as exc:
293
+ raise RuntimeError("defaultParameters must be JSON serializable") from exc
294
+
276
295
  def _validate_registration(self) -> None:
277
296
  registration = self.payload.get("registration")
278
297
  if registration is None:
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Sequence
5
+
6
+
7
+ def apply_gpu_isolation(gpu_ids: Sequence[int] | None) -> list[int]:
8
+ """Mask physical GPUs for this process and return local GPU ordinals."""
9
+ if gpu_ids is None:
10
+ return []
11
+
12
+ physical_gpu_ids = list(gpu_ids)
13
+ if any(
14
+ isinstance(gpu_id, bool)
15
+ or not isinstance(gpu_id, int)
16
+ or gpu_id < 0
17
+ for gpu_id in physical_gpu_ids
18
+ ) or len(physical_gpu_ids) != len(set(physical_gpu_ids)):
19
+ raise ValueError("gpu_ids must be unique non-negative integers")
20
+
21
+ if not physical_gpu_ids:
22
+ return []
23
+
24
+ os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(
25
+ str(gpu_id) for gpu_id in physical_gpu_ids
26
+ )
27
+ return list(range(len(physical_gpu_ids)))
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .context import utc_now
11
+
12
+ _SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
13
+ _TIMESTAMP_GLOB = "[0-9]" * 14
14
+ _FILE_LOCKS_GUARD = threading.Lock()
15
+ _FILE_LOCKS: dict[Path, threading.RLock] = {}
16
+
17
+
18
+ def _safe_name(value: str) -> str:
19
+ cleaned = _SAFE_NAME.sub("_", str(value).strip()).strip("._")
20
+ return cleaned or "job"
21
+
22
+
23
+ def _file_lock(path: Path) -> threading.RLock:
24
+ with _FILE_LOCKS_GUARD:
25
+ return _FILE_LOCKS.setdefault(path, threading.RLock())
26
+
27
+
28
+ def _timestamp_prefix() -> str:
29
+ return utc_now().replace("-", "").replace(":", "").replace("T", "")[:14]
30
+
31
+
32
+ def _job_log_path(directory: Path, job_id: str) -> Path:
33
+ safe_job_id = _safe_name(job_id)
34
+ pattern = f"{_TIMESTAMP_GLOB}-{safe_job_id}.log"
35
+ with _FILE_LOCKS_GUARD:
36
+ existing = sorted(directory.glob(pattern))
37
+ if existing:
38
+ return existing[0]
39
+ timestamp = _timestamp_prefix()
40
+ path = directory / f"{timestamp}-{safe_job_id}.log"
41
+ path.touch(exist_ok=True)
42
+ return path
43
+
44
+
45
+ class ExecutionLog:
46
+ """Per-job, line-buffered log sink attached to context.logger."""
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ log_dir: str | Path,
52
+ job_id: str,
53
+ execution_id: str,
54
+ logger: logging.Logger,
55
+ task_id: str | None = None,
56
+ ) -> None:
57
+ resolved_task_id = task_id or execution_id
58
+ self._event_context = {
59
+ "jobId": job_id,
60
+ "taskId": resolved_task_id,
61
+ "executionId": execution_id,
62
+ "job_id": job_id,
63
+ "task_id": resolved_task_id,
64
+ "execution_id": execution_id,
65
+ }
66
+ directory = Path(log_dir).expanduser()
67
+ directory.mkdir(parents=True, exist_ok=True)
68
+ path = _job_log_path(directory, job_id)
69
+ self.path = path
70
+ self._stream = path.open("a", encoding="utf-8", buffering=1)
71
+ self._lock = _file_lock(path)
72
+ self._handler = _FlushFileHandler(self._stream, self._lock)
73
+ self._handler.setFormatter(
74
+ logging.Formatter(
75
+ f"%(asctime)s %(levelname)s [executionId={execution_id}] %(message)s"
76
+ )
77
+ )
78
+ self._handler.setLevel(logging.DEBUG)
79
+ self.logger = logger
80
+ self.logger.setLevel(logging.DEBUG)
81
+ self.logger.addHandler(self._handler)
82
+ self._closed = False
83
+
84
+ def write_event(self, event: str, **values: Any) -> None:
85
+ payload = {
86
+ "event": event,
87
+ "timestamp": utc_now(),
88
+ **self._event_context,
89
+ **values,
90
+ }
91
+ with self._lock:
92
+ if self._closed:
93
+ return
94
+ self._stream.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n")
95
+ self._stream.flush()
96
+
97
+ def close(self) -> None:
98
+ with self._lock:
99
+ if self._closed:
100
+ return
101
+ self._closed = True
102
+ self.logger.removeHandler(self._handler)
103
+ self._handler.close()
104
+ self._stream.close()
105
+
106
+
107
+ class _FlushFileHandler(logging.Handler):
108
+ def __init__(self, stream: Any, lock: threading.RLock) -> None:
109
+ super().__init__()
110
+ self.stream = stream
111
+ self._lock = lock
112
+
113
+ def emit(self, record: logging.LogRecord) -> None:
114
+ try:
115
+ with self._lock:
116
+ self.stream.write(self.format(record) + "\n")
117
+ self.stream.flush()
118
+ except Exception:
119
+ self.handleError(record)
120
+
121
+ def close(self) -> None:
122
+ try:
123
+ self.stream.flush()
124
+ finally:
125
+ super().close()