"""Configuration model, loading, and validation.""" import json import logging from dataclasses import dataclass from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from schemas.columns import MissingFieldPolicy logger = logging.getLogger(__name__) DEFAULT_THROTTLE_CAPACITY = 1 DEFAULT_THROTTLE_RATE = 2.0 class Config(BaseModel): """Validated configuration loaded from config.json. Required fields: bearer_token. Optional fields mirror CLI arguments — values in config.json act as defaults that CLI flags can override. """ model_config = ConfigDict(frozen=True) # --- Required --- bearer_token: str = Field( ..., min_length=1, description='Bearer token for API authentication' ) # --- CLI-equivalent defaults (all optional — CLI overrides these) --- throttle_capacity: int | None = Field( default=None, ge=1, description='Token bucket capacity (burst size)' ) throttle_rate: float | None = Field( default=None, gt=0, description='Token bucket refill rate (requests per second)', ) skip_if_missing: bool | None = Field( default=None, description='Skip rows with missing required fields', ) # --- GraphQL gateway --- graphql_url: str = Field(..., description='Abacus GraphQL gateway URL') profile_uuid: str = Field(..., description='Orchard profile UUID for GraphQL auth') @field_validator('bearer_token') @classmethod def bearer_token_not_placeholder(cls, v: str) -> str: if v == 'YOUR_BEARER_TOKEN_HERE': raise ValueError( 'bearer_token is still the placeholder — update config.json' ) return v class ConfigError(Exception): """Raised when configuration loading or validation fails.""" pass @dataclass(frozen=True) class RunOptions: """Resolved runtime options for a processing run. All three-way resolution (CLI arg > config > default) is handled by the ``resolve`` classmethod so callers don't repeat the logic. """ dry_run: bool = False limit: int | None = None policy: MissingFieldPolicy = MissingFieldPolicy.DEFAULT throttle_capacity: int = DEFAULT_THROTTLE_CAPACITY throttle_rate: float = DEFAULT_THROTTLE_RATE output: str | None = None resume: str | None = None @classmethod def resolve( cls, config: Config, dry_run: bool = False, limit: int | None = None, skip_if_missing: bool | None = None, throttle_capacity: int | None = None, throttle_rate: float | None = None, output: str | None = None, resume: str | None = None, ) -> 'RunOptions': """Merge CLI overrides with config defaults. Priority: caller arg > config.json > built-in default. Uses ``is not None`` checks (not truthiness) so zero values are respected. """ _skip_flag = ( skip_if_missing if skip_if_missing is not None else ( config.skip_if_missing if config.skip_if_missing is not None else False ) ) _capacity = ( throttle_capacity if throttle_capacity is not None else ( config.throttle_capacity if config.throttle_capacity is not None else DEFAULT_THROTTLE_CAPACITY ) ) _rate = ( throttle_rate if throttle_rate is not None else ( config.throttle_rate if config.throttle_rate is not None else DEFAULT_THROTTLE_RATE ) ) return cls( dry_run=dry_run, limit=limit, policy=( MissingFieldPolicy.SKIP if _skip_flag else MissingFieldPolicy.DEFAULT ), throttle_capacity=_capacity, throttle_rate=_rate, output=output, resume=resume, ) def load_config(config_file: str) -> Config: """Load and validate configuration from JSON file. Returns a validated Config instance. Raises ConfigError on file errors or validation failures. """ try: with open(config_file, 'r') as f: data = json.load(f) except FileNotFoundError: raise ConfigError(f'Config file not found: {config_file}') except json.JSONDecodeError as e: raise ConfigError(f'Invalid JSON in config file: {e}') try: return Config(**data) except ValidationError as e: raise ConfigError(f'Invalid configuration in {config_file}:\n{e}')