logging_util.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. __package__ = 'archivebox'
  2. import re
  3. import os
  4. import sys
  5. import stat
  6. import time
  7. import argparse
  8. from math import log
  9. from multiprocessing import Process
  10. from pathlib import Path
  11. from datetime import datetime, timezone
  12. from dataclasses import dataclass
  13. from typing import Any, Optional, List, Dict, Union, IO, TYPE_CHECKING
  14. if TYPE_CHECKING:
  15. from .index.schema import Link, ArchiveResult
  16. from rich import print
  17. from rich.panel import Panel
  18. from archivebox.config import CONSTANTS, DATA_DIR, VERSION, SHELL_CONFIG
  19. from archivebox.misc.system import get_dir_size
  20. from .util import enforce_types
  21. from .misc.logging import ANSI, stderr
  22. @dataclass
  23. class RuntimeStats:
  24. """mutable stats counter for logging archiving timing info to CLI output"""
  25. skipped: int = 0
  26. succeeded: int = 0
  27. failed: int = 0
  28. parse_start_ts: Optional[datetime] = None
  29. parse_end_ts: Optional[datetime] = None
  30. index_start_ts: Optional[datetime] = None
  31. index_end_ts: Optional[datetime] = None
  32. archiving_start_ts: Optional[datetime] = None
  33. archiving_end_ts: Optional[datetime] = None
  34. # globals are bad, mmkay
  35. _LAST_RUN_STATS = RuntimeStats()
  36. def debug_dict_summary(obj: Dict[Any, Any]) -> None:
  37. stderr(' '.join(f'{key}={str(val).ljust(6)}' for key, val in obj.items()))
  38. def get_fd_info(fd) -> Dict[str, Any]:
  39. NAME = fd.name[1:-1]
  40. FILENO = fd.fileno()
  41. MODE = os.fstat(FILENO).st_mode
  42. IS_TTY = hasattr(fd, 'isatty') and fd.isatty()
  43. IS_PIPE = stat.S_ISFIFO(MODE)
  44. IS_FILE = stat.S_ISREG(MODE)
  45. IS_TERMINAL = not (IS_PIPE or IS_FILE)
  46. IS_LINE_BUFFERED = fd.line_buffering
  47. IS_READABLE = fd.readable()
  48. return {
  49. 'NAME': NAME, 'FILENO': FILENO, 'MODE': MODE,
  50. 'IS_TTY': IS_TTY, 'IS_PIPE': IS_PIPE, 'IS_FILE': IS_FILE,
  51. 'IS_TERMINAL': IS_TERMINAL, 'IS_LINE_BUFFERED': IS_LINE_BUFFERED,
  52. 'IS_READABLE': IS_READABLE,
  53. }
  54. # # Log debug information about stdin, stdout, and stderr
  55. # sys.stdout.write('[>&1] this is python stdout\n')
  56. # sys.stderr.write('[>&2] this is python stderr\n')
  57. # debug_dict_summary(get_fd_info(sys.stdin))
  58. # debug_dict_summary(get_fd_info(sys.stdout))
  59. # debug_dict_summary(get_fd_info(sys.stderr))
  60. class SmartFormatter(argparse.HelpFormatter):
  61. """Patched formatter that prints newlines in argparse help strings"""
  62. def _split_lines(self, text, width):
  63. if '\n' in text:
  64. return text.splitlines()
  65. return argparse.HelpFormatter._split_lines(self, text, width)
  66. def reject_stdin(caller: str, stdin: Optional[IO]=sys.stdin) -> None:
  67. """Tell the user they passed stdin to a command that doesn't accept it"""
  68. if not stdin:
  69. return None
  70. if os.environ.get('IN_DOCKER') in ('1', 'true', 'True', 'TRUE', 'yes'):
  71. # when TTY is disabled in docker we cant tell if stdin is being piped in or not
  72. # if we try to read stdin when its not piped we will hang indefinitely waiting for it
  73. return None
  74. if not stdin.isatty():
  75. # stderr('READING STDIN TO REJECT...')
  76. stdin_raw_text = stdin.read()
  77. if stdin_raw_text.strip():
  78. # stderr('GOT STDIN!', len(stdin_str))
  79. stderr(f'[!] The "{caller}" command does not accept stdin (ignoring).', color='red')
  80. stderr(f' Run archivebox "{caller} --help" to see usage and examples.')
  81. stderr()
  82. # raise SystemExit(1)
  83. return None
  84. def accept_stdin(stdin: Optional[IO]=sys.stdin) -> Optional[str]:
  85. """accept any standard input and return it as a string or None"""
  86. if not stdin:
  87. return None
  88. if not stdin.isatty():
  89. # stderr('READING STDIN TO ACCEPT...')
  90. stdin_str = stdin.read()
  91. if stdin_str:
  92. # stderr('GOT STDIN...', len(stdin_str))
  93. return stdin_str
  94. return None
  95. class TimedProgress:
  96. """Show a progress bar and measure elapsed time until .end() is called"""
  97. def __init__(self, seconds, prefix=''):
  98. self.SHOW_PROGRESS = SHELL_CONFIG.SHOW_PROGRESS
  99. self.ANSI = SHELL_CONFIG.ANSI
  100. if self.SHOW_PROGRESS:
  101. self.p = Process(target=progress_bar, args=(seconds, prefix, self.ANSI))
  102. self.p.start()
  103. self.stats = {'start_ts': datetime.now(timezone.utc), 'end_ts': None}
  104. def end(self):
  105. """immediately end progress, clear the progressbar line, and save end_ts"""
  106. end_ts = datetime.now(timezone.utc)
  107. self.stats['end_ts'] = end_ts
  108. if self.SHOW_PROGRESS:
  109. # terminate if we havent already terminated
  110. try:
  111. # kill the progress bar subprocess
  112. try:
  113. self.p.close() # must be closed *before* its terminnated
  114. except (KeyboardInterrupt, SystemExit):
  115. print()
  116. raise
  117. except BaseException: # lgtm [py/catch-base-exception]
  118. pass
  119. self.p.terminate()
  120. self.p.join()
  121. # clear whole terminal line
  122. try:
  123. sys.stdout.write('\r{}{}\r'.format((' ' * SHELL_CONFIG.TERM_WIDTH), self.ANSI['reset']))
  124. except (IOError, BrokenPipeError):
  125. # ignore when the parent proc has stopped listening to our stdout
  126. pass
  127. except ValueError:
  128. pass
  129. @enforce_types
  130. def progress_bar(seconds: int, prefix: str='', ANSI: Dict[str, str]=ANSI) -> None:
  131. """show timer in the form of progress bar, with percentage and seconds remaining"""
  132. output_buf = (sys.stdout or sys.__stdout__ or sys.stderr or sys.__stderr__)
  133. chunk = '█' if output_buf and output_buf.encoding.upper() == 'UTF-8' else '#'
  134. last_width = SHELL_CONFIG.TERM_WIDTH
  135. chunks = last_width - len(prefix) - 20 # number of progress chunks to show (aka max bar width)
  136. try:
  137. for s in range(seconds * chunks):
  138. max_width = SHELL_CONFIG.TERM_WIDTH
  139. if max_width < last_width:
  140. # when the terminal size is shrunk, we have to write a newline
  141. # otherwise the progress bar will keep wrapping incorrectly
  142. sys.stdout.write('\r\n')
  143. sys.stdout.flush()
  144. chunks = max_width - len(prefix) - 20
  145. pct_complete = s / chunks / seconds * 100
  146. log_pct = (log(pct_complete or 1, 10) / 2) * 100 # everyone likes faster progress bars ;)
  147. bar_width = round(log_pct/(100/chunks))
  148. last_width = max_width
  149. # ████████████████████ 0.9% (1/60sec)
  150. sys.stdout.write('\r{0}{1}{2}{3} {4}% ({5}/{6}sec)'.format(
  151. prefix,
  152. ANSI['green' if pct_complete < 80 else 'lightyellow'],
  153. (chunk * bar_width).ljust(chunks),
  154. ANSI['reset'],
  155. round(pct_complete, 1),
  156. round(s/chunks),
  157. seconds,
  158. ))
  159. sys.stdout.flush()
  160. time.sleep(1 / chunks)
  161. # ██████████████████████████████████ 100.0% (60/60sec)
  162. sys.stdout.write('\r{0}{1}{2}{3} {4}% ({5}/{6}sec)'.format(
  163. prefix,
  164. ANSI['red'],
  165. chunk * chunks,
  166. ANSI['reset'],
  167. 100.0,
  168. seconds,
  169. seconds,
  170. ))
  171. sys.stdout.flush()
  172. # uncomment to have it disappear when it hits 100% instead of staying full red:
  173. # time.sleep(0.5)
  174. # sys.stdout.write('\r{}{}\r'.format((' ' * SHELL_CONFIG.TERM_WIDTH), ANSI['reset']))
  175. # sys.stdout.flush()
  176. except (KeyboardInterrupt, BrokenPipeError):
  177. print()
  178. def log_cli_command(subcommand: str, subcommand_args: List[str], stdin: Optional[str | IO], pwd: str):
  179. args = ' '.join(subcommand_args)
  180. version_msg = '[dark_magenta]\\[i] [{now}] ArchiveBox v{VERSION}: [/dark_magenta][green4]archivebox [green3]{subcommand}[green2] {args}[/green2]'.format(
  181. now=datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),
  182. VERSION=VERSION,
  183. subcommand=subcommand,
  184. args=args,
  185. )
  186. # stderr()
  187. # stderr('[bright_black] > {pwd}[/]'.format(pwd=pwd, **ANSI))
  188. # stderr()
  189. print(Panel(version_msg), file=sys.stderr)
  190. ### Parsing Stage
  191. def log_importing_started(urls: Union[str, List[str]], depth: int, index_only: bool):
  192. _LAST_RUN_STATS.parse_start_ts = datetime.now(timezone.utc)
  193. print('[green][+] [{}] Adding {} links to index (crawl depth={}){}...[/]'.format(
  194. _LAST_RUN_STATS.parse_start_ts.strftime('%Y-%m-%d %H:%M:%S'),
  195. len(urls) if isinstance(urls, list) else len(urls.split('\n')),
  196. depth,
  197. ' (index only)' if index_only else '',
  198. ))
  199. def log_source_saved(source_file: str):
  200. print(' > Saved verbatim input to {}/{}'.format(CONSTANTS.SOURCES_DIR_NAME, source_file.rsplit('/', 1)[-1]))
  201. def log_parsing_finished(num_parsed: int, parser_name: str):
  202. _LAST_RUN_STATS.parse_end_ts = datetime.now(timezone.utc)
  203. print(' > Parsed {} URLs from input ({})'.format(num_parsed, parser_name))
  204. def log_deduping_finished(num_new_links: int):
  205. print(' > Found {} new URLs not already in index'.format(num_new_links))
  206. def log_crawl_started(new_links):
  207. print()
  208. print(f'[green][*] Starting crawl of {len(new_links)} sites 1 hop out from starting point[/]')
  209. ### Indexing Stage
  210. def log_indexing_process_started(num_links: int):
  211. start_ts = datetime.now(timezone.utc)
  212. _LAST_RUN_STATS.index_start_ts = start_ts
  213. print()
  214. print('[bright_black][*] [{}] Writing {} links to main index...[/]'.format(
  215. start_ts.strftime('%Y-%m-%d %H:%M:%S'),
  216. num_links,
  217. ))
  218. def log_indexing_process_finished():
  219. end_ts = datetime.now(timezone.utc)
  220. _LAST_RUN_STATS.index_end_ts = end_ts
  221. def log_indexing_started(out_path: str):
  222. if SHELL_CONFIG.IS_TTY:
  223. sys.stdout.write(f' > ./{Path(out_path).relative_to(DATA_DIR)}')
  224. def log_indexing_finished(out_path: str):
  225. print(f'\r √ ./{Path(out_path).relative_to(DATA_DIR)}')
  226. ### Archiving Stage
  227. def log_archiving_started(num_links: int, resume: Optional[float]=None):
  228. start_ts = datetime.now(timezone.utc)
  229. _LAST_RUN_STATS.archiving_start_ts = start_ts
  230. print()
  231. if resume:
  232. print('[green][▶] [{}] Resuming archive updating for {} pages starting from {}...[/]'.format(
  233. start_ts.strftime('%Y-%m-%d %H:%M:%S'),
  234. num_links,
  235. resume,
  236. ))
  237. else:
  238. print('[green][▶] [{}] Starting archiving of {} snapshots in index...[/]'.format(
  239. start_ts.strftime('%Y-%m-%d %H:%M:%S'),
  240. num_links,
  241. ))
  242. def log_archiving_paused(num_links: int, idx: int, timestamp: str):
  243. end_ts = datetime.now(timezone.utc)
  244. _LAST_RUN_STATS.archiving_end_ts = end_ts
  245. print()
  246. print('\n[yellow3][X] [{now}] Downloading paused on link {timestamp} ({idx}/{total})[/]'.format(
  247. now=end_ts.strftime('%Y-%m-%d %H:%M:%S'),
  248. idx=idx+1,
  249. timestamp=timestamp,
  250. total=num_links,
  251. ))
  252. print()
  253. print(' Continue archiving where you left off by running:')
  254. print(' archivebox update --resume={}'.format(timestamp))
  255. def log_archiving_finished(num_links: int):
  256. from core.models import Snapshot
  257. end_ts = datetime.now(timezone.utc)
  258. _LAST_RUN_STATS.archiving_end_ts = end_ts
  259. assert _LAST_RUN_STATS.archiving_start_ts is not None
  260. seconds = end_ts.timestamp() - _LAST_RUN_STATS.archiving_start_ts.timestamp()
  261. if seconds > 60:
  262. duration = '{0:.2f} min'.format(seconds / 60)
  263. else:
  264. duration = '{0:.2f} sec'.format(seconds)
  265. print()
  266. print('[green][√] [{}] Update of {} pages complete ({})[/]'.format(
  267. end_ts.strftime('%Y-%m-%d %H:%M:%S'),
  268. num_links,
  269. duration,
  270. ))
  271. print(' - {} links skipped'.format(_LAST_RUN_STATS.skipped))
  272. print(' - {} links updated'.format(_LAST_RUN_STATS.succeeded + _LAST_RUN_STATS.failed))
  273. print(' - {} links had errors'.format(_LAST_RUN_STATS.failed))
  274. if Snapshot.objects.count() < 50:
  275. print()
  276. print(' [violet]Hint:[/] To manage your archive in a Web UI, run:')
  277. print(' archivebox server 0.0.0.0:8000')
  278. def log_link_archiving_started(link: "Link", link_dir: str, is_new: bool):
  279. # [*] [2019-03-22 13:46:45] "Log Structured Merge Trees - ben stopford"
  280. # http://www.benstopford.com/2015/02/14/log-structured-merge-trees/
  281. # > output/archive/1478739709
  282. print('\n[[{symbol_color}]{symbol}[/]] [[{symbol_color}]{now}[/]] "{title}"'.format(
  283. symbol_color='green' if is_new else 'bright_black',
  284. symbol='+' if is_new else '√',
  285. now=datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),
  286. title=link.title or link.base_url,
  287. ))
  288. print(f' [sky_blue1]{link.url}[/]')
  289. print(' {} {}'.format(
  290. '>' if is_new else '√',
  291. pretty_path(link_dir),
  292. ))
  293. def log_link_archiving_finished(link: "Link", link_dir: str, is_new: bool, stats: dict, start_ts: datetime):
  294. total = sum(stats.values())
  295. if stats['failed'] > 0 :
  296. _LAST_RUN_STATS.failed += 1
  297. elif stats['skipped'] == total:
  298. _LAST_RUN_STATS.skipped += 1
  299. else:
  300. _LAST_RUN_STATS.succeeded += 1
  301. try:
  302. size = get_dir_size(link_dir)
  303. except FileNotFoundError:
  304. size = (0, None, '0')
  305. end_ts = datetime.now(timezone.utc)
  306. duration = str(end_ts - start_ts).split('.')[0]
  307. print(' [bright_black]{} files ({}) in {}s [/]'.format(size[2], printable_filesize(size[0]), duration))
  308. def log_archive_method_started(method: str):
  309. print(' > {}'.format(method))
  310. def log_archive_method_finished(result: "ArchiveResult"):
  311. """quote the argument with whitespace in a command so the user can
  312. copy-paste the outputted string directly to run the cmd
  313. """
  314. # Prettify CMD string and make it safe to copy-paste by quoting arguments
  315. quoted_cmd = ' '.join(
  316. '"{}"'.format(arg) if (' ' in arg) or (':' in arg) else arg
  317. for arg in result.cmd
  318. )
  319. if result.status == 'failed':
  320. if result.output.__class__.__name__ == 'TimeoutExpired':
  321. duration = (result.end_ts - result.start_ts).seconds
  322. hint_header = [
  323. f'[yellow3]Extractor timed out after {duration}s.[/]',
  324. ]
  325. else:
  326. error_name = result.output.__class__.__name__.replace('ArchiveError', '')
  327. hint_header = [
  328. '[yellow3]Extractor failed:[/]',
  329. f' {error_name} [red1]{result.output}[/]',
  330. ]
  331. # import pudb; pudb.set_trace()
  332. # Prettify error output hints string and limit to five lines
  333. hints = getattr(result.output, 'hints', None) or ()
  334. if hints:
  335. if isinstance(hints, (list, tuple, type(_ for _ in ()))):
  336. hints = [hint.decode() if isinstance(hint, bytes) else str(hint) for hint in hints]
  337. else:
  338. if isinstance(hints, bytes):
  339. hints = hints.decode()
  340. hints = hints.split('\n')
  341. hints = (
  342. f' [yellow1]{line.strip()}[/]'
  343. for line in list(hints)[:5] if line.strip()
  344. )
  345. docker_hints = ()
  346. if os.environ.get('IN_DOCKER') in ('1', 'true', 'True', 'TRUE', 'yes'):
  347. docker_hints = (
  348. ' docker run -it -v $PWD/data:/data archivebox/archivebox /bin/bash',
  349. )
  350. # Collect and prefix output lines with indentation
  351. output_lines = [
  352. *hint_header,
  353. *hints,
  354. '[violet]Run to see full output:[/]',
  355. *docker_hints,
  356. *([' cd {};'.format(result.pwd)] if result.pwd else []),
  357. ' {}'.format(quoted_cmd),
  358. ]
  359. print('\n'.join(
  360. ' {}'.format(line)
  361. for line in output_lines
  362. if line
  363. ))
  364. print()
  365. def log_list_started(filter_patterns: Optional[List[str]], filter_type: str):
  366. print(f'[green][*] Finding links in the archive index matching these {filter_type} patterns:[/]')
  367. print(' {}'.format(' '.join(filter_patterns or ())))
  368. def log_list_finished(links):
  369. from .index.csv import links_to_csv
  370. print()
  371. print('---------------------------------------------------------------------------------------------------')
  372. print(links_to_csv(links, cols=['timestamp', 'is_archived', 'num_outputs', 'url'], header=True, ljust=16, separator=' | '))
  373. print('---------------------------------------------------------------------------------------------------')
  374. print()
  375. def log_removal_started(links: List["Link"], yes: bool, delete: bool):
  376. print(f'[yellow3][i] Found {len(links)} matching URLs to remove.[/]')
  377. if delete:
  378. file_counts = [link.num_outputs for link in links if Path(link.link_dir).exists()]
  379. print(
  380. f' {len(links)} Links will be de-listed from the main index, and their archived content folders will be deleted from disk.\n'
  381. f' ({len(file_counts)} data folders with {sum(file_counts)} archived files will be deleted!)'
  382. )
  383. else:
  384. print(
  385. ' Matching links will be de-listed from the main index, but their archived content folders will remain in place on disk.\n'
  386. ' (Pass --delete if you also want to permanently delete the data folders)'
  387. )
  388. if not yes:
  389. print()
  390. print('[yellow3][?] Do you want to proceed with removing these {len(links)} links?[/]')
  391. try:
  392. assert input(' y/[n]: ').lower() == 'y'
  393. except (KeyboardInterrupt, EOFError, AssertionError):
  394. raise SystemExit(0)
  395. def log_removal_finished(all_links: int, to_remove: int):
  396. if all_links == 0:
  397. print()
  398. print('[red1][X] No matching links found.[/]')
  399. else:
  400. print()
  401. print(f'[red1][√] Removed {to_remove} out of {all_links} links from the archive index.[/]')
  402. print(f' Index now contains {all_links - to_remove} links.')
  403. def log_shell_welcome_msg():
  404. from .cli import CLI_SUBCOMMANDS
  405. print('[green]# ArchiveBox Imports[/]')
  406. print('[green]from core.models import Snapshot, ArchiveResult, Tag, User[/]')
  407. print('[green]from cli import *\n {}[/]'.format("\n ".join(CLI_SUBCOMMANDS.keys())))
  408. print()
  409. print('[i] Welcome to the ArchiveBox Shell!')
  410. print(' https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#Shell-Usage')
  411. print()
  412. print(' [violet]Hint:[/] Example use:')
  413. print(' print(Snapshot.objects.filter(is_archived=True).count())')
  414. print(' Snapshot.objects.get(url="https://example.com").as_json()')
  415. print(' add("https://example.com/some/new/url")')
  416. ### Helpers
  417. @enforce_types
  418. def pretty_path(path: Union[Path, str], pwd: Union[Path, str]=DATA_DIR) -> str:
  419. """convert paths like .../ArchiveBox/archivebox/../output/abc into output/abc"""
  420. pwd = str(Path(pwd)) # .resolve()
  421. path = str(path)
  422. if not path:
  423. return path
  424. # replace long absolute paths with ./ relative ones to save on terminal output width
  425. if path.startswith(pwd) and (pwd != '/'):
  426. path = path.replace(pwd, '.', 1)
  427. # quote paths containing spaces
  428. if ' ' in path:
  429. path = f'"{path}"'
  430. # if path is just a plain dot, replace it back with the absolute path for clarity
  431. if path == '.':
  432. path = pwd
  433. return path
  434. @enforce_types
  435. def printable_filesize(num_bytes: Union[int, float]) -> str:
  436. for count in ['Bytes','KB','MB','GB']:
  437. if num_bytes > -1024.0 and num_bytes < 1024.0:
  438. return '%3.1f %s' % (num_bytes, count)
  439. num_bytes /= 1024.0
  440. return '%3.1f %s' % (num_bytes, 'TB')
  441. @enforce_types
  442. def printable_folders(folders: Dict[str, Optional["Link"]],
  443. with_headers: bool=False) -> str:
  444. return '\n'.join(
  445. f'{folder} {link and link.url} "{link and link.title}"'
  446. for folder, link in folders.items()
  447. )
  448. @enforce_types
  449. def printable_config(config: dict, prefix: str='') -> str:
  450. return f'\n{prefix}'.join(
  451. f'{key}={val}'
  452. for key, val in config.items()
  453. if not (isinstance(val, dict) or callable(val))
  454. )
  455. @enforce_types
  456. def printable_folder_status(name: str, folder: Dict) -> str:
  457. if folder['enabled']:
  458. if folder['is_valid']:
  459. color, symbol, note, num_files = 'green', '√', 'valid', ''
  460. else:
  461. color, symbol, note, num_files = 'red', 'X', 'invalid', '?'
  462. else:
  463. color, symbol, note, num_files = 'lightyellow', '-', 'disabled', '-'
  464. if folder['path']:
  465. if Path(folder['path']).exists():
  466. num_files = (
  467. f'{len(os.listdir(folder["path"]))} files'
  468. if Path(folder['path']).is_dir() else
  469. printable_filesize(Path(folder['path']).stat().st_size)
  470. )
  471. else:
  472. num_files = 'missing'
  473. if folder.get('is_mount'):
  474. # add symbol @ next to filecount if path is a remote filesystem mount
  475. num_files = f'{num_files} @' if num_files else '@'
  476. path = pretty_path(folder['path'])
  477. return ' '.join((
  478. ANSI[color],
  479. symbol,
  480. ANSI['reset'],
  481. name.ljust(21),
  482. num_files.ljust(14),
  483. ANSI[color],
  484. note.ljust(8),
  485. ANSI['reset'],
  486. path.ljust(76),
  487. ))
  488. @enforce_types
  489. def printable_dependency_version(name: str, dependency: Dict) -> str:
  490. color, symbol, note, version = 'red', 'X', 'invalid', '?'
  491. if dependency['enabled']:
  492. if dependency['is_valid']:
  493. color, symbol, note = 'green', '√', 'valid'
  494. parsed_version_num = re.search(r'[\d\.]+', dependency['version'])
  495. if parsed_version_num:
  496. version = f'v{parsed_version_num[0]}'
  497. else:
  498. color, symbol, note, version = 'lightyellow', '-', 'disabled', '-'
  499. path = pretty_path(dependency['path'])
  500. return ' '.join((
  501. ANSI[color],
  502. symbol,
  503. ANSI['reset'],
  504. name.ljust(21),
  505. version.ljust(14),
  506. ANSI[color],
  507. note.ljust(8),
  508. ANSI['reset'],
  509. path.ljust(76),
  510. ))