python-dmon 0.3.1__py3-none-any.whl → 0.5.0__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.
dmon/__init__.py CHANGED
@@ -0,0 +1,23 @@
1
+ from .api import Dmon, DmonConfigError, DmonError
2
+ from .results import (
3
+ ActionResult,
4
+ BatchResult,
5
+ StackResult,
6
+ StackSnapshot,
7
+ TaskResult,
8
+ TaskSnapshot,
9
+ WaitResult,
10
+ )
11
+
12
+ __all__ = [
13
+ "ActionResult",
14
+ "BatchResult",
15
+ "Dmon",
16
+ "DmonConfigError",
17
+ "DmonError",
18
+ "StackResult",
19
+ "StackSnapshot",
20
+ "TaskResult",
21
+ "TaskSnapshot",
22
+ "WaitResult",
23
+ ]
dmon/api.py ADDED
@@ -0,0 +1,295 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from io import StringIO
5
+ import math
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Iterator, Optional, Sequence, Tuple, Union
9
+
10
+ from .config import fill_default_paths, get_task_config, load_config
11
+ from .constants import DEFAULT_META_DIR, STACK_META_SUFFIX
12
+ from .control import (
13
+ check_running,
14
+ diagnostic_output,
15
+ restart,
16
+ start_single_result,
17
+ stop_single,
18
+ task_snapshot,
19
+ task_environment,
20
+ task_owns_listener,
21
+ )
22
+ from .results import (
23
+ ActionResult,
24
+ BatchResult,
25
+ StackResult,
26
+ TaskResult,
27
+ WaitResult,
28
+ )
29
+ from .inspection import inspect_stack, inspect_task
30
+ from .readiness import ready_spec, wait_for_readiness
31
+ from .types import DmonMeta
32
+
33
+
34
+ class DmonError(Exception):
35
+ """Base exception for invalid Python API requests."""
36
+
37
+
38
+ class DmonConfigError(DmonError):
39
+ """Configuration could not be loaded or validated."""
40
+
41
+
42
+ class Dmon:
43
+ """Silent, structured Python interface to dmon task operations.
44
+
45
+ Args:
46
+ config: Optional dmon YAML/TOML file or directory. When omitted, dmon
47
+ uses the same configuration discovery rules as the CLI.
48
+ """
49
+
50
+ def __init__(self, config: Optional[Union[str, os.PathLike]] = None) -> None:
51
+ self.config = str(config) if config is not None else None
52
+
53
+ def start(self, *tasks: str) -> BatchResult:
54
+ """Start one or more configured tasks in the background."""
55
+
56
+ names, configs, _ = self._tasks(tasks)
57
+ self._validate_environments(configs)
58
+ results = []
59
+ with self._operation():
60
+ for name, config in zip(names, configs):
61
+ result = start_single_result(config)
62
+ snapshot = (
63
+ task_snapshot(result.meta) if result.meta is not None else None
64
+ )
65
+ results.append(
66
+ ActionResult(
67
+ action="start",
68
+ name=name,
69
+ ok=result.exit_code == 0,
70
+ exit_code=result.exit_code,
71
+ snapshot=snapshot,
72
+ error=result.error,
73
+ )
74
+ )
75
+ return BatchResult("start", tuple(results))
76
+
77
+ def stop(self, *tasks: str) -> BatchResult:
78
+ """Stop one or more configured tasks using their recorded identities."""
79
+
80
+ names, configs, _ = self._tasks(tasks)
81
+ results = []
82
+ with self._operation():
83
+ for name, config in zip(names, configs):
84
+ exit_code = stop_single(config.meta_path)
85
+ results.append(
86
+ ActionResult(
87
+ action="stop",
88
+ name=name,
89
+ ok=exit_code == 0,
90
+ exit_code=exit_code,
91
+ error="" if exit_code == 0 else "task did not stop",
92
+ )
93
+ )
94
+ return BatchResult("stop", tuple(results))
95
+
96
+ def restart(self, *tasks: str) -> BatchResult:
97
+ """Restart one or more configured tasks."""
98
+
99
+ names, configs, _ = self._tasks(tasks)
100
+ self._validate_environments(configs)
101
+ results = []
102
+ with self._operation():
103
+ for name, config in zip(names, configs):
104
+ exit_code = restart([config])
105
+ meta = self._load_task_meta(Path(config.meta_path))
106
+ snapshot = task_snapshot(meta) if meta is not None else None
107
+ results.append(
108
+ ActionResult(
109
+ action="restart",
110
+ name=name,
111
+ ok=exit_code == 0,
112
+ exit_code=exit_code,
113
+ snapshot=snapshot,
114
+ error="" if exit_code == 0 else "task did not restart",
115
+ )
116
+ )
117
+ return BatchResult("restart", tuple(results))
118
+
119
+ def status(self, task: Optional[str] = None) -> TaskResult:
120
+ """Inspect one configured task without changing its lifecycle."""
121
+
122
+ names, configs, _ = self._tasks(() if task is None else (task,))
123
+ if len(names) != 1:
124
+ raise DmonConfigError("status requires exactly one task")
125
+ with self._operation():
126
+ return self._task_result(names[0], Path(configs[0].meta_path))
127
+
128
+ def list_tasks(self) -> Tuple[TaskResult, ...]:
129
+ """Return recorded task results for the selected project."""
130
+
131
+ project = self._project()
132
+ results = []
133
+ with self._operation():
134
+ meta_dir = project / DEFAULT_META_DIR
135
+ if meta_dir.is_dir():
136
+ for path in sorted(meta_dir.glob("*.meta.json")):
137
+ results.append(
138
+ inspect_task(
139
+ path.name[: -len(".meta.json")],
140
+ path,
141
+ require_running=False,
142
+ )
143
+ )
144
+ return tuple(results)
145
+
146
+ def stack_status(self, stack: str) -> StackResult:
147
+ """Inspect one recorded stack without changing its lifecycle."""
148
+
149
+ if not isinstance(stack, str) or not stack:
150
+ raise DmonConfigError("stack name must not be empty")
151
+ project = self._project()
152
+ name = stack.lower()
153
+ with self._operation():
154
+ return inspect_stack(
155
+ name,
156
+ project / DEFAULT_META_DIR / f"{name}{STACK_META_SUFFIX}",
157
+ require_running=True,
158
+ )
159
+
160
+ def list_stacks(self) -> Tuple[StackResult, ...]:
161
+ """Return recorded stack results for the selected project."""
162
+
163
+ project = self._project()
164
+ results = []
165
+ with self._operation():
166
+ meta_dir = project / DEFAULT_META_DIR
167
+ if meta_dir.is_dir():
168
+ for path in sorted(meta_dir.glob(f"*{STACK_META_SUFFIX}")):
169
+ name = path.name[: -len(STACK_META_SUFFIX)]
170
+ results.append(inspect_stack(name, path, require_running=False))
171
+ return tuple(results)
172
+
173
+ def wait(
174
+ self,
175
+ *tasks: str,
176
+ timeout: Optional[float] = None,
177
+ interval: Optional[float] = None,
178
+ ) -> Tuple[WaitResult, ...]:
179
+ """Wait for configured readiness without starting or stopping tasks."""
180
+
181
+ for name, value in (("timeout", timeout), ("interval", interval)):
182
+ if value is not None and (
183
+ not isinstance(value, (int, float))
184
+ or isinstance(value, bool)
185
+ or not math.isfinite(value)
186
+ or value <= 0
187
+ ):
188
+ raise DmonConfigError(f"{name} must be finite and greater than zero")
189
+ names, configs, _ = self._tasks(tasks)
190
+ self._validate_environments(configs)
191
+ results = []
192
+ with self._operation():
193
+ for name, config in zip(names, configs):
194
+ if not config.ready:
195
+ results.append(
196
+ WaitResult(
197
+ name,
198
+ False,
199
+ "invalid",
200
+ 0.0,
201
+ 0,
202
+ "task has no readiness probe",
203
+ )
204
+ )
205
+ continue
206
+ try:
207
+ meta = DmonMeta.load(config.meta_path)
208
+ except (OSError, ValueError, TypeError) as error:
209
+ results.append(
210
+ WaitResult(name, False, "metadata-error", 0.0, 0, str(error))
211
+ )
212
+ continue
213
+ if meta is None:
214
+ results.append(
215
+ WaitResult(
216
+ name,
217
+ False,
218
+ "not-running",
219
+ 0.0,
220
+ 0,
221
+ "task metadata not found",
222
+ )
223
+ )
224
+ continue
225
+ spec = ready_spec(config.ready, timeout=timeout, interval=interval)
226
+ results.append(
227
+ wait_for_readiness(
228
+ name,
229
+ spec,
230
+ cwd=config.cwd,
231
+ env=task_environment(config),
232
+ process_running=lambda meta=meta: check_running(
233
+ meta.pid, meta.create_time
234
+ ),
235
+ listener_owned=(
236
+ lambda: task_owns_listener(
237
+ meta, spec.tcp_host, spec.tcp_port
238
+ )
239
+ )
240
+ if spec.require_owned
241
+ else None,
242
+ )
243
+ )
244
+ return tuple(results)
245
+
246
+ def _tasks(self, tasks: Sequence[str]):
247
+ if any(not isinstance(task, str) or not task for task in tasks):
248
+ raise DmonConfigError("task names must be non-empty strings")
249
+ try:
250
+ names, configs, path = get_task_config(tasks, self.config)
251
+ fill_default_paths(configs)
252
+ except (OSError, ValueError, TypeError) as error:
253
+ raise DmonConfigError(str(error)) from error
254
+ project = path.parent.resolve()
255
+ for config in configs:
256
+ config.cwd = self._resolve_path(project, config.cwd or ".")
257
+ config.meta_path = self._resolve_path(project, config.meta_path)
258
+ config.log_path = self._resolve_path(project, config.log_path)
259
+ config.rotate_log_path = self._resolve_path(project, config.rotate_log_path)
260
+ config.env_files = [
261
+ self._resolve_path(project, value) for value in config.env_files
262
+ ]
263
+ return names, configs, project
264
+
265
+ def _project(self) -> Path:
266
+ try:
267
+ _, path = load_config(self.config)
268
+ except (OSError, ValueError, TypeError) as error:
269
+ raise DmonConfigError(str(error)) from error
270
+ return path.parent
271
+
272
+ @staticmethod
273
+ def _validate_environments(configs) -> None:
274
+ try:
275
+ for config in configs:
276
+ task_environment(config)
277
+ except (OSError, ValueError) as error:
278
+ raise DmonConfigError(str(error)) from error
279
+
280
+ @contextmanager
281
+ def _operation(self) -> Iterator[None]:
282
+ with diagnostic_output(StringIO()):
283
+ yield
284
+
285
+ @staticmethod
286
+ def _resolve_path(project: Path, value: str) -> str:
287
+ path = Path(value)
288
+ return str(path.resolve() if path.is_absolute() else (project / path).resolve())
289
+
290
+ def _task_result(self, name: str, path: Path) -> TaskResult:
291
+ return inspect_task(name, path, require_running=True)
292
+
293
+ @staticmethod
294
+ def _load_task_meta(path: Path) -> Optional[DmonMeta]:
295
+ return DmonMeta.load(path)