Refactor photo metadata workflow
This commit is contained in:
+179
-30
@@ -1,14 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from math import floor, log10
|
||||
from threading import Lock
|
||||
|
||||
|
||||
_WINDOWS_ANSI_ENABLED: bool | None = None
|
||||
_WINDOWS_CONSOLE_OUTPUT: bool | None = None
|
||||
|
||||
|
||||
def prompt_input(prompt: str) -> str:
|
||||
@@ -44,7 +49,7 @@ def supports_color() -> bool:
|
||||
|
||||
|
||||
def supports_ansi() -> bool:
|
||||
if not sys.stdout.isatty():
|
||||
if not stream_is_interactive(sys.stdout):
|
||||
return False
|
||||
if os.name != "nt":
|
||||
return os.environ.get("TERM", "dumb") != "dumb"
|
||||
@@ -56,6 +61,10 @@ def supports_ansi() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def stream_is_interactive(stream) -> bool:
|
||||
return stream.isatty() or (os.name == "nt" and stream is sys.stdout and _windows_console_output())
|
||||
|
||||
|
||||
def color(text: object, code: str) -> str:
|
||||
value = str(text)
|
||||
if not supports_color():
|
||||
@@ -79,12 +88,74 @@ def red_strikethrough(text: object) -> str:
|
||||
return color(text, "91;9")
|
||||
|
||||
|
||||
def clear_screen() -> None:
|
||||
if not sys.stdout.isatty():
|
||||
def clear_screen(*, scrollback: bool = False) -> None:
|
||||
if not stream_is_interactive(sys.stdout):
|
||||
return
|
||||
if supports_ansi():
|
||||
sys.stdout.write("\x1b[3J" if scrollback else "")
|
||||
sys.stdout.write("\x1b[H\x1b[2J")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
|
||||
def require_terminal_ui() -> None:
|
||||
if not (stream_is_interactive(sys.stdin) and stream_is_interactive(sys.stdout) and supports_ansi()):
|
||||
raise RuntimeError("This settings dialog requires an ANSI-capable interactive terminal.")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def raw_key_input():
|
||||
require_terminal_ui()
|
||||
if os.name == "nt":
|
||||
yield read_key
|
||||
return
|
||||
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
yield read_key
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, settings)
|
||||
|
||||
|
||||
def read_key() -> str:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
key = msvcrt.getwch()
|
||||
if key in {"\x00", "\xe0"}:
|
||||
return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(msvcrt.getwch(), "")
|
||||
else:
|
||||
key = sys.stdin.read(1)
|
||||
if key == "\x1b" and select.select([sys.stdin], [], [], 0.03)[0]:
|
||||
key += sys.stdin.read(2)
|
||||
return {"\x1b[A": "up", "\x1b[B": "down", "\x1b[D": "left", "\x1b[C": "right"}.get(key, "esc")
|
||||
return {"\r": "enter", "\n": "enter", "\x1b": "esc", "\x08": "backspace", "\x7f": "backspace", " ": "space"}.get(key, key)
|
||||
|
||||
|
||||
def draw_screen(lines: list[str]) -> None:
|
||||
require_terminal_ui()
|
||||
sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(lines))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def inverse(text: str) -> str:
|
||||
return f"\x1b[30;47m{text}\x1b[0m"
|
||||
|
||||
|
||||
def dim(text: str) -> str:
|
||||
return f"\x1b[90m{text}\x1b[0m"
|
||||
|
||||
|
||||
def dark_field(text: str) -> str:
|
||||
return f"\x1b[30;100m{text}\x1b[0m"
|
||||
|
||||
|
||||
def _enable_windows_ansi() -> bool:
|
||||
global _WINDOWS_ANSI_ENABLED
|
||||
if _WINDOWS_ANSI_ENABLED is not None:
|
||||
@@ -105,6 +176,23 @@ def _enable_windows_ansi() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _windows_console_output() -> bool:
|
||||
global _WINDOWS_CONSOLE_OUTPUT
|
||||
if _WINDOWS_CONSOLE_OUTPUT is not None:
|
||||
return _WINDOWS_CONSOLE_OUTPUT
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
mode = ctypes.c_uint32()
|
||||
handle = ctypes.windll.kernel32.GetStdHandle(-11)
|
||||
_WINDOWS_CONSOLE_OUTPUT = bool(
|
||||
ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
||||
)
|
||||
except Exception:
|
||||
_WINDOWS_CONSOLE_OUTPUT = False
|
||||
return _WINDOWS_CONSOLE_OUTPUT
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
seconds = max(0, int(round(seconds)))
|
||||
minutes, second = divmod(seconds, 60)
|
||||
@@ -120,30 +208,42 @@ class ProgressView:
|
||||
total: int,
|
||||
label: str,
|
||||
*,
|
||||
total_work: float | None = None,
|
||||
stream=None,
|
||||
embedded_percent: bool = False,
|
||||
show_rate: bool = False,
|
||||
show_elapsed: bool = True,
|
||||
bar_width: int = 24,
|
||||
) -> None:
|
||||
self.total = max(0, total)
|
||||
self.total_work = total if total_work is None else max(0.0, total_work)
|
||||
self.label = label
|
||||
self.stream = stream or sys.stdout
|
||||
self.started_at = time.monotonic()
|
||||
self.samples: deque[tuple[int, float]] = deque()
|
||||
self.samples: deque[tuple[float, float]] = deque()
|
||||
self.last_width = 0
|
||||
self.last_line_count = 0
|
||||
self.embedded_percent = embedded_percent
|
||||
self.show_rate = show_rate
|
||||
self.show_elapsed = show_elapsed
|
||||
self.bar_width = bar_width
|
||||
|
||||
def update(self, processed: int, detail: str = "") -> None:
|
||||
self._write(self.update_line(processed, detail))
|
||||
def update(
|
||||
self, processed: int, detail: str = "", *, completed_work: float | None = None
|
||||
) -> None:
|
||||
self._write(self.update_line(processed, detail, completed_work=completed_work))
|
||||
|
||||
def update_line(self, processed: int, detail: str = "") -> str:
|
||||
def update_line(
|
||||
self, processed: int, detail: str = "", *, completed_work: float | None = None
|
||||
) -> str:
|
||||
processed = max(0, min(processed, self.total))
|
||||
work_done = min(self.total_work, completed_work if completed_work is not None else processed)
|
||||
now = time.monotonic()
|
||||
if not self.samples or self.samples[-1][0] != processed:
|
||||
self.samples.append((processed, now))
|
||||
if not self.samples or self.samples[-1][0] != work_done:
|
||||
self.samples.append((work_done, now))
|
||||
while len(self.samples) > max(2, processed // 2 + 2):
|
||||
self.samples.popleft()
|
||||
return self._line(processed, detail, now)
|
||||
return self._line(processed, detail, now, completed_work=work_done)
|
||||
|
||||
def update_external(
|
||||
self,
|
||||
@@ -167,25 +267,60 @@ class ProgressView:
|
||||
return self._line(processed, detail, None, elapsed=elapsed, eta=eta)
|
||||
|
||||
def finish(self, *, keep: bool = False) -> None:
|
||||
if self.stream.isatty():
|
||||
if stream_is_interactive(self.stream):
|
||||
if keep:
|
||||
self.stream.write("\n")
|
||||
else:
|
||||
self.stream.write("\r" + " " * self.last_width + "\r")
|
||||
self._clear_live_lines()
|
||||
self.stream.flush()
|
||||
elif self.last_width and not keep:
|
||||
self.stream.write("\n")
|
||||
self.last_width = 0
|
||||
self.last_line_count = 0
|
||||
|
||||
def _write(self, line: str) -> None:
|
||||
if self.stream.isatty():
|
||||
def write_lines(self, lines: list[str]) -> None:
|
||||
self._write(lines)
|
||||
|
||||
def _write(self, content: str | list[str]) -> None:
|
||||
lines = [content] if isinstance(content, str) else content
|
||||
if stream_is_interactive(self.stream) and supports_ansi():
|
||||
lines = [wrapped for line in lines for wrapped in _wrap_to_terminal(line, self.stream)]
|
||||
if self.last_line_count > 1:
|
||||
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
|
||||
rows = max(self.last_line_count, len(lines))
|
||||
for index in range(rows):
|
||||
self.stream.write("\r\x1b[2K")
|
||||
if index < len(lines):
|
||||
self.stream.write(lines[index])
|
||||
if index < rows - 1:
|
||||
self.stream.write("\n")
|
||||
if rows > len(lines):
|
||||
self.stream.write(f"\x1b[{rows - len(lines)}F")
|
||||
self.stream.flush()
|
||||
self.last_width = max(map(len, lines), default=0)
|
||||
self.last_line_count = len(lines)
|
||||
elif stream_is_interactive(self.stream):
|
||||
line = " ".join(lines)
|
||||
padding = max(0, self.last_width - len(line))
|
||||
self.stream.write("\r" + line + (" " * padding))
|
||||
self.stream.flush()
|
||||
self.last_width = len(line)
|
||||
else:
|
||||
self.stream.write(line + "\n")
|
||||
self.last_width = len(line)
|
||||
self.stream.write("\n".join(lines) + "\n")
|
||||
self.last_width = max(map(len, lines), default=0)
|
||||
|
||||
def _clear_live_lines(self) -> None:
|
||||
if not self.last_line_count or not supports_ansi():
|
||||
self.stream.write("\r" + " " * self.last_width + "\r")
|
||||
return
|
||||
if self.last_line_count > 1:
|
||||
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
|
||||
for index in range(self.last_line_count):
|
||||
self.stream.write("\r\x1b[2K")
|
||||
if index < self.last_line_count - 1:
|
||||
self.stream.write("\n")
|
||||
if self.last_line_count > 1:
|
||||
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
|
||||
|
||||
def _line(
|
||||
self,
|
||||
@@ -195,13 +330,15 @@ class ProgressView:
|
||||
*,
|
||||
elapsed: float | None = None,
|
||||
eta: float | None = None,
|
||||
completed_work: float | None = None,
|
||||
) -> str:
|
||||
prefix = self.label if not detail else f"{self.label}: {detail}"
|
||||
prefix = self.label
|
||||
if self.total <= 2:
|
||||
return f"{prefix} {processed}/{self.total}"
|
||||
return " ".join(part for part in (prefix, f"{processed}/{self.total}", detail) if part)
|
||||
|
||||
percent = 0.0 if self.total == 0 else processed / self.total
|
||||
bar = _progress_bar(percent, embedded_percent=self.embedded_percent)
|
||||
work_done = processed if completed_work is None else completed_work
|
||||
percent = 0.0 if self.total_work == 0 else work_done / self.total_work
|
||||
bar = _progress_bar(percent, embedded_percent=self.embedded_percent, width=self.bar_width)
|
||||
elapsed_value = elapsed if elapsed is not None else (now or time.monotonic()) - self.started_at
|
||||
parts = [
|
||||
prefix,
|
||||
@@ -212,22 +349,25 @@ class ProgressView:
|
||||
parts.append(f"{percent * 100:5.1f}%")
|
||||
if self.show_rate and processed > 0 and elapsed_value > 0:
|
||||
parts.append(f"{_format_significant(processed / elapsed_value, 2)} fps")
|
||||
parts.append(f"elapsed {format_duration(elapsed_value)}")
|
||||
eta_value = eta if eta is not None else self._eta(processed)
|
||||
if self.show_elapsed:
|
||||
parts.append(f"elapsed {format_duration(elapsed_value)}")
|
||||
eta_value = eta if eta is not None else self._eta(processed, work_done)
|
||||
if processed >= 2 and processed < self.total and eta_value is not None:
|
||||
parts.append(f"ETA {format_duration(eta_value)}")
|
||||
if detail:
|
||||
parts.append(detail)
|
||||
return " ".join(parts)
|
||||
|
||||
def _eta(self, processed: int) -> float | None:
|
||||
def _eta(self, processed: int, work_done: float) -> float | None:
|
||||
if processed < 2 or self.total <= processed or len(self.samples) < 2:
|
||||
return None
|
||||
oldest_processed, oldest_time = self.samples[0]
|
||||
newest_processed, newest_time = self.samples[-1]
|
||||
delta_items = newest_processed - oldest_processed
|
||||
oldest_work, oldest_time = self.samples[0]
|
||||
newest_work, newest_time = self.samples[-1]
|
||||
delta_work = newest_work - oldest_work
|
||||
delta_time = newest_time - oldest_time
|
||||
if delta_items <= 0 or delta_time <= 0:
|
||||
if delta_work <= 0 or delta_time <= 0:
|
||||
return None
|
||||
return (self.total - processed) * (delta_time / delta_items)
|
||||
return (self.total_work - work_done) * (delta_time / delta_work)
|
||||
|
||||
|
||||
class PipelineProgressView:
|
||||
@@ -311,8 +451,7 @@ class PipelineProgressView:
|
||||
self._shown = True
|
||||
|
||||
|
||||
def _progress_bar(percent: float, *, embedded_percent: bool) -> str:
|
||||
width = 24
|
||||
def _progress_bar(percent: float, *, embedded_percent: bool, width: int = 24) -> str:
|
||||
done = round(max(0.0, min(percent, 1.0)) * width)
|
||||
if not embedded_percent:
|
||||
return "[" + ("#" * done).ljust(width, "-") + "]"
|
||||
@@ -335,6 +474,16 @@ def _center_progress_text(text: str, width: int, fill: str) -> str:
|
||||
return content.center(width, fill)
|
||||
|
||||
|
||||
def _wrap_to_terminal(line: str, stream) -> list[str]:
|
||||
try:
|
||||
width = shutil.get_terminal_size().columns
|
||||
except OSError:
|
||||
return [line]
|
||||
if width <= 0 or len(line) <= width:
|
||||
return [line]
|
||||
return textwrap.wrap(line, width=width, break_long_words=False, break_on_hyphens=False) or [line]
|
||||
|
||||
|
||||
def _format_significant(value: float, digits: int) -> str:
|
||||
if value == 0:
|
||||
return "0"
|
||||
|
||||
Reference in New Issue
Block a user