Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf7751c39 | ||
|
|
22c7b3cc8a | ||
|
|
67e073714e | ||
|
|
35f6137c4f | ||
|
|
9f7108d22a | ||
|
|
f4aacb9b26 | ||
|
|
f7c2ec1e9e | ||
|
|
b886d26948 | ||
|
|
35e6410b2b | ||
|
|
6c00b8e733 | ||
|
|
3d8063d984 | ||
|
|
c1b031f29e | ||
|
|
91cc408d34 | ||
|
|
37fbce61c9 | ||
|
|
6536bf43de | ||
|
|
f188dd04de | ||
|
|
c91a151a2b | ||
|
|
0c7f244a99 | ||
|
|
5525d309bf |
100
demo_progress.py
Executable file
100
demo_progress.py
Executable 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()
|
||||
@@ -4,12 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "emulsion"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
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"},
|
||||
@@ -27,3 +28,52 @@ where = ["src"]
|
||||
|
||||
[project.scripts]
|
||||
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",
|
||||
]
|
||||
|
||||
97
src/emulsion/batch_runner.py
Normal file
97
src/emulsion/batch_runner.py
Normal 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
120
src/emulsion/config.py
Normal 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
170
src/emulsion/executor.py
Normal 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')
|
||||
@@ -1,353 +1,286 @@
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import datetime
|
||||
import toml
|
||||
from alive_progress import alive_bar
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import Any
|
||||
|
||||
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():
|
||||
if os.path.isfile(CONFIG_PATH):
|
||||
try:
|
||||
return toml.load(CONFIG_PATH)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not parse config file: {e}")
|
||||
return {}
|
||||
def get_version() -> str:
|
||||
try:
|
||||
return version('emulsion')
|
||||
except PackageNotFoundError:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def parse_args(config):
|
||||
parser = argparse.ArgumentParser(description='A tool for updating exif tags')
|
||||
|
||||
parser.add_argument(
|
||||
'files',
|
||||
nargs='*',
|
||||
help='Image files to process (e.g. *.jpg *.tif).'
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='A tool for updating exif tags'
|
||||
)
|
||||
|
||||
# Configurable fields
|
||||
parser.add_argument('--author', default=None, help='Name of the photographer.')
|
||||
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).')
|
||||
parser.add_argument(
|
||||
'files', nargs='*', help='Image files to process (e.g. *.jpg *.tif).'
|
||||
)
|
||||
|
||||
# Time settings
|
||||
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).')
|
||||
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.')
|
||||
parser.add_argument(
|
||||
'-v', '--version', action='version', version=f'%(prog)s {get_version()}'
|
||||
)
|
||||
|
||||
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
|
||||
if args.author is None and 'author' in config:
|
||||
args.author = config['author']
|
||||
# --- Generic/Custom Fields ---
|
||||
parser.add_argument(
|
||||
'--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:
|
||||
args.lab = config['lab']
|
||||
# --- Process Control ---
|
||||
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:
|
||||
args.make = config['make']
|
||||
parser.add_argument(
|
||||
'--time-increment',
|
||||
type=int,
|
||||
default=None,
|
||||
help='Time increment in seconds between images.',
|
||||
)
|
||||
|
||||
if args.model is None and 'model' in config:
|
||||
args.model = config['model']
|
||||
parser.add_argument(
|
||||
'--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:
|
||||
args.film = config['film']
|
||||
parser.add_argument(
|
||||
'--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:
|
||||
args.time_increment = config['time_increment']
|
||||
parser.add_argument(
|
||||
'-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.
|
||||
(Base date is ephemeral, not stored in config.)
|
||||
Prompts the user for default values to populate the initial 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:
|
||||
if not args.author:
|
||||
args.author = input("Photographer's name (Author)? ").strip()
|
||||
# 1. Load Config
|
||||
loader = ConfigLoader()
|
||||
config = loader.load()
|
||||
|
||||
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.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
|
||||
# 2. Parse CLI
|
||||
args = parse_args()
|
||||
|
||||
# Handle Initialization
|
||||
if args.init_config:
|
||||
prompt_for_config(args)
|
||||
create_config_file(args)
|
||||
if loader.path.exists():
|
||||
print(
|
||||
f'Config file already exists at {loader.path}. Not '
|
||||
'overwriting.'
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
if not args.files:
|
||||
print("No files provided.")
|
||||
# Prompt user for initial values
|
||||
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)
|
||||
|
||||
prompt_if_missing(args)
|
||||
if not args.files:
|
||||
print('No files provided.')
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
base_dt = parse_user_date(args.base_date)
|
||||
except ValueError:
|
||||
print(f"Error: Base date '{args.base_date}' must be 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'.")
|
||||
# Handle Base Date Prompt logic
|
||||
if not args.base_date and not args.no_interaction:
|
||||
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.base_date:
|
||||
print('Error: Base date is required.')
|
||||
sys.exit(1)
|
||||
|
||||
files = sorted(args.files)
|
||||
total_files = len(files)
|
||||
time_increment = args.time_increment if args.time_increment else 60
|
||||
current_dt = base_dt
|
||||
# 3. Prepare Inputs for Resolver
|
||||
# We need to mash --author and --field author=... into one dict
|
||||
user_inputs: dict[str, Any] = {}
|
||||
|
||||
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:
|
||||
if args.workers > 1 and not args.dry_run:
|
||||
executor = ThreadPoolExecutor(max_workers=args.workers)
|
||||
futures = {}
|
||||
supported_idx = 0
|
||||
for f in files:
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext not in ['.jpg', '.jpeg', '.tif', '.tiff']:
|
||||
bar.text(f"Skipping unsupported file: {f}")
|
||||
bar()
|
||||
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
|
||||
# Custom args
|
||||
if args.custom_fields:
|
||||
for item in args.custom_fields:
|
||||
if '=' in item:
|
||||
key, val = item.split('=', 1)
|
||||
user_inputs[key.strip()] = val.strip()
|
||||
else:
|
||||
print(
|
||||
f"Warning: Invalid format for --field '{item}'. "
|
||||
'Expected KEY=VALUE.'
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
bar.text(f"DRY RUN: {cmd}")
|
||||
else:
|
||||
try:
|
||||
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}")
|
||||
# 4. Resolve Metadata
|
||||
resolver = ValueResolver(config)
|
||||
resolved_values = resolver.resolve(
|
||||
user_inputs, interactive=not args.no_interaction
|
||||
)
|
||||
|
||||
current_dt += datetime.timedelta(seconds=time_increment)
|
||||
bar()
|
||||
# 5. Execute Setup
|
||||
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:
|
||||
print("\nInterrupted by user. Exiting.")
|
||||
sys.exit(1)
|
||||
print('\nInterrupted by user. Exiting.')
|
||||
if 'runner' in locals():
|
||||
runner.shutdown()
|
||||
sys.stdout.flush()
|
||||
os._exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
281
src/emulsion/progressbar.py
Normal file
281
src/emulsion/progressbar.py
Normal 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
91
src/emulsion/resolver.py
Normal 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
405
src/emulsion/si_format.py
Normal 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
|
||||
180
src/emulsion/test_si_format.py
Normal file
180
src/emulsion/test_si_format.py
Normal 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'
|
||||
26
src/emulsion/timeformat.py
Normal file
26
src/emulsion/timeformat.py
Normal 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
213
src/emulsion/tui.py
Normal 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
204
uv.lock
generated
@@ -1,54 +1,222 @@
|
||||
version = 1
|
||||
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 }
|
||||
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 },
|
||||
{ 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 }
|
||||
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 },
|
||||
{ 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]]
|
||||
name = "emulsion"
|
||||
version = "0.1.0"
|
||||
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 = "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]]
|
||||
name = "grapheme"
|
||||
version = "0.6.0"
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
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]]
|
||||
name = "toml"
|
||||
version = "0.10.2"
|
||||
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 = [
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user