mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-09-13 18:46:17 +05:00
211 lines
7.1 KiB
Python
211 lines
7.1 KiB
Python
"""Pydantic-backed config loading for ArchiveBox."""
|
|
|
|
__package__ = "archivebox.config"
|
|
|
|
import json
|
|
from collections.abc import Mapping
|
|
from configparser import ConfigParser
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
|
|
from pydantic import BaseModel
|
|
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
|
|
|
|
COMPUTED_CONFIG_KEYS = (
|
|
"TERM_WIDTH",
|
|
"COMMIT_HASH",
|
|
"BUILD_TIME",
|
|
"USES_SUBDOMAIN_ROUTING",
|
|
"ENABLES_FULL_JS_REPLAY",
|
|
"CONTROL_PLANE_ENABLED",
|
|
"BLOCK_UNSAFE_METHODS",
|
|
"SHOULD_NEUTER_RISKY_REPLAY",
|
|
"IS_UNSAFE_MODE",
|
|
"IS_DANGEROUS_MODE",
|
|
"IS_LOWER_SECURITY_MODE",
|
|
"URL_ALLOWLIST_PTN",
|
|
"URL_DENYLIST_PTN",
|
|
)
|
|
|
|
|
|
class CaseConfigParser(ConfigParser):
|
|
def optionxform(self, optionstr: str) -> str:
|
|
return optionstr
|
|
|
|
|
|
def read_ini_config(config_path: str | Path) -> dict[str, Any]:
|
|
"""Read and flatten an ArchiveBox INI config file."""
|
|
config_path = Path(config_path)
|
|
try:
|
|
if not config_path.exists():
|
|
return {}
|
|
parser = CaseConfigParser(interpolation=None)
|
|
parser.read(config_path)
|
|
except OSError:
|
|
return {}
|
|
return {key.upper(): value for section in parser.sections() for key, value in parser.items(section)}
|
|
|
|
|
|
class IniConfigSettingsSource(PydanticBaseSettingsSource):
|
|
"""
|
|
Custom settings source that reads from ArchiveBox.conf (INI format).
|
|
Flattens all sections into a single namespace.
|
|
"""
|
|
|
|
def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
|
|
config_vals = self._load_config_file()
|
|
field_value = config_vals.get(field_name.upper())
|
|
# Mark complex-typed fields (``dict``/``list``) so the parent
|
|
# ``prepare_field_value`` JSON-decodes the INI string before pydantic
|
|
# validates against the dict/list annotation. Without this, e.g.
|
|
# ``ABX_INSTALL_CACHE`` arrives as a raw JSON string and pydantic
|
|
# rejects it with ``Input should be a valid dictionary``.
|
|
value_is_complex = bool(field_value is not None and self.field_is_complex(field))
|
|
return field_value, field_name, value_is_complex
|
|
|
|
def __call__(self) -> dict[str, Any]:
|
|
return decode_config_inputs(self.settings_cls, self._load_config_file())
|
|
|
|
def _load_config_file(self) -> dict[str, Any]:
|
|
try:
|
|
from archivebox.config.constants import CONSTANTS
|
|
|
|
config_path = CONSTANTS.CONFIG_FILE
|
|
except ImportError:
|
|
return {}
|
|
|
|
return read_ini_config(config_path)
|
|
|
|
|
|
def decode_config_inputs(
|
|
settings_cls: type[BaseSettings],
|
|
config: Mapping[str, Any],
|
|
*,
|
|
decode_unknown_json: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Decode string-only config values once at the boundary to typed config."""
|
|
decoder = IniConfigSettingsSource(settings_cls)
|
|
decoded: dict[str, Any] = dict(config)
|
|
for source_key, raw in list(decoded.items()):
|
|
if not isinstance(raw, str) or not raw:
|
|
continue
|
|
field_name = source_key if source_key in settings_cls.model_fields else source_key.upper()
|
|
field = settings_cls.model_fields.get(field_name)
|
|
if field is None:
|
|
if decode_unknown_json and raw[:1] in ("{", "["):
|
|
try:
|
|
decoded[source_key] = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
continue
|
|
if decoder.field_is_complex(field):
|
|
decoded[field_name] = decoder.prepare_field_value(field_name, field, raw, True)
|
|
if source_key != field_name:
|
|
decoded.pop(source_key, None)
|
|
return decoded
|
|
|
|
|
|
class BaseConfigSet(BaseModel):
|
|
"""
|
|
Pure typed runtime model for config sections.
|
|
|
|
Source-loading subclasses combine this model with ``BaseSettings`` and load:
|
|
1. Environment variables
|
|
2. ArchiveBox.conf file (INI format, flattened)
|
|
3. Default values
|
|
|
|
Subclasses define fields with defaults and types:
|
|
|
|
class ShellConfig(BaseConfigSet):
|
|
DEBUG: bool = Field(default=False)
|
|
USE_COLOR: bool = Field(default=True)
|
|
"""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="",
|
|
extra="ignore",
|
|
validate_default=True,
|
|
populate_by_name=True,
|
|
)
|
|
computed_config_keys: ClassVar[tuple[str, ...]] = ()
|
|
|
|
@classmethod
|
|
def settings_customise_sources(
|
|
cls,
|
|
settings_cls: type[BaseSettings],
|
|
init_settings: PydanticBaseSettingsSource,
|
|
env_settings: PydanticBaseSettingsSource,
|
|
dotenv_settings: PydanticBaseSettingsSource,
|
|
file_secret_settings: PydanticBaseSettingsSource,
|
|
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
"""
|
|
Define the order of settings sources (first = highest priority).
|
|
"""
|
|
return (
|
|
init_settings, # 1. Passed to __init__
|
|
env_settings, # 2. Environment variables
|
|
IniConfigSettingsSource(settings_cls), # 3. ArchiveBox.conf file
|
|
# dotenv_settings, # Skip .env files
|
|
# file_secret_settings, # Skip secrets files
|
|
)
|
|
|
|
@classmethod
|
|
def load_from_file(cls, config_path: Path) -> dict[str, str]:
|
|
"""Load config values from INI file."""
|
|
return read_ini_config(config_path)
|
|
|
|
def __getitem__(self, key: str) -> Any:
|
|
if key in type(self).model_fields:
|
|
return getattr(self, key)
|
|
if self.__pydantic_extra__ and key in self.__pydantic_extra__:
|
|
return self.__pydantic_extra__[key]
|
|
if key in self.computed_config_keys:
|
|
return getattr(self, key)
|
|
raise KeyError(key)
|
|
|
|
def __setitem__(self, key: str, value: Any) -> None:
|
|
if key in type(self).model_fields:
|
|
object.__setattr__(self, key, value)
|
|
return
|
|
if key in self.computed_config_keys:
|
|
raise KeyError(f"{key} is computed and cannot be set")
|
|
if self.model_config.get("extra") != "allow":
|
|
raise KeyError(f"Unknown config key: {key}")
|
|
extra = self.__pydantic_extra__
|
|
if extra is None:
|
|
extra = {}
|
|
object.__setattr__(self, "__pydantic_extra__", extra)
|
|
extra[key] = value
|
|
|
|
def update(self, *args, **kwargs) -> None:
|
|
values = dict(*args, **kwargs)
|
|
for key, value in values.items():
|
|
if key in self.computed_config_keys:
|
|
continue
|
|
self[key] = value
|
|
|
|
def __contains__(self, key: str) -> bool:
|
|
return (
|
|
key in type(self).model_fields
|
|
or bool(self.__pydantic_extra__ and key in self.__pydantic_extra__)
|
|
or key in self.computed_config_keys
|
|
)
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
return self[key] if key in self else default
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
data = self.model_dump()
|
|
for key in self.computed_config_keys:
|
|
data[key] = getattr(self, key)
|
|
return data
|
|
|
|
def items(self):
|
|
return self.as_dict().items()
|
|
|
|
def keys(self):
|
|
return self.as_dict().keys()
|
|
|
|
def values(self):
|
|
return self.as_dict().values()
|