dar-backup 1.0.1__py3-none-any.whl → 1.0.2__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.
dar_backup/demo.py CHANGED
@@ -37,17 +37,22 @@ DAR_BACKUP_DIR = util.normalize_dir(util.expand_path("~/dar-backup"))
37
37
 
38
38
  def check_directories(args, vars_map: Dict[str,str]) -> bool:
39
39
  """
40
- Check if the directories exist and create them if they don't.
40
+ Check if target paths already exist and are directories.
41
41
 
42
42
  Returns:
43
- bool: True if the directories were created successfully, False otherwise.
43
+ bool: True if it is safe to proceed, False otherwise.
44
44
  """
45
45
  result = True
46
46
  for key in ("DAR_BACKUP_DIR","BACKUP_DIR","TEST_RESTORE_DIR","CONFIG_DIR","BACKUP_D_DIR"):
47
47
  path = Path(vars_map[key])
48
- if path.exists() and not args.override:
49
- print(f"Directory '{path}' already exists")
50
- result = False
48
+ if path.exists():
49
+ if not path.is_dir():
50
+ print(f"Error: '{path}' exists and is not a directory")
51
+ result = False
52
+ continue
53
+ if not args.override:
54
+ print(f"Directory '{path}' already exists")
55
+ result = False
51
56
  return result
52
57
 
53
58
 
@@ -72,12 +77,17 @@ def generate_file(args, template: str, file_path: Path, vars_map: Dict[str, str]
72
77
  if rendered is None:
73
78
  print(f"Error: Template '{template}' could not be rendered.")
74
79
  return False
75
- if os.path.exists(file_path) and not args.override:
76
- print(f"Error: File '{file_path}' already exists. Use --override to overwrite.")
77
- return False
80
+ if file_path.exists():
81
+ if file_path.is_dir():
82
+ print(f"Error: '{file_path}' is a directory, expected a file.")
83
+ return False
84
+ if not args.override:
85
+ print(f"Error: File '{file_path}' already exists. Use --override to overwrite.")
86
+ return False
78
87
  file_path.parent.mkdir(parents=True, exist_ok=True)
79
88
  file_path.write_text(rendered)
80
89
  print(f"File generated at '{file_path}'")
90
+ return True
81
91
 
82
92
 
83
93
 
@@ -152,7 +162,6 @@ def main():
152
162
  parser.error(
153
163
  "Options --root-dir, --dir-to-backup must all be specified together."
154
164
  )
155
- exit(1)
156
165
 
157
166
  args.root_dir = util.normalize_dir(util.expand_path(args.root_dir)) if args.root_dir else None
158
167
  args.backup_dir = util.normalize_dir(util.expand_path(args.backup_dir)) if args.backup_dir else None
dar_backup/installer.py CHANGED
@@ -43,6 +43,9 @@ def install_autocompletion():
43
43
 
44
44
  # ensure RC file and parent directory exist
45
45
  rc_file.parent.mkdir(parents=True, exist_ok=True)
46
+ if rc_file.exists() and rc_file.is_dir():
47
+ print(f"Error: RC path is a directory: {rc_file}")
48
+ return
46
49
  if not rc_file.exists():
47
50
  rc_file.touch()
48
51
 
@@ -76,11 +79,17 @@ def uninstall_autocompletion() -> str:
76
79
  if not rc_file.exists():
77
80
  print(f"❌ RC file not found: {rc_file}")
78
81
  return
82
+ if rc_file.is_dir():
83
+ print(f"Error: RC path is a directory: {rc_file}")
84
+ return
79
85
 
80
86
  content = rc_file.read_text()
81
87
  if marker not in content:
82
88
  print(f"No autocompletion block found in {rc_file}")
83
89
  return f"No autocompletion block found in {rc_file}" # for unit test
90
+ if end_marker not in content:
91
+ print(f"Error: Autocompletion end marker not found in {rc_file}")
92
+ return f"Autocompletion end marker not found in {rc_file}"
84
93
 
85
94
  lines = content.splitlines(keepends=True)
86
95
  new_lines = []
@@ -128,7 +137,11 @@ def run_installer(config_file: str, create_db_flag: bool):
128
137
  log_to_stdout=True,
129
138
  )
