python-dmon 0.2.0__py3-none-any.whl → 0.2.1__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/cli.py CHANGED
@@ -5,7 +5,7 @@ import sys
5
5
  from colorama import just_fix_windows_console
6
6
 
7
7
  from .config import check_name_in_config, get_task_config
8
- from .control import list_processes, restart, start, stop, status
8
+ from .control import execute, list_processes, restart, start, stop, status
9
9
  from .constants import (
10
10
  DEFAULT_META_DIR,
11
11
  DEFAULT_RUN_NAME,
@@ -16,14 +16,28 @@ from .constants import (
16
16
  from .types import DmonTaskConfig
17
17
 
18
18
 
19
+ def get_version():
20
+ # if python 3.8 or later, use importlib.metadata
21
+ import importlib.metadata
22
+
23
+ return importlib.metadata.version("python-dmon")
24
+
25
+
19
26
  def main():
20
27
  just_fix_windows_console()
21
28
 
22
29
  parser = argparse.ArgumentParser(
23
30
  prog="dmon",
24
- description="Minimal cross-platform daemon manager",
31
+ description=f"dmon v{get_version()} - Lightweight cross-platform daemon manager",
25
32
  # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
26
33
  )
34
+ parser.add_argument(
35
+ "-v",
36
+ "--version",
37
+ action="version",
38
+ version=get_version(),
39
+ )
40
+
27
41
  subparsers = parser.add_subparsers(dest="command")
28
42
 
29
43
  # start subcommand
@@ -34,7 +48,7 @@ def main():
34
48
  # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
35
49
  )
36
50
  sp_start.add_argument(
37
- "name",
51
+ "task",
38
52
  help="Configured task name (default: the only task if there's just one)",
39
53
  nargs="?",
40
54
  )
@@ -55,7 +69,7 @@ def main():
55
69
  # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
56
70
  )
57
71
  sp_stop.add_argument(
58
- "name",
72
+ "task",
59
73
  help="Configured task name (default: the only task if there's just one)",
60
74
  nargs="?",
61
75
  )
@@ -69,7 +83,7 @@ def main():
69
83
  # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
70
84
  )
71
85
  sp_restart.add_argument(
72
- "name",
86
+ "task",
73
87
  help="Configured task name (default: the only task if there's just one)",
74
88
  nargs="?",
75
89
  )
@@ -90,7 +104,7 @@ def main():
90
104
  # formatter_class=argparse.ArgumentDefaultsHelpFormatter,
91
105
  )
92
106
  sp_status.add_argument(
93
- "name",
107
+ "task",
94
108
  help="Configured task name (default: the only task if there's just one)",
95
109
  nargs="?",
96
110
  )
@@ -162,41 +176,65 @@ def main():
162
176
  help="Command (with args) to run",
163
177
  )
164
178
 
179
+ sp_exec = subparsers.add_parser(
180
+ "exec",
181
+ help="Execute a configured task in the foreground",
182
+ description="Execute a configured task in the foreground",
183
+ )
184
+ sp_exec.add_argument(
185
+ "task",
186
+ help="Configured task name (default: the only task if there's just one)",
187
+ nargs="?",
188
+ )
189
+
190
+ # add custom config file option
191
+ for sp in [sp_start, sp_stop, sp_restart, sp_status, sp_exec]:
192
+ sp.add_argument(
193
+ "--config",
194
+ help="Path to config file (YAML or TOML) (default: search from current directory upwards)",
195
+ )
196
+
165
197
  args = parser.parse_args()
166
198
 
167
199
  if args.command in ["start", "restart"]:
168
200
  sp = sp_start if args.command == "start" else sp_restart
169
201
  try:
170
- name, task_cfg = get_task_config(args.name)
202
+ task, task_cfg = get_task_config(args.task, args.config)
171
203
  except Exception as e:
172
204
  sp.error(str(e))
173
205
 
174
206
  task_cfg.meta_path = (
175
- args.meta_file or task_cfg.meta_path or META_PATH_TEMPLATE.format(name=name)
207
+ args.meta_file or task_cfg.meta_path or META_PATH_TEMPLATE.format(task=task)
176
208
  )
