321 lines
8.2 KiB
Python
321 lines
8.2 KiB
Python
"""
|
|
©AngelaMos | 2026
|
|
cli.py
|
|
|
|
Typer CLI application with five encoding subcommands
|
|
|
|
Defines the b64tool Typer app and its five commands: encode, decode,
|
|
detect, peel, and chain. Each command resolves its input from a
|
|
positional argument, --file, or stdin, delegates to the appropriate
|
|
logic module, then passes results to formatter.py for display. The
|
|
chain command applies a comma-separated sequence of formats in order,
|
|
passing each encoded output as the next step's input.
|
|
|
|
Key exports:
|
|
app - The Typer application instance registered as the CLI entry point
|
|
|
|
Connects to:
|
|
__init__.py - imports __version__
|
|
constants.py - imports EncodingFormat, ExitCode, PEEL_MAX_DEPTH
|
|
encoders.py - imports encode, decode, encode_url, decode_url
|
|
detector.py - imports detect_encoding, score_all_formats
|
|
peeler.py - imports peel
|
|
formatter.py - imports all print_* functions
|
|
utils.py - imports resolve_input_bytes, resolve_input_text
|
|
test_cli.py - exercises all commands via Typer's CliRunner
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
import typer
|
|
from rich.console import Console
|
|
|
|
from base64_tool import __version__
|
|
from base64_tool.constants import (
|
|
EncodingFormat,
|
|
ExitCode,
|
|
PEEL_MAX_DEPTH,
|
|
)
|
|
from base64_tool.detector import detect_encoding, score_all_formats
|
|
from base64_tool.encoders import (
|
|
decode,
|
|
encode,
|
|
encode_url,
|
|
decode_url,
|
|
)
|
|
from base64_tool.formatter import (
|
|
print_chain_result,
|
|
print_decoded,
|
|
print_detection,
|
|
print_encoded,
|
|
print_peel_result,
|
|
)
|
|
from base64_tool.peeler import peel
|
|
from base64_tool.utils import (
|
|
resolve_input_bytes,
|
|
resolve_input_text,
|
|
)
|
|
|
|
|
|
app = typer.Typer(
|
|
name = "b64tool",
|
|
help = ("Multi-format encoding/decoding CLI "
|
|
"with recursive layer detection"),
|
|
no_args_is_help = True,
|
|
pretty_exceptions_show_locals = False,
|
|
)
|
|
|
|
_console = Console(stderr = True)
|
|
|
|
|
|
def _version_callback(value: bool) -> None:
|
|
if value:
|
|
_console.print(f"b64tool v{__version__}")
|
|
raise typer.Exit()
|
|
|
|
|
|
@app.callback()
|
|
def main(
|
|
version: Annotated[
|
|
bool,
|
|
typer.Option(
|
|
"--version",
|
|
"-v",
|
|
help = "Show version and exit.",
|
|
callback = _version_callback,
|
|
is_eager = True,
|
|
),
|
|
] = False,
|
|
) -> None:
|
|
pass
|
|
|
|
|
|
@app.command(name = "encode")
|
|
def encode_cmd(
|
|
data: Annotated[
|
|
str | None,
|
|
typer.Argument(help = "Data to encode."),
|
|
] = None,
|
|
fmt: Annotated[
|
|
EncodingFormat,
|
|
typer.Option(
|
|
"--format",
|
|
"-f",
|
|
help = "Target encoding format.",
|
|
),
|
|
] = EncodingFormat.BASE64,
|
|
file: Annotated[
|
|
Path | None,
|
|
typer.Option(
|
|
"--file",
|
|
"-i",
|
|
help = "Read input from file.",
|
|
),
|
|
] = None,
|
|
form: Annotated[
|
|
bool,
|
|
typer.Option(
|
|
"--form",
|
|
help = "Use form-encoding for URL (space becomes +).",
|
|
),
|
|
] = False,
|
|
) -> None:
|
|
try:
|
|
raw = resolve_input_bytes(data, file)
|
|
if fmt == EncodingFormat.URL and form:
|
|
result = encode_url(raw, form = True)
|
|
else:
|
|
result = encode(raw, fmt)
|
|
print_encoded(result, fmt)
|
|
except typer.BadParameter:
|
|
raise
|
|
except Exception as exc:
|
|
_console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(code = ExitCode.ERROR) from None
|
|
|
|
|
|
@app.command(name = "decode")
|
|
def decode_cmd(
|
|
data: Annotated[
|
|
str | None,
|
|
typer.Argument(help = "Data to decode."),
|
|
] = None,
|
|
fmt: Annotated[
|
|
EncodingFormat,
|
|
typer.Option(
|
|
"--format",
|
|
"-f",
|
|
help = "Source encoding format.",
|
|
),
|
|
] = EncodingFormat.BASE64,
|
|
file: Annotated[
|
|
Path | None,
|
|
typer.Option(
|
|
"--file",
|
|
"-i",
|
|
help = "Read input from file.",
|
|
),
|
|
] = None,
|
|
form: Annotated[
|
|
bool,
|
|
typer.Option(
|
|
"--form",
|
|
help = "Use form-decoding for URL (+ becomes space).",
|
|
),
|
|
] = False,
|
|
) -> None:
|
|
try:
|
|
text = resolve_input_text(data, file)
|
|
if fmt == EncodingFormat.URL and form:
|
|
result = decode_url(text, form = True)
|
|
else:
|
|
result = decode(text, fmt)
|
|
print_decoded(result)
|
|
except typer.BadParameter:
|
|
raise
|
|
except Exception as exc:
|
|
_console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(code = ExitCode.ERROR) from None
|
|
|
|
|
|
@app.command(name = "detect")
|
|
def detect_cmd(
|
|
data: Annotated[
|
|
str | None,
|
|
typer.Argument(help = "Data to analyze."),
|
|
] = None,
|
|
file: Annotated[
|
|
Path | None,
|
|
typer.Option(
|
|
"--file",
|
|
"-i",
|
|
help = "Read input from file.",
|
|
),
|
|
] = None,
|
|
verbose: Annotated[
|
|
bool,
|
|
typer.Option(
|
|
"--verbose",
|
|
"-V",
|
|
help = "Show per-format score breakdown.",
|
|
),
|
|
] = False,
|
|
) -> None:
|
|
try:
|
|
text = resolve_input_text(data, file)
|
|
results = detect_encoding(text)
|
|
scores = score_all_formats(text) if verbose else None
|
|
print_detection(results, verbose_scores = scores)
|
|
except typer.BadParameter:
|
|
raise
|
|
except Exception as exc:
|
|
_console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(code = ExitCode.ERROR) from None
|
|
|
|
|
|
@app.command(name = "peel")
|
|
def peel_cmd(
|
|
data: Annotated[
|
|
str | None,
|
|
typer.Argument(help = "Data to recursively decode."),
|
|
] = None,
|
|
file: Annotated[
|
|
Path | None,
|
|
typer.Option(
|
|
"--file",
|
|
"-i",
|
|
help = "Read input from file.",
|
|
),
|
|
] = None,
|
|
max_depth: Annotated[
|
|
int,
|
|
typer.Option(
|
|
"--max-depth",
|
|
"-d",
|
|
help = "Maximum decoding layers.",
|
|
),
|
|
] = PEEL_MAX_DEPTH,
|
|
verbose: Annotated[
|
|
bool,
|
|
typer.Option(
|
|
"--verbose",
|
|
"-V",
|
|
help = "Show per-format score breakdown at each layer.",
|
|
),
|
|
] = False,
|
|
) -> None:
|
|
try:
|
|
text = resolve_input_text(data, file)
|
|
result = peel(text, max_depth = max_depth, verbose = verbose)
|
|
print_peel_result(result, verbose = verbose)
|
|
except typer.BadParameter:
|
|
raise
|
|
except Exception as exc:
|
|
_console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(code = ExitCode.ERROR) from None
|
|
|
|
|
|
@app.command(name = "chain")
|
|
def chain_cmd(
|
|
data: Annotated[
|
|
str | None,
|
|
typer.Argument(help = "Data to encode through chain."),
|
|
] = None,
|
|
steps: Annotated[
|
|
str,
|
|
typer.Option(
|
|
"--steps",
|
|
"-s",
|
|
help = ("Comma-separated encoding formats "
|
|
"(e.g. base64,hex,url)."),
|
|
),
|
|
] = "base64",
|
|
file: Annotated[
|
|
Path | None,
|
|
typer.Option(
|
|
"--file",
|
|
"-i",
|
|
help = "Read input from file.",
|
|
),
|
|
] = None,
|
|
) -> None:
|
|
try:
|
|
raw = resolve_input_bytes(data, file)
|
|
formats = _parse_chain_steps(steps)
|
|
intermediates: list[tuple[EncodingFormat, str]] = []
|
|
current = raw
|
|
|
|
for step_fmt in formats:
|
|
encoded = encode(current, step_fmt)
|
|
intermediates.append((step_fmt, encoded))
|
|
current = encoded.encode("utf-8")
|
|
|
|
final = intermediates[-1][1] if intermediates else ""
|
|
print_chain_result(intermediates, final)
|
|
except typer.BadParameter:
|
|
raise
|
|
except Exception as exc:
|
|
_console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(code = ExitCode.ERROR) from None
|
|
|
|
|
|
def _parse_chain_steps(raw: str) -> list[EncodingFormat]:
|
|
formats: list[EncodingFormat] = []
|
|
valid_names = ", ".join(f.value for f in EncodingFormat)
|
|
|
|
for step in raw.split(","):
|
|
cleaned = step.strip().lower()
|
|
try:
|
|
formats.append(EncodingFormat(cleaned))
|
|
except ValueError:
|
|
raise typer.BadParameter(
|
|
f"Unknown format '{cleaned}'. "
|
|
f"Valid formats: {valid_names}"
|
|
) from None
|
|
|
|
if not formats:
|
|
raise typer.BadParameter("At least one step is required.")
|
|
|
|
return formats
|