python-dmon 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dmon/runner.py ADDED
@@ -0,0 +1,188 @@
1
+ import argparse
2
+ from datetime import datetime
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ 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)
22
+
23
+ def doRollover(self):
24
+ if self.stream:
25
+ 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()
30
+
31
+
32
+ def get_file_dir(file_path):
33
+ return os.path.dirname(file_path)
34
+
35
+
36
+ def make_dir(dir):
37
+ dir = os.path.abspath(dir)
38
+ if not os.path.exists(dir):
39
+ os.makedirs(dir, exist_ok=True)
40
+
41
+
42
+ def make_file_dir(file_path):
43
+ par_dir = get_file_dir(file_path)
44
+ make_dir(par_dir)
45
+
46
+
47
+ def need_rotate(log_path, max_log_size):
48
+ return os.path.getsize(log_path) >= max_log_size and max_log_size > 0
49
+
50
+
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)
60
+
61
+
62
+ def loop_to_log(bin_fd, log_path, max_log_size):
63
+ while True: # loop once whenever need to rotate
64
+ with open(log_path, "ab") as log_file:
65
+ while True:
66
+ try:
67
+ # read byte by byte in binary mode to avoid decoding
68
+ # error caused by utf8 character truncation
69
+ byte = bin_fd.read(1)
70
+ if byte:
71
+ log_file.write(byte)
72
+ log_file.flush() # immediately write the content to the file
73
+ # When a newline character is read, check if
74
+ # rotation is needed; if so, break the loop
75
+ if byte == b"\n" and need_rotate(log_path, max_log_size):
76
+ break
77
+ else:
78
+ logger.info("Read EOF, now closing...")
79
+ return
80
+ except Exception as e:
81
+ logger.exception(f"Exception in while loop: {e}")
82
+ rotate_log(log_path) # rotate log file
83
+
84
+
85
+ def catch_exception(func):
86
+ def wrapper(*args, **kwargs):
87
+ try:
88
+ return func(*args, **kwargs)
89
+ except Exception as e:
90
+ logger.exception(f"Exception in {func.__name__}: {e}")
91
+
92
+ return wrapper
93
+
94
+
95
+ @catch_exception
96
+ def main(
97
+ cmd,
98
+ log_path,
99
+ max_log_size,
100
+ rotate_log_path,
101
+ max_rotate_log_size,
102
+ ):
103
+ # Configure logging
104
+ rh = None
105
+ if rotate_log_path:
106
+ rh = FixedSizeRotatingFileHandler(rotate_log_path, maxBytes=max_rotate_log_size)
107
+ logging.basicConfig(
108
+ format="%(asctime)s.%(msecs)03d - %(process)d - %(levelname)s - %(message)s",
109
+ level=logging.INFO,
110
+ datefmt="%Y-%m-%d %H:%M:%S",
111
+ handlers=[rh] if rh else None,
112
+ )
113
+
114
+ logger.info(
115
+ f"Prepare for rotating logs: {log_path=} {max_log_size=} {rotate_log_path=} {max_rotate_log_size=}"
116
+ )
117
+
118
+ shell = isinstance(cmd, str)
119
+ # Start the child process with stdout/stderr redirected to the log
120
+ # env will be inherited from parent process
121
+ # cwd will be inherited from parent process
122
+ proc = subprocess.Popen(
123
+ cmd,
124
+ stdout=subprocess.PIPE,
125
+ stderr=subprocess.STDOUT,
126
+ shell=shell,
127
+ text=False, # binary mode
128
+ bufsize=0, # unbuffered
129
+ )
130
+
131
+ # register signal handler to terminate the child process
132
+ def signal_handler(signum, frame):
133
+ logger.info(f"Received signal {signum}, forwarding to child process...")
134
+ # proc.terminate()
135
+ proc.send_signal(signum)
136
+ logger.info("Waiting for child process to exit...")
137
+ proc.wait()
138
+ logger.info("Child process exited, all done.")
139
+ sys.exit(0)
140
+
141
+ # Set up signal handlers
142
+ signal.signal(signal.SIGINT, signal_handler)
143
+ signal.signal(signal.SIGTERM, signal_handler)
144
+
145
+ logger.info(f"Started process {proc.pid} with command: {cmd} (shell={shell})")
146
+
147
+ make_file_dir(log_path)
148
+ loop_to_log(proc.stdout, log_path, max_log_size)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ parser = argparse.ArgumentParser(
153
+ description="Dmon task runner with log rotation utility",
154
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
155
+ )
156
+ parser.add_argument(
157
+ "command",
158
+ nargs=argparse.ONE_OR_MORE,
159
+ help="Command with arguments to run",
160
+ )
161
+ parser.add_argument("--shell", action="store_true", help="Run command in shell")
162
+ parser.add_argument("--log-path", help="Log file path", required=True)
163
+ parser.add_argument(
164
+ "--max-log-size",
165
+ help="Max log file size (MB); 0 for no rotation",
166
+ type=int,
167
+ default=5,
168
+ )
169
+ parser.add_argument(
170
+ "--rotate-log-path",
171
+ help="Path of the log output of this rotation process; if not provided, output to stdout",
172
+ default=None,
173
+ )
174
+ parser.add_argument(
175
+ "--max-rotate-log-size",
176
+ help="Max file size (MB) for this rotation log; 0 for no rotation",
177
+ type=int,
178
+ default=5,
179
+ )
180
+ args = parser.parse_args()
181
+ main(
182
+ " ".join(args.command) if args.shell else args.command,
183
+ args.log_path,
184
+ args.max_log_size * 1024 * 1024,
185
+ args.rotate_log_path,
186
+ args.max_rotate_log_size * 1024 * 1024,
187
+ )
188
+ logger.info("Process finished.")
dmon/types.py ADDED
@@ -0,0 +1,63 @@
1
+ from dataclasses import asdict, dataclass, field
2
+ import json
3
+ from os import PathLike
4
+ from pathlib import Path
5
+ import sys
6
+ from typing import Dict, List, Optional, Union
7
+
8
+
9
+ if sys.version_info >= (3, 9):
10
+ PathType = Union[str, PathLike[str]]
11
+ else:
12
+ PathType = Union[str, PathLike]
13
+
14
+
15
+ CmdType = Union[str, List[str]]
16
+
17
+
18
+ @dataclass
19
+ class DmonTaskConfig:
20
+ name: str = ""
21
+ """Name of the task"""
22
+ cmd: CmdType = ""
23
+ """Command to run, either a string (for shell) or a list of strings (for exec)"""
24
+ cwd: str = ""
25
+ """Working directory to run the command in"""
26
+ env: Dict[str, str] = field(default_factory=dict)
27
+ """Environment variables to set for the command"""
28
+ override_env: bool = False
29
+ """Whether to override the entire environment with the provided env"""
30
+ log_path: str = ""
31
+ """Path to log file"""
32
+ log_rotate: bool = False
33
+ """Whether to rotate log file"""
34
+ log_max_size: int = 5
35
+ """Size in MB to rotate log file"""
36
+ rotate_log_path: str = ""
37
+ """Path to rotation log file"""
38
+ rotate_log_max_size: int = 5
39
+ """Size in MB to rotation log file"""
40
+ meta_path: str = ""
41
+ """Path to meta file"""
42
+
43
+
44
+ @dataclass
45
+ class DmonMeta(DmonTaskConfig):
46
+ pid: int = -1
47
+ shell: bool = False
48
+ popen_kwargs: Dict = field(default_factory=dict)
49
+ create_time: float = -1
50
+ create_time_human: str = "N/A"
51
+
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)
55
+
56
+ @staticmethod
57
+ def load(path: PathType) -> Optional["DmonMeta"]:
58
+ p = Path(path)
59
+ if p.exists():
60
+ with p.open("r", encoding="utf-8") as f:
61
+ data = json.load(f)
62
+ return DmonMeta(**data)
63
+ return None
dmon/utils.py ADDED
@@ -0,0 +1,32 @@
1
+ import re
2
+ from typing import Literal
3
+
4
+
5
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
6
+
7
+
8
+ def len_ansi(s: str) -> int:
9
+ return len(ANSI_RE.sub("", s))
10
+
11
+
12
+ def pad_ansi(
13
+ s: str, width: int, align: Literal["<", ">", "^"] = "<", fill: str = " "
14
+ ) -> str:
15
+ """Pad a string with ANSI escape codes to a given width."""
16
+ real_len = len_ansi(s)
17
+ if real_len == width:
18
+ return s
19
+ if real_len > width:
20
+ return s[: width - 3] + "..." # truncate and add ellipsis
21
+
22
+ pad_len = width - real_len
23
+ if align == "<":
24
+ return s + fill * pad_len
25
+ elif align == ">":
26
+ return fill * pad_len + s
27
+ elif align == "^":
28
+ left = pad_len // 2
29
+ right = pad_len - left
30
+ return fill * left + s + fill * right
31
+ else:
32
+ raise ValueError(f"Invalid align: {align}")
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.3
2
+ Name: python-dmon
3
+ Version: 0.2.0
4
+ Summary: A lightweight, cross-platform daemon manager that runs any command as a background process.
5
+ Keywords: python-dmon,dmon,daemon,background,detach,process management
6
+ Author: Atomie CHEN
7
+ Author-email: Atomie CHEN <atomic_cwh@163.com>
8
+ Requires-Dist: colorama>=0.4.6
9
+ Requires-Dist: psutil>=7.1.0
10
+ Requires-Dist: pyyaml>=6.0.3
11
+ Requires-Dist: termcolor>=2.4.0
12
+ Requires-Dist: tomli>=2.2.1 ; python_full_version < '3.11'
13
+ Requires-Python: >=3.8
14
+ Project-URL: Bug Tracker, https://github.com/atomiechen/python-dmon/issues
15
+ Project-URL: Changelog, https://github.com/atomiechen/python-dmon/blob/master/CHANGELOG.md
16
+ Project-URL: Homepage, https://github.com/atomiechen/python-dmon
17
+ Description-Content-Type: text/markdown
18
+
19
+ # python-dmon
20
+
21
+
22
+ [![GitHub](https://img.shields.io/badge/github-python--dmon-blue?logo=github)](https://github.com/atomiechen/python-dmon)
23
+ [![PyPI](https://img.shields.io/pypi/v/python--dmon?logo=pypi&logoColor=white)](https://pypi.org/project/python-dmon/)
24
+
25
+
26
+ A lightweight, cross-platform daemon manager that runs any command — called a *task* — as a background process.
27
+ It also supports logging and log rotation out of the box.
28
+ **No Docker or extra dependencies required**.
29
+
30
+ Shipped as the CLI tool `dmon`.
31
+ It is a Python-based and more powerful successor to the [handy-backend shell scripts](https://github.com/atomiechen/handy-backend).
32
+
33
+
34
+ ## Features
35
+
36
+ - 🖥️ **Cross-platform:** Works on Linux, macOS, and Windows.
37
+ - ⚡ **Lightweight:** Pure Python, no Docker or external dependencies needed.
38
+ - 🧩 **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
+
41
+
42
+ ## Installation
43
+
44
+ ```sh
45
+ pip install python-dmon
46
+ ```
47
+
48
+ To get the latest features, install from source:
49
+
50
+ ```sh
51
+ pip install git+https://github.com/atomiechen/python-dmon.git
52
+ ```
53
+
54
+ ## Getting Started
55
+
56
+ ### Prepare Configuration
57
+
58
+ Create a `dmon.yaml` file:
59
+
60
+ ```yaml
61
+ tasks:
62
+ app: ["python", "-u", "server.py"]
63
+ ```
64
+
65
+ Or add to your `pyproject.toml`:
66
+
67
+ ```toml
68
+ [tool.dmon.tasks]
69
+ app = ["python", "-u", "server.py"]
70
+ ```
71
+
72
+ Commands can be a single string (run in shell), or list of strings (exec form).
73
+ See [Example Task Configuration](#example-task-configuration) for more configuration options.
74
+
75
+
76
+ ### Run tasks
77
+
78
+ Run a configured task by its name:
79
+
80
+ ```sh
81
+ # Start a task
82
+ dmon start app
83
+
84
+ # Stop a running task
85
+ dmon stop app
86
+
87
+ # Check task status
88
+ dmon status app
89
+ ```
90
+
91
+ If only one task is defined in the config file, you can omit the task name:
92
+
93
+ ```sh
94
+ dmon start
95
+ dmon stop
96
+ dmon status
97
+ ```
98
+
99
+
100
+ ### Run an ad-hoc command
101
+
102
+ ```sh
103
+ # Run a command with arguments in the background
104
+ dmon run --name myserver python -u server.py
105
+
106
+ # Run a shell command in the background
107
+ dmon run --shell echo "Hello World"
108
+
109
+ # Run a shell script in the background
110
+ dmon run --cwd /path/to/script bash myscript.sh
111
+ ```
112
+
113
+ > [!NOTE]
114
+ > If no name is provided, `dmon` automatically assigns a fixed task name `default_run` to prevent duplicate runs.
115
+
116
+
117
+ ### List all running tasks
118
+
119
+ ```sh
120
+ dmon list
121
+ ```
122
+
123
+
124
+ ## Example Task Configuration
125
+
126
+ A task can be a **string**, **list**, or **dictionary**.
127
+
128
+ Here is a more complete example with default values:
129
+
130
+ ```yaml
131
+ tasks:
132
+ your_task_name:
133
+ # Command to run; can be a string (run in shell) or list of strings (exec form)
134
+ cmd: ["python", "server.py"] # required
135
+ cwd: "/path/to/working/dir" # (default: current dir)
136
+ env: # (default: inherit from parent process)
137
+ PYTHONUNBUFFERED: "1"
138
+ override_env: false # override parent env and only use env defined here
139
+ log_path: "logs/<name>.log" # path to log file
140
+ log_rotate: false # enable log rotation
141
+ log_max_size: 5 # max log file size before rotation in MB
142
+ rotate_log_path: "logs/<name>.rotate.log" # path to rotation log
143
+ rotate_log_max_size: 5 # max rotation log file size in MB
144
+ meta_path: ".dmon/<name>.meta.json" # path to meta file
145
+ ```
146
+
147
+ ## Under the Hood
148
+
149
+ Each task is associated with a meta file (e.g. `.dmon/<name>.meta.json`) stored in the current working directory.
150
+ The file contains details such as the command, PID, log path, and more.
151
+ **Do not** modify or delete these files manually.
152
+
153
+
154
+ ## License
155
+
156
+ [python-dmon](https://github.com/atomiechen/python-dmon) © 2025 by [Atomie CHEN](https://github.com/atomiechen) is licensed under the [MIT License](https://github.com/atomiechen/python-dmon/blob/main/LICENSE).
@@ -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=IVJ5jCHkaLGTQjMmWCuJbhnt4MWEOaNWQajC-mm3LUQ,7599
4
+ dmon/config.py,sha256=COzwN0Ms8q32pV6dqsZU59RFE2GQmqyAmfygDotc4CA,6610
5
+ dmon/constants.py,sha256=y5S532iTeklaR28BFbvpdOHFj1udbEJ-reD1mTCcC6E,351
6
+ dmon/control.py,sha256=0_NrrOA0sUM_lFHNyKGAmQsBbJhXwXfj8kxOp2S8Ni8,16311
7
+ dmon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ dmon/runner.py,sha256=zEW7TnHMfIw6g3UTjOTUcLFKueXpqSmNDP3ZOptXQKs,5892
9
+ dmon/types.py,sha256=ZtTAK-cfs2MgUFRwDH61zeIU2B4dHZDbx1644tYI1h4,1795
10
+ dmon/utils.py,sha256=zVQC9YNhMQu4hrAp9k2ds2egnbWZZv7S5w8i2oVIPJg,808
11
+ python_dmon-0.2.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
12
+ python_dmon-0.2.0.dist-info/entry_points.txt,sha256=rad5ugz4K_IyNXS_IrFPu0tS-UXlBCSJRhJv-OWd_yk,45
13
+ python_dmon-0.2.0.dist-info/METADATA,sha256=s1AoObf2NB_kK_Era10wpm0X6OwH-J0UVeAS8Dg6pcs,4498
14
+ python_dmon-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dmon = dmon.__main__:main
3
+