Source code for memeplotlib._config

"""Global configuration for memeplotlib (RcParams-style mapping).

The :data:`config` singleton is a :class:`MemeplotlibConfig` mapping with
validated keys. Modify it to change defaults for all subsequent meme calls,
or use :func:`rc_context` for scoped overrides.

Examples
--------
>>> import memeplotlib as memes
>>> memes.config["font"] = "comic"  # doctest: +SKIP
>>> with memes.rc_context({"color": "yellow"}):  # doctest: +SKIP
...     memes.meme("buzz", "hello", "world")
"""

from __future__ import annotations

import contextlib
from collections.abc import Callable, Iterator, Mapping, MutableMapping
from typing import Any

DEFAULT_API_BASE = "https://api.memegen.link"
DEFAULT_FONT = "impact"
DEFAULT_COLOR = "white"
DEFAULT_OUTLINE_COLOR = "black"
DEFAULT_OUTLINE_WIDTH = 2.0
DEFAULT_FONTSIZE = 72.0
DEFAULT_DPI = 150
DEFAULT_STYLE = "upper"
DEFAULT_FIGSIZE_WIDTH = 8.0
DEFAULT_API_TIMEOUT = 10
DEFAULT_IMAGE_TIMEOUT = 15
DEFAULT_MAX_RETRIES = 2
DEFAULT_RETRY_BACKOFF = 0.5
DEFAULT_BACKEND = "auto"
DEFAULT_EXTENSION = "png"

IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"}

_VALID_STYLES = {"upper", "lower", "none"}
_VALID_BACKENDS = {"auto", "memegen", "pillow", "matplotlib"}
_VALID_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}


def _check_str(name: str) -> Callable[[Any], str]:
    def validator(v: Any) -> str:
        if not isinstance(v, str):
            raise ValueError(f"{name!r} must be a string, got {type(v).__name__}")
        return v

    return validator


def _check_optional_str(name: str) -> Callable[[Any], str | None]:
    def validator(v: Any) -> str | None:
        if v is None:
            return None
        if not isinstance(v, str):
            raise ValueError(f"{name!r} must be a string or None, got {type(v).__name__}")
        return v

    return validator


def _check_non_negative_float(name: str) -> Callable[[Any], float]:
    def validator(v: Any) -> float:
        if isinstance(v, bool) or not isinstance(v, (int, float)):
            raise ValueError(f"{name!r} must be a number, got {type(v).__name__}")
        if v < 0:
            raise ValueError(f"{name!r} must be non-negative, got {v}")
        return float(v)

    return validator


def _check_non_negative_int(name: str) -> Callable[[Any], int]:
    def validator(v: Any) -> int:
        if isinstance(v, bool) or not isinstance(v, int):
            raise ValueError(f"{name!r} must be an int, got {type(v).__name__}")
        if v < 0:
            raise ValueError(f"{name!r} must be non-negative, got {v}")
        return int(v)

    return validator


def _check_optional_non_negative_int(name: str) -> Callable[[Any], int | None]:
    def validator(v: Any) -> int | None:
        if v is None:
            return None
        if isinstance(v, bool) or not isinstance(v, int):
            raise ValueError(f"{name!r} must be an int or None, got {type(v).__name__}")
        if v < 0:
            raise ValueError(f"{name!r} must be non-negative, got {v}")
        return int(v)

    return validator


def _check_choice(name: str, choices: set[str]) -> Callable[[Any], str]:
    def validator(v: Any) -> str:
        if v not in choices:
            raise ValueError(f"{name!r} must be one of {sorted(choices)}, got {v!r}")
        return str(v)

    return validator


def _check_style(name: str) -> Callable[[Any], str]:
    def validator(v: Any) -> str:
        if v not in _VALID_STYLES:
            raise ValueError(f"{name!r} must be one of {sorted(_VALID_STYLES)}, got {v!r}")
        return str(v)

    return validator


def _check_bool(name: str) -> Callable[[Any], bool]:
    def validator(v: Any) -> bool:
        if not isinstance(v, bool):
            raise ValueError(f"{name!r} must be a bool, got {type(v).__name__}")
        return v

    return validator


