python-dmon 0.2.4__tar.gz → 0.3.1__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.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
@@ -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.
@@ -36,7 +37,7 @@ It is a Python-based and more powerful successor to the [handy-backend shell scr
36
37
  - 🖥️ **Cross-platform:** Works on Linux, macOS, and Windows.
37
38
  - ⚡ **Lightweight:** Pure Python, no Docker or external dependencies needed.
38
39
  - 🧩 **Flexible tasks:** Tasks can be configured in `pyproject.toml` or `dmon.yaml`; or run ad-hoc commands directly.
39
- - 🪵 **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.
40
41
 
41
42
  ![dmon-demo-gif](https://github.com/user-attachments/assets/9bae2f46-5ef4-4784-aced-18d573204efc)
42
43
 
@@ -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,21 +124,38 @@ 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
+ 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
+
134
+ Or use `--all` to operate on all tasks:
135
+
136
+ ```sh
137
+ # all configured tasks
138
+ dmon start/stop/restart --all
139
+ # all running tasks
140
+ dmon stop/status --all
141
+ ```
142
+
123
143
  If you have defined `default_task`, or only one task is defined in the config file, you can omit the task name:
124
144
 
125
145
  ```sh
126
146
  dmon start
127
147
  dmon stop
148
+ dmon restart
128
149
  dmon status
129
150
  dmon exec
130
151
  ```
131
152
 
132
- 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:
133
154
 
134
155
  ```sh
135
156
  dmon start --config /path/to/dmon.yaml app # YAML
136
157
  dmon start --config /path/to/pyproject.toml app # or TOML
137
- 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`
138
159
  ```
139
160
 
140
161
  And yes, you can use `dmon` to run in a nested manner:
@@ -156,6 +177,9 @@ tasks:
156
177
  # Run a command with arguments in the background
157
178
  dmon run --name myserver python -u server.py
158
179
 
180
+ # Optionally use -- to make the child-command boundary explicit
181
+ dmon run --name timer -- python -c 'import time; time.sleep(30)'
182
+
159
183
  # Run a shell command in the background
160
184
  dmon run --shell echo "Hello World"
161
185
 
@@ -178,6 +202,16 @@ dmon list
178
202
 
179
203
  A task can be a **string**, **list**, or **dictionary**.
180
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
+
181
215
  Here is a more complete example with default values:
182
216
 
183
217
  ```yaml
@@ -192,8 +226,10 @@ tasks:
192
226
  log_path: "logs/<task>.log" # path to log file
193
227
  log_rotate: false # enable log rotation
194
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
195
230
  rotate_log_path: "logs/<task>.rotate.log" # path to rotation log
196
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
197
233
  meta_path: ".dmon/<task>.meta.json" # path to meta file
198
234
  default_task: your_task_name # the default task name
199
235
  ```
@@ -211,6 +247,8 @@ another_task = "cd subdir && ls && bash start.sh"
211
247
  default_task = "your_task_name"
212
248
  ```
213
249
 
250
+ All paths can be absolute or relative to the **config file location**.
251
+
214
252
 
215
253
  ## Under the Hood
216
254
 
@@ -218,6 +256,10 @@ Each task is associated with a meta file (e.g. `.dmon/<task>.meta.json`) stored
218
256
  The file contains details such as the command, PID, log path, and more.
219
257
  **Do not** modify or delete these files manually.
220
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
+
221
263
 
222
264
  ## License
223
265
 
@@ -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.
@@ -18,7 +19,7 @@ It is a Python-based and more powerful successor to the [handy-backend shell scr
18
19
  - 🖥️ **Cross-platform:** Works on Linux, macOS, and Windows.
19
20
  - ⚡ **Lightweight:** Pure Python, no Docker or external dependencies needed.
20
21
  - 🧩 **Flexible tasks:** Tasks can be configured in `pyproject.toml` or `dmon.yaml`; or run ad-hoc commands directly.
21
- - 🪵 **Logging & log rotation:** Automatically manage log files to prevent uncontrolled growth.
22
+ - 🪵 **Logging & log rotation:** Keep active log files manageable, with optional archive retention limits.
22
23
 
23
24
  ![dmon-demo-gif](https://github.com/user-attachments/assets/9bae2f46-5ef4-4784-aced-18d573204efc)
24
25
 
@@ -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,21 +106,38 @@ 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
+ Multi-task `start` is best-effort: dmon attempts every requested task and leaves
112
+ successful tasks running if another task cannot start. The command returns a
113
+ non-zero status and prints a summary naming the failed tasks. This is useful for
114
+ independent background services and does not provide atomic stack semantics.
115
+
116
+ Or use `--all` to operate on all tasks:
117
+
118
+ ```sh
119
+ # all configured tasks
120
+ dmon start/stop/restart --all
121
+ # all running tasks
122
+ dmon stop/status --all
123
+ ```
124
+
105
125
  If you have defined `default_task`, or only one task is defined in the config file, you can omit the task name:
106
126
 
107
127
  ```sh
108
128
  dmon start
109
129
  dmon stop
130
+ dmon restart
110
131
  dmon status
111
132
  dmon exec
112
133
  ```
113
134
 
114
- You can use `--config` to specify a custom config file or the directory containing it:
135
+ You can use `-c` / `--config` to specify a custom config file or the directory containing it:
115
136
 
116
137
  ```sh
117
138
  dmon start --config /path/to/dmon.yaml app # YAML
118
139
  dmon start --config /path/to/pyproject.toml app # or TOML
119
- dmon start --config /path/to/dir app # dir with `dmon.y(a)ml` or `pyproject.toml`
140
+ dmon start -c /path/to/dir app # shorter, dir with `dmon.y(a)ml` or `pyproject.toml`
120
141
  ```
121
142
 
122
143
  And yes, you can use `dmon` to run in a nested manner:
@@ -138,6 +159,9 @@ tasks:
138
159
  # Run a command with arguments in the background
139
160
  dmon run --name myserver python -u server.py
140
161
 
162
+ # Optionally use -- to make the child-command boundary explicit
163
+ dmon run --name timer -- python -c 'import time; time.sleep(30)'
164
+
141
165
  # Run a shell command in the background
142
166
  dmon run --shell echo "Hello World"
143
167
 
@@ -160,6 +184,16 @@ dmon list
160
184
 
161
185
  A task can be a **string**, **list**, or **dictionary**.
162
186
 
187
+ When rotation is enabled, dmon keeps timestamped archives such as
188
+ `app.log.20260807-142106`. Both task and runner logs use this cross-platform
189
+ format; a same-second collision adds `.1`, `.2`, and so on. Archives are never
190
+ deleted by default. Set a backup count explicitly to enable retention cleanup.
191
+ `log_path` contains task output; `rotate_log_path` contains diagnostics from the
192
+ dmon process that captures and rotates that output. They are independent log
193
+ streams and use independent retention settings.
194
+ The size limit is checked at line boundaries, so a single long line may exceed
195
+ the configured limit.
196
+
163
197
  Here is a more complete example with default values:
164
198
 
165
199
  ```yaml
@@ -174,8 +208,10 @@ tasks:
174
208
  log_path: "logs/<task>.log" # path to log file
175
209
  log_rotate: false # enable log rotation
176
210
  log_max_size: 5 # max log file size before rotation in MB
211
+ # log_backup_count: 10 # optional; omit to retain all task log archives
177
212
  rotate_log_path: "logs/<task>.rotate.log" # path to rotation log
178
213
  rotate_log_max_size: 5 # max rotation log file size in MB
214
+ # rotate_log_backup_count: 10 # optional; omit to retain all runner log archives
179
215
  meta_path: ".dmon/<task>.meta.json" # path to meta file
180
216
  default_task: your_task_name # the default task name
181
217
  ```
@@ -193,6 +229,8 @@ another_task = "cd subdir && ls && bash start.sh"
193
229
  default_task = "your_task_name"
194
230
  ```
195
231
 
232
+ All paths can be absolute or relative to the **config file location**.
233
+
196
234
 
197
235
  ## Under the Hood
198
236
 
@@ -200,6 +238,10 @@ Each task is associated with a meta file (e.g. `.dmon/<task>.meta.json`) stored
200
238
  The file contains details such as the command, PID, log path, and more.
201
239
  **Do not** modify or delete these files manually.
202
240
 
241
+ `dmon status` returns a non-zero status if a recorded task has exited. Starting
242
+ that task again removes its stale metadata automatically. `dmon stop` terminates
243
+ the complete process tree and also cleans stale metadata left by an exited task.
244
+
203
245
 
204
246
  ## License
205
247
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-dmon"
3
- version = "0.2.4"
3
+ version = "0.3.1"
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
+ import os
3
+ from pathlib import Path
2
4
  import shlex
3
- import sys
4
5
 
5
6
  from colorama import just_fix_windows_console
6
7
 
7
- from .config import check_name_in_config, get_task_config
8
- from .control import execute, list_processes, restart, start, stop, status
8
+ from .config import check_name_in_config, get_task_config, load_config
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(
@@ -171,9 +193,9 @@ def main():
171
193
  )
172
194
  sp_run.add_argument(
173
195
  "command_list",
174
- metavar="command",
175
- nargs=argparse.ONE_OR_MORE,
176
- help="Command (with args) to run",
196
+ metavar="COMMAND",
197
+ nargs=argparse.REMAINDER,
198
+ help="Command and arguments to run; '--' is an optional separator",
177
199
  )
178
200
 
179
201
  sp_exec = subparsers.add_parser(
@@ -190,7 +212,9 @@ def main():
190
212
  # add custom config file option
191
213
  for sp in [sp_start, sp_stop, sp_restart, sp_status, sp_exec]:
192
214
  sp.add_argument(
215
+ "-c",
193
216
  "--config",
217
+ metavar="PATH",
194
218
  help="Path to config file or the directory containing it (default: search from current directory upwards)",
195
219
  )
196
220
 
@@ -199,50 +223,98 @@ def main():
199
223
  if args.command in ["start", "restart"]:
200
224
  sp = sp_start if args.command == "start" else sp_restart
201
225
  try:
202
- task, task_cfg = get_task_config(args.task, args.config)
226
+ tasks, task_cfgs, cfg_path = get_task_config(
227
+ args.task, args.config, args.all
228
+ )
229
+ os.chdir(cfg_path.parent)
203
230
  except Exception as e:
204
231
  sp.error(str(e))
205
232
 
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
- )
233
+ # check if meta_file or log_file path is provided;
234
+ # if so, only one task should be specified
235
+ if args.meta_file or args.log_file:
236
+ if len(tasks) == 1:
237
+ task_cfgs[0].meta_path = args.meta_file or task_cfgs[0].meta_path
238
+ task_cfgs[0].log_path = args.log_file or task_cfgs[0].log_path
239
+ else:
240
+ sp.error(
241
+ f"'--meta-file' and '--log-file' can only be specified when {args.command}ing a single task"
242
+ )
243
+ # fill in default values if not provided
244
+ for task, task_cfg in zip(tasks, task_cfgs):
245
+ task_cfg.meta_path = task_cfg.meta_path or META_PATH_TEMPLATE.format(
246
+ task=task
247
+ )
248
+ task_cfg.log_path = task_cfg.log_path or LOG_PATH_TEMPLATE.format(task=task)
249
+ task_cfg.rotate_log_path = (
250
+ task_cfg.rotate_log_path or ROTATE_LOG_PATH_TEMPLATE.format(task=task)
251
+ )
215
252
  if args.command == "start":
216
- sys.exit(start(task_cfg))
253
+ sp.exit(start(task_cfgs))
217
254
  else:
218
- sys.exit(restart(task_cfg))
255
+ sp.exit(restart(task_cfgs))
219
256
  elif args.command == "exec":
220
257
  try:
221
- task, task_cfg = 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)
222
260
  except Exception as e:
223
261
  sp_exec.error(str(e))
224
- sys.exit(execute(task_cfg))
262
+ sp_exec.exit(execute(task_cfgs[0]))
225
263
  elif args.command in ["stop", "status"]:
226
264
  sp = sp_stop if args.command == "stop" else sp_status
265
+ meta_paths = []
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
+
281
+ # Collect meta paths from --all
282
+ if args.all:
283
+ meta_paths.extend(get_meta_paths(DEFAULT_META_DIR))
284
+
285
+ # Collect meta paths from --meta-file
227
286
  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)
287
+ meta_paths.append(args.meta_file)
288
+
289
+ # Collect meta paths from task names
290
+ if len(tasks) > 0:
291
+ meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
292
+
293
+ # If no meta paths collected, use default task
294
+ if len(meta_paths) == 0:
295
+ try:
296
+ tasks, _, cfg_path = get_task_config(args.task, args.config)
297
+ os.chdir(cfg_path.parent)
298
+ except Exception as e:
299
+ sp.error(str(e))
300
+ meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
301
+
302
+ # Remove duplicates
303
+ unique_meta_paths = sorted(set(Path(p).resolve() for p in meta_paths))
304
+
238
305
  if args.command == "stop":
239
- sys.exit(stop(meta_path))
306
+ sp.exit(stop(unique_meta_paths))
240
307
  else:
241
- sys.exit(status(meta_path))
308
+ sp.exit(status(unique_meta_paths))
242
309
  elif args.command == "list":
243
310
  dir = args.dir or DEFAULT_META_DIR
244
- sys.exit(list_processes(dir, args.full))
311
+ sp_list.exit(list_processes(dir, args.full))
245
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.")
246
318
  if not args.name:
247
319
  sp_run.error("Please provide a non-empty name for the task.")
248
320
  elif check_name_in_config(args.name):
@@ -252,7 +324,7 @@ def main():
252
324
 
253
325
  task_cfg = DmonTaskConfig(
254
326
  task=args.name,
255
- 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,
256
328
  cwd=args.cwd,
257
329
  meta_path=args.meta_file or META_PATH_TEMPLATE.format(task=args.name),
258
330
  log_path=args.log_file or LOG_PATH_TEMPLATE.format(task=args.name),
@@ -260,10 +332,10 @@ def main():
260
332
  rotate_log_path=args.rotate_log_path
261
333
  or ROTATE_LOG_PATH_TEMPLATE.format(task=args.name),
262
334
  )
263
- sys.exit(start(task_cfg))
335
+ sp_run.exit(start([task_cfg]))
264
336
  else:
265
337
  parser.print_help()
266
- sys.exit(1)
338
+ parser.exit(1)
267
339
 
268
340
 
269
341
  if __name__ == "__main__":
@@ -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
@@ -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
 
@@ -121,12 +125,23 @@ def validate_task(task, name: str) -> DmonTaskConfig:
121
125
  ret.log_rotate = task["log_rotate"]
122
126
 
123
127
  if "log_max_size" in task:
124
- if not isinstance(task["log_max_size"], (int, float)) or task["log_max_size"] <= 0:
128
+ if (
129
+ not isinstance(task["log_max_size"], (int, float))
130
+ or task["log_max_size"] <= 0
131
+ ):
125
132
  raise TypeError(
126
133
  f"Task '{name}' 'log_max_size' field must be a positive number"
127
134
  )
128
135
  ret.log_max_size = task["log_max_size"]
129
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
+
130
145
  if "rotate_log_path" in task:
131
146
  if not isinstance(task["rotate_log_path"], str):
132
147
  raise TypeError(
@@ -144,6 +159,14 @@ def validate_task(task, name: str) -> DmonTaskConfig:
144
159
  )
145
160
  ret.rotate_log_max_size = task["rotate_log_max_size"]
146
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
+
147
170
  if "meta_path" in task:
148
171
  if not isinstance(task["meta_path"], str):
149
172
  raise TypeError(f"Task '{name}' 'meta_path' field must be a string")
@@ -155,11 +178,14 @@ def validate_task(task, name: str) -> DmonTaskConfig:
155
178
  return ret
156
179
 
157
180
 
158
- def get_task_config(name: Optional[str], cfg_path: Optional[str]):
181
+ def get_task_config(
182
+ names: Union[Sequence[str], str, None], cfg_path: Optional[str], all: bool = False
183
+ ) -> Tuple[Sequence[str], List[DmonTaskConfig], Path]:
159
184
  """
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.
185
+ Get the validated task configurations for the given task names.
186
+ If 'all' is True, return all tasks.
187
+ If no name specified, and there is only one task, return that task; otherwise, raise ValueError.
188
+ If any task is not found, or required fields are missing, raise TypeError or ValueError.
163
189
 
164
190
  The config is loaded from the given path, or searched for dmon.yaml or pyproject.toml.
165
191
  """
@@ -169,23 +195,37 @@ def get_task_config(name: Optional[str], cfg_path: Optional[str]):
169
195
  if not isinstance(tasks, dict):
170
196
  raise TypeError("'tasks' must be a table")
171
197
 
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))
198
+ if all:
199
+ names = list(tasks.keys())
200
+ elif isinstance(names, str):
201
+ names = [names]
202
+ elif names is None or len(names) == 0:
203
+ default_task_name = cfg.get("default_task", None)
204
+ if default_task_name:
205
+ if not isinstance(default_task_name, str):
206
+ raise TypeError("'default_task' must be a string")
207
+ names = [default_task_name]
179
208
  else:
180
- raise ValueError(f"Multiple tasks found in {path}; please specify one.")
181
- else:
209
+ if len(tasks) == 0:
210
+ raise ValueError(f"No task found in {path}")
211
+ elif len(tasks) == 1:
212
+ name = next(iter(tasks))
213
+ assert isinstance(name, str)
214
+ names = [name]
215
+ else:
216
+ raise ValueError(f"Multiple tasks found in {path}; please specify one.")
217
+
218
+ ret_names = []
219
+ ret_tasks = []
220
+ for name in names:
182
221
  name = name.lower()
183
222
  if name not in tasks:
184
223
  raise ValueError(f"Task '{name}' not found in {path}")
185
224
 
186
- assert isinstance(name, str)
187
- task = validate_task(tasks[name], name)
188
- return name, task
225
+ task = validate_task(tasks[name], name)
226
+ ret_names.append(name)
227
+ ret_tasks.append(task)
228
+ return ret_names, ret_tasks, path
189
229
 
190
230
 
191
231
  def check_name_in_config(name: str) -> bool:
@@ -193,7 +233,10 @@ def check_name_in_config(name: str) -> bool:
193
233
  Check if the given task name exists in the tasks.
194
234
  Return True if found, False otherwise.
195
235
  """
196
- cfg, _ = load_config()
236
+ try:
237
+ cfg, _ = load_config()
238
+ except FileNotFoundError:
239
+ return False
197
240
  tasks = cfg.get("tasks", {})
198
241
 
199
242
  if not isinstance(tasks, dict):