Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
mikf
GitHub Repository: mikf/gallery-dl
Path: blob/master/gallery_dl/output.py
8857 views
1
# -*- coding: utf-8 -*-
2
3
# Copyright 2015-2025 Mike Fährmann
4
#
5
# This program is free software; you can redistribute it and/or modify
6
# it under the terms of the GNU General Public License version 2 as
7
# published by the Free Software Foundation.
8
9
import os
10
import sys
11
import shutil
12
import logging
13
import unicodedata
14
from . import config, util, formatter
15
16
17
# --------------------------------------------------------------------
18
# Globals
19
20
try:
21
TTY_STDOUT = sys.stdout.isatty()
22
except Exception:
23
TTY_STDOUT = False
24
25
try:
26
TTY_STDERR = sys.stderr.isatty()
27
except Exception:
28
TTY_STDERR = False
29
30
try:
31
TTY_STDIN = sys.stdin.isatty()
32
except Exception:
33
TTY_STDIN = False
34
35
36
COLORS_DEFAULT = {}
37
COLORS = not os.environ.get("NO_COLOR")
38
if COLORS:
39
if TTY_STDOUT:
40
COLORS_DEFAULT["success"] = "1;32"
41
COLORS_DEFAULT["skip"] = "2"
42
if TTY_STDERR:
43
COLORS_DEFAULT["debug"] = "0;37"
44
COLORS_DEFAULT["info"] = "1;37"
45
COLORS_DEFAULT["warning"] = "1;33"
46
COLORS_DEFAULT["error"] = "1;31"
47
48
49
if util.WINDOWS:
50
ANSI = COLORS and os.environ.get("TERM") == "ANSI"
51
OFFSET = 1
52
CHAR_SKIP = "# "
53
CHAR_SUCCESS = "* "
54
CHAR_ELLIPSIES = "..."
55
else:
56
ANSI = COLORS
57
OFFSET = 0
58
CHAR_SKIP = "# "
59
CHAR_SUCCESS = "✔ "
60
CHAR_ELLIPSIES = "…"
61
62
63
# --------------------------------------------------------------------
64
# Logging
65
66
LOG_FORMAT = "[{name}][{levelname}] {message}"
67
LOG_FORMAT_DATE = "%Y-%m-%d %H:%M:%S"
68
LOG_LEVEL = logging.INFO
69
LOG_LEVELS = ("debug", "info", "warning", "error")
70
71
72
class Logger(logging.Logger):
73
"""Custom Logger that includes extra info in log records"""
74
75
def makeRecord(self, name, level, fn, lno, msg, args, exc_info,
76
func=None, extra=None, sinfo=None,
77
factory=logging._logRecordFactory):
78
rv = factory(name, level, fn, lno, msg, args, exc_info, func, sinfo)
79
if extra:
80
rv.__dict__.update(extra)
81
return rv
82
83
84
class LoggerAdapter():
85
"""Trimmed-down version of logging.LoggingAdapter"""
86
__slots__ = ("logger", "extra")
87
88
def __init__(self, logger, job):
89
self.logger = logger
90
self.extra = job._logger_extra
91
92
def traceback(self, exc):
93
if self.logger.isEnabledFor(logging.DEBUG):
94
self.logger._log(
95
logging.DEBUG, "", None, exc_info=exc, extra=self.extra)
96
97
def debug(self, msg, *args, **kwargs):
98
if self.logger.isEnabledFor(logging.DEBUG):
99
kwargs["extra"] = self.extra
100
self.logger._log(logging.DEBUG, msg, args, **kwargs)
101
102
def info(self, msg, *args, **kwargs):
103
if self.logger.isEnabledFor(logging.INFO):
104
kwargs["extra"] = self.extra
105
self.logger._log(logging.INFO, msg, args, **kwargs)
106
107
def warning(self, msg, *args, **kwargs):
108
if self.logger.isEnabledFor(logging.WARNING):
109
kwargs["extra"] = self.extra
110
self.logger._log(logging.WARNING, msg, args, **kwargs)
111
112
def error(self, msg, *args, **kwargs):
113
if self.logger.isEnabledFor(logging.ERROR):
114
kwargs["extra"] = self.extra
115
self.logger._log(logging.ERROR, msg, args, **kwargs)
116
117
118
class PathfmtProxy():
119
__slots__ = ("job",)
120
121
def __init__(self, job):
122
self.job = job
123
124
def __getattribute__(self, name):
125
pathfmt = object.__getattribute__(self, "job").pathfmt
126
return getattr(pathfmt, name, None) if pathfmt else None
127
128
def __str__(self):
129
if pathfmt := object.__getattribute__(self, "job").pathfmt:
130
return pathfmt.path or pathfmt.directory
131
return ""
132
133
134
class KwdictProxy():
135
__slots__ = ("job",)
136
137
def __init__(self, job):
138
self.job = job
139
140
def __getattribute__(self, name):
141
pathfmt = object.__getattribute__(self, "job").pathfmt
142
return pathfmt.kwdict.get(name) if pathfmt else None
143
144
145
class Formatter(logging.Formatter):
146
"""Custom formatter that supports different formats per loglevel"""
147
148
def __init__(self, fmt, datefmt):
149
if isinstance(fmt, dict):
150
for key in LOG_LEVELS:
151
value = fmt[key] if key in fmt else LOG_FORMAT
152
fmt[key] = (formatter.parse(value).format_map,
153
"{asctime" in value)
154
else:
155
if fmt == LOG_FORMAT:
156
fmt = (fmt.format_map, False)
157
else:
158
fmt = (formatter.parse(fmt).format_map, "{asctime" in fmt)
159
fmt = {"debug": fmt, "info": fmt, "warning": fmt, "error": fmt}
160
161
self.formats = fmt
162
self.datefmt = datefmt
163
164
def format(self, record):
165
record.message = record.getMessage()
166
fmt, asctime = self.formats[record.levelname]
167
if asctime:
168
record.asctime = self.formatTime(record, self.datefmt)
169
msg = fmt(record.__dict__)
170
if record.exc_info and not record.exc_text:
171
record.exc_text = self.formatException(record.exc_info)
172
if record.exc_text:
173
msg = f"{msg}\n{record.exc_text}"
174
if record.stack_info:
175
msg = f"{msg}\n{record.stack_info}"
176
return msg
177
178
179
class FileHandler(logging.StreamHandler):
180
def __init__(self, path, mode, encoding, delay=True):
181
self.path = path
182
self.mode = mode
183
self.errors = None
184
self.encoding = encoding
185
186
if delay:
187
logging.Handler.__init__(self)
188
self.stream = None
189
self.emit = self.emit_delayed
190
else:
191
logging.StreamHandler.__init__(self, self._open())
192
193
def close(self):
194
with self.lock:
195
try:
196
if self.stream:
197
try:
198
self.flush()
199
self.stream.close()
200
finally:
201
self.stream = None
202
finally:
203
logging.StreamHandler.close(self)
204
205
def _open(self):
206
try:
207
return open(self.path, self.mode,
208
encoding=self.encoding, errors=self.errors)
209
except FileNotFoundError:
210
os.makedirs(os.path.dirname(self.path))
211
return open(self.path, self.mode,
212
encoding=self.encoding, errors=self.errors)
213
214
def emit_delayed(self, record):
215
if self.mode != "w" or not self._closed:
216
self.stream = self._open()
217
self.emit = logging.StreamHandler.emit.__get__(self)
218
self.emit(record)
219
220
221
def initialize_logging(loglevel):
222
"""Setup basic logging functionality before configfiles have been loaded"""
223
# convert levelnames to lowercase
224
for level in (10, 20, 30, 40, 50):
225
name = logging.getLevelName(level)
226
logging.addLevelName(level, name.lower())
227
228
# register custom Logging class
229
logging.Logger.manager.setLoggerClass(Logger)
230
231
# setup basic logging to stderr
232
formatter = Formatter(LOG_FORMAT, LOG_FORMAT_DATE)
233
handler = logging.StreamHandler()
234
handler.setFormatter(formatter)
235
handler.setLevel(loglevel)
236
root = logging.getLogger()
237
root.setLevel(logging.NOTSET)
238
root.addHandler(handler)
239
240
return logging.getLogger("gallery-dl")
241
242
243
def configure_logging(loglevel):
244
root = logging.getLogger()
245
minlevel = loglevel
246
247
# stream logging handler
248
handler = root.handlers[0]
249
opts = config.interpolate(("output",), "log")
250
251
colors = config.interpolate(("output",), "colors")
252
if colors is None:
253
colors = COLORS_DEFAULT
254
if colors and not opts:
255
opts = LOG_FORMAT
256
257
if opts:
258
if isinstance(opts, str):
259
logfmt = opts
260
opts = {}
261
elif "format" in opts:
262
logfmt = opts["format"]
263
else:
264
logfmt = LOG_FORMAT
265
266
if not isinstance(logfmt, dict) and colors:
267
ansifmt = "\033[{}m{}\033[0m".format
268
lf = {}
269
for level in LOG_LEVELS:
270
c = colors.get(level)
271
lf[level] = ansifmt(c, logfmt) if c else logfmt
272
logfmt = lf
273
274
handler.setFormatter(Formatter(
275
logfmt, opts.get("format-date", LOG_FORMAT_DATE)))
276
277
if "level" in opts and handler.level == LOG_LEVEL:
278
handler.setLevel(opts["level"])
279
280
if minlevel > handler.level:
281
minlevel = handler.level
282
283
# file logging handler
284
if handler := setup_logging_handler("logfile", lvl=loglevel):
285
root.addHandler(handler)
286
if minlevel > handler.level:
287
minlevel = handler.level
288
289
root.setLevel(minlevel)
290
291
292
def setup_logging_handler(key, fmt=LOG_FORMAT, lvl=LOG_LEVEL, mode="w",
293
defer=False):
294
"""Setup a new logging handler"""
295
opts = config.interpolate(("output",), key)
296
if not opts:
297
return None
298
if not isinstance(opts, dict):
299
opts = {"path": opts}
300
301
path = opts.get("path")
302
mode = opts.get("mode", mode)
303
encoding = opts.get("encoding", "utf-8")
304
delay = opts.get("defer", defer)
305
try:
306
path = util.expand_path(path)
307
handler = FileHandler(path, mode, encoding, delay)
308
except (OSError, ValueError) as exc:
309
logging.getLogger("gallery-dl").warning(
310
"%s: %s", key, exc)
311
return None
312
except TypeError as exc:
313
logging.getLogger("gallery-dl").warning(
314
"%s: missing or invalid path (%s)", key, exc)
315
return None
316
317
handler.setLevel(opts.get("level", lvl))
318
handler.setFormatter(Formatter(
319
opts.get("format", fmt),
320
opts.get("format-date", LOG_FORMAT_DATE),
321
))
322
return handler
323
324
325
# --------------------------------------------------------------------
326
# Utility functions
327
328
def stdout_write_flush(s):
329
sys.stdout.write(s)
330
sys.stdout.flush()
331
332
333
def stderr_write_flush(s):
334
sys.stderr.write(s)
335
sys.stderr.flush()
336
337
338
if getattr(sys.stdout, "line_buffering", None):
339
def stdout_write(s):
340
sys.stdout.write(s)
341
else:
342
stdout_write = stdout_write_flush
343
344
345
if getattr(sys.stderr, "line_buffering", None):
346
def stderr_write(s):
347
sys.stderr.write(s)
348
else:
349
stderr_write = stderr_write_flush
350
351
352
def configure_standard_streams():
353
for name in ("stdout", "stderr", "stdin"):
354
stream = getattr(sys, name, None)
355
if not stream:
356
continue
357
358
options = config.get(("output",), name)
359
if not options:
360
options = {"errors": "replace"}
361
elif isinstance(options, str):
362
options = {"errors": "replace", "encoding": options}
363
elif not options.get("errors"):
364
options["errors"] = "replace"
365
366
stream.reconfigure(**options)
367
368
369
# --------------------------------------------------------------------
370
# Downloader output
371
372
def select():
373
"""Select a suitable output class"""
374
mode = config.get(("output",), "mode")
375
376
if mode is None or mode == "auto":
377
try:
378
if TTY_STDOUT:
379
output = ColorOutput() if ANSI else TerminalOutput()
380
else:
381
output = PipeOutput()
382
except Exception:
383
output = PipeOutput()
384
elif isinstance(mode, dict):
385
output = CustomOutput(mode)
386
elif not mode:
387
output = NullOutput()
388
else:
389
output = {
390
"default" : PipeOutput,
391
"pipe" : PipeOutput,
392
"term" : TerminalOutput,
393
"terminal": TerminalOutput,
394
"color" : ColorOutput,
395
"null" : NullOutput,
396
}[mode.lower()]()
397
398
if not config.get(("output",), "skip", True):
399
output.skip = util.identity
400
return output
401
402
403
class NullOutput():
404
405
def start(self, path):
406
"""Print a message indicating the start of a download"""
407
408
def skip(self, path):
409
"""Print a message indicating that a download has been skipped"""
410
411
def success(self, path):
412
"""Print a message indicating the completion of a download"""
413
414
def progress(self, bytes_total, bytes_downloaded, bytes_per_second):
415
"""Display download progress"""
416
417
418
class PipeOutput(NullOutput):
419
420
def skip(self, path):
421
stdout_write(f"{CHAR_SKIP}{path}\n")
422
423
def success(self, path):
424
stdout_write(f"{path}\n")
425
426
427
class TerminalOutput():
428
429
def __init__(self):
430
if shorten := config.get(("output",), "shorten", True):
431
func = shorten_string_eaw if shorten == "eaw" else shorten_string
432
limit = shutil.get_terminal_size().columns - OFFSET
433
sep = CHAR_ELLIPSIES
434
self.shorten = lambda txt: func(txt, limit, sep)
435
else:
436
self.shorten = util.identity
437
438
def start(self, path):
439
stdout_write_flush(self.shorten(f" {path}"))
440
441
def skip(self, path):
442
stdout_write(f"{self.shorten(CHAR_SKIP + path)}\n")
443
444
def success(self, path):
445
stdout_write(f"\r{self.shorten(CHAR_SUCCESS + path)}\n")
446
447
def progress(self, bytes_total, bytes_downloaded, bytes_per_second):
448
bdl = util.format_value(bytes_downloaded)
449
bps = util.format_value(bytes_per_second)
450
if bytes_total is None:
451
stderr_write(f"\r{bdl:>7}B {bps:>7}B/s ")
452
else:
453
stderr_write(f"\r{bytes_downloaded * 100 // bytes_total:>3}% "
454
f"{bdl:>7}B {bps:>7}B/s ")
455
456
457
class ColorOutput(TerminalOutput):
458
459
def __init__(self):
460
TerminalOutput.__init__(self)
461
462
colors = config.interpolate(("output",), "colors")
463
if colors is None:
464
colors = COLORS_DEFAULT
465
466
self.color_skip = f"\x1b[{colors.get('skip', '2')}m"
467
self.color_success = f"\r\x1b[{colors.get('success', '1;32')}m"
468
469
def start(self, path):
470
stdout_write_flush(self.shorten(path))
471
472
def skip(self, path):
473
stdout_write(f"{self.color_skip}{self.shorten(path)}\x1b[0m\n")
474
475
def success(self, path):
476
stdout_write(f"{self.color_success}{self.shorten(path)}\x1b[0m\n")
477
478
479
class CustomOutput():
480
481
def __init__(self, options):
482
483
fmt_skip = options.get("skip")
484
fmt_start = options.get("start")
485
fmt_success = options.get("success")
486
off_skip = off_start = off_success = 0
487
488
if isinstance(fmt_skip, list):
489
off_skip, fmt_skip = fmt_skip
490
if isinstance(fmt_start, list):
491
off_start, fmt_start = fmt_start
492
if isinstance(fmt_success, list):
493
off_success, fmt_success = fmt_success
494
495
if shorten := config.get(("output",), "shorten", True):
496
func = shorten_string_eaw if shorten == "eaw" else shorten_string
497
width = shutil.get_terminal_size().columns
498
499
self._fmt_skip = self._make_func(
500
func, fmt_skip, width - off_skip)
501
self._fmt_start = self._make_func(
502
func, fmt_start, width - off_start)
503
self._fmt_success = self._make_func(
504
func, fmt_success, width - off_success)
505
else:
506
self._fmt_skip = fmt_skip.format
507
self._fmt_start = fmt_start.format
508
self._fmt_success = fmt_success.format
509
510
self._fmt_progress = (options.get("progress") or
511
"\r{0:>7}B {1:>7}B/s ").format
512
self._fmt_progress_total = (options.get("progress-total") or
513
"\r{3:>3}% {0:>7}B {1:>7}B/s ").format
514
515
def _make_func(self, shorten, format_string, limit):
516
fmt = format_string.format
517
return lambda txt: fmt(shorten(txt, limit, CHAR_ELLIPSIES))
518
519
def start(self, path):
520
stdout_write_flush(self._fmt_start(path))
521
522
def skip(self, path):
523
stdout_write(self._fmt_skip(path))
524
525
def success(self, path):
526
stdout_write(self._fmt_success(path))
527
528
def progress(self, bytes_total, bytes_downloaded, bytes_per_second):
529
bdl = util.format_value(bytes_downloaded)
530
bps = util.format_value(bytes_per_second)
531
if bytes_total is None:
532
stderr_write(self._fmt_progress(bdl, bps))
533
else:
534
stderr_write(self._fmt_progress_total(
535
bdl, bps, util.format_value(bytes_total),
536
bytes_downloaded * 100 // bytes_total))
537
538
539
class EAWCache(dict):
540
541
def __missing__(self, key):
542
width = self[key] = \
543
2 if unicodedata.east_asian_width(key) in "WF" else 1
544
return width
545
546
547
def shorten_string(txt, limit, sep="…"):
548
"""Limit width of 'txt'; assume all characters have a width of 1"""
549
if len(txt) <= limit:
550
return txt
551
limit -= len(sep)
552
return f"{txt[:limit // 2]}{sep}{txt[-((limit+1) // 2):]}"
553
554
555
def shorten_string_eaw(txt, limit, sep="…", cache=EAWCache()):
556
"""Limit width of 'txt'; check for east-asian characters with width > 1"""
557
char_widths = [cache[c] for c in txt]
558
text_width = sum(char_widths)
559
560
if text_width <= limit:
561
# no shortening required
562
return txt
563
564
limit -= len(sep)
565
if text_width == len(txt):
566
# all characters have a width of 1
567
return f"{txt[:limit // 2]}{sep}{txt[-((limit+1) // 2):]}"
568
569
# wide characters
570
left = 0
571
lwidth = limit // 2
572
while True:
573
lwidth -= char_widths[left]
574
if lwidth < 0:
575
break
576
left += 1
577
578
right = -1
579
rwidth = (limit+1) // 2 + (lwidth + char_widths[left])
580
while True:
581
rwidth -= char_widths[right]
582
if rwidth < 0:
583
break
584
right -= 1
585
586
return f"{txt[:left]}{sep}{txt[right+1:]}"
587
588