_VALIDATORS: dict[str, Callable[[Any], Any]] = {
    "api_base": _check_str("api_base"),
    "font": _check_str("font"),
    "color": _check_str("color"),
    "outline_color": _check_str("outline_color"),
    "outline_width": _check_non_negative_float("outline_width"),
    "fontsize": _check_non_negative_float("fontsize"),
    "dpi": _check_non_negative_int("dpi"),
    "style": _check_style("style"),
    "cache_enabled": _check_bool("cache_enabled"),
    "cache_dir": _check_optional_str("cache_dir"),
    "api_timeout": _check_non_negative_int("api_timeout"),
    "image_timeout": _check_non_negative_int("image_timeout"),
    "max_retries": _check_non_negative_int("max_retries"),
    "retry_backoff": _check_non_negative_float("retry_backoff"),
    "backend": _check_choice("backend", _VALID_BACKENDS),
    "extension": _check_choice("extension", _VALID_EXTENSIONS),
    "width": _check_optional_non_negative_int("width"),
    "height": _check_optional_non_negative_int("height"),
    "layout": _check_optional_str("layout"),
    "background": _check_optional_str("background"),
}


_DEFAULTS: dict[str, Any] = {
    "api_base": DEFAULT_API_BASE,
    "font": DEFAULT_FONT,
    "color": DEFAULT_COLOR,
    "outline_color": DEFAULT_OUTLINE_COLOR,
    "outline_width": DEFAULT_OUTLINE_WIDTH,
    "fontsize": DEFAULT_FONTSIZE,
    "dpi": DEFAULT_DPI,
    "style": DEFAULT_STYLE,
    "cache_enabled": True,
    "cache_dir": None,
    "api_timeout": DEFAULT_API_TIMEOUT,
    "image_timeout": DEFAULT_IMAGE_TIMEOUT,
    "max_retries": DEFAULT_MAX_RETRIES,
    "retry_backoff": DEFAULT_RETRY_BACKOFF,
    "backend": DEFAULT_BACKEND,
    "extension": DEFAULT_EXTENSION,
    "width": None,
    "height": None,
    "layout": None,
    "background": None,
}


[docs] class MemeplotlibConfig(MutableMapping[str, Any]): """RcParams-style validated mapping of memeplotlib defaults. Backed by an internal dictionary. Setting an unknown key raises ``KeyError``. Setting a known key with an invalid value raises ``ValueError``. Use :func:`rc_context` for scoped overrides. Notes ----- Only the keys defined in :data:`MemeplotlibConfig.VALID_KEYS` are accepted. The set of keys is fixed at class definition time. Examples -------- >>> from memeplotlib import config >>> config["font"] = "comic" # doctest: +SKIP >>> config["dpi"] 150 """ VALID_KEYS: frozenset[str] = frozenset(_VALIDATORS) def __init__(self) -> None: self._data: dict[str, Any] = dict(_DEFAULTS) def __getitem__(self, key: str) -> Any: try: return self._data[key] except KeyError as exc: raise KeyError( f"Unknown config key {key!r}. Valid keys: {sorted(self.VALID_KEYS)}" ) from exc def __setitem__(self, key: str, value: Any) -> None: if key not in _VALIDATORS: raise KeyError(f"Unknown config key {key!r}. Valid keys: {sorted(self.VALID_KEYS)}") self._data[key] = _VALIDATORS[key](value) def __delitem__(self, key: str) -> None: if key not in _DEFAULTS: raise KeyError(f"Unknown config key {key!r}. Valid keys: {sorted(self.VALID_KEYS)}") self._data[key] = _DEFAULTS[key] def __iter__(self) -> Iterator[str]: return iter(self._data) def __len__(self) -> int: return len(self._data) def __repr__(self) -> str: body = ",\n ".join(f"{k!r}: {v!r}" for k, v in sorted(self._data.items())) return f"MemeplotlibConfig({{\n {body}\n}})"
[docs] def reset(self) -> None: """Restore every key to its default value.""" self._data = dict(_DEFAULTS)
config = MemeplotlibConfig()
[docs] @contextlib.contextmanager def rc_context(rc: Mapping[str, Any] | None = None) -> Iterator[MemeplotlibConfig]: """Temporarily override config keys, restoring originals on exit. Mirrors :func:`matplotlib.rc_context`. Useful for scoped styling that should not leak to other code. Parameters ---------- rc : Mapping or None, optional Mapping of key-value pairs to apply within the context. If ``None``, the active config is yielded unchanged but still snapshotted for restoration on exit. Yields ------ MemeplotlibConfig The active config singleton. Examples -------- >>> from memeplotlib import config, rc_context >>> config["font"] = "impact" >>> with rc_context({"font": "comic", "color": "yellow"}): ... config["font"] 'comic' >>> config["font"] 'impact' """ snapshot = dict(config._data) try: if rc is not None: for key, value in rc.items(): config[key] = value yield config finally: config._data = snapshot