python-dmon 0.3.0__py3-none-any.whl → 0.3.1__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/cli.py CHANGED
@@ -1,11 +1,11 @@
1
1
  import argparse
2
+ import os
2
3
  from pathlib import Path
3
4
  import shlex
4
- import sys
5
5
 
6
6
  from colorama import just_fix_windows_console
7
7
 
8
- from .config import check_name_in_config, get_task_config
8
+ from .config import check_name_in_config, get_task_config, load_config
9
9
  from .control import (
10
10
  execute,
11
11
  get_meta_paths,
@@ -193,9 +193,9 @@ def main():
193
193
  )
194
194
  sp_run.add_argument(
195
195
  "command_list",
196
- metavar="command",
197
- nargs=argparse.ONE_OR_MORE,
198
- help="Command (with args) to run",
196
+ metavar="COMMAND",
197
+ nargs=argparse.REMAINDER,
198
+ help="Command and arguments to run; '--' is an optional separator",
199
199
  )
200
200
 
201
201
  sp_exec = subparsers.add_parser(
@@ -212,7 +212,9 @@ def main():
212
212
  # add custom config file option
213
213
  for sp in [sp_start, sp_stop, sp_restart, sp_status, sp_exec]:
214
214
  sp.add_argument(
215
+ "-c",
215
216
  "--config",
217
+ metavar="PATH",
216
218
  help="Path to config file or the directory containing it (default: search from current directory upwards)",
217
219
  )
218
220
 
@@ -221,7 +223,10 @@ def main():
221
223
  if args.command in ["start", "restart"]:
222
224
  sp = sp_start if args.command == "start" else sp_restart
223
225
  try:
224
- tasks, task_cfgs = get_task_config(args.task, args.config, args.all)
226
+ tasks, task_cfgs, cfg_path = get_task_config(
227
+ args.task, args.config, args.all
228
+ )
229
+ os.chdir(cfg_path.parent)
225
230
  except Exception as e:
226
231
  sp.error(str(e))
227
232
 
@@ -245,19 +250,34 @@ def main():
245
250
  task_cfg.rotate_log_path or ROTATE_LOG_PATH_TEMPLATE.format(task=task)
246
251
  )
247
252
  if args.command == "start":
248
- sys.exit(start(task_cfgs))
253
+ sp.exit(start(task_cfgs))
249
254
  else:
250
- sys.exit(restart(task_cfgs))
255
+ sp.exit(restart(task_cfgs))
251
256
  elif args.command == "exec":
252
257
  try:
253
- _, task_cfgs = get_task_config(args.task, args.config)
258
+ _, task_cfgs, cfg_path = get_task_config(args.task, args.config)
259
+ os.chdir(cfg_path.parent)
254
260
  except Exception as e:
255
261
  sp_exec.error(str(e))
256
- sys.exit(execute(task_cfgs[0]))
262
+ sp_exec.exit(execute(task_cfgs[0]))
257
263
  elif args.command in ["stop", "status"]:
258
264
  sp = sp_stop if args.command == "stop" else sp_status
259
265
  meta_paths = []
260
266
 
267
+ tasks = args.task
268
+ if args.task:
269
+ try:
270
+ tasks, _, cfg_path = get_task_config(args.task, args.config)
271
+ os.chdir(cfg_path.parent)
272
+ except Exception as e:
273
+ sp.error(str(e))
274
+ elif args.config:
275
+ try:
276
+ _, cfg_path = load_config(args.config)
277
+ os.chdir(cfg_path.parent)
278
+ except Exception as e:
279
+ sp.error(str(e))
280
+
261
281
  # Collect meta paths from --all
262
282
  if args.all:
263
283
  meta_paths.extend(get_meta_paths(DEFAULT_META_DIR))
@@ -267,14 +287,14 @@ def main():
267
287
  meta_paths.append(args.meta_file)
268
288
 
269
289
  # Collect meta paths from task names
270
- if len(args.task) > 0:
271
- tasks = args.task
290
+ if len(tasks) > 0:
272
291
  meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
273
292
 
274
293
  # If no meta paths collected, use default task
275
294
  if len(meta_paths) == 0:
276
295
  try:
277
- tasks, _ = get_task_config(args.task, args.config)
296
+ tasks, _, cfg_path = get_task_config(args.task, args.config)
297
+ os.chdir(cfg_path.parent)
278
298
  except Exception as e:
279
299
  sp.error(str(e))
280
300
  meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
@@ -283,13 +303,18 @@ def main():
283
303
  unique_meta_paths = sorted(set(Path(p).resolve() for p in meta_paths))
284
304
 
285
305
  if args.command == "stop":
286
- sys.exit(stop(unique_meta_paths))
306
+ sp.exit(stop(unique_meta_paths))
287
307
  else:
288
- sys.exit(status(unique_meta_paths))
308
+ sp.exit(status(unique_meta_paths))
289
309
  elif args.command == "list":
290
310
  dir = args.dir or DEFAULT_META_DIR
291
- sys.exit(list_processes(dir, args.full))
311
+ sp_list.exit(list_processes(dir, args.full))
292
312
  elif args.command == "run":
313
+ command_list = args.command_list
314
+ if command_list and command_list[0] == "--":
315
+ command_list = command_list[1:]
316
+ if not command_list:
317
+ sp_run.error("Please provide a command to run.")
293
318
  if not args.name:
294
319
  sp_run.error("Please provide a non-empty name for the task.")
295
320
  elif check_name_in_config(args.name):
@@ -299,7 +324,7 @@ def main():
299
324
 
300
325
  task_cfg = DmonTaskConfig(
301
326
  task=args.name,
302
- cmd=shlex.join(args.command_list) if args.shell else args.command_list,
327
+ cmd=shlex.join(command_list) if args.shell else command_list,
303
328
  cwd=args.cwd,
304
329
  meta_path=args.meta_file or META_PATH_TEMPLATE.format(task=args.name),
305
330
  log_path=args.log_file or LOG_PATH_TEMPLATE.format(task=args.name),
@@ -307,10 +332,10 @@ def main():
307
332
  rotate_log_path=args.rotate_log_path
308
333
  or ROTATE_LOG_PATH_TEMPLATE.format(task=args.name),
309
334
  )
310
- sys.exit(start([task_cfg]))
335
+ sp_run.exit(start([task_cfg]))
311
336
  else:
312
337
  parser.print_help()
313
- sys.exit(1)
338
+ parser.exit(1)
314
339
 
315
340
 
316
341
  if __name__ == "__main__":
dmon/config.py CHANGED
@@ -64,6 +64,10 @@ def load_config(cfg_path: Optional[str] = None):
64
64
  cfg = cfg.get("tool", {}).get("dmon", {})
65
65
  else:
66
66
  raise ValueError("Config file must be YAML (.yaml/.yml) or TOML (.toml)")
67
+ if cfg is None:
68
+ cfg = {}
69
+ if not isinstance(cfg, dict):
70
+ raise TypeError(f"Config in '{path}' must be a table")
67
71
  return cfg, path
68
72
 
69
73
 
@@ -130,6 +134,14 @@ def validate_task(task, name: str) -> DmonTaskConfig:
130
134
  )
