Compare commits

19 Commits

Author SHA1 Message Date
Alexander Wainwright
9bf7751c39 Reformat code 2026-02-01 21:07:40 +10:00
Alexander Wainwright
22c7b3cc8a Add missing timeformat file 2026-02-01 21:03:36 +10:00
Alexander Wainwright
67e073714e Increase tui framerate
To about 120Hz.
2026-02-01 21:00:26 +10:00
Alexander Wainwright
35f6137c4f Refactor and improve behaviour 2026-02-01 20:55:56 +10:00
Alexander Wainwright
9f7108d22a Add progress bar test tool 2026-01-31 10:59:56 +10:00
Alexander Wainwright
f4aacb9b26 Initial re-write 2026-01-31 10:59:38 +10:00
Alexander Wainwright
f7c2ec1e9e Update lock file 2025-12-27 23:01:38 +10:00
Alexander Wainwright
b886d26948 Add type hinting stuff 2025-12-27 13:55:53 +10:00
Alexander Wainwright
35e6410b2b Switch to pathlib 2025-12-27 13:01:20 +10:00
Alexander Wainwright
6c00b8e733 Use xdg config path 2025-12-27 12:13:22 +10:00
Alexander Wainwright
3d8063d984 Add ruff settings to pyproject 2025-12-27 12:07:17 +10:00
Alexander Wainwright
c1b031f29e Tidy up some formatting 2025-12-27 12:06:32 +10:00
Alexander Wainwright
91cc408d34 Initial commit of restructure 2025-12-27 11:58:03 +10:00
Alexander Wainwright
37fbce61c9 Bump version number 2025-12-21 00:11:14 +10:00
Alexander Wainwright
6536bf43de Update lockfile 2025-12-21 00:10:56 +10:00
Alexander Wainwright
f188dd04de Fix -j argument 2025-12-21 00:10:07 +10:00
Alexander Wainwright
c91a151a2b Add sidecar functionality 2025-12-20 23:44:05 +10:00
Alexander Wainwright
0c7f244a99 Refactor, add side-car and version 2025-12-19 21:30:43 +10:00
Alexander Wainwright
5525d309bf Also set create date 2025-12-13 20:18:18 +10:00
13 changed files with 2164 additions and 330 deletions

100
demo_progress.py Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
import time
import logging
import argparse
import random
from dataclasses import dataclass
from emulsion.tui import DashboardUI
@dataclass
class MockTask:
original_file: str
timestamp: str = "2024-01-01 12:00:00"
class MockRunner:
def __init__(self, total_items: int, rate_per_sec: float, fail_rate: float = 0.01):
self.total_items = total_items
self.rate_per_sec = rate_per_sec
self.fail_rate = fail_rate
# Generate mock tasks
self.tasks = [
MockTask(original_file=f"DSC_{i:05d}.JPG")
for i in range(total_items)
]
self.pending_tasks = list(self.tasks)
self.completed_tasks = [] # (Task, success, msg)
self.dry_run = False
self._start_time = None
self._items_processed = 0
def start(self):
self._start_time = time.time()
def update(self):
if not self._start_time:
return []
elapsed = time.time() - self._start_time
target_processed = int(elapsed * self.rate_per_sec)
# Clamp to total
target_processed = min(target_processed, self.total_items)
newly_completed = []
while self._items_processed < target_processed:
if not self.pending_tasks:
break
task = self.pending_tasks.pop(0)
# Simulate success/failure
is_success = random.random() > self.fail_rate
msg = "Updated timestamp" if is_success else "Permission denied"
result = (task, is_success, msg)
self.completed_tasks.append(result)
newly_completed.append(result)
self._items_processed += 1
return newly_completed
def is_done(self):
return self._items_processed >= self.total_items
@property
def progress(self):
return self._items_processed, self.total_items
def main():
parser = argparse.ArgumentParser(description="Demo the Emulsion Progress Bar")
parser.add_argument("-n", "--count", type=int, default=1000, help="Number of items to process")
parser.add_argument("-r", "--rate", type=float, default=50.0, help="Items per second")
parser.add_argument("-f", "--fail", type=float, default=0.005, help="Failure rate (0.0 to 1.0)")
args = parser.parse_args()
logging.basicConfig(
filename='emulsion.log',
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logging.info('Emulsion started')
print(f"Starting demo with {args.count} items at {args.rate}/sec...")
time.sleep(1)
runner = MockRunner(args.count, args.rate, args.fail)
ui = DashboardUI(runner)
try:
ui.run()
except KeyboardInterrupt:
print("\nAborted by user.")
if __name__ == "__main__":
main()

View File

@@ -4,12 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "emulsion" name = "emulsion"
version = "0.1.0" version = "0.1.2"
description = "A tool for updating exif tags" description = "A tool for updating exif tags"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"toml", "toml",
"alive-progress", "types-toml",
"blessed",
] ]
authors = [ authors = [
{name = "Alexander Wainwright", email = "code@figtree.dev"}, {name = "Alexander Wainwright", email = "code@figtree.dev"},
@@ -27,3 +28,52 @@ where = ["src"]
[project.scripts] [project.scripts]
emulsion = "emulsion.main:main" emulsion = "emulsion.main:main"
[tool.ruff]
line-length = 80
[tool.ruff.lint]
select = [
"B",
"W",
"ANN",
"FIX",
"S",
"F", # Pyflakes rules
"W", # PyCodeStyle warnings
"E", # PyCodeStyle errors
"I", # Sort imports "properly"
"UP", # Warn if certain things can changed due to newer Python versions
"C4", # Catch incorrect use of comprehensions, dict, list, etc
"FA", # Enforce from __future__ import annotations
"ISC", # Good use of string concatenation
"ICN", # Use common import conventions
"RET", # Good return practices
"SIM", # Common simplification rules
"TID", # Some good import practices
"TC", # Enforce importing certain types in a TYPE_CHECKING block
"PTH", # Use pathlib instead of os.path
"NPY", # Some numpy-specific things
]
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",
]

View File

@@ -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)

