diff --git a/pyproject.toml b/pyproject.toml index 5cbb9d3..ebf6a31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,8 @@ description = "A tool for updating exif tags" requires-python = ">=3.10" dependencies = [ "toml", - "alive-progress", "types-toml", + "blessed", ] authors = [ {name = "Alexander Wainwright", email = "code@figtree.dev"}, @@ -59,12 +59,21 @@ select = [ ignore = [ "W191", "E101", # allow spaces for alignment + "SIM108", ] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["E402"] "**/{tests,docs,tools}/*" = ["E402"] +# Ignore "Use of `assert` detected" (S101) in all test files +"tests/**/*.py" = ["S101"] +"test_*.py" = ["S101"] [tool.ruff.format] quote-style = "single" indent-style = "tab" + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/src/emulsion/batch_runner.py b/src/emulsion/batch_runner.py new file mode 100644 index 0000000..c3f99c2 --- /dev/null +++ b/src/emulsion/batch_runner.py @@ -0,0 +1,97 @@ +from concurrent.futures import Future, ThreadPoolExecutor + +from emulsion.executor import Executor, Task + + +class BatchRunner: + """ + Controller that manages the execution of tasks via a ThreadPoolExecutor. + It allows the UI (View) to poll for status updates, decoupling the + execution logic from the display logic. + """ + + def __init__( + self, + executor: Executor, + tasks: list[Task], + dry_run: bool, + workers: int = 1, + ) -> None: + self.executor = executor + self.tasks = tasks + self.dry_run = dry_run + self.workers = workers + + # State + self.pending_tasks = list(tasks) + self.completed_tasks: list[ + tuple[Task, bool, str] + ] = [] # (Task, Success, Msg) + + # Internals + self._pool: ThreadPoolExecutor | None = None + self._futures: dict[Future[tuple[bool, str]], Task] = {} + self._started = False + + def start(self) -> None: + """Starts the background workers.""" + if self._started: + return + self._started = True + self._pool = ThreadPoolExecutor(max_workers=self.workers) + + for task in self.tasks: + future = self._pool.submit( + self.executor.execute_task, task, self.dry_run + ) + self._futures[future] = task + + def update(self) -> list[tuple[Task, bool, str]]: + """ + Checks for completed tasks. + Returns a list of newly completed tasks since the last call. + """ + if not self._started or not self._futures: + return [] + + # Check for completed futures + # We use a quick check logic. Since as_completed is blocking-ish or + # iterator based, we might just want to check `done()` on known futures + # to be non-blocking for the TUI loop. + + newly_completed = [] + done_futures = [] + + for future, task in self._futures.items(): + if future.done(): + try: + success, msg = future.result() + except Exception as e: + success = False + msg = str(e) + + self.completed_tasks.append((task, success, msg)) + newly_completed.append((task, success, msg)) + + if task in self.pending_tasks: + self.pending_tasks.remove(task) + + done_futures.append(future) + + # Clean up processed futures + for f in done_futures: + del self._futures[f] + + return newly_completed + + def is_done(self) -> bool: + return self._started and len(self.pending_tasks) == 0 + + def shutdown(self) -> None: + if self._pool: + self._pool.shutdown(wait=False, cancel_futures=True) + + @property + def progress(self) -> tuple[int, int]: + """Returns (completed_count, total_count)""" + return len(self.completed_tasks), len(self.tasks) diff --git a/src/emulsion/executor.py b/src/emulsion/executor.py index 945b091..539a985 100644 --- a/src/emulsion/executor.py +++ b/src/emulsion/executor.py @@ -2,14 +2,26 @@ import datetime import shlex import subprocess from argparse import Namespace -from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass from pathlib import Path from typing import Any -from alive_progress import alive_bar + +@dataclass(frozen=True) +class Task: + original_file: str + command: tuple[str, ...] + timestamp: str class Executor: + """ + The Executor (Model) is responsible for: + 1. Logic to create tasks (determining timestamps, paths, commands). + 2. Logic to execute a single task (running the subprocess). + It is stateless regarding the batch execution progress. + """ + def __init__(self, config: dict[str, Any]) -> None: self.config = config self.mappings: dict[str, Any] = config.get('mappings', {}) @@ -18,18 +30,11 @@ class Executor: if not self.sidecar_ext.startswith('.'): self.sidecar_ext = f".{self.sidecar_ext}" - def run_batch( + def create_tasks( self, files: list[str], resolved_values: dict[str, str], options: Namespace - ) -> None: - """ - Main execution entry point. - files: List of file paths - resolved_values: Dictionary of final field values - options: Dictionary/Namespace of process options (dry_run, workers, - base_date, etc.) - """ + ) -> list[Task]: # Filter supported files extensions = ['.jpg', '.jpeg', '.tif', '.tiff'] valid_files = [ @@ -38,31 +43,14 @@ class Executor: ] if not valid_files: - print("No valid image files found to process.") - return - - # Sort for consistent time incrementing - valid_files.sort() - total_files = len(valid_files) + return [] # Parse base date - try: - base_dt = self._parse_date(options.base_date) - except ValueError: - print( - f"Error: Base date '{options.base_date}' must be " - "'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'." - ) - return + base_dt = self._parse_date(options.base_date) time_increment = options.time_increment or 60 - workers = options.workers or 1 - dry_run = options.dry_run - print(f"Processing {total_files} file(s)...") - - # Prepare tasks - tasks: list[tuple[list[str], str, str]] = [] + tasks: list[Task] = [] for i, f in enumerate(valid_files): # Calculate timestamp ts_dt = base_dt + datetime.timedelta(seconds=i * time_increment) @@ -80,30 +68,16 @@ class Executor: timestamp_str, sidecar_source ) - tasks.append((cmd, f, timestamp_str)) + tasks.append(Task(f, tuple(cmd), timestamp_str)) - # Execute - with ( - alive_bar(total_files, title="Tagging files") as bar, - ThreadPoolExecutor(max_workers=workers) as executor - ): - futures = { - executor.submit(self._run_exiftool, cmd, dry_run): (f, ts) - for cmd, f, ts in tasks - } + return tasks - for future in as_completed(futures): - original_file, ts = futures[future] - success, msg = future.result() - - if dry_run: - print(msg) - elif not success: - bar.text(f"Failed {original_file}: {msg}") - else: - bar.text(f"Updated {original_file} => {ts}") - - bar() + def execute_task(self, task: Task, dry_run: bool) -> tuple[bool, str]: + """ + Executes a single task using ExifTool. + Returns (success, message). + """ + return self._run_exiftool(list(task.command), dry_run) def _determine_paths( self, original_file: Path, embed: bool diff --git a/src/emulsion/main.py b/src/emulsion/main.py index 01c40a5..b15574d 100644 --- a/src/emulsion/main.py +++ b/src/emulsion/main.py @@ -1,13 +1,16 @@ import argparse import datetime +import logging import os import sys from importlib.metadata import PackageNotFoundError, version from typing import Any +from emulsion.batch_runner import BatchRunner from emulsion.config import ConfigLoader from emulsion.executor import Executor from emulsion.resolver import ValueResolver +from emulsion.tui import UI, DashboardUI, SimpleUI def get_version() -> str: @@ -63,7 +66,7 @@ def parse_args() -> argparse.Namespace: '--time-increment', type=int, default=None, - help='Time increment in seconds between images.', + help='Time increment in seconds between images.' ) parser.add_argument( @@ -77,7 +80,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( '--dry-run', action='store_true', - help='Show what would be changed without modifying files.', + help='Show what would be changed without modifying files.' ) parser.add_argument( @@ -96,13 +99,22 @@ def parse_args() -> argparse.Namespace: parser.add_argument( '--init-config', action='store_true', - help='Create a default config file (if none exists) and exit.', + help='Create a default config file (if none exists) and exit.' ) parser.add_argument( '--no-interaction', action='store_true', - help='Do not prompt for missing fields (skip them if missing).', + help='Do not prompt for missing fields (skip them if missing).' + ) + + # --- UI Options --- + parser.add_argument( + '--simple', + '--no-tui', + dest='simple_ui', + action='store_true', + help='Force the simple text interface.' ) return parser.parse_args() @@ -142,6 +154,12 @@ def prompt_for_defaults(config: dict[str, Any]) -> None: def main() -> None: + logging.basicConfig( + level=logging.WARN, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + logging.info('Emulsion started') + try: # 1. Load Config loader = ConfigLoader() @@ -194,7 +212,7 @@ def main() -> None: # First-Class args for field in ['author', 'lab', 'make', 'model', 'lens', 'film']: val = getattr(args, field, None) - if val: + if val is not None: user_inputs[field] = val # Custom args @@ -215,7 +233,7 @@ def main() -> None: user_inputs, interactive=not args.no_interaction ) - # 5. Execute + # 5. Execute Setup executor = Executor(config) # We pass 'args' as the options object (has dry_run, workers, etc) @@ -226,11 +244,43 @@ def main() -> None: 'time_increment', 60 ) - executor.run_batch(args.files, resolved_values, args) + try: + tasks = executor.create_tasks(args.files, resolved_values, args) + except ValueError as e: + print(f"Error creating tasks: {e}") + sys.exit(1) + + if not tasks: + print("No valid image files found to process.") + sys.exit(0) + + # Create Runner (Controller) + runner = BatchRunner( + executor=executor, + tasks=tasks, + dry_run=args.dry_run, + workers=args.workers or 1 + ) + + # Select UI (View) + ui: UI + # Default to TUI if interactive and not explicitly disabled + use_tui = sys.stdout.isatty() and not args.simple_ui + + if use_tui: + ui = DashboardUI(runner) + else: + ui = SimpleUI(runner) + + # Run + ui.run() except KeyboardInterrupt: print('\nInterrupted by user. Exiting.') - sys.exit(1) + if 'runner' in locals(): + runner.shutdown() + sys.stdout.flush() + os._exit(1) if __name__ == '__main__': diff --git a/src/emulsion/progressbar.py b/src/emulsion/progressbar.py new file mode 100644 index 0000000..9db7658 --- /dev/null +++ b/src/emulsion/progressbar.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import time +import logging + +from collections import deque +from typing import TYPE_CHECKING + +from emulsion.si_format import format_si + +if TYPE_CHECKING: + from blessed import Terminal + + +def format_eta(seconds: int) -> str: + if seconds < 0: + raise ValueError('seconds must be non-negative') + + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + + if hours > 0: + return f'{hours}h {minutes}m' + if minutes > 0: + return f'{minutes}m {secs}s' + return f'{secs}s' + + +class EMA: + def __init__( + self, alpha: float = 0.1, initial: float | None = None + ) -> None: + if not 0 < alpha <= 1: + raise ValueError('alpha must be in (0, 1]') + self.alpha = alpha + self.value = initial + + def update(self, x: float) -> float: + if self.value is None: + self.value = x + else: + self.value = self.alpha * x + (1 - self.alpha) * self.value + return self.value + +class ProgressBar: + def __init__(self, term: Terminal) -> None: + self.term = term + self._last_ticks = 0 + self._last_rendered_ticks = 0 + self._high_res_progress = False + self._smoothing = EMA() + self.spinner_chars = '▁▂▃▄▅▆▇█▇▆▅▄▃▁' + self.tick_frames = [ + '⠀', + '⡀', + '⡄', + '⡆', + '⡇', + '⡏', + '⡟', + '⡿', + ] + self.empty_char = ' ' + self.full_block = '⣿' + self.n_tick_frames = len(self.tick_frames) + self._tick_deltas = deque(maxlen=self.n_tick_frames) + self.start_time = time.time() + + def update(self, current: int, total: int, width: int) -> str: + """ + Renders a Braille bar with dynamic resolution scaling. + Snaps to whole blocks if advancement is too fast to prevent aliasing. + """ + t = self.term + if total == 0: + percent = 0.0 + else: + percent = current / total + + # Spinner animation + spinner_idx = int(time.time() * 10) % len(self.spinner_chars) + spinner = t.bold(self.spinner_chars[spinner_idx]) + + # Stats text + elapsed = time.time() - self.start_time + rate = current / elapsed if elapsed > 0 else 0 + smooth_rate = self._smoothing.update(rate) + try: + eta = int((total - current) / smooth_rate) + eta_s = format_eta(eta) + except ZeroDivisionError: + eta_s = '???' + + STATS_WIDTH = 50 + + rate_val = format_si(smooth_rate, sigfigs=2, width=3).strip() + + parts = [ + f'{current}/{total}', + f'{int(percent * 100)}%', + f'eta {eta_s}', + f'{rate_val} it/s' + ] + + separator = " • " + content = separator.join(parts) + + stats = f'{content: <{STATS_WIDTH}}' + + bar_area_width = max(10, width - len(stats) - 4) + + total_ticks = bar_area_width * self.n_tick_frames + current_ticks_fraction = percent * total_ticks + current_ticks = int(current_ticks_fraction) + logging.debug('%d, %d', current_ticks, total_ticks) + + # check if we are moving faster than the tick size + if current_ticks_fraction != self._last_ticks: + delta = abs(current_ticks_fraction - self._last_ticks) + self._tick_deltas.append(delta) + logging.debug(str(self._tick_deltas)) + + logging.debug(f'delta: {delta}') + + self._high_res_progress = delta <= 1 + + self._last_ticks = current_ticks_fraction + + logging.debug(f'high res {self._high_res_progress}') + + # if we are not high res, let's lop off the remainder. we don't + # explicitly set the remainder to zero so that we can avoid flickering + # when on the cusp of high res and low res + if not self._high_res_progress: + current_ticks = current_ticks - ( + # we mod my half here as we can cleanly render at half steps + # in braille + current_ticks % (-(-self.n_tick_frames // 2)) + ) + + # latch to make sure we don't go backwards if we switch from high res to + # low res + current_ticks = max(self._last_rendered_ticks, current_ticks) + self._last_rendered_ticks = current_ticks + + remainder_idx = current_ticks % self.n_tick_frames + + full_blocks_count = current_ticks // self.n_tick_frames + + partial_char = '' + if full_blocks_count < bar_area_width and remainder_idx > 0: + partial_char = self.tick_frames[remainder_idx] + + # Fill the rest + # Note: We subtract len(partial_char) which is 0 or 1 + empty_blocks_count = ( + bar_area_width - full_blocks_count - len(partial_char) + ) + + bar_segment = ( + (self.full_block * full_blocks_count) + + partial_char + + (self.empty_char * empty_blocks_count) + ) + + return f'{spinner} │{t.bold(bar_segment)}│ {stats}' diff --git a/src/emulsion/resolver.py b/src/emulsion/resolver.py index 2f719fd..0688c72 100644 --- a/src/emulsion/resolver.py +++ b/src/emulsion/resolver.py @@ -64,9 +64,9 @@ class ValueResolver: print("\nInterrupted. Exiting.") sys.exit(1) - # Remove any fields that are still None/Empty (optional, but cleaner) + # Remove any fields that are still None (but keep empty strings) # Also cast values to string - return {k: str(v) for k, v in resolved.items() if v} + return {k: str(v) for k, v in resolved.items() if v is not None} def _prompt_user( self, field_name: str, resolved_dict: dict[str, Any] diff --git a/src/emulsion/si_format.py b/src/emulsion/si_format.py new file mode 100644 index 0000000..c0b9f68 --- /dev/null +++ b/src/emulsion/si_format.py @@ -0,0 +1,407 @@ +""" +si_format: A single-file, dependency-free Python module for SI-prefix scientific +notation. +""" + +import math +from collections.abc import Mapping +from decimal import ( + ROUND_HALF_EVEN, + ROUND_HALF_UP, + Decimal, + InvalidOperation, +) +from typing import TypeAlias + +# --- Types --- + +PrefixTable: TypeAlias = Mapping[int, str] + +# --- Constants --- + +SI_PREFIXES: PrefixTable = { + -24: 'y', + -21: 'z', + -18: 'a', + -15: 'f', + -12: 'p', + -9: 'n', + -6: 'µ', + -3: 'm', + 0: '', + 3: 'k', + 6: 'M', + 9: 'G', + 12: 'T', + 15: 'P', + 18: 'E', + 21: 'Z', + 24: 'Y', +} + +ROUNDING_MODES = { + 'half_up': ROUND_HALF_UP, + 'half_even': ROUND_HALF_EVEN, +} + +# --- Validation and Helpers --- + + +def _validate_args( + sigfigs: int, step: int, align: str, fill: str, rounding: str +) -> None: + if sigfigs < 1: + raise ValueError('sigfigs must be >= 1') + if step < 1: + raise ValueError('step must be >= 1') + if align not in ('<', '>', '^'): + raise ValueError("align must be one of '<', '>', '^'") + if len(fill) != 1: + raise ValueError('fill must be exactly one character') + if rounding not in ROUNDING_MODES: + raise ValueError( + f'rounding must be one of {list(ROUNDING_MODES.keys())}' + ) + + +def _get_prefixes( + prefixes: PrefixTable | str, ascii_mode: bool +) -> PrefixTable: + if isinstance(prefixes, str): + if prefixes != 'SI': + raise ValueError("prefixes must be a dict or 'SI'") + table = SI_PREFIXES.copy() + if ascii_mode: + table[-6] = 'u' + return table + return prefixes + + +def _get_needed_decimals(mantissa: Decimal, sigfigs: int) -> int: + """ + Calculates the number of decimal places needed to preserve `sigfigs`. + """ + if mantissa == 0: + return sigfigs - 1 + try: + mag = math.floor(mantissa.log10()) + except InvalidOperation: + mag = 0 + return sigfigs - 1 - mag + + +def _round_guided( + val: Decimal, sigfigs: int, rounding_mode: str +) -> tuple[Decimal, int]: + """ + Rounds val to sigfigs. Handles the edge case where rounding increases + magnitude (e.g. 0.9995 -> 1.000), reducing the needed decimals. + Returns (rounded_value, decimals_used). + """ + # 1. Initial guess based on current magnitude + decimals = _get_needed_decimals(val, sigfigs) + quantizer = Decimal('1e' + str(-decimals)) + rounded = val.quantize(quantizer, rounding=ROUNDING_MODES[rounding_mode]) + + # 2. Check if magnitude changed in a way that affects sigfigs + # e.g. 0.9995 (mag -1, dec 3) -> 1.000 (mag 0). + # 1.000 has 4 sigfigs. We want 3 sigfigs for 1.x -> 1.00 + try: + new_mag = math.floor(rounded.log10()) + except InvalidOperation: + new_mag = 0 + + # Recalculate needed decimals for the *rounded* value + new_decimals = sigfigs - 1 - new_mag + + # If we have too many decimals (new_decimals < decimals), re-round + if new_decimals < decimals: + decimals = new_decimals + quantizer = Decimal('1e' + str(-decimals)) + rounded = rounded.quantize( + quantizer, rounding=ROUNDING_MODES[rounding_mode] + ) + + return rounded, decimals + + +def _format_finite( + val_dec: Decimal, + sigfigs: int, + step: int, + prefix_table: PrefixTable, + rounding_mode: str, + clamp: bool, + zero_mode: str, +) -> tuple[str, str]: + """ + Core logic for finite numbers. Returns (mantissa_str, prefix_str). + """ + + # Handle Zero + if val_dec == 0: + prefix = '' + if zero_mode == 'plain': + return '0', prefix + decimals = max(0, sigfigs - 1) + mantissa_str = f'{0:.{decimals}f}' + return mantissa_str, prefix + + # 1. Determine initial exponent based on step + try: + mag = math.floor(val_dec.log10()) + except InvalidOperation: + mag = 0 + + exp_candidate = int((mag // step) * step) + + # 2. Renormalization loop + mantissa = Decimal(0) + final_exp = exp_candidate + + for _ in range(3): + scale = Decimal(10) ** final_exp + mantissa = val_dec / scale + + # Round carefully + rounded_mantissa, decimals = _round_guided( + mantissa, sigfigs, rounding_mode + ) + + # Check limit + limit = Decimal(10) ** step + + if rounded_mantissa >= limit: + next_exp = final_exp + step + + if next_exp in prefix_table: + final_exp = next_exp + continue + + # If clamp=True, we accept the overflow (e.g. 1000Y) + # If clamp=False, we fall through and might hit fallback later if + # prefix missing + pass + + break + + # 3. Apply Clamping / Fallback Logic + prefix_str = '' + use_fallback = False + + if final_exp in prefix_table: + prefix_str = prefix_table[final_exp] + else: + if clamp: + supported_exps = sorted(prefix_table.keys()) + if not supported_exps: + use_fallback = True + else: + closest_exp = min( + supported_exps, key=lambda x: abs(x - final_exp) + ) + final_exp = closest_exp + prefix_str = prefix_table[final_exp] + + # Re-calculate mantissa + scale = Decimal(10) ** final_exp + mantissa = val_dec / scale + + # Re-round with new magnitude + rounded_mantissa, decimals = _round_guided( + mantissa, sigfigs, rounding_mode + ) + else: + use_fallback = True + + if use_fallback: + # Scientific fallback + sci_mag = math.floor(val_dec.log10()) + sci_exp = int(sci_mag) + sci_scale = Decimal(10) ** sci_exp + sci_mant = val_dec / sci_scale + + rounded_sci_mant, decimals = _round_guided( + sci_mant, sigfigs, rounding_mode + ) + + # Handle 9.99 -> 10.0 case for scientific + if rounded_sci_mant >= 10: + sci_exp += 1 + rounded_sci_mant = rounded_sci_mant / 10 + rounded_sci_mant, decimals = _round_guided( + rounded_sci_mant, sigfigs, rounding_mode + ) + + fmt_decimals = max(0, decimals) + mantissa_str = f'{rounded_sci_mant:.{fmt_decimals}f}' + prefix_str = f'e{sci_exp}' + return mantissa_str, prefix_str + + # Final string format + fmt_decimals = max(0, decimals) + mantissa_str = f'{rounded_mantissa:.{fmt_decimals}f}' + + return mantissa_str, prefix_str + + +# --- Public API --- + + +def format_parts_si( + value: float | int, + *, + sigfigs: int = 3, + step: int = 3, + prefixes: PrefixTable | str = 'SI', + sign: str = 'auto', + zero: str = 'auto', + rounding: str = 'half_up', + ascii: bool = False, + clamp: bool = True, + nan_inf: str = 'pass', +) -> tuple[str, str, str]: + """ + Returns (mantissa_str, prefix_str, sign_str) before unit/padding. + """ + _validate_args(sigfigs, step, '>', ' ', rounding) + + try: + val_str = str(value) + val_dec = Decimal(val_str) + except Exception: + if nan_inf == 'raise': + raise ValueError( + f'Cannot convert value {value} to number' + ) from None + return str(value), '', '' + + if not val_dec.is_finite(): + if nan_inf == 'raise': + raise ValueError('Value is infinite or NaN') + if nan_inf == 'pass': + return str(value), '', '' + if nan_inf == 'string': + if val_dec.is_nan(): + return 'nan', '', '' + if val_dec.is_infinite(): + return ('inf' if val_dec > 0 else '-inf'), '', '' + else: + raise ValueError(f'Unknown nan_inf mode: {nan_inf}') + + is_negative = val_dec < 0 + abs_val = abs(val_dec) + + sign_str = '' + if sign == 'auto': + if is_negative: + sign_str = '-' + elif sign == 'always': + sign_str = '-' if is_negative else '+' + elif sign == 'space': + sign_str = '-' if is_negative else ' ' + else: + raise ValueError(f'Unknown sign mode: {sign}') + + prefix_map = _get_prefixes(prefixes, ascii) + z_mode = 'auto' if zero == 'sigfig' else zero + + mantissa_str, prefix_str = _format_finite( + abs_val, sigfigs, step, prefix_map, rounding, clamp, z_mode + ) + + return mantissa_str, prefix_str, sign_str + + +def format_si( + value: float | int, + *, + sigfigs: int = 3, + step: int = 3, + prefixes: PrefixTable | str = 'SI', + sign: str = 'auto', + zero: str = 'auto', + rounding: str = 'half_up', + ascii: bool = False, + spacer: str = '', + unit: str = '', + width: int | None = None, + align: str = '>', + fill: str = ' ', + clamp: bool = True, + nan_inf: str = 'pass', +) -> str: + """Formats a number using SI-prefix scientific notation (e.g. 1.23k, 100M). + + Converts a number into a string with a metric prefix (k, M, G, etc.) that + corresponds to its order of magnitude. Supports control over significant + figures, rounding modes, padding, and alignment. + + Args: + value: The numerical value to format. + sigfigs: The number of significant figures to display (default: 3). + Must be >= 1. + step: The exponent step size for choosing prefixes (default: 3). + Use 3 for engineering notation (k, M, G). + prefixes: A custom mapping of {exponent: prefix_symbol} or the + string "SI" to use standard metric prefixes (default: "SI"). + sign: Controls sign display. Options: + "auto": "-" for negative, nothing for positive. + "always": Always show "+" or "-". + "space": "-" for negative, space for positive. + zero: Controls formatting of zero. Options: + "auto" (or "sigfig"): Show trailing zeros (e.g. 0.00). + "plain": Show just "0". + rounding: Rounding mode to use. Options: "half_up", "half_even". + ascii: If True, uses 'u' for micro (10^-6) instead of 'µ'. + spacer: A string to insert between the prefix and the unit. Ignored if + `unit` is empty. + unit: A unit string (e.g. "Hz", "m") to append to the output. + width: Minimum width of the final string for padding. + align: Alignment within `width`. Options: "<" (left), ">" (right), + "^" (center). + fill: Character used for padding if `width` is set (default: " "). + clamp: If True, uses the nearest available prefix if the exponent + is outside the table range (e.g. 1000Y). If False, falls back + to standard scientific notation (e.g. 1.00e27). + nan_inf: Controls handling of NaN and Infinity. Options: + "pass": Returns str(value) (e.g. "nan"). + "string": Returns normalized "nan", "inf", "-inf". + "raise": Raises ValueError. + + Returns: + The formatted string representing the value. + + Raises: + ValueError: If arguments like `sigfigs`, `step`, or options are invalid. + """ + mantissa, prefix, sign_s = format_parts_si( + value, + sigfigs=sigfigs, + step=step, + prefixes=prefixes, + sign=sign, + zero=zero, + rounding=rounding, + ascii=ascii, + clamp=clamp, + nan_inf=nan_inf, + ) + + core = f'{sign_s}{mantissa}{prefix}' + + if unit: + core += f'{spacer}{unit}' + + if width is not None and len(core) < width: + pad_len = width - len(core) + if align == '<': + core = core + (fill * pad_len) + elif align == '>': + core = (fill * pad_len) + core + elif align == '^': + left_pad = pad_len // 2 + right_pad = pad_len - left_pad + core = (fill * left_pad) + core + (fill * right_pad) + + return core diff --git a/src/emulsion/test_si_format.py b/src/emulsion/test_si_format.py new file mode 100644 index 0000000..46dd6ee --- /dev/null +++ b/src/emulsion/test_si_format.py @@ -0,0 +1,180 @@ +import pytest + +from emulsion.si_format import format_parts_si, format_si + + +class TestBasics: + def test_integers(self) -> None: + assert format_si(0) == '0.00' # Default 3 sigfigs + assert format_si(1) == '1.00' + assert format_si(1000) == '1.00k' + assert format_si(10000) == '10.0k' + assert format_si(999) == '999' + + def test_floats(self) -> None: + assert format_si(0.00123) == '1.23m' + assert format_si(1.45e6) == '1.45M' + assert format_si(1.5e9) == '1.50G' + + def test_negative(self) -> None: + assert format_si(-500, sigfigs=3) == '-500' + assert format_si(-1500, sigfigs=3) == '-1.50k' + + +class TestSigFigs: + def test_rounding(self) -> None: + # 1.234 -> 1.23 + assert format_si(1.234, sigfigs=3) == '1.23' + # 1.236 -> 1.24 + assert format_si(1.236, sigfigs=3) == '1.24' + + def test_trailing_zeros(self) -> None: + # Preservation of precision + assert format_si(1.2, sigfigs=3) == '1.20' + assert format_si(1000, sigfigs=4) == '1.000k' + assert format_si(120, sigfigs=2) == '120' + assert format_si(120, sigfigs=3) == '120' + + def test_small_sigfigs(self) -> None: + # 12345 rounds to 10k (1 sigfig) + assert format_si(12345, sigfigs=1) == '10k' + # 12345 rounds to 12k (2 sigfigs) + assert format_si(12345, sigfigs=2) == '12k' + + +class TestRenormalization: + def test_boundary_crossing(self) -> None: + # 999.5 rounds to 1000 -> 1.00k (with step=3) + assert format_si(999.5, sigfigs=3) == '1.00k' + # 999.4 rounds to 999 + assert format_si(999.4, sigfigs=3) == '999' + + def test_cascading(self) -> None: + # 999999.9 -> 1.00M + assert format_si(999999.9, sigfigs=3) == '1.00M' + + def test_small_boundary(self) -> None: + # 0.0009999 -> 1.00m + assert format_si(0.0009999, sigfigs=3) == '1.00m' + + +class TestZeroAndSign: + def test_zero_modes(self) -> None: + assert format_si(0, zero='auto', sigfigs=3) == '0.00' + assert format_si(0, zero='plain', sigfigs=3) == '0' + assert format_si(0, zero='sigfig', sigfigs=2) == '0.0' + + def test_sign_modes(self) -> None: + val = 100 + neg = -100 + assert format_si(val, sign='auto') == '100' + assert format_si(neg, sign='auto') == '-100' + + assert format_si(val, sign='always') == '+100' + assert format_si(neg, sign='always') == '-100' + + assert format_si(val, sign='space') == ' 100' + assert format_si(neg, sign='space') == '-100' + + +class TestNanInf: + def test_pass(self) -> None: + assert format_si(float('nan'), nan_inf='pass') == 'nan' + assert format_si(float('inf'), nan_inf='pass') == 'inf' + + def test_string(self) -> None: + assert format_si(float('nan'), nan_inf='string') == 'nan' + assert format_si(float('inf'), nan_inf='string') == 'inf' + assert format_si(float('-inf'), nan_inf='string') == '-inf' + + def test_raise(self) -> None: + with pytest.raises(ValueError): + format_si(float('nan'), nan_inf='raise') + + +class TestPrefixAndUnit: + def test_ascii_micro(self) -> None: + assert format_si(1e-6, ascii=False) == '1.00µ' + assert format_si(1e-6, ascii=True) == '1.00u' + + def test_units(self) -> None: + assert format_si(1000, unit='Hz') == '1.00kHz' + assert format_si(1000, unit='Hz', spacer=' ') == '1.00k Hz' + # Spacer ignored if unit empty + assert format_si(1000, unit='', spacer=' ') == '1.00k' + + +class TestPadding: + def test_alignment(self) -> None: + val = 1000 # "1.00k" length 5 + assert format_si(val, width=8, align='>') == ' 1.00k' + assert format_si(val, width=8, align='<') == '1.00k ' + assert ( + format_si(val, width=8, align='^') == ' 1.00k ' + ) # 3 spaces, 1 left, 2 right usually + + def test_fill(self) -> None: + assert format_si(1000, width=7, align='>', fill='.') == '..1.00k' + + +class TestClampingFallback: + def test_clamping_max(self) -> None: + # Yotta is 10^24. 1e27 is 1000Y. + assert format_si(1e27, clamp=True) == '1000Y' + # Check boundary of clamping + assert format_si(1.5e27, clamp=True) == '1500Y' + + def test_fallback_false(self) -> None: + # If clamp=False, should revert to 'e' + assert format_si(1e27, clamp=False) == '1.00e27' + + # Test fallback works with sigfigs + assert format_si(1.234e30, clamp=False, sigfigs=3) == '1.23e30' + + def test_clamp_min(self) -> None: + # yocto is -24. 1e-27 is 0.001 * 10^-24. + # With 3 sigfigs, this should be padded to "0.00100y" to show 3 sig + # digits. + assert format_si(1e-27, clamp=True, sigfigs=3) == '0.00100y' + + # 1e-30 -> 0.000001 y + # Scaled to y: 0.00000100 y (3 sigfigs) + assert format_si(1e-30, clamp=True, sigfigs=3) == '0.00000100y' + + # fallback + assert format_si(1.23e-30, clamp=False, sigfigs=3) == '1.23e-30' + + +class TestFloatArtifacts: + def test_precision(self) -> None: + # 0.1 + 0.2 often equals 0.30000000000000004 + val = 0.1 + 0.2 + assert format_si(val, sigfigs=1) == '300m' + assert format_si(val, sigfigs=3) == '300m' + + def test_rounding_mode(self) -> None: + # 2.5 rounds to 3 (half_up) or 2 (half_even) + assert format_si(2.5, sigfigs=1, rounding='half_up') == '3' + assert format_si(2.5, sigfigs=1, rounding='half_even') == '2' + # 3.5 -> 4 (half_up), 4 (half_even) + assert format_si(3.5, sigfigs=1, rounding='half_up') == '4' + assert format_si(3.5, sigfigs=1, rounding='half_even') == '4' + + +class TestAPI: + def test_format_parts(self) -> None: + m, p, s = format_parts_si(1000) + assert m == '1.00' + assert p == 'k' + assert s == '' + + m, p, s = format_parts_si(-0.001) + assert m == '1.00' + assert p == 'm' + assert s == '-' + + def test_custom_table(self) -> None: + # Custom mapping step 2 + custom = {0: '', 2: 'h', 4: 'X'} + assert format_si(100, step=2, prefixes=custom) == '1.00h' + assert format_si(10000, step=2, prefixes=custom) == '1.00X' diff --git a/src/emulsion/tui.py b/src/emulsion/tui.py new file mode 100644 index 0000000..e081e00 --- /dev/null +++ b/src/emulsion/tui.py @@ -0,0 +1,213 @@ +import time +from typing import Protocol + +from blessed import Terminal + +from emulsion.batch_runner import BatchRunner +from emulsion.progressbar import ProgressBar + + +class UI(Protocol): + def run(self) -> None: ... + + +class SimpleUI: + """ + A linear, log-based UI for non-interactive or simple usage. + Replaces the old 'alive-progress' bar. + """ + + def __init__(self, runner: BatchRunner) -> None: + self.runner = runner + self.term = Terminal() + + def run(self) -> None: + t = self.term + self.runner.start() + + print(f'Processing {len(self.runner.tasks)} file(s)...') + + while not self.runner.is_done(): + newly_completed = self.runner.update() + + for task, success, msg in newly_completed: + if not success: + print(t.red(f'Failed: {task.original_file} - {msg}')) + continue + + if self.runner.dry_run: + print(msg) # The dry run msg is the command + continue + + print( + t.green( + f'Updated: {task.original_file} => {task.timestamp}' + ) + ) + + time.sleep(0.1) + + print('Done.') + + +class DashboardUI: + """ + The rich TUI dashboard using 'blessed'. + """ + + def __init__(self, runner: BatchRunner) -> None: + self.runner = runner + self.term = Terminal() + + # Layout calculation + self.overhead = 6 + + self.progress_bar = ProgressBar(term=self.term) + + def render(self, status: str = 'Processing...') -> tuple[str, int]: + t = self.term + out = [] + + # Max width for filenames to prevent wrapping + width = min(t.width or 80, 120) - 5 + + def truncate(s: str) -> str: + if len(s) > width: + return s[: width - 3] + '...' + return s + + def line(s: str) -> str: + return s + t.clear_eol + + # state snapshot + pending = self.runner.pending_tasks + completed = self.runner.completed_tasks + total_count = len(self.runner.tasks) + + # Calculate dynamic heights + term_height = (self.term.height or 24) - 2 + total_needed = total_count + self.overhead + min_needed = self.overhead + 2 + render_height = max(min_needed, min(term_height, total_needed)) + list_capacity = render_height - self.overhead + + # Split capacity between pending and completed + pending_count = len(pending) + if total_count > 0: + pending_ratio = pending_count / total_count + else: + pending_ratio = 0 + + pending_height = int(list_capacity * pending_ratio) + completed_height = list_capacity - pending_height + + # Enforce minimums + if list_capacity >= 2: + if ( + pending_height == 0 and pending + ): # If we have pending but calc gave 0 + pending_height = 1 + completed_height -= 1 + elif completed_height == 0 and completed: + completed_height = 1 + pending_height -= 1 + + # pending section + out.append(line(t.bold(f'⏳ Pending files ({len(pending)})'))) + + lines_generated = 0 + if not pending: + if pending_height > 0: + out.append(line(t.normal + ' No pending tasks.')) + lines_generated += 1 + else: + available_slots = pending_height + if len(pending) > available_slots: + display_count = max(0, available_slots - 1) + show_more = True + else: + display_count = len(pending) + show_more = False + + for i in range(display_count): + task = pending[i] + out.append(line(t.normal + f' {truncate(task.original_file)}')) + lines_generated += 1 + + if show_more: + remaining = len(pending) - display_count + out.append(line(t.normal + f' ... {remaining} more ...')) + lines_generated += 1 + + while lines_generated < pending_height: + out.append(line('')) + lines_generated += 1 + + out.append(line('')) # Spacing + + out.append(line(t.bold(f'✅ Completed files ({len(completed)})'))) + + lines_generated = 0 + if not completed: + if completed_height > 0: + out.append(line(t.normal + ' No completed tasks yet.')) + lines_generated += 1 + else: + available_slots = completed_height + if len(completed) > available_slots: + display_count = max(0, available_slots) + display_items = completed[-display_count:] + else: + display_items = completed + + for task, success, _ in display_items: + if success: + out.append( + line(t.color(2)(f' {truncate(task.original_file)}')) + ) + else: + out.append( + line(t.color(1)(f' {truncate(task.original_file)}')) + ) + lines_generated += 1 + + while lines_generated < completed_height: + out.append(line('')) + lines_generated += 1 + + out.append(line('')) # Spacing + + # progress bar + current, total = self.runner.progress + bar_line = self.progress_bar.update(current, total, width) + out.append(line(bar_line)) + + # status line + out.append(line(t.normal + status)) + + return '\n'.join(out), render_height + + def run(self) -> None: + t = self.term + self.runner.start() + + final_output = '' + + with t.fullscreen(), t.hidden_cursor(): + while not self.runner.is_done(): + output, height = self.render('Processing...') + y = max(0, (t.height or 24) - height) + print(t.move_yx(y, 0) + output, end='', flush=True) + + self.runner.update() + time.sleep(0.05) + + # Final Render in TUI mode + output, height = self.render('Done') + y = max(0, (t.height or 24) - height) + print(t.move_yx(y, 0) + output, end='', flush=True) + final_output = output + + # Print the final state to the main buffer so it persists + if final_output: + print(final_output) diff --git a/uv.lock b/uv.lock index bfcdbd5..75e10cd 100644 --- a/uv.lock +++ b/uv.lock @@ -3,25 +3,34 @@ revision = 3 requires-python = ">=3.10" [[package]] -name = "about-time" -version = "4.2.1" +name = "ansicon" +version = "1.89.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3f/ccb16bdc53ebb81c1bf837c1ee4b5b0b69584fd2e4a802a2a79936691c0a/about-time-4.2.1.tar.gz", hash = "sha256:6a538862d33ce67d997429d14998310e1dbfda6cb7d9bbfbf799c4709847fece", size = 15380, upload-time = "2022-12-21T04:15:54.991Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/e2/1c866404ddbd280efedff4a9f15abfe943cb83cde6e895022370f3a61f85/ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1", size = 67312, upload-time = "2019-04-29T20:23:57.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/cd/7ee00d6aa023b1d0551da0da5fee3bc23c3eeea632fbfc5126d1fec52b7e/about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341", size = 13295, upload-time = "2022-12-21T04:15:53.613Z" }, + { url = "https://files.pythonhosted.org/packages/75/f9/f1c10e223c7b56a38109a3f2eb4e7fe9a757ea3ed3a166754fb30f65e466/ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec", size = 63675, upload-time = "2019-04-29T20:23:53.83Z" }, ] [[package]] -name = "alive-progress" -version = "3.2.0" +name = "blessed" +version = "1.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "about-time" }, - { name = "grapheme" }, + { name = "jinxed", marker = "sys_platform == 'win32'" }, + { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/66/c2c1e6674b3b7202ce529cf7d9971c93031e843b8e0c86a85f693e6185b8/alive-progress-3.2.0.tar.gz", hash = "sha256:ede29d046ff454fe56b941f686f89dd9389430c4a5b7658e445cb0b80e0e4deb", size = 113231, upload-time = "2024-10-26T04:22:31.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/cd/eed8b82f1fabcb817d84b24d0780b86600b5c3df7ec4f890bcbb2371b0ad/blessed-1.25.0.tar.gz", hash = "sha256:606aebfea69f85915c7ca6a96eb028e0031d30feccc5688e13fd5cec8277b28d", size = 6746381, upload-time = "2025-11-18T18:43:52.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/39/cade3a5a97fffa3ae84f298208237b3a9f7112d6b0ed57e8ff4b755e44b4/alive_progress-3.2.0-py3-none-any.whl", hash = "sha256:0677929f8d3202572e9d142f08170b34dbbe256cc6d2afbf75ef187c7da964a8", size = 77106, upload-time = "2024-10-26T04:22:29.103Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/e9b6dd824fb6e76dbd39a308fc6f497320afd455373aac8518ca3eba7948/blessed-1.25.0-py3-none-any.whl", hash = "sha256:e52b9f778b9e10c30b3f17f6b5f5d2208d1e9b53b270f1d94fc61a243fc4708f", size = 95646, upload-time = "2025-11-18T18:43:50.924Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -29,23 +38,103 @@ name = "emulsion" version = "0.1.2" source = { editable = "." } dependencies = [ - { name = "alive-progress" }, + { name = "blessed" }, { name = "toml" }, { name = "types-toml" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ - { name = "alive-progress" }, + { name = "blessed" }, { name = "toml" }, { name = "types-toml" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + [[package]] -name = "grapheme" -version = "0.6.0" +name = "exceptiongroup" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/bbaab0d2a33e07c8278910c1d0d8d4f3781293dfbc70b5c38197159046bf/grapheme-0.6.0.tar.gz", hash = "sha256:44c2b9f21bbe77cfb05835fec230bd435954275267fea1858013b102f8603cca", size = 207306, upload-time = "2020-03-07T17:13:55.492Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinxed" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansicon", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] [[package]] name = "toml" @@ -56,6 +145,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, ] +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + [[package]] name = "types-toml" version = "0.10.8.20240310" @@ -64,3 +202,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/86/47/3e4c75042792bff8e wheels = [ { url = "https://files.pythonhosted.org/packages/da/a2/d32ab58c0b216912638b140ab2170ee4b8644067c293b170e19fba340ccc/types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d", size = 4777, upload-time = "2024-03-10T02:18:36.568Z" }, ] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +]