python-dmon 0.3.0__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.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
 
@@ -19,7 +19,7 @@ It is a Python-based and more powerful successor to the [handy-backend shell scr
19
19
  - 🖥️ **Cross-platform:** Works on Linux, macOS, and Windows.
20
20
  - ⚡ **Lightweight:** Pure Python, no Docker or external dependencies needed.
21
21
  - 🧩 **Flexible tasks:** Tasks can be configured in `pyproject.toml` or `dmon.yaml`; or run ad-hoc commands directly.
22
- - 🪵 **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.
23
23
 
24
24
  ![dmon-demo-gif](https://github.com/user-attachments/assets/9bae2f46-5ef4-4784-aced-18d573204efc)
25
25
 
@@ -108,6 +108,11 @@ dmon exec app
108
108
 
109
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
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
+
111
116
  Or use `--all` to operate on all tasks:
112
117
 
113
118
  ```sh
@@ -127,12 +132,12 @@ dmon status
127
132
  dmon exec
128
133
  ```
129
134
 
130
- 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:
131
136
 
132
137
  ```sh
133
138
  dmon start --config /path/to/dmon.yaml app # YAML
134
139
  dmon start --config /path/to/pyproject.toml app # or TOML
135
- 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`
136
141
  ```
137
142
 
138
143
  And yes, you can use `dmon` to run in a nested manner:
@@ -154,6 +159,9 @@ tasks:
154
159
  # Run a command with arguments in the background
155
160
  dmon run --name myserver python -u server.py
156
161
 
162
+ # Optionally use -- to make the child-command boundary explicit
163
+ dmon run --name timer -- python -c 'import time; time.sleep(30)'
164
+
157
165
  # Run a shell command in the background
158
166
  dmon run --shell echo "Hello World"
159
167
 
@@ -176,6 +184,16 @@ dmon list
176
184
 
177
185
  A task can be a **string**, **list**, or **dictionary**.
178
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
+
179
197
  Here is a more complete example with default values:
180
198
 
181
199
  ```yaml
@@ -190,8 +208,10 @@ tasks:
190
208
  log_path: "logs/<task>.log" # path to log file
191
209
  log_rotate: false # enable log rotation
192
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
193
212
  rotate_log_path: "logs/<task>.rotate.log" # path to rotation log
194
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
195
215
  meta_path: ".dmon/<task>.meta.json" # path to meta file
196
216
  default_task: your_task_name # the default task name
197
217
  ```
@@ -209,6 +229,8 @@ another_task = "cd subdir && ls && bash start.sh"
209
229
  default_task = "your_task_name"
210
230
  ```
211
231
 
232
+ All paths can be absolute or relative to the **config file location**.
233
+
212
234
 
213
235
  ## Under the Hood
214
236
 
@@ -216,6 +238,10 @@ Each task is associated with a meta file (e.g. `.dmon/<task>.meta.json`) stored
216
238
  The file contains details such as the command, PID, log path, and more.
217
239
  **Do not** modify or delete these files manually.
218
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
+
219
245
 
220
246
  ## License
221
247
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-dmon"
3
- version = "0.3.0"
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,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__":
@@ -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):