177
209
  task_cfg.log_path = (
178
- args.log_file or task_cfg.log_path or LOG_PATH_TEMPLATE.format(name=name)
210
+ args.log_file or task_cfg.log_path or LOG_PATH_TEMPLATE.format(task=task)
179
211
  )
180
212
  task_cfg.rotate_log_path = (
181
- task_cfg.rotate_log_path or ROTATE_LOG_PATH_TEMPLATE.format(name=name)
213
+ task_cfg.rotate_log_path or ROTATE_LOG_PATH_TEMPLATE.format(task=task)
182
214
  )
183
215
  if args.command == "start":
184
216
  sys.exit(start(task_cfg))
185
217
  else:
186
218
  sys.exit(restart(task_cfg))
219
+ elif args.command == "exec":
220
+ try:
221
+ task, task_cfg = get_task_config(args.task, args.config)
222
+ except Exception as e:
223
+ sp_exec.error(str(e))
224
+ sys.exit(execute(task_cfg))
187
225
  elif args.command in ["stop", "status"]:
188
226
  sp = sp_stop if args.command == "stop" else sp_status
189
227
  if args.meta_file:
190
228
  meta_path = args.meta_file
191
229
  else:
192
- if args.name:
193
- name = args.name
230
+ if args.task:
231
+ task = args.task
194
232
  else:
195
233
  try:
196
- name, _ = get_task_config(args.name)
234
+ task, _ = get_task_config(args.task, args.config)
197
235
  except Exception as e:
198
236
  sp.error(str(e))
199
- meta_path = META_PATH_TEMPLATE.format(name=name)
237
+ meta_path = META_PATH_TEMPLATE.format(task=task)
200
238
  if args.command == "stop":
201
239
  sys.exit(stop(meta_path))
202
240
  else:
@@ -209,18 +247,18 @@ def main():
209
247
  sp_run.error("Please provide a non-empty name for the task.")
210
248
  elif check_name_in_config(args.name):
211
249
  sp_run.error(
212
- f"Name '{args.name}' already exists in config. Please choose another name."
250
+ f"Task '{args.name}' already exists in config. Please choose another name."
213
251
  )
214
252
 
215
253
  task_cfg = DmonTaskConfig(
216
- name=args.name,
254
+ task=args.name,
217
255
  cmd=shlex.join(args.command_list) if args.shell else args.command_list,
218
256
  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),
257
+ meta_path=args.meta_file or META_PATH_TEMPLATE.format(task=args.name),
258
+ log_path=args.log_file or LOG_PATH_TEMPLATE.format(task=args.name),
221
259
  log_rotate=args.log_rotate,
222
260
  rotate_log_path=args.rotate_log_path
223
- or ROTATE_LOG_PATH_TEMPLATE.format(name=args.name),
261
+ or ROTATE_LOG_PATH_TEMPLATE.format(task=args.name),
224
262
  )
225
263
  sys.exit(start(task_cfg))
226
264
  else:
dmon/config.py CHANGED
@@ -43,7 +43,30 @@ def find_dmon_yaml(start: Optional[Path] = None):
43
43
  # raise FileNotFoundError(f"No dmon.yaml or dmon.yml found from {start} upwards.")
44
44
 
45
45
 
46
- def load_config():
46
+ def load_config(cfg_path: Optional[str] = None):
47
+ """
48
+ Load configuration from the given path, or search for dmon.yaml or pyproject.toml.
49
+ """
50
+
51
+ # Load configuration from the given path
52
+ if cfg_path:
53
+ path = Path(cfg_path).resolve()
54
+ if not path.exists():
55
+ raise FileNotFoundError(f"Config file '{path}' does not exist.")
56
+ if path.suffix in [".yaml", ".yml"]:
57
+ import yaml
58
+
59
+ with path.open("r", encoding="utf-8") as f:
60
+ cfg = yaml.safe_load(f)
61
+ elif path.suffix == ".toml":
62
+ with path.open("rb") as f:
63
+ cfg = tomllib.load(f)
64
+ cfg = cfg.get("tool", {}).get("dmon", {})
65
+ else:
66
+ raise ValueError("Config file must be YAML (.yaml/.yml) or TOML (.toml)")
67
+ return cfg, path
68
+
69
+ # No path provided, search for dmon.yaml first, then pyproject.toml
47
70
  path = find_dmon_yaml()
