Skip to content

Config

Config resolves settings from ~/.opencite/config.toml, .env files, and environment variables, merging them with environment variables taking precedence. Build one with Config.from_env() and pass it to an orchestrator.

from opencite import Config

config = Config.from_env()

See the Configuration guide for the precedence rules and the keys OpenCite reads.

Config dataclass

Configuration for opencite.

Source code in src/opencite/config.py
@dataclass
class Config:
    """Configuration for opencite."""

    # API keys
    semantic_scholar_api_key: str = ""
    pubmed_api_key: str = ""
    openalex_api_key: str = ""
    mistral_api_key: str = ""

    # Additional source keys
    core_api_key: str = ""

    # Publisher tokens
    elsevier_api_key: str = ""
    wiley_tdm_token: str = ""
    springer_api_key: str = ""

    # Preprint server tokens (optional; raise rate limits where supported)
    zenodo_access_token: str = ""

    # Rate limits (requests per second)
    openalex_rate_limit: float = 100.0
    s2_rate_limit: float = 1.0
    pubmed_rate_limit: float = 10.0
    arxiv_rate_limit: float = 3.0
    biorxiv_rate_limit: float = 10.0
    crossref_rate_limit: float = 50.0
    core_rate_limit: float = 0.5
    unpaywall_rate_limit: float = 10.0
    osf_rate_limit: float = 5.0
    zenodo_rate_limit: float = 2.0
    figshare_rate_limit: float = 5.0

    # Request settings
    timeout: float = 30.0
    max_retries: int = 3

    # Output defaults
    default_max_results: int = 20
    default_format: str = "text"
    default_converter: str = "auto"

    # Contact email for APIs that request it
    contact_email: str = ""

    # Logging
    log_level: str = "WARNING"

    # Sources the orchestrator/explorer should skip entirely. Accepts the
    # canonical source keys used by SearchOrchestrator.ALL_SOURCES
    # ("openalex", "s2", "pubmed", "arxiv", ...). Set via Python, TOML
    # ([settings] disabled_sources = "s2,figshare"), or the
    # OPENCITE_DISABLED_SOURCES env var (comma-separated).
    disabled_sources: list[str] = field(default_factory=list)

    @classmethod
    def from_env(cls, config_path: Path | None = None) -> Config:
        """Create config from TOML, .env files, and environment variables.

        Priority (later overrides earlier):
        1. Config dataclass defaults
        2. ~/.opencite/config.toml
        3. .env files (~/.opencite/.env, then CWD .env)
        4. Environment variables
        """
        kwargs: dict[str, Any] = {}

        # Layer 1: TOML config
        toml_values = _load_toml(config_path)
        kwargs.update(toml_values)

        # Layer 2: .env files (merged)
        dotenv_values = _load_all_dotenv()

        # Layer 3: Environment variables (highest priority)
        # Map env var names to field names and apply
        for (_section, _key), (field_name, env_var) in _TOML_MAP.items():
            # Check env var first (highest priority), then .env, then keep TOML value
            env_val = os.environ.get(env_var)
            if env_val is not None:
                kwargs[field_name] = env_val
            elif env_var in dotenv_values:
                kwargs[field_name] = dotenv_values[env_var]

        # Also set env vars from .env for other tools that read os.environ
        for key, value in dotenv_values.items():
            os.environ.setdefault(key, value)

        # Type-coerce values to match field types
        field_types = {f.name: f.type for f in fields(cls)}
        coerced: dict[str, Any] = {}
        for key, val in kwargs.items():
            expected_type = field_types.get(key, "str")
            if expected_type == "float" and not isinstance(val, float):
                coerced[key] = float(val)
            elif expected_type == "int" and not isinstance(val, int):
                coerced[key] = int(val)
            elif expected_type == "list[str]" and isinstance(val, str):
                coerced[key] = _split_csv(val)
            else:
                coerced[key] = val

        config = cls(**coerced)

        # Normalize log_level
        config.log_level = config.log_level.upper()

        return config

    def validate(self) -> list[str]:
        """Validate configuration. Returns list of warnings."""
        warnings = []
        if not self.openalex_api_key:
            warnings.append("OPENALEX_API_KEY not set. OpenAlex requires an API key.")
        if not self.semantic_scholar_api_key:
            warnings.append(
                "SEMANTIC_SCHOLAR_API_KEY not set. "
                "Semantic Scholar will use shared rate limit (unreliable)."
            )
        if not self.pubmed_api_key:
            warnings.append(
                "PUBMED_API_KEY not set. PubMed rate limit: 3 req/sec instead of 10."
            )
        return warnings

    def setup_logging(self) -> None:
        """Configure logging based on log_level."""
        logging.basicConfig(
            level=getattr(logging, self.log_level, logging.WARNING),
            format="%(levelname)s: %(name)s: %(message)s",
        )
        # Reduce noise from libraries
        logging.getLogger("httpx").setLevel(logging.WARNING)
        logging.getLogger("httpcore").setLevel(logging.WARNING)
        logging.getLogger("pyalex").setLevel(logging.WARNING)

    def show(self, mask_keys: bool = True) -> str:
        """Return a human-readable string of the resolved config.

        API keys are masked by default.
        """
        lines = []
        for f in fields(self):
            val = getattr(self, f.name)
            if mask_keys and "key" in f.name.lower() or "token" in f.name.lower():
                if val:
                    val = val[:4] + "..." + val[-4:] if len(val) > 8 else "****"
                else:
                    val = "(not set)"
            lines.append(f"  {f.name}: {val}")
        return "\n".join(lines)