131
135
  ret.log_max_size = task["log_max_size"]
132
136
 
137
+ if "log_backup_count" in task:
138
+ value = task["log_backup_count"]
139
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
140
+ raise TypeError(
141
+ f"Task '{name}' 'log_backup_count' field must be a positive integer"
142
+ )
143
+ ret.log_backup_count = value
144
+
133
145
  if "rotate_log_path" in task:
134
146
  if not isinstance(task["rotate_log_path"], str):
135
147
  raise TypeError(
@@ -147,6 +159,14 @@ def validate_task(task, name: str) -> DmonTaskConfig:
147
159
  )
148
160
  ret.rotate_log_max_size = task["rotate_log_max_size"]
149
161
 
162
+ if "rotate_log_backup_count" in task:
163
+ value = task["rotate_log_backup_count"]
164
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
165
+ raise TypeError(
166
+ f"Task '{name}' 'rotate_log_backup_count' field must be a positive integer"
167
+ )
168
+ ret.rotate_log_backup_count = value
169
+
150
170
  if "meta_path" in task:
151
171
  if not isinstance(task["meta_path"], str):
152
172
  raise TypeError(f"Task '{name}' 'meta_path' field must be a string")
@@ -160,7 +180,7 @@ def validate_task(task, name: str) -> DmonTaskConfig:
160
180
 
161
181
  def get_task_config(
162
182
  names: Union[Sequence[str], str, None], cfg_path: Optional[str], all: bool = False
163
- ) -> Tuple[Sequence[str], List[DmonTaskConfig]]:
183
+ ) -> Tuple[Sequence[str], List[DmonTaskConfig], Path]:
164
184
  """
165
185
  Get the validated task configurations for the given task names.
166
186
  If 'all' is True, return all tasks.
@@ -195,6 +215,7 @@ def get_task_config(
195
215
  else:
196
216
  raise ValueError(f"Multiple tasks found in {path}; please specify one.")
197
217
 
218
+ ret_names = []
198
219
  ret_tasks = []
199
220
  for name in names:
200
221
  name = name.lower()
@@ -202,8 +223,9 @@ def get_task_config(
202
223
  raise ValueError(f"Task '{name}' not found in {path}")
203
224
 
204
225
  task = validate_task(tasks[name], name)
226
+ ret_names.append(name)
205
227
  ret_tasks.append(task)
206
- return names, ret_tasks
228
+ return ret_names, ret_tasks, path
207
229
 
208
230
 
209
231
  def check_name_in_config(name: str) -> bool:
@@ -211,7 +233,10 @@ def check_name_in_config(name: str) -> bool:
211
233
  Check if the given task name exists in the tasks.
212
234
  Return True if found, False otherwise.
213
235
  """
214
- cfg, _ = load_config()
236
+ try:
237
+ cfg, _ = load_config()
238
+ except FileNotFoundError:
239
+ return False
215
240
  tasks = cfg.get("tasks", {})
216
241
 
217
242
  if not isinstance(tasks, dict):
dmon/control.py CHANGED
@@ -35,11 +35,24 @@ def ensure_log_dir(log_path: Path):
35
35
 
36
36
  def start(cfgs: Sequence[DmonTaskConfig]):
37
37
  ret = 0
38
+ failed = []
38
39
  for idx, cfg in enumerate(cfgs):
39
- # non-zero if any start() fails
40
- ret |= start_single(cfg)
40
+ result = start_single(cfg)
41
+ ret |= result
42
+ if result:
43
+ failed.append(cfg.task)
41
44
  if idx < len(cfgs) - 1:
42
45
  print("---", file=sys.stderr) # print a blank line between tasks
46
+ if failed and len(cfgs) > 1:
47
+ succeeded = len(cfgs) - len(failed)
48
+ failed_tasks = ", ".join(
49
+ colored(f"'{task}'", color="cyan", attrs=["bold"]) for task in failed
50
+ )
51
+ print(
52
+ f"\nStarted {succeeded} task(s); failed to start: {failed_tasks}. "
53
+ "Multi-task start is best-effort, so successful tasks were left running.",
54
+ file=sys.stderr,
55
+ )
43
56
  return ret
44
57
 
45
58
 
@@ -49,47 +62,76 @@ def start_single(cfg: DmonTaskConfig):
49
62
  cwd = Path(cfg.cwd).resolve()
50
63
 
51
64
  try:
52
- ret_meta = DmonMeta.load(meta_path)
53
- except Exception:
54
- ret_meta = None
55
- if ret_meta:
65
+ existing_meta = DmonMeta.load(meta_path)
66
+ except (OSError, ValueError, TypeError) as error:
67
+ print(
68
+ colored(
69
+ f"Start failed: cannot read metadata {meta_path}: {error}",
70
+ color="red",
71
+ attrs=["bold"],
72
+ ),
73
+ file=sys.stderr,
74
+ )
75
+ return 1
76
+ if existing_meta and existing_meta.state == "starting":
77
+ print(
78
+ colored(
79
+ f"Start failed: task '{cfg.task}' is already being started by another dmon process.",
80
+ color="red",
81
+ attrs=["bold"],
82
+ ),
83
+ file=sys.stderr,
84
+ )
85
+ print(
86
+ "If the earlier start was interrupted, run 'dmon stop' to remove its reservation.",
87
+ file=sys.stderr,
88
+ )
89
+ return 1
90
+ if existing_meta and check_running(existing_meta.pid, existing_meta.create_time):
56
91
  print(
57
92
  f"{colored('Start failed: meta file already exists', color='red', attrs=['bold'])}",
58
93
  file=sys.stderr,
59
94
  )
60
- print_status(ret_meta)
95
+ print_status(existing_meta)
61
96
  print(
62
97
  "\nRun 'dmon status' / 'dmon list' to check, or 'dmon stop' to stop it.",
63
98
  file=sys.stderr,
64
99
  )
65
100
  return 1
101
+ if existing_meta:
102
+ print(
103
+ colored(
104
+ f"Removing stale metadata for exited task '{cfg.task}'.",
105
+ color="yellow",
106
+ ),
107
+ file=sys.stderr,
108
+ )
109
+ meta_path.unlink(missing_ok=True)
66
110
 
67
111
  ensure_meta_dir(meta_path)
68
112
  ensure_log_dir(log_path)
69
113
 
70
- env = None # default behavior of Popen
71
- if cfg.override_env:
72
- env = cfg.env
73
- elif cfg.env:
74
- env = {**os.environ, **cfg.env}
114
+ env = task_environment(cfg)
75
115
 
76
116
  shell = isinstance(cfg.cmd, str)
77
117
 
78
118
  # Platform-specific parameters to run the process in background detached from parent
79
119
  kwargs = {}
120
+ command = cfg.cmd
80
121
  if ON_WINDOWS:
81
122
  # DETACHED_PROCESS = 0x00000008
82
123
  CREATE_NO_WINDOW = 0x08000000
83
124
  kwargs["creationflags"] = CREATE_NO_WINDOW
84
- if isinstance(cfg.cmd, list) and len(cfg.cmd) > 0:
125
+ if isinstance(command, list) and len(command) > 0:
85
126
  # On Windows, use full path for the executable when shell=False