48
71
  if path:
49
72
  import yaml
@@ -78,7 +101,7 @@ def validate_cmd_type(cmd, name: str) -> CmdType:
78
101
 
79
102
 
80
103
  def validate_task(task, name: str) -> DmonTaskConfig:
81
- ret = DmonTaskConfig(name=name)
104
+ ret = DmonTaskConfig(task=name)
82
105
  if isinstance(task, str) or isinstance(task, list):
83
106
  ret.cmd = validate_cmd_type(task, name)
84
107
  elif isinstance(task, dict):
@@ -151,14 +174,15 @@ def validate_task(task, name: str) -> DmonTaskConfig:
151
174
  return ret
152
175
 
153
176
 
154
- def get_task_config(name: Optional[str] = None):
177
+ def get_task_config(name: Optional[str], cfg_path: Optional[str]):
155
178
  """
156
- Get the validated task configuration for the given task name from [tool.dmon.tasks] or dmon.yaml.
157
-
179
+ Get the validated task configuration for the given task name.
158
180
  If name is None or empty, and there is only one task, return that task; otherwise, raise ValueError.
159
181
  If the task is not found, or required fields are missing, raise TypeError or ValueError.
182
+
183
+ The config is loaded from the given path, or searched for dmon.yaml or pyproject.toml.
160
184
  """
161
- cfg, path = load_config()
185
+ cfg, path = load_config(cfg_path)
162
186
  tasks = cfg.get("tasks", {})
163
187
 
164
188
  if not isinstance(tasks, dict):
dmon/constants.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from pathlib import Path
2
+ import sys
2
3
 
3
4
 
4
5
  DEFAULT_META_DIR = Path(".dmon")
@@ -6,9 +7,11 @@ DEFAULT_LOG_DIR = Path("logs")
6
7
 
7
8
  META_SUFFIX = ".meta.json"
8
9
 
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")
10
+ META_PATH_TEMPLATE = str(DEFAULT_META_DIR / ("{task}" + META_SUFFIX))
11
+ LOG_PATH_TEMPLATE = str(DEFAULT_LOG_DIR / "{task}.log")
12
+ ROTATE_LOG_PATH_TEMPLATE = str(DEFAULT_LOG_DIR / "{task}.rotate.log")
12
13
 
13
14
 
14
15
  DEFAULT_RUN_NAME = "default_run"
16
+
17
+ ON_WINDOWS = sys.platform.startswith("win")
dmon/control.py CHANGED
@@ -2,6 +2,7 @@ import os
2
2
  from pathlib import Path
3
3
  import shlex
4
4
  import shutil
5
+ import signal
5
6
  import sys
6
7
  import subprocess
7
8
  import time
@@ -10,7 +11,7 @@ from typing import List
10
11
  import psutil
11
12
  from termcolor import colored
12
13
 
13
- from .constants import DEFAULT_META_DIR, META_SUFFIX
14
+ from .constants import DEFAULT_META_DIR, META_SUFFIX, ON_WINDOWS
14
15
  from .types import DmonTaskConfig, DmonMeta, PathType
15
16
  from .utils import len_ansi, pad_ansi
16
17
 
@@ -66,7 +67,7 @@ def start(cfg: DmonTaskConfig):
66
67
 
67
68
  # Platform-specific parameters to run the process in background detached from parent
68
69
  kwargs = {}
69
- if sys.platform.startswith("win"):
70
+ if ON_WINDOWS:
70
71
  # DETACHED_PROCESS = 0x00000008
71
72
  CREATE_NO_WINDOW = 0x08000000
72
73
  kwargs["creationflags"] = CREATE_NO_WINDOW
@@ -78,7 +79,7 @@ def start(cfg: DmonTaskConfig):
78
79
  kwargs["start_new_session"] = True
79
80
 
