comicbox.formats.base.online.credentials

[docs] module comicbox.formats.base.online.credentials

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
"""
Credential resolution chain for online sources.

Resolution order, per (source, field):

1. CLI override via ``--auth <source>:<field>=<value>``.
2. Environment variable (``COMICBOX_<SOURCE>_<FIELD>``).
3. Config file value (loaded by confuse into the
   ``online.auth.<source>.*`` block).
4. Keyring lookup (passwords only, optional dependency, never written by
   comicbox).

Outputs an `OnlineSourceCredentials` instance per source. A source is
"configured" iff its required fields resolve to non-null values; that
check lives on the source class.
"""

from __future__ import annotations

import os
from typing import TYPE_CHECKING, Any

from loguru import logger

from comicbox.config.settings import OnlineSourceCredentials
from comicbox.formats.base.online import SOURCE_NAMES
from comicbox.formats.base.online.env import read_credential_env

if TYPE_CHECKING:
    from collections.abc import Mapping

    from comicbox.formats.base.online.cli_overrides import CliOverrides

_FIELDS = ("user", "pass", "key", "url")
_PW = "pass"


def _try_keyring(source: str, username: str | None) -> str | None:
    """
    Look up a password in the system keyring.

    Returns None if `keyring` isn't installed or the entry isn't set.
    Never writes to the keyring; users manage entries out-of-band.
    """
    if not username:
        return None
    try:
        import keyring
    except ImportError:
        return None
    service = f"comicbox-{source}"
    try:
        return keyring.get_password(service, username)
    except Exception as exc:
        logger.debug(f"keyring lookup failed for {service}/{username}: {exc}")
        return None


def _coalesce(*values: str | None) -> str | None:
    """First non-null/non-empty wins."""
    for v in values:
        if v:
            return v
    return None


def resolve_credentials(
    config_creds: Mapping[str, Mapping[str, Any]],
    cli_overrides: CliOverrides | None = None,
    env: Mapping[str, str] | None = None,
    *,
    use_keyring: bool = True,
) -> dict[str, OnlineSourceCredentials]:
    """
    Build the resolved per-source credentials map.

    `config_creds` is the post-confuse view of `online.<source>.*` (file +
    confuse env-var auto-loading). `cli_overrides` is the parsed CLI
    `--api-*` flags. `env` defaults to `os.environ`.
    """
    if env is None:
        env = os.environ
    env_creds = read_credential_env(env)
    cli_creds: Mapping[str, Mapping[str, Any]] = (
        cli_overrides.per_source if cli_overrides else {}
    )

    resolved: dict[str, OnlineSourceCredentials] = {}
    for source in SOURCE_NAMES:
        config_block = dict(config_creds.get(source) or {})
        env_block = env_creds.get(source, {})
        cli_block = dict(cli_creds.get(source) or {})

        values: dict[str, str | None] = {
            field: _coalesce(
                cli_block.get(field),
                env_block.get(field),
                config_block.get(field),
            )
            for field in _FIELDS
        }

        if use_keyring and not values[_PW] and (username := values["user"]):
            values[_PW] = _try_keyring(source, username)

        # OnlineSourceCredentials uses ``password`` for the keyword name;
        # the YAML/env field name is ``pass`` to match the user-facing surface.
        resolved[source] = OnlineSourceCredentials(
            user=values["user"],
            password=values["pass"],
            key=values["key"],
            url=values["url"],
        )
    return resolved