Files
emulsion/src/emulsion/main.py
Alexander Wainwright b886d26948 Add type hinting stuff
2025-12-27 13:55:53 +10:00

238 lines
5.7 KiB
Python

import argparse
import datetime
import os
import sys
from importlib.metadata import PackageNotFoundError, version
from typing import Any
from emulsion.config import ConfigLoader
from emulsion.executor import Executor
from emulsion.resolver import ValueResolver
def get_version() -> str:
try:
return version('emulsion')
except PackageNotFoundError:
return 'unknown'
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description='A tool for updating exif tags'
)
parser.add_argument(
'files', nargs='*', help='Image files to process (e.g. *.jpg *.tif).'
)
parser.add_argument(
'-v', '--version', action='version', version=f'%(prog)s {get_version()}'
)
# --- 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.')
# --- 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").'
),
)
# --- 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).'
),
)
parser.add_argument(
'--time-increment',
type=int,
default=None,
help='Time increment in seconds between images.',
)
parser.add_argument(
'--embed',
action='store_true',
help=(
'Embed EXIF data directly into the image file instead of a sidecar.'
),
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be changed without modifying files.',
)
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.'
),
)
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).',
)
return parser.parse_args()
def prompt_for_defaults(config: dict[str, Any]) -> None:
"""
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:
try:
# 1. Load Config
loader = ConfigLoader()
config = loader.load()
# 2. Parse CLI
args = parse_args()
# Handle Initialization
if args.init_config:
if loader.path.exists():
print(
f'Config file already exists at {loader.path}. Not '
'overwriting.'
)
sys.exit(0)
# 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)
if not args.files:
print('No files provided.')
sys.exit(0)
# 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)
# 3. Prepare Inputs for Resolver
# We need to mash --author and --field author=... into one dict
user_inputs: dict[str, Any] = {}
# First-Class args
for field in ['author', 'lab', 'make', 'model', 'lens', 'film']:
val = getattr(args, field, None)
if val:
user_inputs[field] = val
# 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.'
)
# 4. Resolve Metadata
resolver = ValueResolver(config)
resolved_values = resolver.resolve(
user_inputs, interactive=not args.no_interaction
)
# 5. Execute
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
)
executor.run_batch(args.files, resolved_values, args)
except KeyboardInterrupt:
print('\nInterrupted by user. Exiting.')
sys.exit(1)
if __name__ == '__main__':
main()