130
139
  command_logger = get_logger(command_output_logger=True)
131
- runner = CommandRunner(logger=logger, command_logger=command_logger)
140
+ runner = CommandRunner(
141
+ logger=logger,
142
+ command_logger=command_logger,
143
+ default_capture_limit_bytes=getattr(config_settings, "command_capture_max_bytes", None)
144
+ )
132
145
 
133
146
 
134
147
  # Create required directories
@@ -151,6 +164,10 @@ def run_installer(config_file: str, create_db_flag: bool):
151
164
  # Optionally create databases for all backup definitions
152
165
  if create_db_flag:
153
166
  for file in os.listdir(config_settings.backup_d_dir):
167
+ file_path = os.path.join(config_settings.backup_d_dir, file)
168
+ if not os.path.isfile(file_path):
169
+ logger.info(f"Skipping non-file backup definition: {file}")
170
+ continue
154
171
  backup_def = os.path.basename(file)
155
172
  print(f"Creating catalog for: {backup_def}")
156
173
  result = create_db(backup_def, config_settings, logger, runner)
dar_backup/manager.py CHANGED
@@ -27,6 +27,8 @@ import os
27
27
  import re
28
28
  import sys
29
29
  import subprocess
30
+ import threading
31
+ import shlex
30
32
 
31
33
  from inputimeout import inputimeout, TimeoutOccurred
32
34
 
@@ -36,6 +38,7 @@ from dar_backup.config_settings import ConfigSettings
36
38
  from dar_backup.util import setup_logging
37
39
  from dar_backup.util import CommandResult
38
40
  from dar_backup.util import get_config_file
41
+ from dar_backup.util import send_discord_message
39
42
  from dar_backup.util import get_logger
40
43
  from dar_backup.util import get_binary_info
41
44
  from dar_backup.util import show_version
@@ -62,6 +65,25 @@ logger = None
62
65
  runner = None
63
66
 
64
67
 
68
+ def _open_command_log(command: List[str]):
69
+ command_logger = get_logger(command_output_logger=True)
70
+ log_path = None
71
+ for handler in getattr(command_logger, "handlers", []):
72
+ if hasattr(handler, "baseFilename"):
73
+ log_path = handler.baseFilename
74
+ break
75
+ if not log_path:
76
+ return None, None
77
+ log_file = open(log_path, "ab")
78
+ header = (
79
+ f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - COMMAND: "
80
+ f"{' '.join(map(shlex.quote, command))}\n"
81
+ ).encode("utf-8", errors="replace")
82
+ log_file.write(header)
83
+ log_file.flush()
84
+ return log_file, threading.Lock()
85
+
86
+
65
87
  def get_db_dir(config_settings: ConfigSettings) -> str:
