# Architecture ## Design Philosophy Every module in this project has one job. `encoders.py` transforms data. `detector.py` scores formats. `peeler.py` orchestrates recursive decoding. `formatter.py` renders output. `cli.py` wires user input to functions. No module reaches into another module's concern. This isn't over-engineering for a small project. It's how you keep a small project from becoming an unmanageable one. When you need to add a new encoding format, you touch `encoders.py` and `detector.py`. That's it. The CLI, formatter, and peeler don't change. ## Module Dependency Graph ``` cli.py ├── constants.py (EncodingFormat, ExitCode, PEEL_MAX_DEPTH) ├── encoders.py (encode, decode, encode_url, decode_url) ├── detector.py (detect_encoding) ├── peeler.py (peel) ├── formatter.py (print_encoded, print_decoded, print_detection, print_peel_result, print_chain_result) └── utils.py (resolve_input_bytes, resolve_input_text) peeler.py ├── constants.py (CONFIDENCE_THRESHOLD, PEEL_MAX_DEPTH, EncodingFormat) ├── detector.py (detect_best) └── utils.py (safe_bytes_preview, truncate) detector.py ├── constants.py (charsets, thresholds, EncodingFormat) ├── encoders.py (try_decode) └── utils.py (is_printable_text) formatter.py ├── constants.py (EncodingFormat, PREVIEW_LENGTH) ├── detector.py (DetectionResult — type only) ├── peeler.py (PeelResult — type only) └── utils.py (safe_bytes_preview) encoders.py └── constants.py (EncodingFormat) utils.py └── (no internal deps) constants.py └── (no internal deps) ``` The dependency arrows always point downward. `constants.py` and `utils.py` sit at the bottom with zero internal dependencies. `cli.py` sits at the top, importing from everything. Nothing in the middle reaches upward. This is a directed acyclic graph (DAG), and if you ever create a circular import, Python will tell you immediately. ## Data Flow ### Encode Command ``` User Input (str or file or stdin) │ ▼ resolve_input_bytes() ← utils.py:12 │ Converts any input source to raw bytes ▼ encode(raw, fmt) ← encoders.py:88 │ Dispatches via ENCODER_REGISTRY to format-specific function ▼ encode_base64(data) (or other) ← encoders.py:22 │ Returns encoded string ▼ print_encoded(result, fmt) ← formatter.py:31 │ Rich panel if terminal, raw stdout if piped ▼ Output ``` ### Decode Command ``` User Input (str or file or stdin) │ ▼ resolve_input_text() ← utils.py:29 │ Converts any input source to stripped text ▼ decode(text, fmt) ← encoders.py:93 │ Dispatches via ENCODER_REGISTRY to format-specific function ▼ decode_base64(data) (or other) ← encoders.py:26 │ Returns decoded bytes ▼ print_decoded(result) ← formatter.py:44 │ Safe preview (UTF-8 if possible, hex fallback) ▼ Output ``` ### Detect Command ``` User Input (str) │ ▼ detect_encoding(text) ← detector.py:206 │ ├──► _score_base64(text) ← detector.py:31 ├──► _score_base64url(text)← detector.py:70 ├──► _score_base32(text) ← detector.py:97 ├──► _score_hex(text) ← detector.py:126 └──► _score_url(text) ← detector.py:174 │ │ Each scorer returns 0.0–1.0 │ Results filtered by CONFIDENCE_THRESHOLD (0.6) │ Sorted by confidence descending ▼ print_detection(results) ← formatter.py:58 │ Rich table: format, confidence %, decoded preview ▼ Output ``` ### Peel Command (the star feature) ``` User Input (str) │ ▼ peel(text, max_depth=20) ← peeler.py:33 │ ├──► LOOP (up to max_depth iterations): │ │ │ ├── detect_best(current_text) ← detector.py:226 │ │ Returns highest-confidence detection │ │ │ ├── Break if: no detection, below threshold, decode fails │ │ │ ├── Record PeelLayer (depth, format, confidence, previews) │ │ │ └── decoded_bytes → current_text for next iteration │ (break if bytes aren't valid UTF-8) │ ▼ PeelResult(layers, final_output, success) │ ▼ print_peel_result(result) ← formatter.py:94 │ Layer-by-layer display + final output panel ▼ Output ``` ### Chain Command ``` User Input (str) + --steps "base64,hex,url" │ ▼ resolve_input_bytes() ← utils.py:12 │ ▼ _parse_chain_steps("base64,hex,url") ← cli.py:264 │ Validates each format name against EncodingFormat enum │ Returns [BASE64, HEX, URL] ▼ LOOP over formats: │ ├── encode(current_bytes, fmt) ← encoders.py:88 ├── Record (fmt, encoded_string) └── encoded_string → bytes for next iteration │ ▼ print_chain_result(steps, final) ← formatter.py:130 │ Step-by-step display + final panel ▼ Output ``` ## Key Patterns ### Registry Pattern (encoders.py:73–85) Instead of a chain of `if fmt == "base64": ... elif fmt == "base64url": ...`, every encoder and decoder pair is registered in a dictionary: ``` ENCODER_REGISTRY: dict[EncodingFormat, tuple[EncoderFn, DecoderFn]] ``` Adding a new format means adding one entry to the registry and writing the two functions. The dispatch functions `encode()` and `decode()` never change. This is the open-closed principle: open for extension, closed for modification. ### Frozen Dataclasses (detector.py:24, peeler.py:17, peeler.py:26) All result types use `@dataclass(frozen=True, slots=True)`. Frozen means the fields can't be mutated after creation. Slots means no `__dict__` per instance, which uses less memory and is slightly faster. For data that flows through a pipeline and should never be changed, frozen dataclasses are the right tool. ### Pipeline-Friendly Output (formatter.py:22–28) The tool detects whether stdout is a terminal or a pipe. When piped (`echo "data" | b64tool decode | other_tool`), it writes raw text to stdout with no Rich formatting. When interactive, it shows panels, tables, and colors. This happens via `is_piped()` checking `sys.stdout.isatty()`. Rich output goes to stderr (`Console(stderr=True)` at `formatter.py:19`), so diagnostic messages never contaminate piped data. This is a standard Unix convention that many CLI tools get wrong. ### Scorer Architecture (detector.py:195–203) Detection uses the same registry pattern as encoding. Each format has a scorer function with the signature `Callable[[str], float]`. The `_SCORERS` dictionary maps `EncodingFormat` to its scorer. This means adding detection for a new format requires writing one scorer function and adding one dict entry. Every scorer follows the same structure: 1. Quick rejection (charset check, length check) 2. Accumulate a confidence score based on structural signals 3. Attempt actual decoding 4. Bonus if decoded output is printable text 5. Return clamped to [0.0, 1.0] ### Type Aliases with PEP 695 (encoders.py:18–19) ``` type EncoderFn = Callable[[bytes], str] type DecoderFn = Callable[[str], bytes] ``` Python 3.12+ `type` statements (PEP 695) replace `TypeAlias` from `typing`. They're lazily evaluated and more readable. These aliases document the contract: encoders take bytes and return strings, decoders take strings and return bytes. ## Error Handling Strategy Errors are handled at two levels: **Module level**: Functions like `try_decode()` (`encoders.py:98`) catch encoding-specific exceptions and return `None`. The detector and peeler use this to gracefully handle decode failures without crashing. **CLI level**: Each command (`cli.py`) wraps its body in a try/except. `typer.BadParameter` is re-raised (Typer formats these nicely). All other exceptions get a `[red]Error:[/red]` message and exit code 1. This prevents stack traces from leaking to end users. The intermediate modules (detector, peeler) never catch exceptions themselves. They call `try_decode()` and check for `None`. This keeps error handling at the boundaries, not scattered through business logic. ## Why Not a Class? None of the core modules use classes for behavior (only for data: `EncodingFormat`, `DetectionResult`, `PeelLayer`, `PeelResult`). The encoder functions are pure functions. The scorers are pure functions. The peeler is a function. There's no shared mutable state to encapsulate, so there's no reason for a class. An `Encoder` class with `encode()` and `decode()` methods would add indirection without adding value. The registry dict achieves the same polymorphism with less ceremony. This is idiomatic Python: use classes for data, functions for behavior, unless you have state to manage.