120
src/emulsion/config.py Normal file
View File

@@ -0,0 +1,120 @@
import copy
import os
from pathlib import Path
from typing import Any
import toml
# Default Schema
# This defines the "First Class" feel of the app, but is fully overridable.
DEFAULT_CONFIG: dict[str, Any] = {
'sidecar': {'extension': '.xmp'},
'defaults': {'time_increment': 60},
'mappings': {
'author': {
'flags': [
'-Artist={value}',
'-Creator={value}',
'-By-line={value}',
'-Credit={value}',
'-CopyrightNotice=© {year} {value}',
'-Copyright=© {year} {value}',
],
'prompt': True,
'help': 'Name of the photographer',
},
'lab': {
'flags': ['-XMP:DevelopedBy={value}'],
'prompt': True,
'help': 'Lab name',
},
'make': {
'flags': ['-Make={value}'],
'prompt': True,
'help': 'Camera make',
},
'model': {
'flags': ['-Model={value}'],
'prompt': True,
'help': 'Camera model',
},
'lens': {
'flags': ['-LensModel={value}', '-Lens={value}'],
'prompt': True,
'help': 'Lens model',
},
'film': {
'flags': ['-UserComment={value}', '-XMP:Description={value}'],
'prompt': False,
'help': 'Film stock',
},
},
}
def get_config_path() -> Path:
xdg_config_home = os.environ.get(
'XDG_CONFIG_HOME', str(Path('~/.config').expanduser())
)
return Path(xdg_config_home) / 'emulsion' / 'config.toml'
class ConfigLoader:
def __init__(self, path: Path | None = None) -> None:
self.path: Path = path or get_config_path()
self.config: dict[str, Any] = copy.deepcopy(DEFAULT_CONFIG)
def load(self) -> dict[str, Any]:
"""
Loads the config from disk and merges it into the defaults.
Returns the full config dictionary.
"""
if self.path.is_file():
try:
user_config = toml.load(self.path)
self._merge(self.config, user_config)
except Exception as e:
# We might want to let the caller handle this, or just print
# warning
print(
f'Warning: Could not parse config file at {self.path}: {e}'
)
return self.config
def _merge(self, base: dict[str, Any], update: dict[str, Any]) -> None:
"""
Recursively merges 'update' dict into 'base' dict.
"""
for key, value in update.items():
if (
isinstance(value, dict)
and key in base
and isinstance(base[key], dict)
):
self._merge(base[key], value)
else:
base[key] = value
def save_defaults(self, current_defaults: dict[str, Any]) -> bool:
"""
Helpers to write a new config file (for --init-config). This is a bit
tricky because we don't want to just dump the massive DEFAULT_CONFIG. We
likely want to write a file that reflects what the user *currently* has
+ defaults.
"""
# For now, simplistic implementation: Dump the merged state.
# In a real app, we might want to preserve comments etc, but TOML lib
# doesn't do that.
# Ensure directory exists
self.path.parent.mkdir(parents=True, exist_ok=True)
# We probably only want to write if it doesn't exist, to avoid
# clobbering.
if self.path.exists():
return False
with self.path.open('w', encoding='utf-8') as f:
toml.dump(current_defaults, f)
return True

170
src/emulsion/executor.py Normal file
View File

