@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)