80
81
  meta = DmonMeta(
81
- name=cfg.name,
82
+ task=cfg.task,
82
83
  meta_path=str(meta_path),
83
84
  log_path=str(log_path),
84
85
  log_rotate=cfg.log_rotate,
@@ -213,7 +214,7 @@ def stop(
213
214
  meta_path.unlink(missing_ok=True)
214
215
  return 1
215
216
 
216
- if sys.platform.startswith("win"):
217
+ if ON_WINDOWS:
217
218
  ret = terminate_win(proc, timeout)
218
219
  else:
219
220
  ret = terminate_posix(proc, timeout)
@@ -416,7 +417,7 @@ def print_status(meta: DmonMeta):
416
417
 
417
418
  # key-value pairs with aligned keys
418
419
  rows = [
419
- ("TASK", colored(meta.name, "cyan", attrs=["bold"])),
420
+ ("TASK", colored(meta.task, "cyan", attrs=["bold"])),
420
421
  ("PID", colored(str(meta.pid), "cyan", attrs=["bold"])),
421
422
  ("STATUS", status),
422
423
  ("CMD", meta.cmd),
@@ -477,7 +478,7 @@ def print_process_table(metas: List[DmonMeta], full_width: bool = False):
477
478
  ppid = "N/A"
478
479
  rows.append(
479
480
  (
480
- colored(meta.name, "cyan", attrs=["bold"]),
481
+ colored(meta.task, "cyan", attrs=["bold"]),
481
482
  colored(meta.pid, "cyan", attrs=["bold"]),
482
483
  ppid,
483
484
  status,
@@ -540,3 +541,41 @@ def list_processes(dir: PathType, full_width: bool):
540
541
  file=sys.stderr,
541
542
  )
542
543
  return 0
544
+
545
+
546
+ def execute(cfg: DmonTaskConfig):
547
+ """
548
+ Execute the command in the foreground.
549
+ This is used for the 'dmon exec' command.
550
+ """
551
+ cwd = Path(cfg.cwd).resolve()
552
+
553
+ env = None # default behavior of Popen
554
+ if cfg.override_env:
555
+ env = cfg.env
556
+ elif cfg.env:
557
+ env = {**os.environ, **cfg.env}
558
+
559
+ shell = isinstance(cfg.cmd, str)
560
+
561
+ def signal_handler(signum: int, frame) -> None:
562
+ if ON_WINDOWS and signum == signal.SIGINT:
563
+ signum = signal.SIGTERM
564
+ proc.send_signal(signum)
565
+
566
+ prev_handle_int = signal.signal(signal.SIGINT, signal_handler)
567
+ prev_handle_term = signal.signal(signal.SIGTERM, signal_handler)
568
+
569
+ proc = subprocess.Popen(
570
+ cfg.cmd,
571
+ cwd=cwd,
572
+ env=env,
573
+ shell=shell,
574
+ bufsize=0, # unbuffered
575
+ close_fds=False,
576
+ )
577
+ ret = proc.wait()
578
+
579
+ signal.signal(signal.SIGTERM, prev_handle_term)
580
+ signal.signal(signal.SIGINT, prev_handle_int)
581
+ return ret
dmon/runner.py CHANGED
@@ -116,21 +116,13 @@ def main(
116
116
  )
117
117
 
118
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
119
 
131
120
  # register signal handler to terminate the child process
132
- def signal_handler(signum, frame):
121
+ def signal_handler(signum: int, frame):
133
122
  logger.info(f"Received signal {signum}, forwarding to child process...")
123
+ if sys.platform.startswith("win") and signum == signal.SIGINT:
124
+ signum = signal.SIGTERM
125
+ logger.info(f"On Windows, convert SIGINT to SIGTERM ({signum})")
134
126
  # proc.terminate()
135
127
  proc.send_signal(signum)
136
128
  logger.info("Waiting for child process to exit...")
@@ -142,6 +134,18 @@ def main(
142
134
  signal.signal(signal.SIGINT, signal_handler)
143
135
  signal.signal(signal.SIGTERM, signal_handler)
144
136
 
137
+ # Start the child process with stdout/stderr redirected to the log
138
+ # env will be inherited from parent process
139
+ # cwd will be inherited from parent process
140
+ proc = subprocess.Popen(
141
+ cmd,
142
+ stdout=subprocess.PIPE,
143
+ stderr=subprocess.STDOUT,
144
+ shell=shell,
145
+ text=False, # binary mode
146
+ bufsize=0, # unbuffered
147
+ )
148
+
145
149
  logger.info(f"Started process {proc.pid} with command: {cmd} (shell={shell})")
146
150
 
147
151
  make_file_dir(log_path)
dmon/types.py CHANGED
@@ -17,7 +17,7 @@ CmdType = Union[str, List[str]]
17
17
 
18
18
  @dataclass
19
19
  class DmonTaskConfig:
20
- name: str = ""
20
+ task: str = ""
21
21
  """Name of the task"""
22
22
  cmd: CmdType = ""
23
23
  """Command to run, either a string (for shell) or a list of strings (for exec)"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-dmon
3
- Version: 0.2.0
3
+ Version: 0.2.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
@@ -12,7 +12,7 @@ Requires-Dist: termcolor>=2.4.0
12
12
  Requires-Dist: tomli>=2.2.1 ; python_full_version < '3.11'
13
13
  Requires-Python: >=3.8
14
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
15
+ Project-URL: Changelog, https://github.com/atomiechen/python-dmon/blob/main/CHANGELOG.md
16
16
  Project-URL: Homepage, https://github.com/atomiechen/python-dmon
17
17
  Description-Content-Type: text/markdown
18
18
 
@@ -136,17 +136,17 @@ tasks:
136
136
  env: # (default: inherit from parent process)
137
137
  PYTHONUNBUFFERED: "1"
138
138
  override_env: false # override parent env and only use env defined here
139
- log_path: "logs/<name>.log" # path to log file
139
+ log_path: "logs/<task>.log" # path to log file
140
140
  log_rotate: false # enable log rotation
141
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
142
+ rotate_log_path: "logs/<task>.rotate.log" # path to rotation log
143
143
  rotate_log_max_size: 5 # max rotation log file size in MB
144
- meta_path: ".dmon/<name>.meta.json" # path to meta file
144
+ meta_path: ".dmon/<task>.meta.json" # path to meta file
145
145
  ```
146
146
 
147
147
  ## Under the Hood
148
148
 
149
- Each task is associated with a meta file (e.g. `.dmon/<name>.meta.json`) stored in the current working directory.
149
+ Each task is associated with a meta file (e.g. `.dmon/<task>.meta.json`) stored in the current working directory.
150
150
  The file contains details such as the command, PID, log path, and more.
151
151
  **Do not** modify or delete these files manually.
152
152
 
@@ -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=Dqu43_hcsx8iCJXAL1YXqBpp1jFt0Y7qBEr9mYzkAn8,8764
4
+ dmon/config.py,sha256=a7YM4U_iMB1wQ03NpWGAjbX-N-8Rdt9n6XTzKzeGZq4,7566
5
+ dmon/constants.py,sha256=uJPI66IJvT6vhamqEDeqHjZQS12RxCPe3DD5yCU0UR8,407
6
+ dmon/control.py,sha256=u1sz2MtxHHDqsaFyc7QaG7HFPLnAkser_8riTYxFK1A,17276
7
+ dmon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ dmon/runner.py,sha256=iyotNIrbO6I_u02EcqwO7R7N3BEBP6b_8eL8yb3zeJQ,6082
9
+ dmon/types.py,sha256=tTVoJzWk-Z8XiZCUt0VE8Z7-URpXMoS54HfVYXhW-0Q,1795
10
+ dmon/utils.py,sha256=zVQC9YNhMQu4hrAp9k2ds2egnbWZZv7S5w8i2oVIPJg,808
11
+ python_dmon-0.2.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
12
+ python_dmon-0.2.1.dist-info/entry_points.txt,sha256=rad5ugz4K_IyNXS_IrFPu0tS-UXlBCSJRhJv-OWd_yk,45
13
+ python_dmon-0.2.1.dist-info/METADATA,sha256=gIMrWHILmOEnjtU-gZAoYdz8JAOxFYibkos4DYs_ba0,4496
14
+ python_dmon-0.2.1.dist-info/RECORD,,
@@ -1,14 +0,0 @@
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,,