66
88
  """
67
89
  Return the correct directory for storing catalog databases.
@@ -131,24 +153,106 @@ def list_catalogs(backup_def: str, config_settings: ConfigSettings, suppress_out
131
153
  return CommandResult(1, '', error_msg)
132
154
 
133
155
  command = ['dar_manager', '--base', database_path, '--list']
134
- process = runner.run(command)
135
- stdout, stderr = process.stdout, process.stderr
156
+ if runner is not None and not hasattr(runner, "default_capture_limit_bytes"):
157
+ process = runner.run(command, capture_output_limit_bytes=-1)
158
+ stdout, stderr = process.stdout, process.stderr
136
159
 
137
- if process.returncode != 0:
138
- logger.error(f'Error listing catalogs for: "{database_path}"')
139
- logger.error(f"stderr: {stderr}")
140
- logger.error(f"stdout: {stdout}")
141
- return process
142
-
143
- # Extract only archive basenames from stdout
144
- archive_names = []
145
- for line in stdout.splitlines():
146
- line = line.strip()
147
- if not line or "archive #" in line or "dar path" in line or "compression" in line:
148
- continue
149
- parts = line.split("\t")
150
- if len(parts) >= 3:
151
- archive_names.append(parts[2].strip())
160
+ if process.returncode != 0:
161
+ logger.error(f'Error listing catalogs for: "{database_path}"')
162
+ logger.error(f"stderr: {stderr}")
163
+ logger.error(f"stdout: {stdout}")
164
+ return process
165
+
166
+ # Extract only archive basenames from stdout
167
+ archive_names = []
168
+ archive_lines = []
169
+ for line in stdout.splitlines():
170
+ line = line.strip()
171
+ if not line or "archive #" in line or "dar path" in line or "compression" in line:
172
+ continue
173
+ parts = line.split("\t")
174
+ if len(parts) >= 3:
175
+ archive_names.append(parts[2].strip())
176
+ archive_lines.append(line)
177
+ else:
178
+ stderr_lines: List[str] = []
179
+ stderr_bytes = 0
180
+ cap = getattr(config_settings, "command_capture_max_bytes", None)
181
+ if not isinstance(cap, int):
182
+ cap = None
183
+ log_file, log_lock = _open_command_log(command)
184
+
185
+ process = subprocess.Popen(
186
+ command,
187
+ stdout=subprocess.PIPE,
188
+ stderr=subprocess.PIPE,
189
+ text=False,
190
+ bufsize=0
191
+ )
192
+
193
+ def read_stderr():
194
+ nonlocal stderr_bytes
195
+ if process.stderr is None:
196
+ return
197
+ while True:
198
+ chunk = process.stderr.read(1024)
199
+ if not chunk:
200
+ break
201
+ if log_file:
202
+ with log_lock:
203
+ log_file.write(chunk)
204
+ log_file.flush()
205
+ if cap is None:
206
+ stderr_lines.append(chunk)
207
+ elif cap > 0 and stderr_bytes < cap:
208
+ remaining = cap - stderr_bytes
209
+ if len(chunk) <= remaining:
210
+ stderr_lines.append(chunk)
211
+ stderr_bytes += len(chunk)
212
+ else:
213
+ stderr_lines.append(chunk[:remaining])
214
+ stderr_bytes = cap
215
+
216
+ stderr_thread = threading.Thread(target=read_stderr)
217
+ stderr_thread.start()
218
+
219
+ archive_names = []
220
+ archive_lines = []
221
+ if process.stdout is not None:
222
+ buffer = b""
223
+ while True:
224
+ chunk = process.stdout.read(1024)
225
+ if not chunk:
226
+ break
227
+ if log_file:
228
+ with log_lock:
229
+ log_file.write(chunk)
230
+ buffer += chunk
231
+ while b"\n" in buffer:
232
+ line, buffer = buffer.split(b"\n", 1)
233
+ stripped = line.strip()
234
+ if not stripped:
235
+ continue
236
+ decoded = stripped.decode("utf-8", errors="replace")
237
+ if "archive #" in decoded or "dar path" in decoded or "compression" in decoded:
238
+ continue
239
+ parts = decoded.split("\t")
240
+ if len(parts) >= 3:
241
+ archive_names.append(parts[2].strip())
242
+ archive_lines.append(decoded)
243
+ process.stdout.close()
244
+
245
+ process.wait()
246
+ stderr_thread.join()
247
+ if log_file:
248
+ log_file.close()
249
+
250
+ if process.returncode != 0:
251
+ logger.error(f'Error listing catalogs for: "{database_path}"')
252
+ stderr_text = "".join(stderr_lines)
253
+ if stderr_text:
254
+ logger.error(f"stderr: {stderr_text}")
255
+ return CommandResult(process.returncode, "", stderr_text)
152
256
 
153
257
  # Sort by prefix and date
154
258
  def extract_date(arch_name):
@@ -167,7 +271,7 @@ def list_catalogs(backup_def: str, config_settings: ConfigSettings, suppress_out
167
271
  for name in archive_names:
168
272
  print(name)
169
273
 
170
- return process
274
+ return CommandResult(0, "\n".join(archive_lines), "")
171
275
 
172
276
 
173
277
  def cat_no_for_name(archive: str, config_settings: ConfigSettings) -> int:
@@ -184,9 +288,7 @@ def cat_no_for_name(archive: str, config_settings: ConfigSettings) -> int:
184
288
  if process.returncode != 0:
185
289
  logger.error(f"Error listing catalogs for backup def: '{backup_def}'")
186
290
  return -1
187
- line_no = 1
188
291
  for line in process.stdout.splitlines():
189
- line_no += 1
190
292
  search = re.search(rf".*?(\d+)\s+.*?({archive}).*", line)
191
293
  if search:
192
294
  logger.info(f"Found archive: '{archive}', catalog #: '{search.group(1)}'")
@@ -215,26 +317,104 @@ def list_archive_contents(archive: str, config_settings: ConfigSettings) -> int:
215
317
 
216
318
 
217
319
  command = ['dar_manager', '--base', database_path, '-u', f"{cat_no}"]
218
- process = runner.run(command, timeout = 10)
320
+ if runner is not None and not hasattr(runner, "default_capture_limit_bytes"):
321
+ process = runner.run(command, timeout=10)
322
+ stdout = process.stdout or ""
323
+ stderr = process.stderr or ""
324
+ if process.returncode != 0:
325
+ logger.error(f'Error listing catalogs for: "{database_path}"')
326
+ logger.error(f"stderr: {stderr}")
327
+ logger.error(f"stdout: {stdout}")
328
+ return process.returncode
219
329
 
330
+ combined_lines = (stdout + "\n" + stderr).splitlines()
331
+ file_lines = [line for line in combined_lines if line.strip().startswith("[ Saved ]")]
220
332
 
221
- stdout = process.stdout or ""
222
- stderr = process.stderr or ""
333
+ if file_lines:
334
+ for line in file_lines:
335
+ print(line)
336
+ else:
337
+ print(f"[info] Archive '{archive}' is empty.")
338
+
339
+ return process.returncode
340
+
341
+ stderr_lines: List[str] = []
342
+ stderr_bytes = 0
343
+ cap = getattr(config_settings, "command_capture_max_bytes", None)
344
+ log_file, log_lock = _open_command_log(command)
345
+
346
+ process = subprocess.Popen(
347
+ command,
348
+ stdout=subprocess.PIPE,
349
+ stderr=subprocess.PIPE,
350
+ text=False,
351
+ bufsize=0
352
+ )
353
+
354
+ def read_stderr():
355
+ nonlocal stderr_bytes
356
+ if process.stderr is None:
357
+ return
358
+ while True:
359
+ chunk = process.stderr.read(1024)
360
+ if not chunk:
361
+ break
362
+ if log_file:
363
+ with log_lock:
364
+ log_file.write(chunk)
365
+ log_file.flush()
366
+ if cap is None:
367
+ stderr_lines.append(chunk)
368
+ elif cap > 0 and stderr_bytes < cap:
369
+ remaining = cap - stderr_bytes
370
+ if len(chunk) <= remaining:
371
+ stderr_lines.append(chunk)
372
+ stderr_bytes += len(chunk)
373
+ else:
374
+ stderr_lines.append(chunk[:remaining])
375
+ stderr_bytes = cap
376
+
377
+ stderr_thread = threading.Thread(target=read_stderr)
378
+ stderr_thread.start()
379
+
380
+ found = False
381
+ if process.stdout is not None:
382
+ buffer = b""
383
+ while True:
384
+ chunk = process.stdout.read(1024)
385
+ if not chunk:
386
+ break
387
+ if log_file:
388
+ with log_lock:
389
+ log_file.write(chunk)
390
+ buffer += chunk
391
+ while b"\n" in buffer:
392
+ line, buffer = buffer.split(b"\n", 1)
393
+ if line.strip().startswith(b"[ Saved ]"):
394
+ print(line.decode("utf-8", errors="replace"))
395
+ found = True
396
+ process.stdout.close()
223
397
 
398
+ try:
399
+ process.wait(timeout=10)
400
+ except subprocess.TimeoutExpired:
401
+ process.kill()
402
+ stderr_thread.join()
403
+ logger.error(f"Timeout listing contents of archive: '{archive}'")
404
+ return 1
405
+
406
+ stderr_thread.join()
407
+ if log_file:
408
+ log_file.close()
224
409
 
225
410
  if process.returncode != 0:
226
411
  logger.error(f'Error listing catalogs for: "{database_path}"')
227
- logger.error(f"stderr: {stderr}")
228
- logger.error(f"stdout: {stdout}")
229
-
230
-
231
- combined_lines = (stdout + "\n" + stderr).splitlines()
232
- file_lines = [line for line in combined_lines if line.strip().startswith("[ Saved ]")]
412
+ stderr_text = "".join(stderr_lines)
413
+ if stderr_text:
414
+ logger.error(f"stderr: {stderr_text}")
415
+ return process.returncode
233
416
 
234
- if file_lines:
235
- for line in file_lines:
236
- print(line)
237
- else:
417
+ if not found:
238
418
  print(f"[info] Archive '{archive}' is empty.")
239
419
 
240
420
  return process.returncode
@@ -252,7 +432,7 @@ def list_catalog_contents(catalog_number: int, backup_def: str, config_settings:
252
432
  logger.error(f'Catalog database not found: "{database_path}"')
253
433
  return 1
254
434
  command = ['dar_manager', '--base', database_path, '-u', f"{catalog_number}"]
255
- process = runner.run(command)
435
+ process = runner.run(command, capture_output_limit_bytes=-1)
256
436
  stdout, stderr = process.stdout, process.stderr
257
437
  if process.returncode != 0:
258
438
  logger.error(f'Error listing catalogs for: "{database_path}"')
@@ -273,7 +453,7 @@ def find_file(file, backup_def, config_settings):
273
453
  logger.error(f'Database not found: "{database_path}"')
274
454
  return 1
275
455
  command = ['dar_manager', '--base', database_path, '-f', f"{file}"]
276
- process = runner.run(command)
456
+ process = runner.run(command, capture_output_limit_bytes=-1)
277
457
  stdout, stderr = process.stdout, process.stderr
278
458
  if process.returncode != 0:
279
459
  logger.error(f'Error finding file: {file} in: "{database_path}"')
@@ -544,7 +724,14 @@ def main():
544
724
  raise SystemExit(127)
545
725
  args.config_file = config_settings_path
546
726
 
547
- config_settings = ConfigSettings(args.config_file)
727
+ try:
728
+ config_settings = ConfigSettings(args.config_file)
729
+ except Exception as exc:
730
+ msg = f"Config error: {exc}"
731
+ print(msg, file=stderr)
732
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
733
+ send_discord_message(f"{ts} - manager: FAILURE - {msg}")
734
+ sys.exit(127)
548
735
 
549
736
  if not os.path.dirname(config_settings.logfile_location):
550
737
  print(f"Directory for log file '{config_settings.logfile_location}' does not exist, exiting")
@@ -552,9 +739,22 @@ def main():
552
739
  return
553
740
 
554
741
  command_output_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
555
- logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout, logfile_max_bytes=config_settings.logfile_max_bytes, logfile_backup_count=config_settings.logfile_backup_count)
742
+ logger = setup_logging(
743
+ config_settings.logfile_location,
744
+ command_output_log,
745
+ args.log_level,
746
+ args.log_stdout,
747
+ logfile_max_bytes=config_settings.logfile_max_bytes,
748
+ logfile_backup_count=config_settings.logfile_backup_count,
749
+ trace_log_max_bytes=getattr(config_settings, "trace_log_max_bytes", 10485760),
750
+ trace_log_backup_count=getattr(config_settings, "trace_log_backup_count", 1)
751
+ )
556
752
  command_logger = get_logger(command_output_logger=True)
557
- runner = CommandRunner(logger=logger, command_logger=command_logger)
753
+ runner = CommandRunner(
754
+ logger=logger,
755
+ command_logger=command_logger,
756
+ default_capture_limit_bytes=getattr(config_settings, "command_capture_max_bytes", None)
757
+ )
558
758
 
559
759
  start_msgs: List[Tuple[str, str]] = []
560
760
 
@@ -626,63 +826,70 @@ def main():
626
826
  return
627
827
 
628
828
  # --- Modify settings ---
629
- if args.alternate_archive_dir:
630
- if not os.path.exists(args.alternate_archive_dir):
631
- logger.error(f"Alternate archive dir '{args.alternate_archive_dir}' does not exist, exiting")
632
- sys.exit(1)
829
+ try:
830
+ if args.alternate_archive_dir:
831
+ if not os.path.exists(args.alternate_archive_dir):
832
+ logger.error(f"Alternate archive dir '{args.alternate_archive_dir}' does not exist, exiting")
833
+ sys.exit(1)
834
+ return
835
+ config_settings.backup_dir = args.alternate_archive_dir
836
+
837
+ # --- Functional logic ---
838
+ if args.create_db:
839
+ if args.backup_def:
840
+ sys.exit(create_db(args.backup_def, config_settings, logger, runner))
841
+ return
842
+ else:
843
+ for root, dirs, files in os.walk(config_settings.backup_d_dir):
844
+ for file in files:
845
+ current_backupdef = os.path.basename(file)
846
+ logger.debug(f"Create catalog db for backup definition: '{current_backupdef}'")
847
+ result = create_db(current_backupdef, config_settings, logger, runner)
848
+ if result != 0:
849
+ sys.exit(result)
850
+ return
851
+
852
+ if args.add_specific_archive:
853
+ sys.exit(add_specific_archive(args.add_specific_archive, config_settings))
633
854
  return
634
- config_settings.backup_dir = args.alternate_archive_dir
635
855
 
636
- # --- Functional logic ---
637
- if args.create_db:
638
- if args.backup_def:
639
- sys.exit(create_db(args.backup_def, config_settings, logger, runner))
856
+ if args.add_dir:
857
+ sys.exit(add_directory(args, config_settings))
640
858
  return
641
- else:
642
- for root, dirs, files in os.walk(config_settings.backup_d_dir):
643
- for file in files:
644
- current_backupdef = os.path.basename(file)
645
- logger.debug(f"Create catalog db for backup definition: '{current_backupdef}'")
646
- result = create_db(current_backupdef, config_settings, logger, runner)
647
- if result != 0:
648
- sys.exit(result)
649
- return
650
-
651
- if args.add_specific_archive:
652
- sys.exit(add_specific_archive(args.add_specific_archive, config_settings))
653
- return
654
859
 
655
- if args.add_dir:
656
- sys.exit(add_directory(args, config_settings))
657
- return
658
-
659
- if args.remove_specific_archive:
660
- return remove_specific_archive(args.remove_specific_archive, config_settings)
661
-
662
- if args.list_catalogs:
663
- if args.backup_def:
664
- process = list_catalogs(args.backup_def, config_settings)
665
- result = process.returncode
666
- else:
667
- result = 0
668
- for root, dirs, files in os.walk(config_settings.backup_d_dir):
669
- for file in files:
670
- current_backupdef = os.path.basename(file)
671
- if list_catalogs(current_backupdef, config_settings).returncode != 0:
672
- result = 1
673
- sys.exit(result)
674
- return
860
+ if args.remove_specific_archive:
861
+ return remove_specific_archive(args.remove_specific_archive, config_settings)
862
+
863
+ if args.list_catalogs:
864
+ if args.backup_def:
865
+ process = list_catalogs(args.backup_def, config_settings)
866
+ result = process.returncode
867
+ else:
868
+ result = 0
869
+ for root, dirs, files in os.walk(config_settings.backup_d_dir):
870
+ for file in files:
871
+ current_backupdef = os.path.basename(file)
872
+ if list_catalogs(current_backupdef, config_settings).returncode != 0:
873
+ result = 1
874
+ sys.exit(result)
875
+ return
675
876
 
676
- if args.list_archive_contents:
677
- result = list_archive_contents(args.list_archive_contents, config_settings)
678
- sys.exit(result)
679
- return
877
+ if args.list_archive_contents:
878
+ result = list_archive_contents(args.list_archive_contents, config_settings)
879
+ sys.exit(result)
880
+ return
680
881
 
681
882
 
682
- if args.find_file:
683
- result = find_file(args.find_file, args.backup_def, config_settings)
684
- sys.exit(result)
685
- return
883
+ if args.find_file:
884
+ result = find_file(args.find_file, args.backup_def, config_settings)
885
+ sys.exit(result)
886
+ return
887
+ except Exception as e:
888
+ msg = f"Unexpected error during manager operation: {e}"
889
+ logger.error(msg, exc_info=True)
890
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
891
+ send_discord_message(f"{ts} - manager: FAILURE - {msg}", config_settings=config_settings)
892
+ sys.exit(1)
686
893
 
687
894
 
688
895
  if __name__ == "__main__":