"""
Four output renderers for :class:`~animedex.api._envelope.RawResponse`.
Per #3 ยง5.0:
* :func:`render_body` - default, body text only (gh api equivalent).
* :func:`render_include` - ``-i``, curl-style status + headers + body.
* :func:`render_head` - ``-I``, status + headers only.
* :func:`render_debug` - ``--debug``, structured JSON envelope.
The CLI in :mod:`animedex.entry.cli` picks one of these based on the
mutually-exclusive output flags.
"""
from __future__ import annotations
import base64
import json
from typing import Optional
from animedex.api._envelope import RawResponse
_DEBUG_BODY_CAP_BYTES = 65536 # 64 KiB default truncation in --debug
[docs]
def render_body(envelope: RawResponse) -> str:
"""Default mode: print the response body as text.
Returns ``body_text`` when it decoded as UTF-8; otherwise base64-
encodes ``body_bytes`` so the output is still printable. Callers
that need the raw bytes for binary content should use the
library API (``animedex.api.call``) instead of the CLI.
:param envelope: The response envelope.
:type envelope: RawResponse
:return: Body text (or base64-encoded bytes when not decodable).
:rtype: str
"""
if envelope.firewall_rejected is not None:
return envelope.firewall_rejected.get("message", "")
if envelope.body_text is not None:
return envelope.body_text
return base64.b64encode(envelope.body_bytes).decode("ascii")
def _format_status_line(envelope: RawResponse) -> str:
if envelope.firewall_rejected is not None:
reason = envelope.firewall_rejected.get("reason", "rejected")
return f"HTTP/0 0 firewall-rejected ({reason})"
return f"HTTP/1.1 {envelope.status}"
def _format_headers_block(envelope: RawResponse) -> str:
lines = []
for name, value in envelope.response_headers.items():
lines.append(f"{name}: {value}")
return "\n".join(lines)
[docs]
def render_include(envelope: RawResponse) -> str:
"""``-i`` mode: status line + response headers + blank + body.
Mirrors ``curl -i`` output. The header block uses the response's
own header casing as captured.
:param envelope: The response envelope.
:type envelope: RawResponse
:return: Status + headers + body, separated by blank line.
:rtype: str
"""
status_line = _format_status_line(envelope)
headers_block = _format_headers_block(envelope)
body = render_body(envelope)
return f"{status_line}\n{headers_block}\n\n{body}"
[docs]
def render_head(envelope: RawResponse) -> str:
"""``-I`` mode: status line + response headers, no body.
:param envelope: The response envelope.
:type envelope: RawResponse
:return: Status + headers.
:rtype: str
"""
status_line = _format_status_line(envelope)
headers_block = _format_headers_block(envelope)
return f"{status_line}\n{headers_block}"
[docs]
def render_debug(envelope: RawResponse, *, full_body: bool = False) -> str:
"""``--debug`` mode: structured JSON envelope.
Emits the entire :class:`RawResponse` as indented JSON. Body
content is truncated to :data:`_DEBUG_BODY_CAP_BYTES` (64 KiB) by
default and tagged with ``body_truncated_at_bytes``; pass
``full_body=True`` to emit the full body verbatim. Binary bodies
that did not decode as UTF-8 are base64-encoded inside the JSON.
Credential headers in ``request.headers`` are already redacted by
the dispatcher (see :func:`animedex.api._envelope.redact_headers`);
this renderer does not perform additional redaction.
:param envelope: The response envelope.
:type envelope: RawResponse
:param full_body: When ``True``, do not truncate the body.
:type full_body: bool
:return: Indented JSON of the envelope.
:rtype: str
"""
payload = envelope.model_dump(mode="json")
# Body handling: when the body decoded as UTF-8 we have body_text;
# otherwise body_bytes is base64-encoded by pydantic's bytes
# serialization (which is what model_dump emits for bytes fields).
body_text: Optional[str] = payload.get("body_text")
if body_text is not None and not full_body and len(body_text) > _DEBUG_BODY_CAP_BYTES:
payload["body_text"] = body_text[:_DEBUG_BODY_CAP_BYTES]
payload["body_truncated_at_bytes"] = _DEBUG_BODY_CAP_BYTES
elif body_text is None and not full_body:
# Binary body: model_dump renders bytes as base64; cap the
# base64 string accordingly (cap reflects encoded length, not
# original byte length, but it bounds the JSON output size
# which is what matters for stdout safety).
b64 = payload.get("body_bytes", "")
if isinstance(b64, str) and len(b64) > _DEBUG_BODY_CAP_BYTES:
payload["body_bytes"] = b64[:_DEBUG_BODY_CAP_BYTES]
payload["body_truncated_at_bytes"] = _DEBUG_BODY_CAP_BYTES
return json.dumps(payload, indent=2, ensure_ascii=False)
[docs]
def selftest() -> bool:
"""Smoke-test the four renderers.
:return: ``True`` on success.
:rtype: bool
"""
from animedex.api._envelope import (
RawCacheInfo,
RawRequest,
RawResponse as _RR,
RawTiming,
)
env = _RR(
backend="_selftest",
request=RawRequest(method="GET", url="https://x.invalid/", headers={"User-Agent": "x"}),
status=200,
response_headers={"Content-Type": "application/json"},
body_bytes=b'{"ok":true}',
body_text='{"ok":true}',
timing=RawTiming(total_ms=1.0, rate_limit_wait_ms=0, request_ms=1.0),
cache=RawCacheInfo(hit=False),
)
assert render_body(env) == '{"ok":true}'
assert "200" in render_include(env)
assert "200" in render_head(env)
assert json.loads(render_debug(env))["status"] == 200
return True