from_env classmethod

from_env(config_path: Path | None = None) -> Config

Create config from TOML, .env files, and environment variables.

Priority (later overrides earlier): 1. Config dataclass defaults 2. ~/.opencite/config.toml 3. .env files (~/.opencite/.env, then CWD .env) 4. Environment variables

Source code in src/opencite/config.py
@classmethod
def from_env(cls, config_path: Path | None = None) -> Config:
    """Create config from TOML, .env files, and environment variables.

    Priority (later overrides earlier):
    1. Config dataclass defaults
    2. ~/.opencite/config.toml
    3. .env files (~/.opencite/.env, then CWD .env)
    4. Environment variables
    """
    kwargs: dict[str, Any] = {}

    # Layer 1: TOML config
    toml_values = _load_toml(config_path)
    kwargs.update(toml_values)

    # Layer 2: .env files (merged)
    dotenv_values = _load_all_dotenv()

    # Layer 3: Environment variables (highest priority)
    # Map env var names to field names and apply
    for (_section, _key), (field_name, env_var) in _TOML_MAP.items():
        # Check env var first (highest priority), then .env, then keep TOML value
        env_val = os.environ.get(env_var)
        if env_val is not None:
            kwargs[field_name] = env_val
        elif env_var in dotenv_values:
            kwargs[field_name] = dotenv_values[env_var]

    # Also set env vars from .env for other tools that read os.environ
    for key, value in dotenv_values.items():
        os.environ.setdefault(key, value)

    # Type-coerce values to match field types
    field_types = {f.name: f.type for f in fields(cls)}
    coerced: dict[str, Any] = {}
    for key, val in kwargs.items():
        expected_type = field_types.get(key, "str")
        if expected_type == "float" and not isinstance(val, float):
            coerced[key] = float(val)
        elif expected_type == "int" and not isinstance(val, int):
            coerced[key] = int(val)
        elif expected_type == "list[str]" and isinstance(val, str):
            coerced[key] = _split_csv(val)
        else:
            coerced[key] = val

    config = cls(**coerced)

    # Normalize log_level
    config.log_level = config.log_level.upper()

    return config

validate

validate() -> list[str]

Validate configuration. Returns list of warnings.

Source code in src/opencite/config.py
def validate(self) -> list[str]:
    """Validate configuration. Returns list of warnings."""
    warnings = []
    if not self.openalex_api_key:
        warnings.append("OPENALEX_API_KEY not set. OpenAlex requires an API key.")
    if not self.semantic_scholar_api_key:
        warnings.append(
            "SEMANTIC_SCHOLAR_API_KEY not set. "
            "Semantic Scholar will use shared rate limit (unreliable)."
        )
    if not self.pubmed_api_key:
        warnings.append(
            "PUBMED_API_KEY not set. PubMed rate limit: 3 req/sec instead of 10."
        )
    return warnings

setup_logging

setup_logging() -> None

Configure logging based on log_level.

Source code in src/opencite/config.py
def setup_logging(self) -> None:
    """Configure logging based on log_level."""
    logging.basicConfig(
        level=getattr(logging, self.log_level, logging.WARNING),
        format="%(levelname)s: %(name)s: %(message)s",
    )
    # Reduce noise from libraries
    logging.getLogger("httpx").setLevel(logging.WARNING)
    logging.getLogger("httpcore").setLevel(logging.WARNING)
    logging.getLogger("pyalex").setLevel(logging.WARNING)

show

show(mask_keys: bool = True) -> str

Return a human-readable string of the resolved config.

API keys are masked by default.

Source code in src/opencite/config.py
def show(self, mask_keys: bool = True) -> str:
    """Return a human-readable string of the resolved config.

    API keys are masked by default.
    """
    lines = []
    for f in fields(self):
        val = getattr(self, f.name)
        if mask_keys and "key" in f.name.lower() or "token" in f.name.lower():
            if val:
                val = val[:4] + "..." + val[-4:] if len(val) > 8 else "****"
            else:
                val = "(not set)"
        lines.append(f"  {f.name}: {val}")
    return "\n".join(lines)