python-dmon 0.2.4__tar.gz → 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-dmon
3
- Version: 0.2.4
3
+ Version: 0.3.0
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
@@ -21,6 +21,7 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  [![GitHub](https://img.shields.io/badge/github-python--dmon-blue?logo=github)](https://github.com/atomiechen/python-dmon)
23
23
  [![PyPI](https://img.shields.io/pypi/v/python--dmon?logo=pypi&logoColor=white)](https://pypi.org/project/python-dmon/)
24
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/atomiechen/python-dmon)
24
25
 
25
26
 
26
27
  A lightweight, cross-platform daemon manager that runs any command — called a *task* — as a background process.
@@ -113,6 +114,9 @@ dmon start app
113
114
  # Stop a running task
114
115
  dmon stop app
115
116
 
117
+ # Restart a task
118
+ dmon restart app
119
+
116
120
  # Check task status
117
121
  dmon status app
118
122
 
@@ -120,11 +124,23 @@ dmon status app
120
124
  dmon exec app
121
125
  ```
122
126
 
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
+
129
+ Or use `--all` to operate on all tasks:
130
+
131
+ ```sh
132
+ # all configured tasks
133
+ dmon start/stop/restart --all
134
+ # all running tasks
135
+ dmon stop/status --all
136
+ ```
137
+
123
138
  If you have defined `default_task`, or only one task is defined in the config file, you can omit the task name:
124
139
 
125
140
  ```sh
126
141
  dmon start
127
142
  dmon stop
143
+ dmon restart
128
144
  dmon status
129
145
  dmon exec
130
146
  ```
@@ -3,6 +3,7 @@
3
3
 
4
4
  [![GitHub](https://img.shields.io/badge/github-python--dmon-blue?logo=github)](https://github.com/atomiechen/python-dmon)
5
5
  [![PyPI](https://img.shields.io/pypi/v/python--dmon?logo=pypi&logoColor=white)](https://pypi.org/project/python-dmon/)
6
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/atomiechen/python-dmon)
6
7
 
7
8
 
8
9
  A lightweight, cross-platform daemon manager that runs any command — called a *task* — as a background process.
@@ -95,6 +96,9 @@ dmon start app
95
96
  # Stop a running task
96
97
  dmon stop app
97
98
 
99
+ # Restart a task
100
+ dmon restart app
101
+
98
102
  # Check task status
99
103
  dmon status app
100
104
 
@@ -102,11 +106,23 @@ dmon status app
102
106
  dmon exec app
103
107
  ```
104
108
 
109
+ You can specify multiple tasks at once, e.g.: `dmon start app1 app2 app3`, except for `dmon exec` which only accepts one task.
110
+
111
+ Or use `--all` to operate on all tasks:
112
+
113
+ ```sh
114
+ # all configured tasks
115
+ dmon start/stop/restart --all
116
+ # all running tasks
117
+ dmon stop/status --all
118
+ ```
119
+
105
120
  If you have defined `default_task`, or only one task is defined in the config file, you can omit the task name:
106
121
 
107
122
  ```sh
108
123
  dmon start
109
124
  dmon stop
125
+ dmon restart
110
126
  dmon status
111
127
  dmon exec
112
128
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-dmon"
3
- version = "0.2.4"
3
+ version = "0.3.0"
4
4
  description = "A lightweight, cross-platform daemon manager that runs any command as a background process."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,11 +1,20 @@
1
1
  import argparse
2
+ from pathlib import Path
2
3
  import shlex
3
4
  import sys
4
5
 
5
6
  from colorama import just_fix_windows_console
6
7
 
7
8
  from .config import check_name_in_config, get_task_config
8
- from .control import execute, list_processes, restart, start, stop, status
9
+ from .control import (
10
+ execute,
11
+ get_meta_paths,
12
+ list_processes,
13
+ restart,
14
+ start,
15
+ stop,
16
+ status,
17
+ )
9
18
  from .constants import (
10
19
  DEFAULT_META_DIR,
11
20
  DEFAULT_RUN_NAME,
@@ -50,7 +59,7 @@ def main():
50
59
  sp_start.add_argument(
51
60
  "task",
52
61
  help="Configured task name (default: the only task if there's just one)",
53
- nargs="?",
62
+ nargs="*",
54
63
  )
55
64
  sp_start.add_argument(
56
65
  "--meta-file",
@@ -60,20 +69,26 @@ def main():
60
69
  "--log-file",
61
70
  help=f"Path to log file (default: task configured or {LOG_PATH_TEMPLATE})",
62
71
  )
72
+ sp_start.add_argument("--all", action="store_true", help="Start all processes")
63
73
 
64
74
  # stop subcommand
65
75
  sp_stop = subparsers.add_parser(
66
76
  "stop",
67
- help="Stop a background process",
68
- description="Stop a background process given name or meta file",
77
+ help="Stop background process(es)",
78
+ description="Stop background process(es) given name or meta file",
69
79
  # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
70
80
  )
71
81
  sp_stop.add_argument(
72
82
  "task",
73
83
  help="Configured task name (default: the only task if there's just one)",
74
- nargs="?",
84
+ nargs="*",
75
85
  )
76
86
  sp_stop.add_argument("--meta-file", help="Path to meta file")
87
+ sp_stop.add_argument(
88
+ "--all",
89
+ action="store_true",
90
+ help=f"Stop all processes in meta dir ({DEFAULT_META_DIR})",
91
+ )
77
92
 
78
93
  # restart subcommand
79
94
  sp_restart = subparsers.add_parser(
@@ -85,7 +100,7 @@ def main():
85
100
  sp_restart.add_argument(
86
101
  "task",
87
102
  help="Configured task name (default: the only task if there's just one)",
88
- nargs="?",
103
+ nargs="*",
89
104
  )
90
105
  sp_restart.add_argument(
91
106
  "--meta-file",
@@ -95,23 +110,30 @@ def main():
95
110
  "--log-file",
96
111
  help=f"Path to log file (default: task configured or {LOG_PATH_TEMPLATE})",
97
112
  )
113
+ sp_restart.add_argument("--all", action="store_true", help="Restart all processes")
98
114
 
99
115
  # status subcommand
100
116
  sp_status = subparsers.add_parser(
101
117
  "status",
102
- help="Check process status",
103
- description="Check status of a background process given name or meta file",
118
+ help="Check status of background process(es)",
119
+ description="Check status of background process(es) given name or meta file",
104
120
  # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
105
121
  )
106
122
  sp_status.add_argument(
107
123
  "task",
108
124
  help="Configured task name (default: the only task if there's just one)",
109
- nargs="?",
125
+ nargs="*",
110
126
  )
111
127
  sp_status.add_argument(
112
128
  "--meta-file",
113
129
  help=f"Path to meta file (default: {META_PATH_TEMPLATE})",
114
130
  )
131
+ sp_status.add_argument(
132
+ "-a",
133
+ "--all",
134
+ action="store_true",
135
+ help=f"Check status of all processes in meta dir ({DEFAULT_META_DIR})",
136
+ )
115
137
 
116
138
  # list subcommand
117
139
  sp_list = subparsers.add_parser(
@@ -199,46 +221,71 @@ def main():
199
221
  if args.command in ["start", "restart"]:
200
222
  sp = sp_start if args.command == "start" else sp_restart
201
223
  try:
202
- task, task_cfg = get_task_config(args.task, args.config)
224
+ tasks, task_cfgs = get_task_config(args.task, args.config, args.all)
203
225
  except Exception as e:
204
226
  sp.error(str(e))
205
227
 
206
- task_cfg.meta_path = (
207
- args.meta_file or task_cfg.meta_path or META_PATH_TEMPLATE.format(task=task)
208
- )
209
- task_cfg.log_path = (
210
- args.log_file or task_cfg.log_path or LOG_PATH_TEMPLATE.format(task=task)
211
- )
212
- task_cfg.rotate_log_path = (
213
- task_cfg.rotate_log_path or ROTATE_LOG_PATH_TEMPLATE.format(task=task)
214
- )
228
+ # check if meta_file or log_file path is provided;
229
+ # if so, only one task should be specified
230
+ if args.meta_file or args.log_file:
231
+ if len(tasks) == 1:
232
+ task_cfgs[0].meta_path = args.meta_file or task_cfgs[0].meta_path
233
+ task_cfgs[0].log_path = args.log_file or task_cfgs[0].log_path
234
+ else:
235
+ sp.error(
236
+ f"'--meta-file' and '--log-file' can only be specified when {args.command}ing a single task"
237
+ )
238
+ # fill in default values if not provided
239
+ for task, task_cfg in zip(tasks, task_cfgs):
240
+ task_cfg.meta_path = task_cfg.meta_path or META_PATH_TEMPLATE.format(
241
+ task=task
242
+ )
243
+ task_cfg.log_path = task_cfg.log_path or LOG_PATH_TEMPLATE.format(task=task)
244
+ task_cfg.rotate_log_path = (
245
+ task_cfg.rotate_log_path or ROTATE_LOG_PATH_TEMPLATE.format(task=task)
246
+ )
215
247
  if args.command == "start":
216
- sys.exit(start(task_cfg))
248
+ sys.exit(start(task_cfgs))
217
249
  else:
218
- sys.exit(restart(task_cfg))
250
+ sys.exit(restart(task_cfgs))
219
251
  elif args.command == "exec":
220
252
  try:
221
- task, task_cfg = get_task_config(args.task, args.config)
253
+ _, task_cfgs = get_task_config(args.task, args.config)
222
254
  except Exception as e:
223
255
  sp_exec.error(str(e))
224
- sys.exit(execute(task_cfg))
256
+ sys.exit(execute(task_cfgs[0]))
225
257
  elif args.command in ["stop", "status"]:
226
258
  sp = sp_stop if args.command == "stop" else sp_status
259
+ meta_paths = []
260
+
261
+ # Collect meta paths from --all
262
+ if args.all:
263
+ meta_paths.extend(get_meta_paths(DEFAULT_META_DIR))
264
+
265
+ # Collect meta paths from --meta-file
227
266
  if args.meta_file:
228
- meta_path = args.meta_file
229
- else:
230
- if args.task:
231
- task = args.task
232
- else:
233
- try:
234
- task, _ = get_task_config(args.task, args.config)
235
- except Exception as e:
236
- sp.error(str(e))
237
- meta_path = META_PATH_TEMPLATE.format(task=task)
267
+ meta_paths.append(args.meta_file)
268
+
269
+ # Collect meta paths from task names
270
+ if len(args.task) > 0:
271
+ tasks = args.task
272
+ meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
273
+
274
+ # If no meta paths collected, use default task
275
+ if len(meta_paths) == 0:
276
+ try:
277
+ tasks, _ = get_task_config(args.task, args.config)
278
+ except Exception as e:
279
+ sp.error(str(e))
280
+ meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
281
+
282
+ # Remove duplicates
283
+ unique_meta_paths = sorted(set(Path(p).resolve() for p in meta_paths))
284
+
238
285
  if args.command == "stop":
239
- sys.exit(stop(meta_path))
286
+ sys.exit(stop(unique_meta_paths))
240
287
  else:
241
- sys.exit(status(meta_path))
288
+ sys.exit(status(unique_meta_paths))
242
289
  elif args.command == "list":
243
290
  dir = args.dir or DEFAULT_META_DIR
244
291
  sys.exit(list_processes(dir, args.full))
@@ -260,7 +307,7 @@ def main():
260
307
  rotate_log_path=args.rotate_log_path
261
308
  or ROTATE_LOG_PATH_TEMPLATE.format(task=args.name),
262
309
  )
263
- sys.exit(start(task_cfg))
310
+ sys.exit(start([task_cfg]))
264
311
  else:
265
312
  parser.print_help()
266
313
  sys.exit(1)
@@ -1,6 +1,6 @@
1
1
  import sys
2
2
  from pathlib import Path
3
- from typing import Dict, Optional, cast
3
+ from typing import Dict, List, Optional, Sequence, Tuple, Union, cast
4
4
 
5
5
  if sys.version_info >= (3, 11):
6
6
  import tomllib
@@ -121,7 +121,10 @@ def validate_task(task, name: str) -> DmonTaskConfig:
121
121
  ret.log_rotate = task["log_rotate"]
122
122
 
123
123
  if "log_max_size" in task:
124
- if not isinstance(task["log_max_size"], (int, float)) or task["log_max_size"] <= 0:
124
+ if (
125
+ not isinstance(task["log_max_size"], (int, float))
126
+ or task["log_max_size"] <= 0
127
+ ):
125
128
  raise TypeError(
126
129
  f"Task '{name}' 'log_max_size' field must be a positive number"
127
130
  )
@@ -155,11 +158,14 @@ def validate_task(task, name: str) -> DmonTaskConfig:
155
158
  return ret
156
159
 
157
160
 
158
- def get_task_config(name: Optional[str], cfg_path: Optional[str]):
161
+ def get_task_config(
162
+ names: Union[Sequence[str], str, None], cfg_path: Optional[str], all: bool = False
163
+ ) -> Tuple[Sequence[str], List[DmonTaskConfig]]:
159
164
  """
160
- Get the validated task configuration for the given task name.
161
- If name is None or empty, and there is only one task, return that task; otherwise, raise ValueError.
162
- If the task is not found, or required fields are missing, raise TypeError or ValueError.
165
+ Get the validated task configurations for the given task names.
166
+ If 'all' is True, return all tasks.
167
+ If no name specified, and there is only one task, return that task; otherwise, raise ValueError.
168
+ If any task is not found, or required fields are missing, raise TypeError or ValueError.
163
169
 
164
170
  The config is loaded from the given path, or searched for dmon.yaml or pyproject.toml.
165
171
  """
@@ -169,23 +175,35 @@ def get_task_config(name: Optional[str], cfg_path: Optional[str]):
169
175
  if not isinstance(tasks, dict):
170
176
  raise TypeError("'tasks' must be a table")
171
177
 
172
- name = name or cfg.get("default_task", None)
173
-
174
- if not name:
175
- if len(tasks) == 0:
176
- raise ValueError(f"No task found in {path}")
177
- elif len(tasks) == 1:
178
- name = next(iter(tasks))
178
+ if all:
179
+ names = list(tasks.keys())
180
+ elif isinstance(names, str):
181
+ names = [names]
182
+ elif names is None or len(names) == 0:
183
+ default_task_name = cfg.get("default_task", None)
184
+ if default_task_name:
185
+ if not isinstance(default_task_name, str):
186
+ raise TypeError("'default_task' must be a string")
187
+ names = [default_task_name]
179
188
  else:
180
- raise ValueError(f"Multiple tasks found in {path}; please specify one.")
181
- else:
189
+ if len(tasks) == 0:
190
+ raise ValueError(f"No task found in {path}")
191
+ elif len(tasks) == 1:
192
+ name = next(iter(tasks))
193
+ assert isinstance(name, str)
194
+ names = [name]
195
+ else:
196
+ raise ValueError(f"Multiple tasks found in {path}; please specify one.")
197
+
198
+ ret_tasks = []
199
+ for name in names:
182
200
  name = name.lower()
183
201
  if name not in tasks:
184
202
  raise ValueError(f"Task '{name}' not found in {path}")
185
203
 
186
- assert isinstance(name, str)
187
- task = validate_task(tasks[name], name)
188
- return name, task
204
+ task = validate_task(tasks[name], name)
205
+ ret_tasks.append(task)
206
+ return names, ret_tasks
189
207
 
190
208
 
191
209
  def check_name_in_config(name: str) -> bool:
@@ -6,7 +6,7 @@ import signal
6
6
  import sys
7
7
  import subprocess
8
8
  import time
9
- from typing import List
9
+ from typing import List, Sequence
10
10
 
11
11
  import psutil
12
12
  from termcolor import colored
@@ -33,7 +33,17 @@ def ensure_log_dir(log_path: Path):
33
33
  log_path.parent.mkdir(parents=True, exist_ok=True)
34
34
 
35
35
 
36
- def start(cfg: DmonTaskConfig):
36
+ def start(cfgs: Sequence[DmonTaskConfig]):
37
+ ret = 0
38
+ for idx, cfg in enumerate(cfgs):
39
+ # non-zero if any start() fails
40
+ ret |= start_single(cfg)
41
+ if idx < len(cfgs) - 1:
42
+ print("---", file=sys.stderr) # print a blank line between tasks
43
+ return ret
44
+
45
+
46
+ def start_single(cfg: DmonTaskConfig):
37
47
  meta_path = Path(cfg.meta_path).resolve()
38
48
  log_path = Path(cfg.log_path).resolve()
39
49
  cwd = Path(cfg.cwd).resolve()
@@ -166,7 +176,17 @@ def start(cfg: DmonTaskConfig):
166
176
  return 0
167
177
 
168
178
 
169
- def stop(
179
+ def stop(meta_paths: Sequence[PathType], timeout=5.0):
180
+ ret = 0
181
+ for idx, meta_path in enumerate(meta_paths):
182
+ # non-zero if any stop() fails
183
+ ret |= stop_single(meta_path=meta_path, timeout=timeout)
184
+ if idx < len(meta_paths) - 1:
185
+ print(file=sys.stderr) # print a blank line between tasks
186
+ return ret
187
+
188
+
189
+ def stop_single(
170
190
  meta_path: PathType,
171
191
  timeout=5.0,
172
192
  ):
@@ -345,33 +365,41 @@ def terminate_win(proc: psutil.Process, timeout):
345
365
 
346
366
 
347
367
  def restart(
348
- cfg: DmonTaskConfig,
368
+ cfgs: Sequence[DmonTaskConfig],
349
369
  timeout=5.0,
350
370
  ):
351
- stop(cfg.meta_path, timeout)
371
+ meta_paths = [cfg.meta_path for cfg in cfgs]
372
+ stop(meta_paths, timeout=timeout)
352
373
  print("--- Restarting ---", file=sys.stderr)
353
- return start(cfg)
374
+ return start(cfgs)
354
375
 
355
376
 
356
- def status(meta_path: PathType):
357
- meta_path = Path(meta_path).resolve()
358
- meta = DmonMeta.load(meta_path)
359
- if meta is None:
360
- print(
361
- colored(
362
- "Status failed: meta file not found (maybe not started)\n",
363
- color="red",
364
- attrs=["bold"],
377
+ def status(meta_paths: Sequence[PathType]):
378
+ ret = 0
379
+ metas = []
380
+ for idx, meta_path in enumerate(meta_paths):
381
+ meta_path = Path(meta_path).resolve()
382
+ meta = DmonMeta.load(meta_path)
383
+ if meta is None:
384
+ print(
385
+ colored(
386
+ "Status failed: meta file not found (maybe not started)\n",
387
+ color="red",
388
+ attrs=["bold"],
389
+ )
390
+ + str(meta_path),
391
+ file=sys.stderr,
365
392
  )
366
- + str(meta_path),
367
- file=sys.stderr,
368
- )
369
- return 1
370
-
371
- print_status(meta)
372
- print("\nProcess Tree:", file=sys.stderr)
373
- print_process_table([meta])
374
- return 0
393
+ ret |= 1
394
+ else:
395
+ print_status(meta)
396
+ metas.append(meta)
397
+ if idx < len(meta_paths) - 1:
398
+ print("---", file=sys.stderr)
399
+ if metas:
400
+ print("\nProcess Tree:", file=sys.stderr)
401
+ print_process_table(metas)
402
+ return ret
375
403
 
376
404
 
377
405
  def check_same_process(proc: psutil.Process, create_time: float) -> bool:
@@ -523,14 +551,23 @@ def print_process_table(metas: List[DmonMeta], full_width: bool = False):
523
551
  return processes
524
552
 
525
553
 
526
- def list_processes(dir: PathType, full_width: bool):
554
+ def get_meta_paths(dir: PathType) -> List[Path]:
527
555
  target_dmon_dir = Path(dir).resolve()
528
- metas: List[DmonMeta] = []
556
+ meta_paths: List[Path] = []
529
557
  if target_dmon_dir.exists() and target_dmon_dir.is_dir():
530
558
  for meta_file in target_dmon_dir.glob(f"*{META_SUFFIX}"):
531
- meta = DmonMeta.load(meta_file)
532
- if meta is not None:
533
- metas.append(meta)
559
+ meta_paths.append(meta_file)
560
+ return meta_paths
561
+
562
+
563
+ def list_processes(dir: PathType, full_width: bool):
564
+ target_dmon_dir = Path(dir).resolve()
565
+ meta_paths = get_meta_paths(target_dmon_dir)
566
+ metas = []
567
+ for meta_path in meta_paths:
568
+ meta = DmonMeta.load(meta_path)
569
+ if meta is not None:
570
+ metas.append(meta)
534
571
  # sort by name (case-insensitive)
535
572
  metas.sort(key=lambda m: m.task.lower())
536
573
  n_task = len(metas)