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/__init__.py ADDED
File without changes
dmon/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
dmon/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ import argparse
2
+ import shlex
3
+ import sys
4
+
5
+ from colorama import just_fix_windows_console
6
+
7
+ from .config import check_name_in_config, get_task_config
8
+ from .control import list_processes, restart, start, stop, status
9
+ from .constants import (
10
+ DEFAULT_META_DIR,
11
+ DEFAULT_RUN_NAME,
12
+ LOG_PATH_TEMPLATE,
13
+ META_PATH_TEMPLATE,
14
+ ROTATE_LOG_PATH_TEMPLATE,
15
+ )
16
+ from .types import DmonTaskConfig
17
+
18
+
19
+ def main():
20
+ just_fix_windows_console()
21
+
22
+ parser = argparse.ArgumentParser(
23
+ prog="dmon",
24
+ description="Minimal cross-platform daemon manager",
25
+ # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
26
+ )
27
+ subparsers = parser.add_subparsers(dest="command")
28
+
29
+ # start subcommand
30
+ sp_start = subparsers.add_parser(
31
+ "start",
32
+ help="Start a configured task as a background process",
33
+ description="Start a configured task as a background process",
34
+ # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
35
+ )
36
+ sp_start.add_argument(
37
+ "name",
38
+ help="Configured task name (default: the only task if there's just one)",
39
+ nargs="?",
40
+ )
41
+ sp_start.add_argument(
42
+ "--meta-file",
43
+ help=f"Path to meta file (default: {META_PATH_TEMPLATE})",
44
+ )
45
+ sp_start.add_argument(
46
+ "--log-file",
47
+ help=f"Path to log file (default: task configured or {LOG_PATH_TEMPLATE})",
48
+ )
49
+
50
+ # stop subcommand
51
+ sp_stop = subparsers.add_parser(
52
+ "stop",
53
+ help="Stop a background process",
54
+ description="Stop a background process given name or meta file",
55
+ # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
56
+ )
57
+ sp_stop.add_argument(
58
+ "name",
59
+ help="Configured task name (default: the only task if there's just one)",
60
+ nargs="?",
61
+ )
62
+ sp_stop.add_argument("--meta-file", help="Path to meta file")
63
+
64
+ # restart subcommand
65
+ sp_restart = subparsers.add_parser(
66
+ "restart",
67
+ help="Restart a configured task as a background process",
68
+ description="Restart a configured task as a background process",
69
+ # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
70
+ )
71
+ sp_restart.add_argument(
72
+ "name",
73
+ help="Configured task name (default: the only task if there's just one)",
74
+ nargs="?",
75
+ )
76
+ sp_restart.add_argument(
77
+ "--meta-file",
78
+ help=f"Path to meta file (default: {META_PATH_TEMPLATE})",
79
+ )
80
+ sp_restart.add_argument(
81
+ "--log-file",
82
+ help=f"Path to log file (default: task configured or {LOG_PATH_TEMPLATE})",
83
+ )
84
+
85
+ # status subcommand
86
+ sp_status = subparsers.add_parser(
87
+ "status",
88
+ help="Check process status",
89
+ description="Check status of a background process given name or meta file",
90
+ # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
91
+ )
92
+ sp_status.add_argument(
93
+ "name",
94
+ help="Configured task name (default: the only task if there's just one)",
95
+ nargs="?",
96
+ )
97
+ sp_status.add_argument(
98
+ "--meta-file",
99
+ help=f"Path to meta file (default: {META_PATH_TEMPLATE})",
100
+ )
101
+
102
+ # list subcommand
103
+ sp_list = subparsers.add_parser(
104
+ "list",
105
+ help="List all processes and their status",
106
+ description="List all processes and their status managed by dmon in the given directory",
107
+ # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
108
+ )
109
+ sp_list.add_argument(
110
+ "dir",
111
+ help=f"Directory to look for meta files (default: {DEFAULT_META_DIR})",
112
+ nargs="?",
113
+ )
114
+ sp_list.add_argument(
115
+ "--full",
116
+ action="store_true",
117
+ help="Show full width without truncating column (default: False)",
118
+ )
119
+
120
+ # run subcommand
121
+ sp_run = subparsers.add_parser(
122
+ "run",
123
+ help="Run a custom task (not in config) as a background process",
124
+ description="Run a custom task (not in config) as a background process",
125
+ # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
126
+ )
127
+ sp_run.add_argument(
128
+ "--name",
129
+ "-n",
130
+ default=DEFAULT_RUN_NAME,
131
+ help=f"Name for this task (default: {DEFAULT_RUN_NAME})",
132
+ )
133
+ sp_run.add_argument(
134
+ "--cwd",
135
+ help="Working directory to run the command in (default: current directory)",
136
+ default="",
137
+ )
138
+ sp_run.add_argument(
139
+ "--shell", action="store_true", help="Run task in shell (default: False)"
140
+ )
141
+ sp_run.add_argument(
142
+ "--meta-file",
143
+ help=f"Path to meta file (default: {META_PATH_TEMPLATE})",
144
+ )
145
+ sp_run.add_argument(
146
+ "--log-file",
147
+ help=f"Path to log file (default: {LOG_PATH_TEMPLATE})",
148
+ )
149
+ sp_run.add_argument(
150
+ "--log-rotate",
151
+ action="store_true",
152
+ help="Whether to rotate log file (default: False)",
153
+ )
154
+ sp_run.add_argument(
155
+ "--rotate-log-path",
156
+ help=f"Path to rotation log file (default: {ROTATE_LOG_PATH_TEMPLATE})",
157
+ )
158
+ sp_run.add_argument(
159
+ "command_list",
160
+ metavar="command",
161
+ nargs=argparse.ONE_OR_MORE,
162
+ help="Command (with args) to run",
163
+ )
164
+
165
+ args = parser.parse_args()
166
+
167
+ if args.command in ["start", "restart"]:
168
+ sp = sp_start if args.command == "start" else sp_restart
169
+ try:
170
+ name, task_cfg = get_task_config(args.name)
171
+ except Exception as e:
172
+ sp.error(str(e))
173
+
174
+ task_cfg.meta_path = (
175
+ args.meta_file or task_cfg.meta_path or META_PATH_TEMPLATE.format(name=name)
176
+ )
177
+ task_cfg.log_path = (
178
+ args.log_file or task_cfg.log_path or LOG_PATH_TEMPLATE.format(name=name)
179
+ )
180
+ task_cfg.rotate_log_path = (
181
+ task_cfg.rotate_log_path or ROTATE_LOG_PATH_TEMPLATE.format(name=name)
182
+ )
183
+ if args.command == "start":
184
+ sys.exit(start(task_cfg))
185
+ else:
186
+ sys.exit(restart(task_cfg))
187
+ elif args.command in ["stop", "status"]:
188
+ sp = sp_stop if args.command == "stop" else sp_status
189
+ if args.meta_file:
190
+ meta_path = args.meta_file
191
+ else:
192
+ if args.name:
193
+ name = args.name
194
+ else:
195
+ try:
196
+ name, _ = get_task_config(args.name)
197
+ except Exception as e:
198
+ sp.error(str(e))
199
+ meta_path = META_PATH_TEMPLATE.format(name=name)
200
+ if args.command == "stop":
201
+ sys.exit(stop(meta_path))
202
+ else:
203
+ sys.exit(status(meta_path))
204
+ elif args.command == "list":
205
+ dir = args.dir or DEFAULT_META_DIR
206
+ sys.exit(list_processes(dir, args.full))
207
+ elif args.command == "run":
208
+ if not args.name:
209
+ sp_run.error("Please provide a non-empty name for the task.")
210
+ elif check_name_in_config(args.name):
211
+ sp_run.error(
212
+ f"Name '{args.name}' already exists in config. Please choose another name."
213
+ )
214
+
215
+ task_cfg = DmonTaskConfig(
216
+ name=args.name,
217
+ cmd=shlex.join(args.command_list) if args.shell else args.command_list,
218
+ cwd=args.cwd,
219
+ meta_path=args.meta_file or META_PATH_TEMPLATE.format(name=args.name),
220
+ log_path=args.log_file or LOG_PATH_TEMPLATE.format(name=args.name),
221
+ log_rotate=args.log_rotate,
222
+ rotate_log_path=args.rotate_log_path
223
+ or ROTATE_LOG_PATH_TEMPLATE.format(name=args.name),
224
+ )
225
+ sys.exit(start(task_cfg))
226
+ else:
227
+ parser.print_help()
228
+ sys.exit(1)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()
dmon/config.py ADDED
@@ -0,0 +1,195 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from typing import Dict, Optional, cast
4
+
5
+ if sys.version_info >= (3, 11):
6
+ import tomllib
7
+ else:
8
+ import tomli as tomllib
9
+
10
+ from .types import CmdType, DmonTaskConfig
11
+
12
+
13
+ def find_pyproject_toml(start: Optional[Path] = None):
14
+ """
15
+ Search for pyproject.toml from the current directory (or specified directory) upwards.
16
+ Return a Path object if found.
17
+ """
18
+ start = start or Path.cwd()
19
+ current = start.resolve()
20
+
21
+ for parent in [current, *current.parents]:
22
+ candidate = parent / "pyproject.toml"
23
+ if candidate.is_file():
24
+ return candidate
25
+
26
+ # raise FileNotFoundError(f"No pyproject.toml found from {start} upwards.")
27
+
28
+
29
+ def find_dmon_yaml(start: Optional[Path] = None):
30
+ """
31
+ Search for dmon.yaml / dmon.yml from the current directory (or specified directory) upwards.
32
+ Return a Path object if found.
33
+ """
34
+ start = start or Path.cwd()
35
+ current = start.resolve()
36
+
37
+ for parent in [current, *current.parents]:
38
+ for filename in ["dmon.yaml", "dmon.yml"]:
39
+ candidate = parent / filename
40
+ if candidate.is_file():
41
+ return candidate
42
+
43
+ # raise FileNotFoundError(f"No dmon.yaml or dmon.yml found from {start} upwards.")
44
+
45
+
46
+ def load_config():
47
+ path = find_dmon_yaml()
48
+ if path:
49
+ import yaml
50
+
51
+ with path.open("r", encoding="utf-8") as f:
52
+ cfg = yaml.safe_load(f)
53
+ else:
54
+ path = find_pyproject_toml()
55
+ if not path:
56
+ raise FileNotFoundError(
57
+ "No dmon.yaml or pyproject.toml found in current or any parent directory."
58
+ )
59
+
60
+ with path.open("rb") as f:
61
+ cfg = tomllib.load(f)
62
+ cfg = cfg.get("tool", {}).get("dmon", {})
63
+ return cfg, path
64
+
65
+
66
+ def validate_cmd_type(cmd, name: str) -> CmdType:
67
+ if isinstance(cmd, str):
68
+ return cmd
69
+ elif isinstance(cmd, list):
70
+ if not all(isinstance(item, str) for item in cmd):
71
+ # check if it's a list of strings
72
+ raise TypeError(f"Task '{name}' list items must be strings")
73
+ return cmd
74
+ else:
75
+ raise TypeError(
76
+ f"Task '{name}' 'cmd' field must be a string, or list of strings; got {type(cmd)}"
77
+ )
78
+
79
+
80
+ def validate_task(task, name: str) -> DmonTaskConfig:
81
+ ret = DmonTaskConfig(name=name)
82
+ if isinstance(task, str) or isinstance(task, list):
83
+ ret.cmd = validate_cmd_type(task, name)
84
+ elif isinstance(task, dict):
85
+ if "cmd" not in task:
86
+ raise TypeError(f"Task '{name}' must have a 'cmd' field")
87
+ ret.cmd = validate_cmd_type(task["cmd"], name)
88
+
89
+ if "cwd" in task:
90
+ if not isinstance(task["cwd"], str):
91
+ raise TypeError(f"Task '{name}' 'cwd' field must be a string")
92
+ ret.cwd = task["cwd"]
93
+
94
+ if "env" in task:
95
+ if not isinstance(task["env"], dict) or not all(
96
+ isinstance(k, str) and isinstance(v, str)
97
+ for k, v in task["env"].items()
98
+ ):
99
+ raise TypeError(
100
+ f"Task '{name}' 'env' field must be a table of string to string"
101
+ )
102
+ ret.env = cast(Dict[str, str], task["env"])
103
+
104
+ if "override_env" in task:
105
+ if not isinstance(task["override_env"], bool):
106
+ raise TypeError(f"Task '{name}' 'override_env' field must be a boolean")
107
+ ret.override_env = task["override_env"]
108
+
109
+ if "log_path" in task:
110
+ if not isinstance(task["log_path"], str):
111
+ raise TypeError(f"Task '{name}' 'log_path' field must be a string")
112
+ ret.log_path = task["log_path"]
113
+
114
+ if "log_rotate" in task:
115
+ if not isinstance(task["log_rotate"], bool):
116
+ raise TypeError(f"Task '{name}' 'log_rotate' field must be a boolean")
117
+ ret.log_rotate = task["log_rotate"]
118
+
119
+ if "log_max_size" in task:
120
+ if not isinstance(task["log_max_size"], int) or task["log_max_size"] <= 0:
121
+ raise TypeError(
122
+ f"Task '{name}' 'log_max_size' field must be a positive integer"
123
+ )
124
+ ret.log_max_size = task["log_max_size"]
125
+
126
+ if "rotate_log_path" in task:
127
+ if not isinstance(task["rotate_log_path"], str):
128
+ raise TypeError(
129
+ f"Task '{name}' 'rotate_log_path' field must be a string"
130
+ )
131
+ ret.rotate_log_path = task["rotate_log_path"]
132
+
133
+ if "rotate_log_max_size" in task:
134
+ if (
135
+ not isinstance(task["rotate_log_max_size"], int)
136
+ or task["rotate_log_max_size"] <= 0
137
+ ):
138
+ raise TypeError(
139
+ f"Task '{name}' 'rotate_log_max_size' field must be a positive integer"
140
+ )
141
+ ret.rotate_log_max_size = task["rotate_log_max_size"]
142
+
143
+ if "meta_path" in task:
144
+ if not isinstance(task["meta_path"], str):
145
+ raise TypeError(f"Task '{name}' 'meta_path' field must be a string")
146
+ ret.meta_path = task["meta_path"]
147
+ else:
148
+ raise TypeError(
149
+ f"Task '{name}' must be a string, list of strings, or a table; got {type(task)}"
150
+ )
151
+ return ret
152
+
153
+
154
+ def get_task_config(name: Optional[str] = None):
155
+ """
156
+ Get the validated task configuration for the given task name from [tool.dmon.tasks] or dmon.yaml.
157
+
158
+ If name is None or empty, and there is only one task, return that task; otherwise, raise ValueError.
159
+ If the task is not found, or required fields are missing, raise TypeError or ValueError.
160
+ """
161
+ cfg, path = load_config()
162
+ tasks = cfg.get("tasks", {})
163
+
164
+ if not isinstance(tasks, dict):
165
+ raise TypeError("'tasks' must be a table")
166
+
167
+ if not name:
168
+ if len(tasks) == 0:
169
+ raise ValueError(f"No task found in {path}")
170
+ elif len(tasks) == 1:
171
+ name = next(iter(tasks))
172
+ else:
173
+ raise ValueError(f"Multiple tasks found in {path}; please specify one.")
174
+ else:
175
+ name = name.lower()
176
+ if name not in tasks:
177
+ raise ValueError(f"Task '{name}' not found in {path}")
178
+
179
+ assert isinstance(name, str)
180
+ task = validate_task(tasks[name], name)
181
+ return name, task
182
+
183
+
184
+ def check_name_in_config(name: str) -> bool:
185
+ """
186
+ Check if the given task name exists in the tasks.
187
+ Return True if found, False otherwise.
188
+ """
189
+ cfg, _ = load_config()
190
+ tasks = cfg.get("tasks", {})
191
+
192
+ if not isinstance(tasks, dict):
193
+ return False
194
+
195
+ return name.lower() in tasks
dmon/constants.py ADDED
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+
3
+
4
+ DEFAULT_META_DIR = Path(".dmon")
5
+ DEFAULT_LOG_DIR = Path("logs")
6
+
7
+ META_SUFFIX = ".meta.json"
8
+
9
+ META_PATH_TEMPLATE = str(DEFAULT_META_DIR / ("{name}" + META_SUFFIX))
10
+ LOG_PATH_TEMPLATE = str(DEFAULT_LOG_DIR / "{name}.log")
11
+ ROTATE_LOG_PATH_TEMPLATE = str(DEFAULT_LOG_DIR / "{name}.rotate.log")
12
+
13
+
14
+ DEFAULT_RUN_NAME = "default_run"