python-dmon 0.3.0__py3-none-any.whl → 0.4.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,264 @@
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
+ )
21
+ from .results import (
22
+ ActionResult,
23
+ BatchResult,
24
+ StackResult,
25
+ TaskResult,
26
+ WaitResult,
27
+ )
28
+ from .inspection import inspect_stack, inspect_task
29
+ from .readiness import ready_spec, wait_for_readiness
30
+ from .types import DmonMeta
31
+
32
+
33
+ class DmonError(Exception):
34
+ """Base exception for invalid Python API requests."""
35
+
36
+
37
+ class DmonConfigError(DmonError):
38
+ """Configuration could not be loaded or validated."""
39
+
40
+
41
+ class Dmon:
42
+ def __init__(self, config: Optional[Union[str, os.PathLike]] = None) -> None:
43
+ self.config = str(config) if config is not None else None
44
+
45
+ def start(self, *tasks: str) -> BatchResult:
46
+ names, configs, _ = self._tasks(tasks)
47
+ self._validate_environments(configs)
48
+ results = []
49
+ with self._operation():
50
+ for name, config in zip(names, configs):
51
+ result = start_single_result(config)
52
+ snapshot = (
53
+ task_snapshot(result.meta) if result.meta is not None else None
54
+ )
55
+ results.append(
56
+ ActionResult(
57
+ action="start",
58
+ name=name,
59
+ ok=result.exit_code == 0,
60
+ exit_code=result.exit_code,
61
+ snapshot=snapshot,
62
+ error="" if result.exit_code == 0 else "task did not start",
63
+ )
64
+ )
65
+ return BatchResult("start", tuple(results))
66
+
67
+ def stop(self, *tasks: str) -> BatchResult:
68
+ names, configs, _ = self._tasks(tasks)
69
+ results = []
70
+ with self._operation():
71
+ for name, config in zip(names, configs):
72
+ exit_code = stop_single(config.meta_path)
73
+ results.append(
74
+ ActionResult(
75
+ action="stop",
76
+ name=name,
77
+ ok=exit_code == 0,
78
+ exit_code=exit_code,
79
+ error="" if exit_code == 0 else "task did not stop",
80
+ )
81
+ )
82
+ return BatchResult("stop", tuple(results))
83
+
84
+ def restart(self, *tasks: str) -> BatchResult:
85
+ names, configs, _ = self._tasks(tasks)
86
+ self._validate_environments(configs)
87
+ results = []
88
+ with self._operation():
89
+ for name, config in zip(names, configs):
90
+ exit_code = restart([config])
91
+ meta = self._load_task_meta(Path(config.meta_path))
92
+ snapshot = task_snapshot(meta) if meta is not None else None
93
+ results.append(
94
+ ActionResult(
95
+ action="restart",
96
+ name=name,
97
+ ok=exit_code == 0,
98
+ exit_code=exit_code,
99
+ snapshot=snapshot,
100
+ error="" if exit_code == 0 else "task did not restart",
101
+ )
102
+ )
103
+ return BatchResult("restart", tuple(results))
104
+
105
+ def status(self, task: Optional[str] = None) -> TaskResult:
106
+ names, configs, _ = self._tasks(() if task is None else (task,))
107
+ if len(names) != 1:
108
+ raise DmonConfigError("status requires exactly one task")
109
+ with self._operation():
110
+ return self._task_result(names[0], Path(configs[0].meta_path))
111
+
112
+ def list_tasks(self) -> Tuple[TaskResult, ...]:
113
+ project = self._project()
114
+ results = []
115
+ with self._operation():
116
+ meta_dir = project / DEFAULT_META_DIR
117
+ if meta_dir.is_dir():
118
+ for path in sorted(meta_dir.glob("*.meta.json")):
119
+ results.append(
120
+ inspect_task(
121
+ path.name[: -len(".meta.json")],
122
+ path,
123
+ require_running=False,
124
+ )
125
+ )
126
+ return tuple(results)
127
+
128
+ def stack_status(self, stack: str) -> StackResult:
129
+ if not isinstance(stack, str) or not stack:
130
+ raise DmonConfigError("stack name must not be empty")
131
+ project = self._project()
132
+ name = stack.lower()
133
+ with self._operation():
134
+ return inspect_stack(
135
+ name,
136
+ project / DEFAULT_META_DIR / f"{name}{STACK_META_SUFFIX}",
137
+ require_running=True,
138
+ )
139
+
140
+ def list_stacks(self) -> Tuple[StackResult, ...]:
141
+ project = self._project()
142
+ results = []
143
+ with self._operation():
144
+ meta_dir = project / DEFAULT_META_DIR
145
+ if meta_dir.is_dir():
146
+ for path in sorted(meta_dir.glob(f"*{STACK_META_SUFFIX}")):
147
+ name = path.name[: -len(STACK_META_SUFFIX)]
148
+ results.append(inspect_stack(name, path, require_running=False))
149
+ return tuple(results)
150
+
151
+ def wait(
152
+ self,
153
+ *tasks: str,
154
+ timeout: Optional[float] = None,
155
+ interval: Optional[float] = None,
156
+ ) -> Tuple[WaitResult, ...]:
157
+ for name, value in (("timeout", timeout), ("interval", interval)):
158
+ if value is not None and (
159
+ not isinstance(value, (int, float))
160
+ or isinstance(value, bool)
161
+ or not math.isfinite(value)
162
+ or value <= 0
163
+ ):
164
+ raise DmonConfigError(f"{name} must be finite and greater than zero")
165
+ names, configs, _ = self._tasks(tasks)
166
+ self._validate_environments(configs)
167
+ results = []
168
+ with self._operation():
169
+ for name, config in zip(names, configs):
170
+ if not config.ready:
171
+ results.append(
172
+ WaitResult(
173
+ name,
174
+ False,
175
+ "invalid",
176
+ 0.0,
177
+ 0,
178
+ "task has no readiness probe",
179
+ )
180
+ )
181
+ continue
182
+ try:
183
+ meta = DmonMeta.load(config.meta_path)
184
+ except (OSError, ValueError, TypeError) as error:
185
+ results.append(
186
+ WaitResult(name, False, "metadata-error", 0.0, 0, str(error))
187
+ )
188
+ continue
189
+ if meta is None:
190
+ results.append(
191
+ WaitResult(
192
+ name,
193
+ False,
194
+ "not-running",
195
+ 0.0,
196
+ 0,
197
+ "task metadata not found",
198
+ )
199
+ )
200
+ continue
201
+ spec = ready_spec(config.ready, timeout=timeout, interval=interval)
202
+ results.append(
203
+ wait_for_readiness(
204
+ name,
205
+ spec,
206
+ cwd=config.cwd,
207
+ env=task_environment(config),
208
+ process_running=lambda meta=meta: check_running(
209
+ meta.pid, meta.create_time
210
+ ),
211
+ )
212
+ )
213
+ return tuple(results)
214
+
215
+ def _tasks(self, tasks: Sequence[str]):
216
+ if any(not isinstance(task, str) or not task for task in tasks):
217
+ raise DmonConfigError("task names must be non-empty strings")
218
+ try:
219
+ names, configs, path = get_task_config(tasks, self.config)
220
+ fill_default_paths(configs)
221
+ except (OSError, ValueError, TypeError) as error:
222
+ raise DmonConfigError(str(error)) from error
223
+ project = path.parent.resolve()
224
+ for config in configs:
225
+ config.cwd = self._resolve_path(project, config.cwd or ".")
226
+ config.meta_path = self._resolve_path(project, config.meta_path)
227
+ config.log_path = self._resolve_path(project, config.log_path)
228
+ config.rotate_log_path = self._resolve_path(project, config.rotate_log_path)
229
+ config.env_files = [
230
+ self._resolve_path(project, value) for value in config.env_files
231
+ ]
232
+ return names, configs, project
233
+
234
+ def _project(self) -> Path:
235
+ try:
236
+ _, path = load_config(self.config)
237
+ except (OSError, ValueError, TypeError) as error:
238
+ raise DmonConfigError(str(error)) from error
239
+ return path.parent
240
+
241
+ @staticmethod
242
+ def _validate_environments(configs) -> None:
243
+ try:
244
+ for config in configs:
245
+ task_environment(config)
246
+ except (OSError, ValueError) as error:
247
+ raise DmonConfigError(str(error)) from error
248
+
249
+ @contextmanager
250
+ def _operation(self) -> Iterator[None]:
251
+ with diagnostic_output(StringIO()):
252
+ yield
253
+
254
+ @staticmethod
255
+ def _resolve_path(project: Path, value: str) -> str:
256
+ path = Path(value)
257
+ return str(path.resolve() if path.is_absolute() else (project / path).resolve())
258
+
259
+ def _task_result(self, name: str, path: Path) -> TaskResult:
260
+ return inspect_task(name, path, require_running=True)
261
+
262
+ @staticmethod
263
+ def _load_task_meta(path: Path) -> Optional[DmonMeta]:
264
+ return DmonMeta.load(path)