@@ -0,0 +1,170 @@
import datetime
import shlex
import subprocess
from argparse import Namespace
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@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', {})
self.sidecar_ext: str = config.get('sidecar', {}).get(
'extension', '.xmp'
)
if not self.sidecar_ext.startswith('.'):
self.sidecar_ext = f'.{self.sidecar_ext}'
def create_tasks(
self,
files: list[str],
resolved_values: dict[str, str],
options: Namespace,
) -> list[Task]:
# Filter supported files
extensions = ['.jpg', '.jpeg', '.tif', '.tiff']
valid_files = [f for f in files if Path(f).suffix.lower() in extensions]
if not valid_files:
return []
# Parse base date
base_dt = self._parse_date(options.base_date)
time_increment = options.time_increment or 60
tasks: list[Task] = []
for i, f in enumerate(valid_files):
# Calculate timestamp
ts_dt = base_dt + datetime.timedelta(seconds=i * time_increment)
timestamp_str = ts_dt.strftime('%Y:%m:%d %H:%M:%S')
# Determine file targets (Sidecar logic)
target_path, sidecar_source = self._determine_paths(
Path(f), options.embed
)
# Build Command
cmd = self._build_cmd(
target_path, resolved_values, timestamp_str, sidecar_source
)
tasks.append(Task(f, tuple(cmd), timestamp_str))
return tasks
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
) -> tuple[Path, Path | None]:
"""
Returns (target_path, sidecar_source_if_needed)
"""
if embed:
return Path(original_file), None
target_path = Path(f'{original_file}{self.sidecar_ext}')
# If sidecar doesn't exist, we need to tell ExifTool to read from source
# and write to the new sidecar file.
if not Path(target_path).exists():
return target_path, original_file
return target_path, None
def _build_cmd(
self,
file_path: Path,
field_values: dict[str, str],
timestamp_str: str,
sidecar_source: Path | None = None,
) -> list[str]:
current_year = datetime.datetime.now().year
# Core setup
cmd = [
'exiftool',
'-overwrite_original',
f'-DateTimeOriginal={timestamp_str}',
f'-CreateDate={timestamp_str}',
'-WebStatement=',
'-CreatorWorkURL=',
]
# Add mapped fields
for field_name, val in field_values.items():
if field_name in self.mappings:
schema = self.mappings[field_name]
# Schema can be dict (new style) or list (old style/simple)
flags = (
schema.get('flags', [])
if isinstance(schema, dict)
else schema
)
# Ensure flags is a list (just in case)
if isinstance(flags, list):
for flag in flags:
safe_flag = flag.replace('{value}', str(val)).replace(
'{year}', str(current_year)
)
cmd.append(safe_flag)
# Sidecar handling
if sidecar_source:
# -srcfile SOURCE TARGET
cmd.append('-srcfile')
cmd.append(str(file_path))
cmd.append(str(sidecar_source))
else:
cmd.append(str(file_path))
return cmd
def _run_exiftool(self, cmd: list[str], dry_run: bool) -> tuple[bool, str]:
if dry_run:
safe_cmd = shlex.join(cmd)
return True, f'[DRY RUN] {safe_cmd}'
try:
subprocess.run( # noqa: S603
cmd,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return True, 'Updated'
except subprocess.CalledProcessError as e:
return False, f'Error: {e}'
except FileNotFoundError:
return False, "Error: 'exiftool' not found. Please install it."
def _parse_date(self, dt_str: str | None) -> datetime.datetime:
if not dt_str:
# Should be handled by resolver/validator, but safe fallback
return datetime.datetime.now()
dt_str = dt_str.strip()
if ' ' in dt_str:
return datetime.datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
return datetime.datetime.strptime(dt_str, '%Y-%m-%d')

View File

@@ -1,353 +1,286 @@
import argparse import argparse
import datetime
import logging
import os import os
import sys import sys
import subprocess from importlib.metadata import PackageNotFoundError, version
import datetime from typing import Any
import toml
from alive_progress import alive_bar
from concurrent.futures import ThreadPoolExecutor, as_completed
CONFIG_PATH = os.path.expanduser("~/.config/emulsion/config.toml") 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 load_config(): def get_version() -> str:
if os.path.isfile(CONFIG_PATH): try:
try: return version('emulsion')
return toml.load(CONFIG_PATH) except PackageNotFoundError:
except Exception as e: return 'unknown'
print(f"Warning: Could not parse config file: {e}")
return {}
def parse_args(config): def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='A tool for updating exif tags') parser = argparse.ArgumentParser(
description='A tool for updating exif tags'
parser.add_argument(
'files',
nargs='*',
help='Image files to process (e.g. *.jpg *.tif).'
) )
# Configurable fields parser.add_argument(
parser.add_argument('--author', default=None, help='Name of the photographer.') 'files', nargs='*', help='Image files to process (e.g. *.jpg *.tif).'
parser.add_argument('--lab', default=None, help='Name of the lab who developed the film.') )
parser.add_argument('--make', default=None, help='Camera make (stored in EXIF:Make).')
parser.add_argument('--model', default=None, help='Camera model (stored in EXIF:Model).')
parser.add_argument('--film', default=None, help='Film stock (stored in EXIF:UserComment and XMP:Description).')
# Time settings parser.add_argument(
parser.add_argument('--base-date', default=None, help='Base date or date/time (e.g. 2023-04-10 or 2023-04-10 12:00:00).') '-v', '--version', action='version', version=f'%(prog)s {get_version()}'
parser.add_argument('--time-increment', type=int, default=None, help='Time increment in seconds between images.') )
parser.add_argument('--dry-run', action='store_true', help='Show what would be changed without modifying files.')
parser.add_argument('-j', '--workers', type=int, default=os.cpu_count() or 1, help='Number of parallel workers to run exiftool; defaults to number of CPUs.')
parser.add_argument('--init-config', action='store_true', help='Create a default config file (if none exists) and exit.')
args = parser.parse_args() # --- First-Class Fields ---
parser.add_argument('--author', help='Name of the photographer.')
parser.add_argument('--lab', help='Name of the lab who developed the film.')
parser.add_argument('--make', help='Camera make.')
parser.add_argument('--model', help='Camera model.')
parser.add_argument('--lens', help='Lens model.')
parser.add_argument('--film', help='Film stock.')
# Merge from config # --- Generic/Custom Fields ---
if args.author is None and 'author' in config: parser.add_argument(
args.author = config['author'] '--field',
action='append',
dest='custom_fields',
metavar='KEY=VALUE',
help=(
'Set a custom field defined in config (e.g., '
'--field location="Paris").'
),
)
if args.lab is None and 'lab' in config: # --- Process Control ---
args.lab = config['lab'] parser.add_argument(
'--base-date',
default=None,
help=(
'Base date or date/time (e.g. 2023-04-10 or 2023-04-10 12:00:00).'
),
)
if args.make is None and 'make' in config: parser.add_argument(
args.make = config['make'] '--time-increment',
type=int,
default=None,
help='Time increment in seconds between images.',
)
if args.model is None and 'model' in config: parser.add_argument(
args.model = config['model'] '--embed',
action='store_true',
help=(
'Embed EXIF data directly into the image file instead of a sidecar.'
),
)
if args.film is None and 'film' in config: parser.add_argument(
args.film = config['film'] '--dry-run',
action='store_true',
help='Show what would be changed without modifying files.',
)
if args.time_increment is None and 'time_increment' in config: parser.add_argument(
args.time_increment = config['time_increment'] '-j',
'--workers',
type=int,
nargs='?',
const=os.cpu_count() or 1,
default=os.cpu_count() or 1,
help=(
'Number of parallel workers to run exiftool; defaults to number '
'of CPUs.'
),
)
return args parser.add_argument(
'--init-config',
action='store_true',
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).',
)
# --- UI Options ---
parser.add_argument(
'--simple',
'--no-tui',
dest='simple_ui',
action='store_true',
help='Force the simple text interface.',
)
return parser.parse_args()
def prompt_for_config(args): def prompt_for_defaults(config: dict[str, Any]) -> None:
""" """
Prompt for config-only fields before creating a config file. Prompts the user for default values to populate the initial config.
(Base date is ephemeral, not stored in config.)
""" """
print('Initializing configuration. Press Enter to skip any field.')
# We'll iterate over the 'mappings' to find what fields are available,
# but we'll prioritize the 'core' ones for a better UX order.
core_fields = ['author', 'lab', 'make', 'model', 'lens', 'film']
mappings = config.get('mappings', {})
defaults = config.setdefault('defaults', {})
# Prompt for core fields first
for field in core_fields:
if field in mappings:
schema = mappings[field]
help_text = (
schema.get('help', field) if isinstance(schema, dict) else field
)
val = input(f'Default {help_text} (optional): ').strip()
if val:
defaults[field] = val
# Time increment
dflt_inc = defaults.get('time_increment', 60)
val = input(f'Default Time Increment [seconds] ({dflt_inc}): ').strip()
if val:
try:
defaults['time_increment'] = int(val)
except ValueError:
print('Invalid number, keeping default.')
def main() -> None:
logging.basicConfig(
level=logging.WARN,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logging.info('Emulsion started')
try: try:
if not args.author: # 1. Load Config
args.author = input("Photographer's name (Author)? ").strip() loader = ConfigLoader()
config = loader.load()
if args.lab is None: # 2. Parse CLI
resp = input("Lab name (optional, enter to skip)? ").strip() args = parse_args()
args.lab = resp if resp else ""
if args.make is None:
resp = input("Camera make (optional, enter to skip)? ").strip()
args.make = resp if resp else ""
if args.model is None:
resp = input("Camera model (optional, enter to skip)? ").strip()
args.model = resp if resp else ""
if args.film is None:
resp = input("Film stock (optional, enter to skip)? ").strip()
args.film = resp if resp else ""
if not args.time_increment:
dflt = "60"
resp = input(f"Time increment in seconds [{dflt}]: ").strip()
args.time_increment = int(resp) if resp else int(dflt)
except KeyboardInterrupt:
print("\nInterrupted by user. Exiting.")
sys.exit(1)
def prompt_if_missing(args):
"""
Prompt for ephemeral fields like base_date if missing,
and also fill in other fields if user didn't supply them.
"""
try:
if not args.author:
args.author = input("Photographer's name (Author)? ").strip()
if args.lab is None:
resp = input("Lab name (optional, enter to skip)? ").strip()
args.lab = resp if resp else ""
if args.make is None:
resp = input("Camera make (optional, enter to skip)? ").strip()
args.make = resp if resp else ""
if args.model is None:
resp = input("Camera model (optional, enter to skip)? ").strip()
args.model = resp if resp else ""
if args.film is None:
resp = input("Film stock (optional, enter to skip)? ").strip()
args.film = resp if resp else ""
if not args.base_date:
dflt = datetime.datetime.now().strftime("%Y-%m-%d")
resp = input(f"Base date/time for first image [{dflt}]: ").strip()
args.base_date = resp if resp else dflt
if not args.time_increment:
dflt = "60"
resp = input(f"Time increment in seconds [{dflt}]: ").strip()
args.time_increment = int(resp) if resp else int(dflt)
except KeyboardInterrupt:
print("\nInterrupted by user. Exiting.")
sys.exit(1)
def parse_user_date(dt_str):
dt_str = dt_str.strip()
if " " in dt_str:
return datetime.datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
else:
return datetime.datetime.strptime(dt_str, "%Y-%m-%d")
def build_exiftool_cmd(file_path, author, lab, make, model, film, timestamp, dry_run=False):
"""
Use standard EXIF fields:
- EXIF:Make (args.make)
- EXIF:Model (args.model)
- EXIF:UserComment (args.film)
Also store film in XMP:Description for better compatibility.
"""
current_year = datetime.datetime.now().year
cmd = [
"exiftool",
"-overwrite_original",
# Photographer info
f"-Artist={author}",
f"-Creator={author}",
f"-By-line={author}",
f"-Credit={author}",
f"-CopyrightNotice=© {current_year} {author}",
f"-Copyright=© {current_year} {author}",
# Timestamps
f"-DateTimeOriginal={timestamp}",
# Clear out some lab fields
"-WebStatement=",
"-CreatorWorkURL="
]
# Lab in XMP:DevelopedBy
if lab:
cmd.append(f"-XMP:DevelopedBy={lab}")
# If user gave a make, store it in EXIF:Make
if make:
cmd.append(f"-Make={make}")
# If user gave a model, store it in EXIF:Model
if model:
cmd.append(f"-Model={model}")
# If user gave a film stock, store it in EXIF:UserComment AND XMP:Description
if film:
cmd.append(f"-UserComment={film}")
cmd.append(f"-XMP:Description={film}")
cmd.append(file_path)
if dry_run:
return " ".join(cmd)
return cmd
def create_config_file(args):
if os.path.exists(CONFIG_PATH):
print("Config file already exists. Not overwriting.")
sys.exit(0)
defaults = {
"author": args.author or "Your Name",
"lab": args.lab or "",
"make": args.make or "",
"model": args.model or "",
"film": args.film or "",
"time_increment": args.time_increment if args.time_increment else 60
}
# Remove empty values so user is prompted next time if they left something blank
keys_to_remove = []
for k, v in defaults.items():
if isinstance(v, str) and not v.strip():
keys_to_remove.append(k)
for k in keys_to_remove:
del defaults[k]
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
toml.dump(defaults, f)
print(f"Created config file at {CONFIG_PATH}")
sys.exit(0)
def main():
try:
config = load_config()
args = parse_args(config)
# Default to number of CPUs if workers not specified
if args.workers is None:
args.workers = os.cpu_count() or 1
# Handle Initialization
if args.init_config: if args.init_config:
prompt_for_config(args) if loader.path.exists():
create_config_file(args) print(
f'Config file already exists at {loader.path}. Not '
'overwriting.'
)
sys.exit(0)
if not args.files: # Prompt user for initial values
print("No files provided.") try:
prompt_for_defaults(config)
except KeyboardInterrupt:
print('\nAborted.')
sys.exit(1)
if loader.save_defaults(config):
print(f'Created config file at {loader.path}')
else:
# Should be caught by the check above, but for safety
print('Config file already exists. Not overwriting.')
sys.exit(0) sys.exit(0)
prompt_if_missing(args) if not args.files:
print('No files provided.')
sys.exit(0)
try: # Handle Base Date Prompt logic
base_dt = parse_user_date(args.base_date) if not args.base_date and not args.no_interaction:
except ValueError: dflt = datetime.datetime.now().strftime('%Y-%m-%d')
print(f"Error: Base date '{args.base_date}' must be 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'.") resp = input(f'Base date/time for first image [{dflt}]: ').strip()
args.base_date = resp if resp else dflt
if not args.base_date:
print('Error: Base date is required.')
sys.exit(1) sys.exit(1)
files = sorted(args.files) # 3. Prepare Inputs for Resolver
total_files = len(files) # We need to mash --author and --field author=... into one dict
time_increment = args.time_increment if args.time_increment else 60 user_inputs: dict[str, Any] = {}
current_dt = base_dt
print(f"Processing {total_files} file(s)...") # First-Class args
for field in ['author', 'lab', 'make', 'model', 'lens', 'film']:
val = getattr(args, field, None)
if val is not None:
user_inputs[field] = val
with alive_bar(total_files, title="Tagging files") as bar: # Custom args
if args.workers > 1 and not args.dry_run: if args.custom_fields:
executor = ThreadPoolExecutor(max_workers=args.workers) for item in args.custom_fields:
futures = {} if '=' in item:
supported_idx = 0 key, val = item.split('=', 1)
for f in files: user_inputs[key.strip()] = val.strip()
ext = os.path.splitext(f)[1].lower() else:
if ext not in ['.jpg', '.jpeg', '.tif', '.tiff']: print(
bar.text(f"Skipping unsupported file: {f}") f"Warning: Invalid format for --field '{item}'. "
bar() 'Expected KEY=VALUE.'
continue
ts_dt = base_dt + datetime.timedelta(seconds=supported_idx * time_increment)
timestamp_str = ts_dt.strftime("%Y:%m:%d %H:%M:%S")
cmd = build_exiftool_cmd(
file_path=f,
author=args.author,
lab=args.lab,
make=args.make,
model=args.model,
film=args.film,
timestamp=timestamp_str,
dry_run=False
)
future = executor.submit(
subprocess.run, cmd, check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
futures[future] = (f, timestamp_str)
supported_idx += 1
for future in as_completed(futures):
f, ts = futures[future]
try:
future.result()
bar.text(f"Updated {f} => {ts}")
except subprocess.CalledProcessError as e:
bar.text(f"Failed to update {f}: {e}")
bar()
executor.shutdown(wait=True)
else:
supported_idx = 0
current_dt = base_dt
for f in files:
ext = os.path.splitext(f.lower())[1]
if ext not in ['.jpg', '.jpeg', '.tif', '.tiff']:
bar.text(f"Skipping unsupported file: {f}")
bar()
continue
timestamp_str = current_dt.strftime("%Y:%m:%d %H:%M:%S")
cmd = build_exiftool_cmd(
file_path=f,
author=args.author,
lab=args.lab,
make=args.make,
model=args.model,
film=args.film,
timestamp=timestamp_str,
dry_run=args.dry_run
) )
if args.dry_run: # 4. Resolve Metadata
bar.text(f"DRY RUN: {cmd}") resolver = ValueResolver(config)
else: resolved_values = resolver.resolve(
try: user_inputs, interactive=not args.no_interaction
subprocess.run( )
cmd,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
bar.text(f"Updated {f} => {timestamp_str}")
except subprocess.CalledProcessError as e:
bar.text(f"Failed to update {f}: {e}")
current_dt += datetime.timedelta(seconds=time_increment) # 5. Execute Setup
bar() executor = Executor(config)
# We pass 'args' as the options object (has dry_run, workers, etc)
# Just need to make sure time_increment is resolved from config defaults
# if missing
if args.time_increment is None:
args.time_increment = config.get('defaults', {}).get(
'time_increment', 60
)
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: except KeyboardInterrupt:
print("\nInterrupted by user. Exiting.") print('\nInterrupted by user. Exiting.')
sys.exit(1) if 'runner' in locals():
runner.shutdown()
sys.stdout.flush()
os._exit(1)
if __name__ == '__main__': if __name__ == '__main__':

281
src/emulsion/progressbar.py Normal file
View File

@@ -0,0 +1,281 @@
from __future__ import annotations
import logging
import math
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import astuple, dataclass
from typing import TYPE_CHECKING
from emulsion.si_format import format_si
from emulsion.timeformat import format_time
if TYPE_CHECKING:
from blessed import Terminal
class SmoothingFunction(ABC):
@abstractmethod
def update(self, x: float | None) -> float | None:
pass
class EMA(SmoothingFunction):
def __init__(
self, alpha: float = 0.01, initial: float | None = None
) -> None:
if not 0 < alpha <= 1:
raise ValueError('alpha must be in (0, 1]')
self.alpha = alpha
self.smooth_val = initial
def update(self, x: float | None) -> float | None:
if x and self.smooth_val:
self.smooth_val = (
self.alpha * x + (1 - self.alpha) * self.smooth_val
)
elif x:
self.smooth_val = x
return self.smooth_val
class RollingWindow(SmoothingFunction):
def __init__(self, size: int = 10) -> None:
self._buffer = deque(maxlen=size)
def update(self, x: float | None) -> float | None:
if x is not None:
self._buffer.append(x)
if len(self._buffer) == 0:
return None
return sum(self._buffer) / len(self._buffer)
class TimeBasedEMA(SmoothingFunction):
def __init__(
self, time_window: float = 40.0, initial: float | None = None
) -> None:
if time_window <= 0:
raise ValueError('time_window must be > 0')
self.tau = time_window
self.smooth_val = initial
self.last_time: float | None = None
def update(self, x: float | None) -> float | None:
now = time.monotonic()
if x is None:
return self.smooth_val
if self.smooth_val is None or self.last_time is None:
self.smooth_val = x
self.last_time = now
return x
dt = now - self.last_time
self.last_time = now
# Guard against extremely fast updates or clock quirks
if dt <= 0:
return self.smooth_val
alpha = 1.0 - math.exp(-dt / self.tau)
self.smooth_val = alpha * x + (1.0 - alpha) * self.smooth_val
return self.smooth_val
@dataclass
class PhysicsState:
rate: float | None = None
eta: float | None = None
percent: float = 0.0
def __iter__(
self,
) -> tuple[
float | None,
float | None,
float,
]:
return iter(astuple(self))
class Physics:
def __init__(self) -> None:
# state variables
self._state = PhysicsState()
# last run data
self._last_time: float | None = None
self._last_count: float | None = None
# other shit
# self._smoothing = RollingWindow()
self._smoothing: SmoothingFunction = TimeBasedEMA()
self._start_time = time.time()
def update(self, count: int, total: int) -> PhysicsState:
"""Calculate the rate, eta and percent"""
now = time.time()
# on updates to make if the count has not changed
if count == self._last_count:
return self._state
# percentage complete
if total == 0:
self._state.percent = 0.0
else:
self._state.percent = count / total
# processing rate
rate: float | None = None
try:
if self._last_count and self._last_time:
rate = (count - self._last_count) / (now - self._last_time)
except ZeroDivisionError:
logging.debug('zero division')
rate = None
self._state.rate = self._smoothing.update(rate)
if self._state.rate:
self._state.eta = int((total - count) / self._state.rate)
logging.debug('eta: %f', self._state.eta)
self._last_count = count
self._last_time = now
return self._state
class BrailleBar:
"""The windy man. The long mover."""
def __init__(self) -> None:
self.tick_frames = ['', '', '', '', '', '', '', '']
self.full_block = ''
self.empty_char = ' '
self.n_tick_frames = len(self.tick_frames)
# Internal display state
self._last_width: int | None = None
self._last_ticks: float = 0.0
self._last_rendered_ticks: int = 0
self._high_res_progress: bool = False
def render(self, percent: float, width: int) -> str:
total_ticks = width * self.n_tick_frames
current_ticks_fraction = percent * total_ticks
current_ticks = int(current_ticks_fraction)
# Hysteresis / Anti-flicker logic. Check if we are moving faster than
# the tick size
if current_ticks_fraction != self._last_ticks:
delta_ticks = abs(current_ticks_fraction - self._last_ticks)
self._high_res_progress = delta_ticks <= 1
self._last_ticks = current_ticks_fraction
# 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:
# we mod my half here as we can cleanly render at half steps
# in braille
step = -(-self.n_tick_frames // 2)
current_ticks -= current_ticks % step
# latch to make sure we don't go backwards if we switch resolution
# but release the latch if the terminal has resized
if self._last_width == width:
current_ticks = max(self._last_rendered_ticks, current_ticks)
self._last_rendered_ticks = current_ticks
self._last_width = width
remainder_idx = current_ticks % self.n_tick_frames
full_blocks_count = current_ticks // self.n_tick_frames
# String construction
partial_char = ''
if full_blocks_count < 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 = width - full_blocks_count - len(partial_char)
return (
(self.full_block * full_blocks_count)
+ partial_char
+ (self.empty_char * empty_blocks_count)
)
class ProgressBar:
def __init__(self, term: Terminal) -> None:
self.term: Terminal = term
self.physics = Physics()
self.bar_renderer = BrailleBar()
self.spinner_chars = '▁▂▃▄▅▆▇█▇▆▅▄▃▂▁'
def _get_spinner(self, now: float) -> str:
spinner_idx = int(now * 10) % len(self.spinner_chars)
return self.spinner_chars[spinner_idx]
def _render_stats(
self,
current: int,
total: int,
rate: float,
eta: float,
percent: float,
) -> str:
eta_s = format_time(int(eta)) if eta is not None else '???'
rate_val = format_si(rate or 0, 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_WIDTH = 50
return f'{content: <{STATS_WIDTH}}'
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.
Arguments:
current (int): The number of steps completed
total (int): The total number of steps
width (int): The terminal width
Returns:
str: The rendered progress bar
"""
now = time.time()
# Spinner animation
spinner = self._get_spinner(now)
# calculate rate info
rate, eta, percent = self.physics.update(current, total)
# render that section
stats = self._render_stats(current, total, rate, eta, percent)
bar_area_width = max(10, width - len(stats) - 4)
bar_segment = self.bar_renderer.render(percent, bar_area_width)
return f'{spinner}{bar_segment}{stats}'

91
src/emulsion/resolver.py Normal file
View File

@@ -0,0 +1,91 @@
import sys
from typing import Any
class ValueResolver:
def __init__(self, config: dict[str, Any]) -> None:
"""
config: The loaded configuration dictionary containing 'mappings' and
'defaults'.
"""
self.mappings: dict[str, Any] = config.get('mappings', {})
self.defaults: dict[str, Any] = config.get('defaults', {})
def resolve(
self, cli_args: dict[str, Any], interactive: bool = True
) -> dict[str, str]:
"""
Resolves the final values for all fields.
Strategy:
1. Start with Config Defaults.
2. Overlay CLI Arguments.
3. Identify fields that require prompting (prompt=True in config).
4. If interactive, prompt for missing required fields.
5. Return final dictionary of {field: value}.
"""
# 1. Start with Defaults
# We filter defaults to only include things that might be fields (or
# core settings)
# Actually, 'defaults' in config might mix settings (time_increment) and
# fields (author).
# The executor will ignore keys it doesn't understand, so it's safe to
# pass all.
resolved = self.defaults.copy()
# 2. Overlay CLI Inputs
# cli_args is expected to be a dict of {key: value} provided by the
# user. This merges both --author and --field author=...
for key, val in cli_args.items():
if val is not None:
resolved[key] = val
# 3. Identify Prompts
# We look at the 'mappings' to see which fields want to be prompted.
if interactive:
fields_to_prompt: list[str] = []
for field_name, schema in self.mappings.items():
# Check if prompt is requested
if (
isinstance(schema, dict)
and schema.get('prompt', False)
# Check if we already have a value
and (field_name not in resolved or not resolved[field_name])
):
fields_to_prompt.append(field_name)
# Sort for stability (or maybe define priority in config later?)
fields_to_prompt.sort()
# 4. Prompt Loop
try:
for field in fields_to_prompt:
self._prompt_user(field, resolved)
except KeyboardInterrupt:
print('\nInterrupted. Exiting.')
sys.exit(1)
# 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 is not None}
def _prompt_user(
self, field_name: str, resolved_dict: dict[str, Any]
) -> None:
"""
Helper to prompt a single field.
"""
schema = self.mappings.get(field_name, {})
help_text = (
schema.get('help', field_name)
if isinstance(schema, dict)
else field_name
)
# We capitalize the field name for the prompt label if help text matches
# name
label = help_text
val = input(f'{label} (Optional): ').strip()
if val:
resolved_dict[field_name] = val

405
src/emulsion/si_format.py Normal file
View File

@@ -0,0 +1,405 @@
"""
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

View File

@@ -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'

View File

@@ -0,0 +1,26 @@
# Source - https://stackoverflow.com/a/24542445
# Posted by Mr. B, modified by community. See post 'Timeline' for change history
# Retrieved 2026-01-31, License - CC BY-SA 4.0
intervals = (
('yr', 31536000), # 60 * 60 * 24 * 365
('mo', 2592000), # 60 * 60 * 24 * 30
('wks', 604800), # 60 * 60 * 24 * 7
('d', 86400), # 60 * 60 * 24
('h', 3600), # 60 * 60
('m', 60),
('s', 1),
)
def format_time(seconds: float, granularity: int = 2) -> str:
result = []
for name, count in intervals:
value = seconds // count
if value:
seconds -= value * count
if value == 1:
name = name.rstrip('s')
result.append(f'{value}{name}')
return ' '.join(result[:granularity])

213
src/emulsion/tui.py Normal file
View File

@@ -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.008)
# 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)

204
uv.lock generated
View File

@@ -1,54 +1,222 @@
version = 1 version = 1
revision = 3
requires-python = ">=3.10" requires-python = ">=3.10"
[[package]] [[package]]
name = "about-time" name = "ansicon"
version = "4.2.1" version = "1.89.0"
source = { registry = "https://pypi.org/simple" } 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 } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/cd/7ee00d6aa023b1d0551da0da5fee3bc23c3eeea632fbfc5126d1fec52b7e/about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341", size = 13295 }, { 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]] [[package]]
name = "alive-progress" name = "blessed"
version = "3.2.0" version = "1.25.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "about-time" }, { name = "jinxed", marker = "sys_platform == 'win32'" },
{ name = "grapheme" }, { name = "wcwidth" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/28/66/c2c1e6674b3b7202ce529cf7d9971c93031e843b8e0c86a85f693e6185b8/alive-progress-3.2.0.tar.gz", hash = "sha256:ede29d046ff454fe56b941f686f89dd9389430c4a5b7658e445cb0b80e0e4deb", size = 113231 } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/39/cade3a5a97fffa3ae84f298208237b3a9f7112d6b0ed57e8ff4b755e44b4/alive_progress-3.2.0-py3-none-any.whl", hash = "sha256:0677929f8d3202572e9d142f08170b34dbbe256cc6d2afbf75ef187c7da964a8", size = 77106 }, { 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]] [[package]]
name = "emulsion" name = "emulsion"
version = "0.1.0" version = "0.1.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alive-progress" }, { name = "blessed" },
{ name = "toml" }, { name = "toml" },
{ name = "types-toml" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alive-progress" }, { name = "blessed" },
{ name = "toml" }, { name = "toml" },
{ name = "types-toml" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
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]] [[package]]
name = "grapheme" name = "iniconfig"
version = "0.6.0" version = "2.3.0"
source = { registry = "https://pypi.org/simple" } 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 } 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]] [[package]]
name = "toml" name = "toml"
version = "0.10.2" version = "0.10.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, { 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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/47/3e4c75042792bff8e90d7991aa5c51812cc668828cc6cce711e97f63a607/types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331", size = 4392, upload-time = "2024-03-10T02:18:37.518Z" }
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" },
] ]