86
- cfg.cmd[0] = shutil.which(cfg.cmd[0]) or cfg.cmd[0]
127
+ command = [shutil.which(command[0]) or command[0], *command[1:]]
87
128
  else:
88
129
  # Make the child process independent of the parent process in Unix-like systems
89
130
  kwargs["start_new_session"] = True
90
131
 
91
132
  meta = DmonMeta(
92
133
  task=cfg.task,
134
+ state="starting",
93
135
  meta_path=str(meta_path),
94
136
  log_path=str(log_path),
95
137
  log_rotate=cfg.log_rotate,
@@ -101,63 +143,97 @@ def start_single(cfg: DmonTaskConfig):
101
143
  popen_kwargs=kwargs,
102
144
  )
103
145
 
104
- if cfg.log_rotate:
105
- rotate_log_path = Path(cfg.rotate_log_path).resolve()
106
-
107
- meta.rotate_log_path = str(rotate_log_path)
108
- meta.log_max_size = cfg.log_max_size
109
- meta.rotate_log_max_size = cfg.rotate_log_max_size
110
-
111
- ensure_log_dir(rotate_log_path)
112
-
113
- # use runner to start user process and handle log rotation
114
- if isinstance(cfg.cmd, str):
115
- # when cmd is str, we need to split it into list for subprocess
116
- cmd = shlex.split(cfg.cmd)
117
- else:
118
- cmd = cfg.cmd
119
- args = [
120
- sys.executable,
121
- "-m",
122
- "dmon.runner",
123
- "--log-path",
124
- str(log_path),
125
- "--max-log-size",
126
- str(cfg.log_max_size),
127
- "--rotate-log-path",
128
- str(rotate_log_path),
129
- "--max-rotate-log-size",
130
- str(cfg.rotate_log_max_size),
131
- ]
132
- if shell:
133
- args.append("--shell")
134
- args.append("--")
135
- args.extend(cmd)
136
- proc = subprocess.Popen(
137
- args,
138
- cwd=cwd,
139
- env=env,
140
- stdout=subprocess.DEVNULL,
141
- stderr=subprocess.STDOUT,
142
- **kwargs,
146
+ try:
147
+ meta.dump(meta_path, exclusive=True)
148
+ except FileExistsError:
149
+ print(
150
+ colored(
151
+ f"Start failed: another dmon process is starting task '{cfg.task}'.",
152
+ color="red",
153
+ attrs=["bold"],
154
+ ),
155
+ file=sys.stderr,
143
156
  )
144
- else:
145
- # Open the log file (append binary mode)
146
- with open(log_path, "ab", buffering=0) as lof:
147
- # Start the child process with stdout/stderr redirected to the log
157
+ return 1
158
+
159
+ try:
160
+ if cfg.log_rotate:
161
+ rotate_log_path = Path(cfg.rotate_log_path).resolve()
162
+
163
+ meta.rotate_log_path = str(rotate_log_path)
164
+ meta.log_max_size = cfg.log_max_size
165
+ meta.log_backup_count = cfg.log_backup_count
166
+ meta.rotate_log_max_size = cfg.rotate_log_max_size
167
+ meta.rotate_log_backup_count = cfg.rotate_log_backup_count
168
+
169
+ ensure_log_dir(rotate_log_path)
170
+
171
+ # use runner to start user process and handle log rotation
172
+ if isinstance(command, str):
173
+ # when cmd is str, we need to split it into list for subprocess
174
+ cmd = shlex.split(command)
175
+ else:
176
+ cmd = command
177
+ args = [
178
+ sys.executable,
179
+ "-m",
180
+ "dmon.runner",
181
+ "--log-path",
182
+ str(log_path),
183
+ "--max-log-size",
184
+ str(cfg.log_max_size),
185
+ "--rotate-log-path",
186
+ str(rotate_log_path),
187
+ "--max-rotate-log-size",
188
+ str(cfg.rotate_log_max_size),
189
+ ]
190
+ if cfg.log_backup_count is not None:
191
+ args.extend(["--log-backup-count", str(cfg.log_backup_count)])
192
+ if cfg.rotate_log_backup_count is not None:
193
+ args.extend(
194
+ ["--rotate-log-backup-count", str(cfg.rotate_log_backup_count)]
195
+ )
196
+ if shell:
197
+ args.append("--shell")
198
+ args.append("--")
199
+ args.extend(cmd)
148
200
  proc = subprocess.Popen(
149
- cfg.cmd,
150
- stdout=lof,
151
- stderr=subprocess.STDOUT,
201
+ args,
152
202
  cwd=cwd,
153
203
  env=env,
154
- shell=shell,
155
- text=False, # binary mode
156
- bufsize=0, # unbuffered
204
+ stdout=subprocess.DEVNULL,
205
+ stderr=subprocess.STDOUT,
157
206
  **kwargs,
158
207
  )
208
+ else:
209
+ # Open the log file (append binary mode)
210
+ with open(log_path, "ab", buffering=0) as lof:
211
+ # Start the child process with stdout/stderr redirected to the log
212
+ proc = subprocess.Popen(
213
+ command,
214
+ stdout=lof,
215
+ stderr=subprocess.STDOUT,
216
+ cwd=cwd,
217
+ env=env,
218
+ shell=shell,
219
+ text=False, # binary mode
220
+ bufsize=0, # unbuffered
221
+ **kwargs,
222
+ )
223
+ except OSError as error:
224
+ meta_path.unlink(missing_ok=True)
225
+ print(
226
+ colored(
227
+ f"Start failed for task '{cfg.task}': {error}",
228
+ color="red",
229
+ attrs=["bold"],
230
+ ),
231
+ file=sys.stderr,
232
+ )
233
+ return 1
159
234
 
160
235
  meta.pid = proc.pid
236
+ meta.state = "running"
161
237
  try:
162
238
  p = psutil.Process(proc.pid)
163
239
  create_time = p.create_time()
@@ -166,16 +242,42 @@ def start_single(cfg: DmonTaskConfig):
166
242
  )
167
243
  meta.create_time = create_time
168
244
  meta.create_time_human = create_time_human
169
- except psutil.NoSuchProcess:
245
+ except (psutil.NoSuchProcess, ValueError):
170
246
  # process already exited?
171
247
  pass
172
248
 
173
- meta.dump(meta_path)
249
+ try:
250
+ meta.dump(meta_path)
251
+ except OSError as error:
252
+ try:
253
+ managed_process = psutil.Process(proc.pid)
254
+ except psutil.NoSuchProcess:
255
+ pass
256
+ else:
257
+ terminate_process(managed_process, timeout=2.0)
258
+ meta_path.unlink(missing_ok=True)
259
+ print(
260
+ colored(
261
+ f"Start failed: cannot save metadata for task '{cfg.task}': {error}",
262
+ color="red",
263
+ attrs=["bold"],
264
+ ),
265
+ file=sys.stderr,
266
+ )
267
+ return 1
174
268
 
175
269
  print_status(meta)
176
270
  return 0
177
271
 
178
272
 
273
+ def task_environment(cfg: DmonTaskConfig):
274
+ if cfg.override_env:
275
+ return cfg.env
276
+ if cfg.env:
277
+ return {**os.environ, **cfg.env}
278
+ return None
279
+
280
+
179
281
  def stop(meta_paths: Sequence[PathType], timeout=5.0):
180
282
  ret = 0
181
283
  for idx, meta_path in enumerate(meta_paths):
@@ -191,7 +293,18 @@ def stop_single(
191
293
  timeout=5.0,
192
294
  ):
193
295
  meta_path = Path(meta_path).resolve()
194
- meta = DmonMeta.load(meta_path)
296
+ try:
297
+ meta = DmonMeta.load(meta_path)
298
+ except (OSError, ValueError, TypeError) as error:
299
+ print(
300
+ colored(
301
+ f"Stop failed: cannot read metadata {meta_path}: {error}",
302
+ color="red",
303
+ attrs=["bold"],
304
+ ),
305
+ file=sys.stderr,
306
+ )
307
+ return 1
195
308
  if meta is None:
196
309
  print(
197
310
  colored(
@@ -207,7 +320,7 @@ def stop_single(
207
320
  pid = meta.pid
208
321
  try:
209
322
  proc = psutil.Process(pid)
210
- except psutil.NoSuchProcess:
323
+ except (psutil.NoSuchProcess, ValueError):
211
324
  print(
212
325
  colored(
213
326
  f"Process {pid} not found (already exited); removing stale meta file",
@@ -218,7 +331,7 @@ def stop_single(
218
331
  )
219
332
  print_status(meta)
220
333
  meta_path.unlink(missing_ok=True)
221
- return 1
334
+ return 0
222
335
 
223
336
  # check if it's the same process by comparing create_time
224
337
  if not check_same_process(proc, meta.create_time):
@@ -232,135 +345,132 @@ def stop_single(
232
345
  )
233
346
  print_status(meta)
234
347
  meta_path.unlink(missing_ok=True)
235
- return 1
348
+ return 0
349
+ if not process_is_alive(proc):
350
+ print(
351
+ colored(
352
+ f"Process {pid} already exited; removing stale meta file",
353
+ color="yellow",
354
+ attrs=["bold"],
355
+ ),
356
+ file=sys.stderr,
357
+ )
358
+ print_status(meta)
359
+ meta_path.unlink(missing_ok=True)
360
+ return 0
236
361
 
237
- if ON_WINDOWS:
238
- ret = terminate_win(proc, timeout)
239
- else:
240
- ret = terminate_posix(proc, timeout)
362
+ ret = terminate_process(proc, timeout)
241
363
  print_status(meta)
242
364
  if ret == 0:
243
365
  meta_path.unlink(missing_ok=True)
244
366
  return ret
245
367
 
246
368
 
247
- def terminate_posix(proc: psutil.Process, timeout):
248
- # send SIGTERM first for graceful shutdown (if platform supports)
249
- proc.terminate()
369
+ def terminate_process(proc: psutil.Process, timeout: float) -> int:
370
+ if not ON_WINDOWS:
371
+ try:
372
+ process_group = os.getpgid(proc.pid)
373
+ except (ProcessLookupError, psutil.NoSuchProcess):
374
+ return 0
375
+ if process_group == proc.pid:
376
+ return terminate_posix_group(proc, process_group, timeout)
377
+ return terminate_process_tree(proc, timeout)
378
+
250
379
 
251
- # wait for the process to exit or timeout
380
+ def terminate_posix_group(
381
+ proc: psutil.Process, process_group: int, timeout: float
382
+ ) -> int:
252
383
  try:
253
- ret = proc.wait(timeout)
254
- print(
255
- colored(
256
- f"Process {proc.pid} exited with code {ret}; removing meta file",
257
- color="green",
258
- attrs=["bold"],
259
- ),
260
- file=sys.stderr,
261
- )
262
- except psutil.TimeoutExpired:
384
+ os.killpg(process_group, signal.SIGTERM)
385
+ except ProcessLookupError:
386
+ return 0
387
+
388
+ deadline = time.monotonic() + timeout
389
+ while time.monotonic() < deadline and process_group_exists(process_group):
390
+ try:
391
+ proc.wait(timeout=0)
392
+ except (psutil.NoSuchProcess, psutil.TimeoutExpired):
393
+ pass
394
+ time.sleep(0.05)
395
+ if process_group_exists(process_group):
263
396
  print(
264
397
  colored(
265
- f"Process {proc.pid} did not exit in time; shutting down child processes",
398
+ f"Process group {process_group} did not exit in time; killing it",
266
399
  color="yellow",
267
400
  attrs=["bold"],
268
401
  ),
269
402
  file=sys.stderr,
270
403
  )
271
-
272
- # first shut down child processes (leaf nodes first)
273
- for child in reversed(proc.children(recursive=True)):
274
- try:
275
- child.terminate()
276
- child.wait(timeout=2)
277
- except Exception:
278
- try:
279
- child.kill()
280
- except Exception:
281
- pass
282
- # then shut down the parent process
283
404
  try:
284
- # check if process is already exited
285
- ret = proc.wait(timeout=2)
286
- print(
287
- colored(
288
- f"Process {proc.pid} exited with code {ret} after terminating children; removing meta file",
289
- color="green",
290
- attrs=["bold"],
291
- ),
292
- file=sys.stderr,
293
- )
294
- except psutil.TimeoutExpired:
295
- print(
296
- colored(
297
- f"Process {proc.pid} did not exit in time after terminating children; killing it",
298
- color="yellow",
299
- attrs=["bold"],
300
- ),
301
- file=sys.stderr,
302
- )
303
- try:
304
- proc.kill()
305
- print(
306
- colored(
307
- f"Killed process {proc.pid}; removing meta file",
308
- color="green",
309
- attrs=["bold"],
310
- ),
311
- file=sys.stderr,
312
- )
313
- except Exception as e:
314
- print(
315
- colored(
316
- f"Failed to kill process {proc.pid}: {e}",
317
- color="red",
318
- attrs=["bold"],
319
- ),
320
- file=sys.stderr,
321
- )
405
+ os.killpg(process_group, signal.SIGKILL)
406
+ except ProcessLookupError:
407
+ return 0
408
+ except PermissionError:
409
+ if process_group_exists(process_group):
322
410
  return 1
411
+ try:
412
+ proc.wait(timeout=1)
413
+ except (psutil.NoSuchProcess, psutil.TimeoutExpired):
414
+ pass
415
+ print(
416
+ colored(
417
+ f"Process group {process_group} stopped; removing meta file",
418
+ color="green",
419
+ attrs=["bold"],
420
+ ),
421
+ file=sys.stderr,
422
+ )
323
423
  return 0
324
424
 
325
425
 
326
- def terminate_win(proc: psutil.Process, timeout):
327
- # On Windows, we need to stop child processes first
328
- children = proc.children(recursive=True)
329
- # reverse to kill leaf nodes first
330
- for child in reversed(children):
426
+ def process_group_exists(process_group: int) -> bool:
427
+ for process in psutil.process_iter(["pid", "status"]):
428
+ if process.info["status"] == psutil.STATUS_ZOMBIE:
429
+ continue
331
430
  try:
332
- child.kill()
333
- child.wait(timeout=2)
334
- except Exception:
335
- pass
431
+ if os.getpgid(process.info["pid"]) == process_group:
432
+ return True
433
+ except (OSError, psutil.NoSuchProcess):
434
+ continue
435
+ return False
436
+
437
+
438
+ def terminate_process_tree(proc: psutil.Process, timeout: float) -> int:
336
439
  try:
337
- ret = proc.wait(timeout=timeout)
338
- print(
339
- colored(
340
- f"Process {proc.pid} exited with code {ret}; removing meta file",
341
- color="green",
342
- attrs=["bold"],
343
- ),
344
- file=sys.stderr,
345
- )
346
- except psutil.TimeoutExpired:
347
- print(
348
- colored(
349
- f"Process {proc.pid} did not exit in time after killing children; killing it",
350
- color="yellow",
351
- attrs=["bold"],
352
- ),
353
- file=sys.stderr,
354
- )
355
- proc.kill()
440
+ processes = [*proc.children(recursive=True), proc]
441
+ except psutil.NoSuchProcess:
442
+ return 0
443
+ for process in reversed(processes):
444
+ try:
445
+ process.terminate()
446
+ except psutil.NoSuchProcess:
447
+ pass
448
+ _, alive = psutil.wait_procs(processes, timeout=timeout)
449
+ for process in alive:
450
+ try:
451
+ process.kill()
452
+ except psutil.NoSuchProcess:
453
+ pass
454
+ _, alive = psutil.wait_procs(alive, timeout=1)
455
+ if alive:
356
456
  print(
357
457
  colored(
358
- f"Killed process {proc.pid}; removing meta file",
359
- color="green",
458
+ "Failed to stop process tree: "
459
+ + ", ".join(str(process.pid) for process in alive),
460
+ color="red",
360
461
  attrs=["bold"],
361
462
  ),
362
463
  file=sys.stderr,
363
464
  )
465
+ return 1
466
+ print(
467
+ colored(
468
+ f"Process tree {proc.pid} stopped; removing meta file",
469
+ color="green",
470
+ attrs=["bold"],
471
+ ),
472
+ file=sys.stderr,
473
+ )
364
474
  return 0
365
475
 
366
476
 
@@ -379,7 +489,21 @@ def status(meta_paths: Sequence[PathType]):
379
489
  metas = []
380
490
  for idx, meta_path in enumerate(meta_paths):
381
491
  meta_path = Path(meta_path).resolve()
382
- meta = DmonMeta.load(meta_path)
492
+ try:
493
+ meta = DmonMeta.load(meta_path)
494
+ except (OSError, ValueError, TypeError) as error:
495
+ print(
496
+ colored(
497
+ f"Status failed: cannot read metadata {meta_path}: {error}",
498
+ color="red",
499
+ attrs=["bold"],
500
+ ),
501
+ file=sys.stderr,
502
+ )
503
+ ret |= 1
504
+ if idx < len(meta_paths) - 1:
505
+ print("---", file=sys.stderr)
506
+ continue
383
507
  if meta is None:
384
508
  print(
385
509
  colored(
@@ -394,6 +518,8 @@ def status(meta_paths: Sequence[PathType]):
394
518
  else:
395
519
  print_status(meta)
396
520
  metas.append(meta)
521
+ if not check_running(meta.pid, meta.create_time):
522
+ ret |= 1
397
523
  if idx < len(meta_paths) - 1:
398
524
  print("---", file=sys.stderr)
399
525
  if metas:
@@ -408,6 +534,13 @@ def check_same_process(proc: psutil.Process, create_time: float) -> bool:
408
534
  return abs(proc.create_time() - create_time) < 1e-3
409
535
 
410
536
 
537
+ def process_is_alive(proc: psutil.Process) -> bool:
538
+ try:
539
+ return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE
540
+ except psutil.NoSuchProcess:
541
+ return False
542
+
543
+
411
544
  def get_unique_process(pid: int, create_time: float):
412
545
  """
413
546
  Get the process with given PID and create_time if it is still the same process.
@@ -415,7 +548,7 @@ def get_unique_process(pid: int, create_time: float):
415
548
  if create_time >= 0:
416
549
  try:
417
550
  p = psutil.Process(pid)
418
- if check_same_process(p, create_time):
551
+ if check_same_process(p, create_time) and process_is_alive(p):
419
552
  return p
420
553
  except psutil.NoSuchProcess:
421
554
  pass
@@ -431,7 +564,7 @@ def check_running(pid: int, create_time: float) -> bool:
431
564
  return False
432
565
  try:
433
566
  p = psutil.Process(pid)
434
- return abs(p.create_time() - create_time) < 1e-3
567
+ return check_same_process(p, create_time) and process_is_alive(p)
435
568
  except psutil.NoSuchProcess:
436
569
  return False
437
570
 
@@ -458,7 +591,11 @@ def print_status(meta: DmonMeta):
458
591
  if meta.log_rotate:
459
592
  rows.append(("ROTATE LOG PATH", meta.rotate_log_path))
460
593
  rows.append(("LOG MAX SIZE", f"{meta.log_max_size} MB"))
594
+ if meta.log_backup_count is not None:
595
+ rows.append(("LOG BACKUP COUNT", meta.log_backup_count))
461
596
  rows.append(("ROTATE LOG MAX SIZE", f"{meta.rotate_log_max_size} MB"))
597
+ if meta.rotate_log_backup_count is not None:
598
+ rows.append(("ROTATE LOG BACKUP COUNT", meta.rotate_log_backup_count))
462
599
 
463
600
  # calculate the max width of the keys
464
601
  key_width = max(len(key) for key, _ in rows)
@@ -564,8 +701,21 @@ def list_processes(dir: PathType, full_width: bool):
564
701
  target_dmon_dir = Path(dir).resolve()
565
702
  meta_paths = get_meta_paths(target_dmon_dir)
566
703
  metas = []
704
+ errors = 0
567
705
  for meta_path in meta_paths:
568
- meta = DmonMeta.load(meta_path)
706
+ try:
707
+ meta = DmonMeta.load(meta_path)
708
+ except (OSError, ValueError, TypeError) as error:
709
+ print(
710
+ colored(
711
+ f"Cannot read metadata {meta_path}: {error}",
712
+ color="red",
713
+ attrs=["bold"],
714
+ ),
715
+ file=sys.stderr,
716
+ )
717
+ errors += 1
718
+ continue
569
719
  if meta is not None:
570
720
  metas.append(meta)
571
721
  # sort by name (case-insensitive)
@@ -577,7 +727,7 @@ def list_processes(dir: PathType, full_width: bool):
577
727
  f"\nFound {n_task} task{'s' if n_task > 1 else ''} ({n_proc} process{'es' if n_proc > 1 else ''}) in {target_dmon_dir}",
578
728
  file=sys.stderr,
579
729
  )
580
- return 0
730
+ return 1 if errors else 0
581
731
 
582
732
 
583
733
  def execute(cfg: DmonTaskConfig):
@@ -587,25 +737,13 @@ def execute(cfg: DmonTaskConfig):
587
737
  """
588
738
  cwd = Path(cfg.cwd).resolve()
589
739
 
590
- env = None # default behavior of Popen
591
- if cfg.override_env:
592
- env = cfg.env
593
- elif cfg.env:
594
- env = {**os.environ, **cfg.env}
740
+ env = task_environment(cfg)
595
741
 
596
742
  shell = isinstance(cfg.cmd, str)
597
743
  if ON_WINDOWS and isinstance(cfg.cmd, list) and len(cfg.cmd) > 0:
598
744
  # On Windows, use full path for the executable when shell=False
599
745
  cfg.cmd[0] = shutil.which(cfg.cmd[0]) or cfg.cmd[0]
600
746
 
601
- def signal_handler(signum: int, frame) -> None:
602
- if ON_WINDOWS and signum == signal.SIGINT:
603
- signum = signal.SIGTERM
604
- proc.send_signal(signum)
605
-
606
- prev_handle_int = signal.signal(signal.SIGINT, signal_handler)
607
- prev_handle_term = signal.signal(signal.SIGTERM, signal_handler)
608
-
609
747
  proc = subprocess.Popen(
610
748
  cfg.cmd,
611
749
  cwd=cwd,
@@ -614,8 +752,24 @@ def execute(cfg: DmonTaskConfig):
614
752
  bufsize=0, # unbuffered
615
753
  close_fds=False,
616
754
  )
617
- ret = proc.wait()
618
755
 
619
- signal.signal(signal.SIGTERM, prev_handle_term)
620
- signal.signal(signal.SIGINT, prev_handle_int)
621
- return ret
756
+ def signal_handler(signum: int, frame) -> None:
757
+ # POSIX terminals deliver SIGINT to both foreground processes. Forwarding
758
+ # it would make the child receive Ctrl-C twice. Windows needs explicit
759
+ # forwarding, translated to a signal subprocess can deliver reliably.
760
+ if not ON_WINDOWS and signum == signal.SIGINT:
761
+ return
762
+ if ON_WINDOWS and signum == signal.SIGINT:
763
+ signum = signal.SIGTERM
764
+ try:
765
+ proc.send_signal(signum)
766
+ except ProcessLookupError:
767
+ pass
768
+
769
+ prev_handle_int = signal.signal(signal.SIGINT, signal_handler)
770
+ prev_handle_term = signal.signal(signal.SIGTERM, signal_handler)
771
+ try:
772
+ return proc.wait()
773
+ finally:
774
+ signal.signal(signal.SIGTERM, prev_handle_term)
775
+ signal.signal(signal.SIGINT, prev_handle_int)
dmon/runner.py CHANGED
@@ -3,30 +3,63 @@ from datetime import datetime
3
3
  import logging
4
4
  from logging.handlers import RotatingFileHandler
5
5
  import os
6
+ from pathlib import Path
7
+ import re
6
8
  import signal
7
9
  import subprocess
8
10
  import sys
9
11
 
10
12
 
11
13
  logger = logging.getLogger("dmon.runner")
12
-
13
-
14
- class FixedSizeRotatingFileHandler(RotatingFileHandler):
15
- """
16
- Custom RotatingFileHandler that renames the old log file with a timestamp suffix
17
- instead of deleting it.
18
- """
19
-
20
- def __init__(self, filename, maxBytes):
21
- super().__init__(filename, maxBytes=maxBytes)
14
+ TIMESTAMP_FORMAT = "%Y%m%d-%H%M%S"
15
+
16
+
17
+ def timestamped_backups(log_path):
18
+ path = Path(log_path)
19
+ # Include the pre-0.3.1 runner-log format so opt-in retention can also
20
+ # clean up legacy archives on platforms where ':' was a valid filename.
21
+ timestamp = r"(?:\d{8}-\d{6}|\d{8}-\d{2}:\d{2}:\d{2})"
22
+ pattern = re.compile(rf"^{re.escape(path.name)}\.{timestamp}(?:\.\d+)?$")
23
+ return [
24
+ candidate
25
+ for candidate in path.parent.iterdir()
26
+ if pattern.fullmatch(candidate.name)
27
+ ]
28
+
29
+
30
+ def rotate_file(log_path, timestamp=None, backup_count=None):
31
+ path = Path(log_path)
32
+ timestamp = timestamp or datetime.now().strftime(TIMESTAMP_FORMAT)
33
+ backup = Path(f"{path}.{timestamp}")
34
+ counter = 1
35
+ while backup.exists():
36
+ backup = Path(f"{path}.{timestamp}.{counter}")
37
+ counter += 1
38
+
39
+ os.replace(path, backup)
40
+ if backup_count is not None:
41
+ backups = sorted(
42
+ timestamped_backups(path),
43
+ key=lambda item: (item.stat().st_mtime_ns, item.name),
44
+ )
45
+ for old_backup in backups[:-backup_count]:
46
+ old_backup.unlink()
47
+ return backup
48
+
49
+
50
+ class TimestampedRotatingFileHandler(RotatingFileHandler):
51
+ def __init__(self, filename, maxBytes, backup_count=None):
52
+ super().__init__(filename, maxBytes=maxBytes, backupCount=1)
53
+ self.backup_count = backup_count
22
54
 
23
55
  def doRollover(self):
24
56
  if self.stream:
25
57
  self.stream.close()
26
- # Create timestamped filename for the rollover logs
27
- current_time = datetime.now().strftime(".%Y%m%d-%H:%M:%S")
28
- self.rotate(self.baseFilename, self.baseFilename + current_time)
29
- self.stream = self._open()
58
+ self.stream = None
59
+ if os.path.exists(self.baseFilename):
60
+ rotate_file(self.baseFilename, backup_count=self.backup_count)
61
+ if not self.delay:
62
+ self.stream = self._open()
30
63
 
31
64
 
32
65
  def get_file_dir(file_path):
@@ -48,18 +81,12 @@ def need_rotate(log_path, max_log_size):
48
81
  return os.path.getsize(log_path) >= max_log_size and max_log_size > 0
49
82
 
50
83
 
51
- def rotate_log(log_path):
52
- current_time = datetime.now().strftime(".%Y%m%d-%H%M%S")
53
- new_name = log_path + current_time
54
- logger.info(f"Rotating {log_path} to {new_name}")
55
- if os.path.exists(new_name):
56
- logger.warning(f"{new_name} already exists, skip renaming")
57
- else:
58
- make_file_dir(new_name)
59
- os.rename(log_path, new_name)
84
+ def rotate_log(log_path, backup_count=None):
85
+ logger.info(f"Rotating {log_path}")
86
+ rotate_file(log_path, backup_count=backup_count)
60
87
 
61
88
 
62
- def loop_to_log(bin_fd, log_path, max_log_size):
89
+ def loop_to_log(bin_fd, log_path, max_log_size, backup_count=None):
63
90
  while True: # loop once whenever need to rotate
64
91
  with open(log_path, "ab") as log_file:
65
92
  while True:
@@ -79,7 +106,7 @@ def loop_to_log(bin_fd, log_path, max_log_size):
79
106
  return
80
107
  except Exception as e:
81
108
  logger.exception(f"Exception in while loop: {e}")
82
- rotate_log(log_path) # rotate log file
109
+ rotate_log(log_path, backup_count) # rotate log file
83
110
 
84
111
 
85
112
  def catch_exception(func):
@@ -97,13 +124,19 @@ def main(
97
124
  cmd,
98
125
  log_path,
99
126
  max_log_size,
127
+ log_backup_count,
100
128
  rotate_log_path,
101
129
  max_rotate_log_size,
130
+ rotate_log_backup_count,
102
131
  ):
103
132
  # Configure logging
104
133
  rh = None
105
134
  if rotate_log_path:
106
- rh = FixedSizeRotatingFileHandler(rotate_log_path, maxBytes=max_rotate_log_size)
135
+ rh = TimestampedRotatingFileHandler(
136
+ rotate_log_path,
137
+ maxBytes=max_rotate_log_size,
138
+ backup_count=rotate_log_backup_count,
139
+ )
107
140
  logging.basicConfig(
108
141
  format="%(asctime)s.%(msecs)03d - %(process)d - %(levelname)s - %(message)s",
109
142
  level=logging.INFO,
@@ -149,7 +182,7 @@ def main(
149
182
  logger.info(f"Started process {proc.pid} with command: {cmd} (shell={shell})")
150
183
 
151
184
  make_file_dir(log_path)
152
- loop_to_log(proc.stdout, log_path, max_log_size)
185
+ loop_to_log(proc.stdout, log_path, max_log_size, log_backup_count)
153
186
 
154
187
 
155
188
  if __name__ == "__main__":
@@ -175,18 +208,32 @@ if __name__ == "__main__":
175
208
  help="Path of the log output of this rotation process; if not provided, output to stdout",
176
209
  default=None,
177
210
  )
211
+ parser.add_argument(
212
+ "--log-backup-count",
213
+ help="Number of task log archives to retain; omit to retain all",
214
+ type=int,
215
+ default=None,
216
+ )
178
217
  parser.add_argument(
179
218
  "--max-rotate-log-size",
180
219
  help="Max file size (MB) for this rotation log; 0 for no rotation",
181
220
  type=float,
182
221
  default=5,
183
222
  )
223
+ parser.add_argument(
224
+ "--rotate-log-backup-count",
225
+ help="Number of runner log archives to retain; omit to retain all",
226
+ type=int,
227
+ default=None,
228
+ )
184
229
  args = parser.parse_args()
185
230
  main(
186
231
  " ".join(args.command) if args.shell else args.command,
187
232
  args.log_path,
188
233
  int(args.max_log_size * 1024 * 1024),
234
+ args.log_backup_count,
189
235
  args.rotate_log_path,
190
236
  int(args.max_rotate_log_size * 1024 * 1024),
237
+ args.rotate_log_backup_count,
191
238
  )
192
239
  logger.info("Process finished.")
dmon/types.py CHANGED
@@ -1,8 +1,10 @@
1
1
  from dataclasses import asdict, dataclass, field
2
2
  import json
3
+ import os
3
4
  from os import PathLike
4
5
  from pathlib import Path
5
6
  import sys
7
+ import tempfile
6
8
  from typing import Dict, List, Optional, Union
7
9
 
8
10
 
@@ -33,10 +35,14 @@ class DmonTaskConfig:
33
35
  """Whether to rotate log file"""
34
36
  log_max_size: float = 5
35
37
  """Size in MB to rotate log file"""
38
+ log_backup_count: Optional[int] = None
39
+ """Number of task log archives to retain; None keeps all archives"""
36
40
  rotate_log_path: str = ""
37
41
  """Path to rotation log file"""
38
42
  rotate_log_max_size: float = 5
39
43
  """Size in MB to rotation log file"""
44
+ rotate_log_backup_count: Optional[int] = None
45
+ """Number of runner log archives to retain; None keeps all archives"""
40
46
  meta_path: str = ""
41
47
  """Path to meta file"""
42
48
 
@@ -44,14 +50,45 @@ class DmonTaskConfig:
44
50
  @dataclass
45
51
  class DmonMeta(DmonTaskConfig):
46
52
  pid: int = -1
53
+ state: str = "running"
47
54
  shell: bool = False
48
55
  popen_kwargs: Dict = field(default_factory=dict)
49
56
  create_time: float = -1
50
57
  create_time_human: str = "N/A"
51
58
 
52
- def dump(self, path: PathType):
53
- with open(path, "w", encoding="utf-8") as f:
54
- json.dump(asdict(self), f, indent=2, ensure_ascii=False)
59
+ def dump(self, path: PathType, *, exclusive: bool = False):
60
+ target = Path(path)
61
+ data = asdict(self)
62
+ if exclusive:
63
+ descriptor = os.open(
64
+ target,
65
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
66
+ 0o600,
67
+ )
68
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
69
+ json.dump(data, stream, indent=2, ensure_ascii=False)
70
+ stream.flush()
71
+ os.fsync(stream.fileno())
72
+ return
73
+
74
+ temporary_path = None
75
+ try:
76
+ with tempfile.NamedTemporaryFile(
77
+ mode="w",
78
+ encoding="utf-8",
79
+ dir=target.parent,
80
+ prefix=f".{target.name}.",
81
+ suffix=".tmp",
82
+ delete=False,
83
+ ) as stream:
84
+ temporary_path = Path(stream.name)
85
+ json.dump(data, stream, indent=2, ensure_ascii=False)
86
+ stream.flush()
87
+ os.fsync(stream.fileno())
88
+ os.replace(temporary_path, target)
89
+ finally:
90
+ if temporary_path is not None:
91
+ temporary_path.unlink(missing_ok=True)
55
92
 
56
93
  @staticmethod
57
94
  def load(path: PathType) -> Optional["DmonMeta"]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-dmon
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A lightweight, cross-platform daemon manager that runs any command as a background process.
5
5
  Keywords: python-dmon,dmon,daemon,background,detach,process management
6
6
  Author: Atomie CHEN
@@ -37,7 +37,7 @@ It is a Python-based and more powerful successor to the [handy-backend shell scr
37
37
  - 🖥️ **Cross-platform:** Works on Linux, macOS, and Windows.
38
38
  - ⚡ **Lightweight:** Pure Python, no Docker or external dependencies needed.
39
39
  - 🧩 **Flexible tasks:** Tasks can be configured in `pyproject.toml` or `dmon.yaml`; or run ad-hoc commands directly.
40
- - 🪵 **Logging & log rotation:** Automatically manage log files to prevent uncontrolled growth.
40
+ - 🪵 **Logging & log rotation:** Keep active log files manageable, with optional archive retention limits.
41
41
 
42
42
  ![dmon-demo-gif](https://github.com/user-attachments/assets/9bae2f46-5ef4-4784-aced-18d573204efc)
43
43
 
@@ -126,6 +126,11 @@ dmon exec app
126
126
 
127
127
  You can specify multiple tasks at once, e.g.: `dmon start app1 app2 app3`, except for `dmon exec` which only accepts one task.
128
128
 
129
+ Multi-task `start` is best-effort: dmon attempts every requested task and leaves
130
+ successful tasks running if another task cannot start. The command returns a
131
+ non-zero status and prints a summary naming the failed tasks. This is useful for
132
+ independent background services and does not provide atomic stack semantics.
133
+
129
134
  Or use `--all` to operate on all tasks:
130
135
 
131
136
  ```sh
@@ -145,12 +150,12 @@ dmon status
145
150
  dmon exec
146
151
  ```
147
152
 
148
- You can use `--config` to specify a custom config file or the directory containing it:
153
+ You can use `-c` / `--config` to specify a custom config file or the directory containing it:
149
154
 
150
155
  ```sh
151
156
  dmon start --config /path/to/dmon.yaml app # YAML
152
157
  dmon start --config /path/to/pyproject.toml app # or TOML
153
- dmon start --config /path/to/dir app # dir with `dmon.y(a)ml` or `pyproject.toml`
158
+ dmon start -c /path/to/dir app # shorter, dir with `dmon.y(a)ml` or `pyproject.toml`
154
159
  ```
155
160
 
156
161
  And yes, you can use `dmon` to run in a nested manner:
@@ -172,6 +177,9 @@ tasks:
172
177
  # Run a command with arguments in the background
173
178
  dmon run --name myserver python -u server.py
174
179
 
180
+ # Optionally use -- to make the child-command boundary explicit
181
+ dmon run --name timer -- python -c 'import time; time.sleep(30)'
182
+
175
183
  # Run a shell command in the background
176
184
  dmon run --shell echo "Hello World"
177
185
 
@@ -194,6 +202,16 @@ dmon list
194
202
 
195
203
  A task can be a **string**, **list**, or **dictionary**.
196
204
 
205
+ When rotation is enabled, dmon keeps timestamped archives such as
206
+ `app.log.20260807-142106`. Both task and runner logs use this cross-platform
207
+ format; a same-second collision adds `.1`, `.2`, and so on. Archives are never
208
+ deleted by default. Set a backup count explicitly to enable retention cleanup.
209
+ `log_path` contains task output; `rotate_log_path` contains diagnostics from the
210
+ dmon process that captures and rotates that output. They are independent log
211
+ streams and use independent retention settings.
212
+ The size limit is checked at line boundaries, so a single long line may exceed
213
+ the configured limit.
214
+
197
215
  Here is a more complete example with default values:
198
216
 
199
217
  ```yaml
@@ -208,8 +226,10 @@ tasks:
208
226
  log_path: "logs/<task>.log" # path to log file
209
227
  log_rotate: false # enable log rotation
210
228
  log_max_size: 5 # max log file size before rotation in MB
229
+ # log_backup_count: 10 # optional; omit to retain all task log archives
211
230
  rotate_log_path: "logs/<task>.rotate.log" # path to rotation log
212
231
  rotate_log_max_size: 5 # max rotation log file size in MB
232
+ # rotate_log_backup_count: 10 # optional; omit to retain all runner log archives
213
233
  meta_path: ".dmon/<task>.meta.json" # path to meta file
214
234
  default_task: your_task_name # the default task name
215
235
  ```
@@ -227,6 +247,8 @@ another_task = "cd subdir && ls && bash start.sh"
227
247
  default_task = "your_task_name"
228
248
  ```
229
249
 
250
+ All paths can be absolute or relative to the **config file location**.
251
+
230
252
 
231
253
  ## Under the Hood
232
254
 
@@ -234,6 +256,10 @@ Each task is associated with a meta file (e.g. `.dmon/<task>.meta.json`) stored
234
256
  The file contains details such as the command, PID, log path, and more.
235
257
  **Do not** modify or delete these files manually.
236
258
 
259
+ `dmon status` returns a non-zero status if a recorded task has exited. Starting
260
+ that task again removes its stale metadata automatically. `dmon stop` terminates
261
+ the complete process tree and also cleans stale metadata left by an exited task.
262
+
237
263
 
238
264
  ## License
239
265
 
@@ -0,0 +1,14 @@
1
+ dmon/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ dmon/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
3
+ dmon/cli.py,sha256=M4T7QY5fbeQ94Dx3_OA-FQKVXnJlEStIAtOefmoaaYg,11436
4
+ dmon/config.py,sha256=cYK-KV3sSc6UqOm5cN1dgU-SWRBGoiNE_xFOg2D4GKU,8903
5
+ dmon/constants.py,sha256=uJPI66IJvT6vhamqEDeqHjZQS12RxCPe3DD5yCU0UR8,407
6
+ dmon/control.py,sha256=XaEz8JS2MnsztQC23Dj7guhg6IgNz3uavdfcAyWDbrE,24064
7
+ dmon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ dmon/runner.py,sha256=XNUbvw-eOZUQBQ_CLpZNLTFjjI3Uh0IDK_PgqggUPyk,7597
9
+ dmon/types.py,sha256=0BoCkc1hyqriDrPleDVQ7Z53f-U5WeF_DyLLVCVru3k,3131
10
+ dmon/utils.py,sha256=zVQC9YNhMQu4hrAp9k2ds2egnbWZZv7S5w8i2oVIPJg,808
11
+ python_dmon-0.3.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
12
+ python_dmon-0.3.1.dist-info/entry_points.txt,sha256=1XmEpyjShANXEHMIMa4p3WXpcllIZfGt5DQyTFuSI74,78
13
+ python_dmon-0.3.1.dist-info/METADATA,sha256=W4_--3lc_zT4RkTC83susBVqNVUF5WZSBOgtAlY1QzU,8216
14
+ python_dmon-0.3.1.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- dmon/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- dmon/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
3
- dmon/cli.py,sha256=NgTrOsYjXRPI6BO1T9VReO__quyis91sY04nfk7OWTQ,10514
4
- dmon/config.py,sha256=JAxasW5G0NIok6hZUZVEpsvF3vp5iiDNr2pjnLpUmh4,7911
5
- dmon/constants.py,sha256=uJPI66IJvT6vhamqEDeqHjZQS12RxCPe3DD5yCU0UR8,407
6
- dmon/control.py,sha256=bjRv2YWSo4RFoUHNl1kU1S_XHjC3tWH0aR9QgQtpDY0,18758
7
- dmon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- dmon/runner.py,sha256=zW5dPan0RFAlTsuHwCZ7Pklxt6M740Cu7y8m1f5U9xU,6096
9
- dmon/types.py,sha256=Cbppb5os7GoKCcwr66S5ge_DunuJepeefpnZGaSr788,1799
10
- dmon/utils.py,sha256=zVQC9YNhMQu4hrAp9k2ds2egnbWZZv7S5w8i2oVIPJg,808
11
- python_dmon-0.3.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
12
- python_dmon-0.3.0.dist-info/entry_points.txt,sha256=1XmEpyjShANXEHMIMa4p3WXpcllIZfGt5DQyTFuSI74,78
13
- python_dmon-0.3.0.dist-info/METADATA,sha256=KmrhqMP-5A4CVpv1iJS1UDbmMGEpfELMtFLMAs-k8u0,6661
14
- python_dmon-0.3.0.dist-info/RECORD,,