comicbox.cli

[docs] package comicbox.cli

  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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
"""Cli for comicbox."""

import sys
from argparse import Namespace
from collections.abc import Sequence
from types import MappingProxyType
from typing import Any

from rich import print as rich_print

from comicbox.box.online_lookup import OnlineLookupAbortedError
from comicbox.cli.parser import build_parser
from comicbox.exceptions import UnsupportedArchiveTypeError
from comicbox.run import Runner

_HANDLED_EXCEPTIONS = (UnsupportedArchiveTypeError, OnlineLookupAbortedError)
_QUIET_LOGLEVEL = MappingProxyType({1: "INFO", 2: "SUCCESS", 3: "WARNING", 4: "ERROR"})


def _drain_attrs(cns: Namespace, prefix: str) -> dict[str, Any]:
    """Pop and return all flat attrs on ``cns`` whose names start with ``prefix``."""
    out: dict[str, Any] = {}
    for attr in [a for a in vars(cns) if a.startswith(prefix)]:
        value = getattr(cns, attr)
        delattr(cns, attr)
        if value is None:
            continue
        out[attr.removeprefix(prefix)] = value
    return out


def _build_nested(cns: Namespace, prefix: str) -> Namespace:
    """Drain prefix-keyed flat attrs into a nested Namespace."""
    return Namespace(**_drain_attrs(cns, prefix))


def _reshape_print(cns: Namespace) -> None:
    """Fold the three print convenience flags into ``print.phases``."""
    raw_phases: str = getattr(cns, "print_phases", None) or ""
    if getattr(cns, "print_version", None):
        raw_phases += "v"
    if getattr(cns, "print_metadata", None):
        raw_phases += "p"
    validate = getattr(cns, "print_validate", None)
    for attr in ("print_phases", "print_metadata", "print_version", "print_validate"):
        if hasattr(cns, attr):
            delattr(cns, attr)
    nested = Namespace()
    if raw_phases:
        nested.phases = raw_phases
    if validate is not None:
        nested.validate = validate
    cns.print = nested


def _reshape_read(cns: Namespace) -> None:
    """``--read``/``--read-except`` → ``cns.read`` Namespace."""
    formats = getattr(cns, "read_formats", None)
    except_ = getattr(cns, "read_except", None)
    for attr in ("read_formats", "read_except"):
        if hasattr(cns, attr):
            delattr(cns, attr)
    nested = Namespace()
    if formats is not None:
        nested.formats = formats
    if except_ is not None:
        # YAML key is ``except`` (reserved word in Python — set via setattr).
        setattr(nested, "except", except_)
    cns.read = nested


def _reshape_convert(cns: Namespace) -> None:
    """Convert nested namespace + page-range expansion."""
    nested = _build_nested(cns, "convert_")
    # PageRangeAction wrote to flat extract_pages_from / extract_pages_to.
    for attr in ("extract_pages_from", "extract_pages_to", "extract_pages"):
        if (val := getattr(cns, attr, None)) is not None and attr != "extract_pages":
            setattr(nested, attr, val)
        if hasattr(cns, attr):
            delattr(cns, attr)
    cns.convert = nested


def post_process_args(cns: Namespace) -> None:
    """
    Reshape the flat argparse namespace into the nested config shape.

    The new config tree (``general / read / write / print / convert /
    compute / online``) lives under ``cns.<group>.*`` so confuse's
    ``set_args`` overlays each CLI value at the matching YAML path.

    Online runtime fields (``--online``, ``--id``, ``--match``, ...)
    stay flat at the top of ``cns`` — they're consumed by
    ``runtime_online_inputs`` / ``build_online_settings`` directly,
    not via confuse layering.
    """
    # General — fold -Q into loglevel.
    quiet = getattr(cns, "general_quiet", None)
    if hasattr(cns, "general_quiet"):
        delattr(cns, "general_quiet")
    general = _build_nested(cns, "general_")
    if quiet is not None and quiet > 0:
        general.loglevel = _QUIET_LOGLEVEL.get(quiet, "CRITICAL")
    cns.general = general

    _reshape_read(cns)

    cns.write = _build_nested(cns, "write_")
    _reshape_print(cns)
    _reshape_convert(cns)


def get_args(params: Sequence[str] | None = None) -> Namespace:
    """
    Parse CLI arguments and reshape into the nested config namespace.

    ``params`` is an argv-style sequence: ``params[0]`` is treated as the
    program name and dropped before parsing, mirroring ``sys.argv``. Pass
    ``None`` to parse ``sys.argv`` itself.
    """
    parser = build_parser()
    if params is not None:
        params = params[1:]
    cns = parser.parse_args(params)
    # --id is single-comic only; mass-tagging would mistag.
    explicit_ids = getattr(cns, "explicit_ids", None) or ()
    if explicit_ids and len(cns.paths or ()) > 1:
        parser.error("--id requires exactly one input path")
    post_process_args(cns)
    return cns


def main(params: Sequence[str] | None = None) -> None:
    """Get CLI arguments and perform the operation on the archive."""
    cns = get_args(params)
    args = Namespace(comicbox=cns)

    runner = Runner(args)
    try:
        runner.run()
    except _HANDLED_EXCEPTIONS as exc:
        rich_print(f"[yellow]{exc}[/yellow]")
        sys.exit(1)