"""Configuration handling for sphinx-typesense.
This module manages all Typesense-related configuration values in Sphinx's
conf.py, including validation, defaults, and environment variable support.
Configuration Values:
Required:
- typesense_host: Typesense server hostname
- typesense_port: Typesense server port
- typesense_protocol: Connection protocol (http/https)
- typesense_api_key: Admin API key for indexing
- typesense_search_api_key: Search-only API key for frontend
Optional:
- typesense_collection_name: Name of the Typesense collection
- typesense_doc_version: Documentation version tag
- typesense_placeholder: Search input placeholder text
- typesense_num_typos: Typo tolerance level (0-2)
- typesense_per_page: Results per page
- typesense_container: CSS selector for search container
- typesense_filter_by: Default search filter
- typesense_content_selectors: Theme content selectors
- typesense_enable_indexing: Enable/disable indexing
- typesense_drop_existing: Drop collection before reindex
Example:
In conf.py::
import os
typesense_host = "localhost"
typesense_port = "8108"
typesense_protocol = "http"
typesense_api_key = os.environ.get("TYPESENSE_API_KEY", "")
typesense_search_api_key = os.environ.get("TYPESENSE_SEARCH_KEY", "")
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from sphinx.errors import ConfigError
from sphinx.util import logging
if TYPE_CHECKING:
from sphinx.application import Sphinx
from sphinx.config import Config
logger = logging.getLogger(__name__)
# Default configuration values
DEFAULT_HOST = "localhost"
DEFAULT_PORT = "8108"
DEFAULT_PROTOCOL = "http"
DEFAULT_COLLECTION_NAME = "sphinx_docs"
DEFAULT_PLACEHOLDER = "Search documentation..."
DEFAULT_NUM_TYPOS = 2
DEFAULT_PER_PAGE = 10
DEFAULT_CONTAINER = "#typesense-search"
# Typo tolerance bounds
MIN_NUM_TYPOS = 0
MAX_NUM_TYPOS = 2
# Theme-specific content selectors (in priority order)
DEFAULT_CONTENT_SELECTORS = [
".wy-nav-content-wrap", # RTD theme
"article.bd-article", # PyData theme
".body", # Alabaster
"article[role=main]", # Furo
"main", # Generic fallback
]
# Environment variable names for API keys
ENV_API_KEY = "TYPESENSE_API_KEY"
ENV_SEARCH_API_KEY = "TYPESENSE_SEARCH_API_KEY"
# Valid protocols
VALID_PROTOCOLS = {"http", "https"}
# Valid backends for search
VALID_BACKENDS = {"auto", "typesense", "pagefind"}
[docs]
def get_effective_backend(config: Config) -> str:
"""Determine which backend to use based on configuration.
This function resolves the 'auto' backend setting to either 'typesense'
or 'pagefind' based on the availability of API keys.
Args:
config: The Sphinx configuration object.
Returns:
'typesense' or 'pagefind' - the effective backend to use.
Note:
When backend is 'auto':
- Returns 'typesense' if an API key is present (config or environment)
- Returns 'pagefind' if no API key is available
"""
backend: str = config.typesense_backend
if backend == "auto":
# Use typesense if API key is present, otherwise pagefind
if config.typesense_api_key or os.environ.get(ENV_API_KEY):
return "typesense"
return "pagefind"
return backend
[docs]
def setup_config(app: Sphinx) -> None:
"""Register Typesense configuration values with Sphinx.
Args:
app: The Sphinx application instance.
Note:
Configuration values are registered with 'html' as their rebuild
trigger, meaning changes will cause HTML rebuild.
"""
logger.debug("sphinx-typesense: Registering configuration values with Sphinx")
# Backend selection
app.add_config_value("typesense_backend", "auto", "html")
# Required settings
app.add_config_value("typesense_host", DEFAULT_HOST, "html")
app.add_config_value("typesense_port", DEFAULT_PORT, "html")
app.add_config_value("typesense_protocol", DEFAULT_PROTOCOL, "html")
app.add_config_value("typesense_api_key", "", "html")
app.add_config_value("typesense_search_api_key", "", "html")
# Optional settings - Collection
app.add_config_value("typesense_collection_name", DEFAULT_COLLECTION_NAME, "html")
app.add_config_value("typesense_doc_version", "", "html")
# Optional settings - Search UI
app.add_config_value("typesense_placeholder", DEFAULT_PLACEHOLDER, "html")
app.add_config_value("typesense_num_typos", DEFAULT_NUM_TYPOS, "html")
app.add_config_value("typesense_per_page", DEFAULT_PER_PAGE, "html")
app.add_config_value("typesense_container", DEFAULT_CONTAINER, "html")
app.add_config_value("typesense_filter_by", "", "html")
# Optional settings - Content extraction
app.add_config_value("typesense_content_selectors", DEFAULT_CONTENT_SELECTORS, "html")
# Optional settings - Advanced
app.add_config_value("typesense_enable_indexing", default=True, rebuild="html")
app.add_config_value("typesense_drop_existing", default=False, rebuild="html")
logger.debug("sphinx-typesense: Configuration values registered successfully")
[docs]
def validate_config(app: Sphinx, config: Config) -> None: # noqa: ARG001
"""Validate Typesense configuration at build time.
This function performs comprehensive validation of all Typesense configuration
values, with support for environment variable fallback for API keys.
Args:
app: The Sphinx application instance (unused but required by Sphinx event signature).
config: The Sphinx configuration object.
Raises:
sphinx.errors.ConfigError: If required configuration is missing or invalid.
Note:
- API keys can be provided via environment variables (TYPESENSE_API_KEY,
TYPESENSE_SEARCH_API_KEY) as fallback when not set in conf.py.
- A warning is issued if admin and search API keys are identical,
as this may indicate a security concern.
- When typesense_enable_indexing is False, API keys are not required.
- When typesense_backend is 'pagefind', Typesense API keys are not required.
- When typesense_backend is 'auto' and no API keys are present, Pagefind
will be used and API key validation is skipped.
"""
logger.debug("sphinx-typesense: Validating configuration")
# Validate backend setting first
_validate_backend(config)
# Resolve API keys with environment variable fallback
api_key = config.typesense_api_key or os.environ.get(ENV_API_KEY, "")
search_api_key = config.typesense_search_api_key or os.environ.get(ENV_SEARCH_API_KEY, "")
# Log API key sources (without exposing actual keys)
if config.typesense_api_key:
logger.debug("sphinx-typesense: Admin API key provided via conf.py")
elif os.environ.get(ENV_API_KEY):
logger.debug("sphinx-typesense: Admin API key resolved from %s environment variable", ENV_API_KEY)
else:
logger.debug("sphinx-typesense: No admin API key configured")
if config.typesense_search_api_key:
logger.debug("sphinx-typesense: Search API key provided via conf.py")
elif os.environ.get(ENV_SEARCH_API_KEY):
logger.debug("sphinx-typesense: Search API key resolved from %s environment variable", ENV_SEARCH_API_KEY)
else:
logger.debug("sphinx-typesense: No search API key configured")
# Update config with resolved values for downstream use
config.typesense_api_key = api_key
config.typesense_search_api_key = search_api_key
# Determine effective backend and log info
effective_backend = get_effective_backend(config)
if config.typesense_backend == "auto":
logger.info(
"sphinx-typesense: Backend 'auto' resolved to '%s' (API key %s)",
effective_backend,
"present" if api_key else "not present",
)
else:
logger.debug("sphinx-typesense: Using '%s' backend", effective_backend)
# Log effective configuration values (excluding sensitive data)
logger.debug(
"sphinx-typesense: Configuration values - host=%s, port=%s, protocol=%s, collection=%s",
config.typesense_host,
config.typesense_port,
config.typesense_protocol,
config.typesense_collection_name,
)
# Skip strict validation if indexing is disabled
if not config.typesense_enable_indexing:
logger.info("sphinx-typesense: Indexing disabled, skipping API key validation")
return
# Skip Typesense API key validation if using Pagefind backend
if effective_backend == "pagefind":
logger.info("sphinx-typesense: Using Pagefind backend, skipping Typesense API key validation")
# Still validate protocol and numeric settings
_validate_protocol(config)
_validate_numeric_settings(config)
logger.debug("sphinx-typesense: Configuration validation complete")
return
# Validate required settings (only when using Typesense backend)
_validate_required_settings(config, api_key, search_api_key)
# Validate protocol
_validate_protocol(config)
# Warn if admin and search keys are the same
_check_key_security(api_key, search_api_key)
# Validate numeric settings
_validate_numeric_settings(config)
logger.debug("sphinx-typesense: Configuration validation complete")
def _validate_backend(config: Config) -> None:
"""Validate the backend setting.
Args:
config: The Sphinx configuration object.
Raises:
ConfigError: If backend is not one of 'auto', 'typesense', or 'pagefind'.
"""
backend = config.typesense_backend
logger.debug("sphinx-typesense: Validating backend: %s", backend)
if backend not in VALID_BACKENDS:
logger.error(
"sphinx-typesense: Invalid backend '%s', must be one of: %s",
backend,
", ".join(sorted(VALID_BACKENDS)),
)
msg = (
f"sphinx-typesense: Invalid typesense_backend '{backend}'. "
f"Must be one of: {', '.join(sorted(VALID_BACKENDS))}"
)
raise ConfigError(msg)
def _validate_required_settings(config: Config, api_key: str, search_api_key: str) -> None:
"""Validate that all required settings are present.
Args:
config: The Sphinx configuration object.
api_key: Resolved admin API key.
search_api_key: Resolved search API key.
Raises:
ConfigError: If any required setting is missing.
"""
logger.debug("sphinx-typesense: Validating required settings")
missing = []
if not config.typesense_host:
missing.append("typesense_host")
if not config.typesense_port:
missing.append("typesense_port")
if not config.typesense_protocol:
missing.append("typesense_protocol")
if not api_key:
missing.append(f"typesense_api_key (or {ENV_API_KEY} environment variable)")
if not search_api_key:
missing.append(f"typesense_search_api_key (or {ENV_SEARCH_API_KEY} environment variable)")
if missing:
logger.error("sphinx-typesense: Missing required configuration: %s", ", ".join(missing))
msg = f"sphinx-typesense: Missing required configuration: {', '.join(missing)}"
raise ConfigError(msg)
logger.debug("sphinx-typesense: All required settings present")
def _validate_protocol(config: Config) -> None:
"""Validate the protocol setting.
Args:
config: The Sphinx configuration object.
Raises:
ConfigError: If protocol is not 'http' or 'https'.
"""
protocol = config.typesense_protocol
logger.debug("sphinx-typesense: Validating protocol: %s", protocol)
if protocol not in VALID_PROTOCOLS:
logger.error(
"sphinx-typesense: Invalid protocol '%s', must be one of: %s",
protocol,
", ".join(sorted(VALID_PROTOCOLS)),
)
msg = (
f"sphinx-typesense: Invalid typesense_protocol '{protocol}'. "
f"Must be one of: {', '.join(sorted(VALID_PROTOCOLS))}"
)
raise ConfigError(msg)
def _check_key_security(api_key: str, search_api_key: str) -> None:
"""Check for potential security issues with API keys.
Args:
api_key: Admin API key.
search_api_key: Search API key.
Note:
This issues a warning but does not raise an error, as there may
be legitimate use cases for identical keys in development.
"""
if api_key and search_api_key and api_key == search_api_key:
logger.warning(
"sphinx-typesense: Admin API key and search API key are identical. "
"For production, use a separate search-only key with limited permissions."
)
def _validate_numeric_settings(config: Config) -> None:
"""Validate numeric configuration settings.
Args:
config: The Sphinx configuration object.
Raises:
ConfigError: If numeric settings have invalid values.
"""
logger.debug("sphinx-typesense: Validating numeric settings")
# Validate num_typos (must be 0-2)
num_typos = config.typesense_num_typos
logger.debug("sphinx-typesense: Validating num_typos=%s", num_typos)
if not isinstance(num_typos, int) or num_typos < MIN_NUM_TYPOS or num_typos > MAX_NUM_TYPOS:
logger.error(
"sphinx-typesense: Invalid num_typos '%s', must be integer between %d and %d",
num_typos,
MIN_NUM_TYPOS,
MAX_NUM_TYPOS,
)
msg = (
f"sphinx-typesense: Invalid typesense_num_typos '{num_typos}'. "
f"Must be an integer between {MIN_NUM_TYPOS} and {MAX_NUM_TYPOS}."
)
raise ConfigError(msg)
# Validate per_page (must be positive)
per_page = config.typesense_per_page
logger.debug("sphinx-typesense: Validating per_page=%s", per_page)
if not isinstance(per_page, int) or per_page < 1:
logger.error("sphinx-typesense: Invalid per_page '%s', must be a positive integer", per_page)
msg = f"sphinx-typesense: Invalid typesense_per_page '{per_page}'. Must be a positive integer."
raise ConfigError(msg)
logger.debug("sphinx-typesense: Numeric settings validation complete")