python-dmon 0.2.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/control.py ADDED
@@ -0,0 +1,542 @@
1
+ import os
2
+ from pathlib import Path
3
+ import shlex
4
+ import shutil
5
+ import sys
6
+ import subprocess
7
+ import time
8
+ from typing import List
9
+
10
+ import psutil
11
+ from termcolor import colored
12
+
13
+ from .constants import DEFAULT_META_DIR, META_SUFFIX
14
+ from .types import DmonTaskConfig, DmonMeta, PathType
15
+ from .utils import len_ansi, pad_ansi
16
+
17
+
18
+ def ensure_meta_dir(meta_path: Path):
19
+ """
20
+ Ensure the meta directory exists.
21
+ If it is under the default meta directory, create .gitignore to ignore all files in it.
22
+ """
23
+ meta_dir = meta_path.resolve().parent
24
+ meta_dir.mkdir(parents=True, exist_ok=True)
25
+ if meta_dir == DEFAULT_META_DIR.resolve():
26
+ gitignore = DEFAULT_META_DIR / ".gitignore"
27
+ if not gitignore.exists():
28
+ gitignore.write_text("# Auto-generated by dmon\n*\n")
29
+
30
+
31
+ def ensure_log_dir(log_path: Path):
32
+ log_path.parent.mkdir(parents=True, exist_ok=True)
33
+
34
+
35
+ def start(cfg: DmonTaskConfig):
36
+ meta_path = Path(cfg.meta_path).resolve()
37
+ log_path = Path(cfg.log_path).resolve()
38
+ cwd = Path(cfg.cwd).resolve()
39
+
40
+ try:
41
+ ret_meta = DmonMeta.load(meta_path)
42
+ except Exception:
43
+ ret_meta = None
44
+ if ret_meta:
45
+ print(
46
+ f"{colored('Start failed: meta file already exists', color='red', attrs=['bold'])}",
47
+ file=sys.stderr,
48
+ )
49
+ print_status(ret_meta)
50
+ print(
51
+ "\nRun 'dmon status' / 'dmon list' to check, or 'dmon stop' to stop it.",
52
+ file=sys.stderr,
53
+ )
54
+ return 1
55
+
56
+ ensure_meta_dir(meta_path)
57
+ ensure_log_dir(log_path)
58
+
59
+ env = None # default behavior of Popen
60
+ if cfg.override_env:
61
+ env = cfg.env
62
+ elif cfg.env:
63
+ env = {**os.environ, **cfg.env}
64
+
65
+ shell = isinstance(cfg.cmd, str)
66
+
67
+ # Platform-specific parameters to run the process in background detached from parent
68
+ kwargs = {}
69
+ if sys.platform.startswith("win"):
70
+ # DETACHED_PROCESS = 0x00000008
71
+ CREATE_NO_WINDOW = 0x08000000
72
+ kwargs["creationflags"] = CREATE_NO_WINDOW
73
+ if isinstance(cfg.cmd, list) and len(cfg.cmd) > 0:
74
+ # On Windows, use full path for the executable when shell=False
75
+ cfg.cmd[0] = shutil.which(cfg.cmd[0]) or cfg.cmd[0]
76
+ else:
77
+ # Make the child process independent of the parent process in Unix-like systems
78
+ kwargs["start_new_session"] = True
79
+
80
+ meta = DmonMeta(
81
+ name=cfg.name,
82
+ meta_path=str(meta_path),
83
+ log_path=str(log_path),
84
+ log_rotate=cfg.log_rotate,
85
+ cmd=cfg.cmd,
86
+ cwd=str(cwd),
87
+ env=cfg.env,
88
+ override_env=cfg.override_env,
89
+ shell=shell,
90
+ popen_kwargs=kwargs,
91
+ )
92
+
93
+ if cfg.log_rotate:
94
+ rotate_log_path = Path(cfg.rotate_log_path).resolve()
95
+
96
+ meta.rotate_log_path = str(rotate_log_path)
97
+ meta.log_max_size = cfg.log_max_size
98
+ meta.rotate_log_max_size = cfg.rotate_log_max_size
99
+
100
+ ensure_log_dir(rotate_log_path)
101
+
102
+ # use runner to start user process and handle log rotation
103
+ if isinstance(cfg.cmd, str):
104
+ # when cmd is str, we need to split it into list for subprocess
105
+ cmd = shlex.split(cfg.cmd)
106
+ else:
107
+ cmd = cfg.cmd
108
+ args = [
109
+ sys.executable,
110
+ "-m",
111
+ "dmon.runner",
112
+ "--log-path",
113
+ str(log_path),
114
+ "--max-log-size",
115
+ str(cfg.log_max_size),
116
+ "--rotate-log-path",
117
+ str(rotate_log_path),
118
+ "--max-rotate-log-size",
119
+ str(cfg.rotate_log_max_size),
120
+ ]
121
+ if shell:
122
+ args.append("--shell")
123
+ args.append("--")
124
+ args.extend(cmd)
125
+ proc = subprocess.Popen(
126
+ args,
127
+ cwd=cwd,
128
+ env=env,
129
+ stdout=subprocess.DEVNULL,
130
+ stderr=subprocess.STDOUT,
131
+ **kwargs,
132
+ )
133
+ else:
134
+ # Open the log file (append binary mode)
135
+ with open(log_path, "ab", buffering=0) as lof:
136
+ # Start the child process with stdout/stderr redirected to the log
137
+ proc = subprocess.Popen(
138
+ cfg.cmd,
139
+ stdout=lof,
140
+ stderr=subprocess.STDOUT,
141
+ cwd=cwd,
142
+ env=env,
143
+ shell=shell,
144
+ text=False, # binary mode
145
+ bufsize=0, # unbuffered
146
+ **kwargs,
147
+ )
148
+
149
+ meta.pid = proc.pid
150
+ try:
151
+ p = psutil.Process(proc.pid)
152
+ create_time = p.create_time()
153
+ create_time_human = time.strftime(
154
+ "%Y-%m-%d %H:%M:%S", time.localtime(create_time)
155
+ )
156
+ meta.create_time = create_time
157
+ meta.create_time_human = create_time_human
158
+ except psutil.NoSuchProcess:
159
+ # process already exited?
160
+ pass
161
+
162
+ meta.dump(meta_path)
163
+
164
+ print_status(meta)
165
+ return 0
166
+
167
+
168
+ def stop(
169
+ meta_path: PathType,
170
+ timeout=5.0,
171
+ ):
172
+ meta_path = Path(meta_path).resolve()
173
+ meta = DmonMeta.load(meta_path)
174
+ if meta is None:
175
+ print(
176
+ colored(
177
+ "Stop failed: meta file not found (maybe not started)\n",
178
+ color="red",
179
+ attrs=["bold"],
180
+ )
181
+ + str(meta_path),
182
+ file=sys.stderr,
183
+ )
184
+ return 1
185
+
186
+ pid = meta.pid
187
+ try:
188
+ proc = psutil.Process(pid)
189
+ except psutil.NoSuchProcess:
190
+ print(
191
+ colored(
192
+ f"Process {pid} not found (already exited); removing stale meta file",
193
+ color="yellow",
194
+ attrs=["bold"],
195
+ ),
196
+ file=sys.stderr,
197
+ )
198
+ print_status(meta)
199
+ meta_path.unlink(missing_ok=True)
200
+ return 1
201
+
202
+ # check if it's the same process by comparing create_time
203
+ if not check_same_process(proc, meta.create_time):
204
+ print(
205
+ colored(
206
+ f"Warning: PID {pid} exists but create_time does not match (maybe reused by another process); removing stale meta file",
207
+ color="yellow",
208
+ attrs=["bold"],
209
+ ),
210
+ file=sys.stderr,
211
+ )
212
+ print_status(meta)
213
+ meta_path.unlink(missing_ok=True)
214
+ return 1
215
+
216
+ if sys.platform.startswith("win"):
217
+ ret = terminate_win(proc, timeout)
218
+ else:
219
+ ret = terminate_posix(proc, timeout)
220
+ print_status(meta)
221
+ if ret == 0:
222
+ meta_path.unlink(missing_ok=True)
223
+ return ret
224
+
225
+
226
+ def terminate_posix(proc: psutil.Process, timeout):
227
+ # send SIGTERM first for graceful shutdown (if platform supports)
228
+ proc.terminate()
229
+
230
+ # wait for the process to exit or timeout
231
+ try:
232
+ ret = proc.wait(timeout)
233
+ print(
234
+ colored(
235
+ f"Process {proc.pid} exited with code {ret}; removing meta file",
236
+ color="green",
237
+ attrs=["bold"],
238
+ ),
239
+ file=sys.stderr,
240
+ )
241
+ except psutil.TimeoutExpired:
242
+ print(
243
+ colored(
244
+ f"Process {proc.pid} did not exit in time; shutting down child processes",
245
+ color="yellow",
246
+ attrs=["bold"],
247
+ ),
248
+ file=sys.stderr,
249
+ )
250
+
251
+ # first shut down child processes (leaf nodes first)
252
+ for child in reversed(proc.children(recursive=True)):
253
+ try:
254
+ child.terminate()
255
+ child.wait(timeout=2)
256
+ except Exception:
257
+ try:
258
+ child.kill()
259
+ except Exception:
260
+ pass
261
+ # then shut down the parent process
262
+ try:
263
+ # check if process is already exited
264
+ ret = proc.wait(timeout=2)
265
+ print(
266
+ colored(
267
+ f"Process {proc.pid} exited with code {ret} after terminating children; removing meta file",
268
+ color="green",
269
+ attrs=["bold"],
270
+ ),
271
+ file=sys.stderr,
272
+ )
273
+ except psutil.TimeoutExpired:
274
+ print(
275
+ colored(
276
+ f"Process {proc.pid} did not exit in time after terminating children; killing it",
277
+ color="yellow",
278
+ attrs=["bold"],
279
+ ),
280
+ file=sys.stderr,
281
+ )
282
+ try:
283
+ proc.kill()
284
+ print(
285
+ colored(
286
+ f"Killed process {proc.pid}; removing meta file",
287
+ color="green",
288
+ attrs=["bold"],
289
+ ),
290
+ file=sys.stderr,
291
+ )
292
+ except Exception as e:
293
+ print(
294
+ colored(
295
+ f"Failed to kill process {proc.pid}: {e}",
296
+ color="red",
297
+ attrs=["bold"],
298
+ ),
299
+ file=sys.stderr,
300
+ )
301
+ return 1
302
+ return 0
303
+
304
+
305
+ def terminate_win(proc: psutil.Process, timeout):
306
+ # On Windows, we need to stop child processes first
307
+ children = proc.children(recursive=True)
308
+ # reverse to kill leaf nodes first
309
+ for child in reversed(children):
310
+ try:
311
+ child.kill()
312
+ child.wait(timeout=2)
313
+ except Exception:
314
+ pass
315
+ try:
316
+ ret = proc.wait(timeout=timeout)
317
+ print(
318
+ colored(
319
+ f"Process {proc.pid} exited with code {ret}; removing meta file",
320
+ color="green",
321
+ attrs=["bold"],
322
+ ),
323
+ file=sys.stderr,
324
+ )
325
+ except psutil.TimeoutExpired:
326
+ print(
327
+ colored(
328
+ f"Process {proc.pid} did not exit in time after killing children; killing it",
329
+ color="yellow",
330
+ attrs=["bold"],
331
+ ),
332
+ file=sys.stderr,
333
+ )
334
+ proc.kill()
335
+ print(
336
+ colored(
337
+ f"Killed process {proc.pid}; removing meta file",
338
+ color="green",
339
+ attrs=["bold"],
340
+ ),
341
+ file=sys.stderr,
342
+ )
343
+ return 0
344
+
345
+
346
+ def restart(
347
+ cfg: DmonTaskConfig,
348
+ timeout=5.0,
349
+ ):
350
+ stop(cfg.meta_path, timeout)
351
+ print("--- Restarting ---", file=sys.stderr)
352
+ return start(cfg)
353
+
354
+
355
+ def status(meta_path: PathType):
356
+ meta_path = Path(meta_path).resolve()
357
+ meta = DmonMeta.load(meta_path)
358
+ if meta is None:
359
+ print(
360
+ colored(
361
+ "Status failed: meta file not found (maybe not started)\n",
362
+ color="red",
363
+ attrs=["bold"],
364
+ )
365
+ + str(meta_path),
366
+ file=sys.stderr,
367
+ )
368
+ return 1
369
+
370
+ print_status(meta)
371
+ print("\nProcess Tree:", file=sys.stderr)
372
+ print_process_table([meta])
373
+ return 0
374
+
375
+
376
+ def check_same_process(proc: psutil.Process, create_time: float) -> bool:
377
+ if create_time < 0:
378
+ return False
379
+ return abs(proc.create_time() - create_time) < 1e-3
380
+
381
+
382
+ def get_unique_process(pid: int, create_time: float):
383
+ """
384
+ Get the process with given PID and create_time if it is still the same process.
385
+ """
386
+ if create_time >= 0:
387
+ try:
388
+ p = psutil.Process(pid)
389
+ if check_same_process(p, create_time):
390
+ return p
391
+ except psutil.NoSuchProcess:
392
+ pass
393
+ return None
394
+
395
+
396
+ def check_running(pid: int, create_time: float) -> bool:
397
+ """
398
+ Check if a process with given PID and create_time is running.
399
+ """
400
+ # return psutil.pid_exists(pid)
401
+ if create_time < 0:
402
+ return False
403
+ try:
404
+ p = psutil.Process(pid)
405
+ return abs(p.create_time() - create_time) < 1e-3
406
+ except psutil.NoSuchProcess:
407
+ return False
408
+
409
+
410
+ def print_status(meta: DmonMeta):
411
+ status = (
412
+ colored("Running", on_color="on_green")
413
+ if check_running(meta.pid, meta.create_time)
414
+ else colored("Exited", on_color="on_light_red")
415
+ )
416
+
417
+ # key-value pairs with aligned keys
418
+ rows = [
419
+ ("TASK", colored(meta.name, "cyan", attrs=["bold"])),
420
+ ("PID", colored(str(meta.pid), "cyan", attrs=["bold"])),
421
+ ("STATUS", status),
422
+ ("CMD", meta.cmd),
423
+ ("WORKING DIR", meta.cwd),
424
+ ("CREATE TIME", meta.create_time_human),
425
+ ("META PATH", meta.meta_path),
426
+ ("LOG ROTATE", meta.log_rotate),
427
+ ("LOG PATH", meta.log_path),
428
+ ]
429
+ if meta.log_rotate:
430
+ rows.append(("ROTATE LOG PATH", meta.rotate_log_path))
431
+ rows.append(("LOG MAX SIZE", f"{meta.log_max_size} MB"))
432
+ rows.append(("ROTATE LOG MAX SIZE", f"{meta.rotate_log_max_size} MB"))
433
+
434
+ # calculate the max width of the keys
435
+ key_width = max(len(key) for key, _ in rows)
436
+ # align the keys and print
437
+ lines = [
438
+ f"{colored(f'{key:<{key_width}}: ', attrs=['dark'])}{value}"
439
+ for key, value in rows
440
+ ]
441
+ print("\n".join(lines), file=sys.stderr)
442
+
443
+
444
+ def get_table_row(p: psutil.Process, target_ppid: int, prefix=""):
445
+ ppid = p.ppid()
446
+ ppid_str = (
447
+ colored(str(ppid), "cyan", attrs=["bold"]) if ppid == target_ppid else str(ppid)
448
+ )
449
+ return (
450
+ prefix + p.name(),
451
+ p.pid,
452
+ ppid_str,
453
+ p.status(),
454
+ p.cmdline(),
455
+ time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(p.create_time())),
456
+ "N/A",
457
+ )
458
+
459
+
460
+ def print_process_table(metas: List[DmonMeta], full_width: bool = False):
461
+ headers = ("TASK", "PID", "PPID", "STATUS", "CMD", "CREATE TIME", "LOG PATH")
462
+ align = ("<", ">", ">", "<", "<", "<", "<")
463
+
464
+ # add table header
465
+ rows = []
466
+ rows.append(headers)
467
+
468
+ processes = []
469
+ for meta in metas:
470
+ proc = get_unique_process(meta.pid, meta.create_time)
471
+ if proc:
472
+ status = colored("Running", on_color="on_green")
473
+ ppid = proc.ppid()
474
+ processes.append(proc)
475
+ else:
476
+ status = colored("Exited", on_color="on_light_red")
477
+ ppid = "N/A"
478
+ rows.append(
479
+ (
480
+ colored(meta.name, "cyan", attrs=["bold"]),
481
+ colored(meta.pid, "cyan", attrs=["bold"]),
482
+ ppid,
483
+ status,
484
+ meta.cmd,
485
+ meta.create_time_human,
486
+ meta.log_path,
487
+ )
488
+ )
489
+ # add child processes indented
490
+ if proc:
491
+ children = proc.children(recursive=True)
492
+ for idx, child in enumerate(children):
493
+ prefix = "├ " if idx < len(children) - 1 else "└ "
494
+ rows.append(get_table_row(child, target_ppid=proc.pid, prefix=prefix))
495
+ processes.append(child)
496
+
497
+ # calculate column widths
498
+ widths = [max(len_ansi(str(row[i])) for row in rows) for i in range(len(headers))]
499
+
500
+ term_width = shutil.get_terminal_size().columns
501
+ diff = sum(widths) + 2 * (len(headers) - 1) - term_width
502
+ if diff > 0 and not full_width:
503
+ # truncate CMD column
504
+ cmd_idx = headers.index("CMD")
505
+ widths[cmd_idx] = max(widths[cmd_idx] - diff, 10) # at least 10 chars
506
+
507
+ # print the table with proper padding
508
+ lines = []
509
+ for row in rows:
510
+ line = " ".join(
511
+ # pad the cell except the last one if left-aligned (no need for extra spaces)
512
+ (
513
+ pad_ansi(str(cell), widths[i], align[i])
514
+ if i < len(align) - 1 or align[i] != "<"
515
+ else str(cell)
516
+ )
517
+ for i, cell in enumerate(row)
518
+ )
519
+ lines.append(line)
520
+ print("\n".join(lines), file=sys.stderr)
521
+
522
+ return processes
523
+
524
+
525
+ def list_processes(dir: PathType, full_width: bool):
526
+ target_dmon_dir = Path(dir).resolve()
527
+ metas = []
528
+ if target_dmon_dir.exists() and target_dmon_dir.is_dir():
529
+ for meta_file in target_dmon_dir.glob(f"*{META_SUFFIX}"):
530
+ meta = DmonMeta.load(meta_file)
531
+ if meta is not None:
532
+ metas.append(meta)
533
+ # sort by name (case-insensitive)
534
+ metas.sort(key=lambda m: m.name.lower())
535
+ n_task = len(metas)
536
+ processes = print_process_table(metas, full_width)
537
+ n_proc = len(processes)
538
+ print(
539
+ f"\nFound {n_task} task{'s' if n_task > 1 else ''} ({n_proc} process{'es' if n_proc > 1 else ''}) in {target_dmon_dir}",
540
+ file=sys.stderr,
541
+ )
542
+ return 0
dmon/py.typed ADDED
File without changes