feat: complete base64tool

This commit is contained in:
CarterPerez-dev 2026-02-09 07:26:42 -05:00
parent aff62711f1
commit 9205d3ce4d
28 changed files with 3654 additions and 3 deletions

View File

@ -0,0 +1,50 @@
# =============================================================================
# AngelaMos | 2026
# publish-base64-tool.yml
# =============================================================================
name: Publish b64tool to PyPI
on:
push:
branches:
- main
paths:
- 'PROJECTS/beginner/base64-tool/**'
- '!PROJECTS/beginner/base64-tool/README.md'
- '!PROJECTS/beginner/base64-tool/Justfile'
permissions:
contents: read
jobs:
pypi-publish:
name: Upload b64tool to PyPI
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/b64tool
permissions:
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package
working-directory: PROJECTS/beginner/base64-tool
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: PROJECTS/beginner/base64-tool/dist/

View File

@ -1,7 +1,5 @@
# =============================================================================
# AngelaMos | 2026
# dev.compose.yml
# =============================================================================
name: ${APP_NAME:-template}-dev

View File

@ -0,0 +1,15 @@
# ©AngelaMos | 2026
# .gitignore
.venv
venv/
__pycache__
*.pyc
.mypy_cache
.pytest_cache
.ruff_cache
htmlcov
.coverage
dist
*.egg-info
.hypothesis

View File

@ -0,0 +1,47 @@
# ©AngelaMos | 2026
[style]
based_on_style = pep8
column_limit = 89
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0

View File

@ -0,0 +1,135 @@
# ©AngelaMos | 2026
# Justfile
# =============================================================================
set dotenv-load
set export
set shell := ["bash", "-uc"]
project := file_name(justfile_directory())
version := `git describe --tags --always 2>/dev/null || echo "dev"`
# =============================================================================
# Default
# =============================================================================
default:
@just --list --unsorted
# =============================================================================
# Run
# =============================================================================
[group('run')]
run *ARGS:
uv run b64tool {{ARGS}}
[group('run')]
encode DATA *ARGS:
uv run b64tool encode "{{DATA}}" {{ARGS}}
[group('run')]
decode DATA *ARGS:
uv run b64tool decode "{{DATA}}" {{ARGS}}
[group('run')]
detect DATA:
uv run b64tool detect "{{DATA}}"
[group('run')]
peel DATA:
uv run b64tool peel "{{DATA}}"
[group('run')]
chain DATA STEPS:
uv run b64tool chain "{{DATA}}" --steps {{STEPS}}
# =============================================================================
# Linting and Formatting
# =============================================================================
[group('lint')]
ruff *ARGS:
uv run ruff check src/ tests/ {{ARGS}}
[group('lint')]
ruff-fix:
uv run ruff check src/ tests/ --fix
uv run ruff format src/ tests/
[group('lint')]
ruff-format:
uv run ruff format src/ tests/
[group('lint')]
lint: ruff
# =============================================================================
# Type Checking
# =============================================================================
[group('types')]
mypy *ARGS:
uv run mypy src/ {{ARGS}}
[group('types')]
typecheck: mypy
# =============================================================================
# Testing
# =============================================================================
[group('test')]
test *ARGS:
uv run pytest {{ARGS}}
[group('test')]
test-cov:
uv run pytest --cov=base64_tool --cov-report=term-missing --cov-report=html
# =============================================================================
# CI / Quality
# =============================================================================
[group('ci')]
ci: lint typecheck test
[group('ci')]
check: ruff mypy
# =============================================================================
# Setup
# =============================================================================
[group('setup')]
setup:
uv sync --all-extras
[group('setup')]
install:
uv sync
[group('setup')]
install-dev:
uv sync --all-extras
# =============================================================================
# Utilities
# =============================================================================
[group('util')]
info:
@echo "Project: {{project}}"
@echo "Version: {{version}}"
@echo "OS: {{os()}} ({{arch()}})"
[group('util')]
clean:
-rm -rf .mypy_cache
-rm -rf .pytest_cache
-rm -rf .ruff_cache
-rm -rf htmlcov
-rm -rf .coverage
-rm -rf dist
-rm -rf *.egg-info
@echo "Cache directories cleaned"

View File

@ -0,0 +1,95 @@
```ruby
██████╗ ██████╗ ██╗ ██╗████████╗ ██████╗ ██████╗ ██╗
██╔══██╗██╔════╝ ██║ ██║╚══██╔══╝██╔═══██╗██╔═══██╗██║
██████╔╝███████╗ ███████║ ██║ ██║ ██║██║ ██║██║
██╔══██╗██╔═══██╗╚════██║ ██║ ██║ ██║██║ ██║██║
██████╔╝╚██████╔╝ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗
╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
```
* **Multi format encoding/decoding CLI with recursive layer detection for security analysis.**
## Features
- **Encode/Decode** across Base64, Base64URL, Base32, Hex, and URL encoding formats
- **Auto-detect** encoding format with confidence scoring
- **Peel** recursively through multi-layered encoding (the kind attackers use to evade WAFs and IDS)
- **Chain** multiple encoding steps together for testing obfuscation patterns
- **Pipeline-friendly** output for integration into security workflows
## Quick Start
```bash
uv sync
uv run b64tool encode "Hello World"
uv run b64tool decode "SGVsbG8gV29ybGQ="
uv run b64tool detect "SGVsbG8gV29ybGQ="
uv run b64tool peel "NjQ0ODY1NmM2YzZmMjA1NzZmNzI2YzY0"
uv run b64tool chain "secret" --steps base64,hex,url
```
## Installation
```bash
uv sync --all-extras
```
## Usage
### Encode
```bash
b64tool encode "Hello World" # Base64 (default)
b64tool encode "Hello World" --format hex # Hex
b64tool encode "Hello World" --format base32 # Base32
b64tool encode "hello world&foo=bar" --format url # URL encoding
echo "piped input" | b64tool encode # Stdin
b64tool encode --file secret.txt # File input
```
### Decode
```bash
b64tool decode "SGVsbG8gV29ybGQ=" # Base64 (default)
b64tool decode "48656c6c6f" --format hex # Hex
b64tool decode "JBSWY3DP" --format base32 # Base32
```
### Detect
Identifies the encoding format with confidence scoring:
```bash
b64tool detect "SGVsbG8gV29ybGQ="
```
### Peel (Recursive Layer Detection)
The signature feature. Automatically peels back multiple encoding layers, the way real malware and attack payloads obfuscate data:
```bash
b64tool peel "NjQ0ODY1NmM2YzZmMjA1NzZmNzI2YzY0"
```
### Chain
Build multi-layered encodings for testing WAF rules, IDS signatures, or understanding obfuscation:
```bash
b64tool chain "alert('xss')" --steps base64,hex,url
```
## Security Context
Encoding layering is a real attack technique:
- **WAF Bypass**: Double-encoding payloads to slip past web application firewalls (OWASP)
- **Malware Obfuscation**: DARKGATE malware uses custom Base64 alphabets with randomized shuffling
- **Data Exfiltration**: Base64-encoded data hidden in DNS queries and HTTP headers
- **IDS Evasion**: Multi-layer encoding to avoid signature-based detection
This tool helps security professionals analyze and understand these patterns.
## License
MIT

View File

@ -0,0 +1,89 @@
# Overview
## What This Project Does
b64tool is a multi-format encoding/decoding CLI that handles Base64, Base64URL, Base32, hex, and URL encoding. The standout feature is recursive layer peeling, which automatically detects and decodes multiple stacked encoding layers, the same technique attackers use to obfuscate malware payloads and bypass web application firewalls.
You give it an encoded blob. It figures out what format it is, decodes it, checks if the result is itself encoded, and keeps going until it reaches the original data. Think of it like an onion. Each layer is a different encoding, and the tool peels them one at a time.
## Why This Exists
In real security work, you constantly encounter encoded data. JWT tokens are base64. Certificates are base64. Hex dumps show up in packet captures and malware analysis. URL encoding appears in every web request. And when attackers want to hide payloads, they don't just encode once. They stack encodings: base64 the payload, hex encode that, then URL encode the hex. This tool handles all of that.
## What You'll Learn
- How Base64, Base32, hex, and URL encoding actually work at the byte level
- Why encoding is not encryption (and why confusing them causes real vulnerabilities)
- Pattern recognition for identifying encoding formats
- How attackers layer encodings for obfuscation (with real examples from DARKGATE malware)
- Building a clean, typed Python CLI with typer and rich
- Confidence-based detection algorithms
## Prerequisites
- Python 3.14+
- `uv` package manager
- Basic command line comfort
- Understanding of bytes vs strings (helpful, not required)
## Quick Start
```bash
cd PROJECTS/beginner/base64-tool
uv sync
uv run b64tool --help
```
### Try the basics
```bash
uv run b64tool encode "Hello World"
# Output: SGVsbG8gV29ybGQ=
uv run b64tool decode "SGVsbG8gV29ybGQ="
# Output: Hello World
uv run b64tool detect "SGVsbG8gV29ybGQ="
# Shows: base64, 95% confidence
```
### Try multi-layer encoding
```bash
uv run b64tool chain "alert('xss')" --steps base64,hex
# Produces a hex-encoded base64 string
uv run b64tool peel "5957786c636e516f4a33687a63796370"
# Peels: hex → base64 → alert('xss')
```
### Pipe support
```bash
echo "SGVsbG8=" | uv run b64tool decode
cat encoded_payload.txt | uv run b64tool peel
```
## Project Structure
```
base64-tool/
├── src/base64_tool/
│ ├── cli.py # Typer commands (encode, decode, detect, peel, chain)
│ ├── constants.py # Enums, thresholds, character sets
│ ├── encoders.py # Pure encode/decode functions + registry
│ ├── detector.py # Format detection with confidence scoring
│ ├── peeler.py # Recursive multi-layer decoding
│ ├── formatter.py # Rich terminal output
│ └── utils.py # Input resolution, text helpers
├── tests/ # 78 tests across all modules
├── pyproject.toml # Python 3.14, typer, rich
└── Justfile # Task runner shortcuts
```
## Next Steps
- [01-CONCEPTS.md](./01-CONCEPTS.md) - How encoding works, real CVEs, encoding vs encryption
- [02-ARCHITECTURE.md](./02-ARCHITECTURE.md) - System design, module relationships, data flow
- [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) - Code walkthrough with file references
- [04-CHALLENGES.md](./04-CHALLENGES.md) - Extension ideas from easy to expert

View File

@ -0,0 +1,171 @@
# Concepts
## Encoding vs. Encryption
This is the single most important concept in this project, and confusing them causes real vulnerabilities.
**Encoding** transforms data into a different representation. Anyone can reverse it. There is no secret key. Base64 encoding is as "secure" as writing something in pig latin.
**Encryption** transforms data using a secret key. Without the key, you can't get the original back. AES, RSA, ChaCha20. These are encryption.
CWE-261 (Weak Encoding for Password) exists specifically because developers store passwords with base64 "encryption" instead of actual hashing. In 2018, a D-Link camera (CVE-2017-8417) stored admin credentials in base64 on the device. Anyone with network access could decode them instantly.
The rule: if you can reverse it without a password or key, it's encoding. Encoding is for compatibility, not security.
## How Base64 Works
Base64 converts binary data into 64 printable ASCII characters. The alphabet is `A-Z`, `a-z`, `0-9`, `+`, `/`, with `=` for padding.
### The Encoding Process
1. Take the input bytes
2. Read them as a stream of bits
3. Split into groups of 6 bits (not 8)
4. Map each 6-bit value (0-63) to a character in the alphabet
Why 6 bits? Because 2^6 = 64, which gives exactly 64 possible values per character.
```
Input: "Hi"
Bytes: 0x48 0x69
Binary: 01001000 01101001
Split into 6-bit groups:
010010 | 000110 | 1001xx
Pad the last group with zeros:
010010 | 000110 | 100100
Map to alphabet:
18 → S 6 → G 36 → k
Add padding (input was 2 bytes, need 1 pad):
Result: "SGk="
```
Three input bytes produce exactly four output characters. When the input isn't divisible by 3, you get `=` padding. One leftover byte gets `==`, two leftover bytes get `=`.
### Base64URL Variant
Standard base64 uses `+` and `/` which are special characters in URLs. Base64URL (RFC 4648 Section 5) replaces them:
- `+` becomes `-`
- `/` becomes `_`
JWTs use base64url. So do many API tokens. If you see `-` or `_` in what looks like base64, it's probably base64url.
### Size Overhead
Base64 converts 3 bytes into 4 characters. That's a 33% size increase. A 1 MB file becomes ~1.33 MB when base64 encoded. This matters for things like embedding images in HTML or sending attachments in email (which is exactly why base64 was invented for MIME).
## How Base32 Works
Same idea as base64, but uses only 32 characters: `A-Z` and `2-7`. Groups of 5 bits instead of 6.
Why would you use base32 over base64? Environments where case sensitivity is a problem. DNS names are case insensitive, so base64 doesn't work there. TOTP tokens (Google Authenticator) use base32 for the shared secret because humans might misread uppercase/lowercase when typing a key.
Base32 produces longer output than base64 (60% overhead vs 33%). The tradeoff is fewer possible characters means fewer ambiguity issues.
## Hexadecimal Encoding
Hex represents each byte as two characters from `0-9` and `a-f`. It's a direct byte-to-text mapping.
```
Input: "Hi"
Bytes: 0x48 0x69
Hex: 4869
```
100% size overhead (every byte becomes two characters), but it's the most straightforward encoding and universally readable. You see hex in:
- MAC addresses: `aa:bb:cc:dd:ee:ff`
- Color codes: `#FF5733`
- Packet captures and hex dumps
- Malware hash signatures: `d41d8cd98f00b204e9800998ecf8427e`
- Binary file analysis
## URL Encoding (Percent Encoding)
URLs have reserved characters (`?`, `&`, `=`, `/`, `#`, etc.) that have special meaning. URL encoding replaces unsafe characters with `%XX` where `XX` is the hex value.
```
Input: "hello world&key=val"
Encoded: "hello%20world%26key%3Dval"
```
Space is `%20` in standard URL encoding but `+` in form encoding (`application/x-www-form-urlencoded`). The distinction matters for web security testing.
## RFC 4648
RFC 4648 is the definitive standard for Base16, Base32, and Base64 encoding. Key points:
- Defines the exact alphabets and padding rules
- Specifies when decoders MUST reject vs MAY accept malformed input
- Base64 padding is `=`, and it MUST appear only at the end
- Decoders that accept non-alphabet characters create security issues (padding oracle attacks become possible)
Python's `base64` module follows RFC 4648. The `validate=True` parameter in `b64decode` enforces strict compliance, rejecting any non-alphabet characters. This project uses strict decoding.
## Encoding in Attacks
### Double Encoding (WAF Bypass)
Web Application Firewalls (WAFs) check request parameters for malicious content. If you URL-encode a payload, the WAF decodes it once and checks. But what if you double-encode?
```
Payload: <script>alert(1)</script>
Single: %3Cscript%3Ealert(1)%3C/script%3E ← WAF catches this
Double: %253Cscript%253Ealert(1)%253C/script%253E ← WAF decodes once,
sees %3C (looks harmless), passes it through
```
The web server decodes again, and the browser gets the original XSS payload. This is OWASP's A05:2025 Injection category. Double encoding still works against poorly configured WAFs in 2026.
### Multi-Layer Obfuscation (Malware)
The DARKGATE malware (actively sold on dark web forums) encodes its configuration and keylogger output using a custom, non-standard base64 alphabet. Older versions used a hardcoded custom alphabet. Version 5.2.3+ randomizes the alphabet based on a hardware ID seed.
Researchers at Kroll found a weakness: the hardware ID is a 32-byte ASCII MD5 hash, and DARKGATE sums these bytes as a seed. The sum has limited entropy, making brute-force recovery of the custom alphabet feasible.
This is why understanding encoding matters for security: real malware uses real encoding tricks, and analysts need to decode them.
### Data Exfiltration
Attackers exfiltrate data by encoding it and embedding it in seemingly normal traffic:
- Base64 data in DNS TXT record queries
- Hex-encoded payloads in HTTP headers
- URL-encoded data in query parameters that look like analytics tracking
Tools like this one help defenders spot and decode these patterns.
## Encoding Detection
How do you figure out what encoding something is? Pattern matching and heuristics.
**Base64 signals:**
- Characters from `A-Za-z0-9+/=`
- Length divisible by 4
- Ends with 0, 1, or 2 `=` characters
- Mixed case (uppercase AND lowercase letters)
**Base32 signals:**
- All uppercase `A-Z` and digits `2-7`
- Length divisible by 8
- Padding with 0, 1, 3, 4, or 6 `=` characters
**Hex signals:**
- Only `0-9` and `a-f` (or `A-F`)
- Even length
- Consistent casing (all lower or all upper)
- Sometimes has separators (`:`, `-`, spaces)
**URL encoding signals:**
- Contains `%XX` sequences where XX is valid hex
The tricky part: some strings match multiple formats. The hex string `CAFE` is also valid base32 (if padded to 8 chars) and base64 (if length works out). Good detection uses confidence scoring, not binary yes/no. This project assigns each format a confidence from 0.0 to 1.0 and ranks them.
## Testing Your Understanding
1. Why does base64 output always have a length divisible by 4?
2. A developer stores API keys as base64 in a config file and calls it "encrypted configuration." What's wrong?
3. You see the string `JBSWY3DPEBLW64TMMQ======`. What encoding is this and how can you tell?
4. An IDS alert shows `%253Cscript%253E` in a URL parameter. What happened?
5. Why would malware use a non-standard base64 alphabet instead of the RFC 4648 one?

View File

@ -0,0 +1,228 @@
# 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.01.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:7385)
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:2228)
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:195203)
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:1819)
```
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.

View File

@ -0,0 +1,260 @@
# Implementation
## constants.py — The Foundation
Everything that other modules depend on lives here. No business logic, just definitions.
### EncodingFormat Enum (line 10)
```python
class EncodingFormat(StrEnum):
BASE64 = "base64"
BASE64URL = "base64url"
BASE32 = "base32"
HEX = "hex"
URL = "url"
```
`StrEnum` (Python 3.11+) means each member is also a string. You can compare `EncodingFormat.BASE64 == "base64"` and it's `True`. Typer uses this directly for the `--format` option: it auto-generates the choices from the enum values. No separate validation code needed.
### Character Sets (lines 3446)
```python
BASE64_CHARSET: Final[frozenset[str]] = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
```
Each encoding's valid character set is a `frozenset` for O(1) membership testing. The detector checks `all(c in BASE64_CHARSET for c in stripped)`. With a frozenset, each character lookup is a hash table check instead of a linear scan. For short strings the difference is negligible, but it's the right data structure regardless.
`Final` from `typing` tells mypy these are constants that should never be reassigned.
### Thresholds (lines 2433)
`PEEL_MAX_DEPTH = 20` caps recursion. In practice, even heavily obfuscated malware payloads rarely exceed 56 layers, but the cap prevents pathological inputs from running forever.
`CONFIDENCE_THRESHOLD = 0.6` is the minimum score for a detection to be considered valid. This value was tuned to avoid false positives while catching legitimate encodings. Too low and random strings trigger false detections. Too high and short or ambiguous encodings get missed.
`MIN_INPUT_LENGTH = 4` rejects trivially short inputs from detection. A 3-character string could match almost any format by coincidence.
## encoders.py — Pure Transformations
### Encode/Decode Functions (lines 2270)
Each format has a dedicated encode and decode function. They're small, focused, and independently testable.
Notable implementation details:
**decode_base64 (line 26)**: Uses `validate=True` in `b64.b64decode()`. Without this flag, Python's base64 decoder silently ignores non-alphabet characters. With it, any invalid character raises `binascii.Error`. This is RFC 4648 strict compliance. The `01-CONCEPTS.md` doc explains why lax decoders create security issues (padding oracle attacks).
**decode_base64 and decode_base64url (lines 27, 36)**: Both call `"".join(data.split())` before decoding. This strips all whitespace (newlines, spaces, tabs). Base64 data often comes wrapped at 76 characters (MIME standard), and the decoder needs to handle that.
**decode_base32 (line 45)**: Calls `.upper()` after stripping whitespace. Base32's alphabet is uppercase only, but users might paste lowercase. Case-normalizing before decoding is more forgiving without being unsafe.
**decode_hex (lines 5357)**: Strips common hex separators (space, colon, dash, dot). This means `AA:BB:CC`, `AA BB CC`, `AA-BB-CC`, and `AABBCC` all decode correctly. Real hex data comes in many formats: MAC addresses use colons, hex dumps use spaces, some tools use dashes.
**encode_url / decode_url (lines 6070)**: Support a `form` keyword-only argument. Standard URL encoding uses `%20` for spaces. Form encoding (`application/x-www-form-urlencoded`) uses `+`. The distinction matters for web security testing, so both are supported. The `*` in `def encode_url(data: bytes, *, form: bool = False)` forces `form` to be keyword-only, preventing accidental positional usage.
### The Registry (lines 7385)
```python
ENCODER_REGISTRY: dict[EncodingFormat, tuple[EncoderFn, DecoderFn]] = {
EncodingFormat.BASE64: (encode_base64, decode_base64),
...
}
```
Functions are first-class objects in Python. This dict maps each format to its (encoder, decoder) pair. Dispatch is a dictionary lookup:
```python
def encode(data: bytes, fmt: EncodingFormat) -> str:
encoder_fn, _ = ENCODER_REGISTRY[fmt]
return encoder_fn(data)
```
The URL entry uses lambdas (`lambda data: encode_url(data)`) because the URL functions have an extra `form` parameter that doesn't match the `EncoderFn` / `DecoderFn` type signatures. The lambdas create wrapper functions with the default `form=False`.
### try_decode (lines 98107)
```python
def try_decode(data: str, fmt: EncodingFormat) -> bytes | None:
try:
return decode(data, fmt)
except (ValueError, binascii.Error, UnicodeDecodeError, UnicodeEncodeError):
return None
```
The detector and peeler need to attempt decoding without crashing if the input is invalid. `try_decode` wraps `decode` and converts exceptions to `None`. The exception tuple covers all possible decode failures across all formats. This is the "errors as values" pattern, common in functional programming.
## detector.py — Confidence Scoring
### DetectionResult (line 24)
```python
@dataclass(frozen=True, slots=True)
class DetectionResult:
format: EncodingFormat
confidence: float
decoded: bytes | None
```
Every detection result carries its confidence as a float from 0.0 to 1.0. This is fundamentally different from a binary "is it base64? yes/no" check. A string like `CAFE` could be hex or base64. Confidence scoring lets the system say "it's 80% likely hex, 50% likely base64" and rank accordingly.
### Scorer Walkthrough: _score_base64 (lines 3167)
This is the most nuanced scorer because base64's character set overlaps with other formats.
**Phase 1 — Quick rejection (lines 3238)**:
- Strip whitespace
- Reject if shorter than 4 characters
- Reject if any character isn't in `BASE64_CHARSET`
- Reject if length isn't divisible by 4
These checks are cheap. If any fails, the function returns 0.0 immediately without attempting a decode.
**Phase 2 — Structural scoring (lines 4058)**:
- Start at 0.4 (baseline for matching charset + length)
- +0.1 for valid padding (0, 1, or 2 `=` characters)
- +0.1 if `+` or `/` present (these distinguish base64 from hex/base32)
- +0.1 for mixed case (uppercase AND lowercase letters)
- -0.2 penalty if no uppercase and no special characters (`+`, `/`, `=`)
- +0.05 for length >= 8
That -0.2 penalty is critical. A hex string like `6132666338` contains only lowercase letters and digits. Without the penalty, it scores 0.4 + 0.1 (padding) + 0.05 (length) + 0.15 (decode) + 0.15 (printable) = 0.85 as base64. With the penalty: 0.65. Meanwhile the hex scorer gives it 0.80. Hex wins.
**Phase 3 — Decode validation (lines 6066)**:
- Attempt actual base64 decode with `try_decode`
- If decode fails, return 0.0 (invalid base64 structure)
- +0.15 for successful decode
- +0.15 if decoded output is printable text
### The Hex vs Base64 Disambiguation Problem
This was the hardest detection challenge. Consider the hex string `6332566a636d563049484268655778765957513d` (which is hex-encoded `c2VjcmV0IHBheWxvYWQ=`, which is base64-encoded `secret payload`).
Every character in that hex string (`0-9`, `a-f`) is also a valid base64 character. The string's length happens to be divisible by 4. Without careful scoring, both formats get similar confidence.
The solution uses two signals:
1. **Mixed-case check in base64 scorer (lines 5055)**: Hex strings are typically all lowercase or all uppercase. Base64 almost always has mixed case. If there's no uppercase and no special chars, base64 gets a -0.2 penalty.
2. **Consistent-case bonus in hex scorer (lines 154156)**: If all alphabetic characters are the same case, hex gets a +0.1 bonus.
These two adjustments create reliable separation. The hex string scores ~0.80 for hex and ~0.50 for base64.
### _score_url (lines 174192)
The URL scorer works differently from the others. Instead of checking a character set, it uses a regex to find `%XX` patterns:
```python
_URL_PATTERN = re.compile(r"%[0-9a-fA-F]{2}")
```
The confidence scales with the ratio of encoded characters to total length. A string that's 50% percent-encoded sequences gets a higher score than one with a single `%20`. This handles the spectrum from "mostly normal text with one encoded space" to "completely percent-encoded payload."
### detect_encoding (lines 206223)
The orchestrator. Runs every scorer, filters by threshold, sorts by confidence descending. The caller gets a ranked list of all possible formats above 0.6 confidence.
`detect_best` (lines 226228) is a convenience that returns just the top result.
## peeler.py — Recursive Layer Stripping
### The Core Loop (lines 3373)
The peel function is iterative, not recursive. Each iteration:
1. Calls `detect_best(current_text)` to find the most likely format
2. Checks three break conditions: no detection, below threshold, decode returned None
3. Records a `PeelLayer` with the current depth, format, confidence, and previews
4. Decodes and tries to interpret the result as UTF-8
The UTF-8 check on line 65 is the natural stop condition. When you hit the original data (which might be binary), it won't be valid UTF-8, and the loop breaks. This also prevents the peeler from trying to "detect" encodings in binary garbage.
### Why Iterative Instead of Recursive
The function uses a `for` loop with `max_depth` instead of calling itself. In Python, recursive calls have overhead (new stack frames) and risk `RecursionError` for deep chains. An iterative approach with explicit loop state is cleaner and has a hard cap via `range(max_depth)`.
### PeelResult Design (line 26)
```python
@dataclass(frozen=True, slots=True)
class PeelResult:
layers: tuple[PeelLayer, ...]
final_output: bytes
success: bool
```
`layers` is a tuple, not a list. Since the result is frozen, the layers shouldn't be mutable either. `final_output` is `bytes` rather than `str` because the final decoded content might be binary data. `success` is `True` if at least one layer was peeled, `False` if the input didn't look encoded at all.
## formatter.py — Terminal Output
### stderr for Rich, stdout for Data (line 19)
```python
console = Console(stderr=True)
```
All Rich output (panels, tables, color) goes to stderr. When the user pipes output (`b64tool decode "..." | other_tool`), only raw data hits stdout. The `is_piped()` check on line 22 controls this: piped mode writes raw strings, terminal mode writes Rich panels.
### Confidence Color Coding (lines 164169)
```python
def _confidence_color(confidence: float) -> str:
if confidence >= 0.9:
return "green"
if confidence >= 0.7:
return "yellow"
return "red"
```
Visual feedback in the peel output. Green means the tool is very confident about the detected format. Yellow means it's likely correct but there's some ambiguity. Red means the detection is marginal (just above the 0.6 threshold).
## cli.py — Wiring It Together
### Typer Application (lines 3945)
```python
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,
)
```
`no_args_is_help=True` shows the help message when the user runs `b64tool` with no subcommand. `pretty_exceptions_show_locals=False` prevents Typer from dumping local variables on crash, which could expose sensitive data being encoded/decoded.
### Annotated Type Hints (throughout)
Every CLI argument and option uses `Annotated[type, typer.Option(...)]`. This is the modern Typer pattern (replacing the older `typer.Option(default, ...)` style). The type annotation and CLI metadata stay together, and mypy validates the types correctly.
### Chain Step Parsing (lines 264281)
```python
def _parse_chain_steps(raw: str) -> list[EncodingFormat]:
```
Parses `"base64,hex,url"` into `[EncodingFormat.BASE64, EncodingFormat.HEX, EncodingFormat.URL]`. Each step is stripped, lowercased, and validated against the enum. Invalid format names produce a clear error listing all valid options. This is a private function (prefixed with `_`) because it's implementation detail of the chain command.
## utils.py — Input Resolution
### Three Input Sources (lines 1243)
Both `resolve_input_bytes` and `resolve_input_text` follow the same priority:
1. **File** (`--file` flag): Read from disk
2. **Argument**: Direct string on the command line
3. **Stdin**: Piped input (only if stdin is not a TTY)
4. If none: raise `typer.BadParameter`
This means these all work:
```bash
b64tool encode "Hello"
b64tool encode --file data.txt
echo "Hello" | b64tool encode
```
The distinction between bytes and text matters. Encoding operates on bytes (you can encode a binary file). Decoding operates on text (the encoded string is always ASCII-safe). This is why there are two separate functions rather than one generic one.
### is_printable_text (lines 6068)
Used by the detector to boost confidence when decoded output looks like readable text. The threshold (default 0.8) means at least 80% of characters must be printable or common whitespace (`\n`, `\r`, `\t`). This prevents binary data that happens to decode without errors from getting a "printable text" bonus.

View File

@ -0,0 +1,206 @@
# Challenges
## Easy
### 1. Add ROT13 Encoding
ROT13 shifts each letter by 13 positions. It's a Caesar cipher with a fixed key that's its own inverse (encoding and decoding are the same operation).
**What to do:**
- Add `ROT13 = "rot13"` to `EncodingFormat` in `constants.py`
- Write `encode_rot13(data: bytes) -> str` and `decode_rot13(data: str) -> bytes` in `encoders.py`
- Add the entry to `ENCODER_REGISTRY`
- Write a scorer `_score_rot13` in `detector.py` (hint: try decoding and check if the result has more recognizable English words than the input)
- Add tests
**Why this matters:** ROT13 is trivial to implement but the detection scorer is tricky. How do you distinguish ROT13 text from plain ASCII text? Both use the same character set. You'll need a language-level heuristic (letter frequency analysis, common word matching).
### 2. Add JSON Output Mode
Currently output is either Rich panels or raw text. Add a `--json` flag that outputs structured JSON.
**What to do:**
- Add a `--json` / `-j` flag to the `detect` and `peel` commands in `cli.py`
- When active, serialize `DetectionResult` and `PeelResult` to JSON and write to stdout
- Skip Rich formatting entirely in JSON mode
**Example output:**
```json
{
"results": [
{
"format": "base64",
"confidence": 0.95,
"decoded_preview": "Hello World"
}
]
}
```
**Why this matters:** Machine-readable output is essential for integrating CLI tools into automated pipelines. Security automation workflows (SOAR, SIEM correlation) consume JSON, not colored terminal text.
### 3. Add `--output` File Flag
Add a `-o` / `--output` flag to write results directly to a file instead of stdout.
**What to do:**
- Add the option to `encode`, `decode`, and `peel` commands
- For encode/decode, write the raw result to the file
- For peel, write the final output
- Print a confirmation message to stderr
**Why this matters:** When decoding binary payloads (malware samples, images), you need the exact bytes written to disk, not a terminal preview.
---
## Medium
### 4. Add ASCII85 (Base85) Encoding
ASCII85 encodes 4 bytes into 5 characters using 85 printable ASCII characters. It's used in PDF files and Git binary patches.
**What to do:**
- Add `ASCII85 = "ascii85"` to `EncodingFormat`
- Use `base64.a85encode()` and `base64.a85decode()` from Python's standard library
- Write a scorer for detection (ASCII85 data has a distinctive character distribution, often includes characters like `!`, `@`, `#` that don't appear in other encodings)
- Handle the `<~...~>` wrapper that Adobe's variant uses
**Why this matters:** You'll encounter ASCII85 in PDF malware analysis. Malicious PDFs embed executable content using ASCII85 streams.
### 5. Decode-Chain Command (Reverse of Chain)
The `chain` command encodes through multiple layers. Build a `decode-chain` command that takes a chain specification and decodes in reverse order.
**What to do:**
- Add a `decode-chain` command to `cli.py`
- Accept `--steps` like the chain command
- Apply decoders in reverse order: `--steps base64,hex` decodes hex first, then base64
- Show each intermediate step
**Example:**
```bash
b64tool decode-chain "4147567362473873" --steps base64,hex
# Step 1: hex decode → "AGVsbG8s"
# Step 2: base64 decode → "Hello,"
```
**Why this matters:** When you know the encoding order from analysis (or from the `peel` output), you can verify by explicitly specifying the decode chain. This is useful for validating peel results.
### 6. Hex Dump Display
Add a `hexdump` command that shows decoded binary data in traditional hex dump format (offset, hex bytes, ASCII representation).
**What to do:**
- Add a `hexdump` command that takes encoded input and a format flag
- Decode the input, then display in hex dump format:
```
00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 0a 00 ff fe |Hello World!....|
```
- 16 bytes per line, offset on the left, ASCII on the right (non-printable as `.`)
**Why this matters:** Hex dump is the standard view for binary analysis. Malware analysts live in hex dump views. Combining decode + hexdump in one tool saves piping through `xxd` or `hexdump`.
---
## Hard
### 7. Custom Base64 Alphabet Support
The DARKGATE malware uses custom base64 alphabets to evade detection tools that only understand RFC 4648. Build support for encoding/decoding with arbitrary alphabets.
**What to do:**
- Add a `--alphabet` option to encode and decode commands
- Accept a 64-character string (or 65 with padding character)
- Implement custom alphabet encode/decode without relying on Python's `base64` module (it doesn't support custom alphabets directly)
- The encoding logic: take 3 bytes, split into four 6-bit values, map each to the custom alphabet
- Add a `--alphabet-file` option for reading the alphabet from a file
**Example:**
```bash
b64tool encode "secret" --format base64 --alphabet "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/"
```
**Why this matters:** Real malware uses this. Building it teaches you the actual encoding math (bit manipulation, not just calling library functions). Threat intelligence teams need tools that handle non-standard alphabets.
### 8. Encoding Frequency Analysis
Add an `analyze` command that takes a file of encoded samples and reports statistics.
**What to do:**
- Read a file where each line is an encoded sample
- Run detection on every line
- Report: format distribution, average confidence per format, detection failures
- Flag samples that match multiple formats (ambiguous detections)
- Output as a summary table and optionally as JSON
**Why this matters:** SOC analysts often receive bulk indicators (IOCs) and need to quickly categorize them. Is this list of strings mostly base64 tokens? Hex hashes? Mixed? Bulk analysis answers that.
### 9. Streaming Mode for Large Files
Currently the tool reads entire files into memory. Add streaming support for large file processing.
**What to do:**
- For base64 encoding: read 3 bytes at a time (produces 4 output chars), or buffer in multiples of 3
- For base64 decoding: read 4 characters at a time
- For hex: read 1 byte / 2 characters at a time
- Implement with generators to maintain constant memory usage
- Add a `--stream` flag or auto-detect based on file size
**Why this matters:** Memory-mapping a 2 GB file to base64 encode it will crash most systems. Streaming processes any file size with constant memory. This is how production tools like `base64` (coreutils) work.
---
## Expert
### 10. Binary Format Detection in Decoded Output
After decoding, automatically detect what the decoded binary data is (file type magic bytes).
**What to do:**
- After decode or peel, check the first few bytes against known magic numbers:
- `\x89PNG\r\n\x1a\n` → PNG image
- `PK\x03\x04` → ZIP/DOCX/JAR archive
- `\x7fELF` → ELF executable (Linux binary)
- `MZ` → PE executable (Windows binary)
- `%PDF` → PDF document
- `GIF87a` / `GIF89a` → GIF image
- Display the detected file type alongside the decoded output
- Optionally save to a file with the correct extension
**Why this matters:** Malware payloads are often base64-encoded executables. Knowing that a decoded blob is a PE executable versus a PNG image changes your entire analysis approach. The DARKGATE loader encodes its second-stage payload in custom base64 — the decoded output is a DLL.
### 11. Entropy Analysis
Add an `entropy` command that calculates Shannon entropy of data before and after decoding.
**What to do:**
- Calculate Shannon entropy: `H = -Σ p(x) log2(p(x))` for each byte value
- Report entropy on a 08 scale (8 bits per byte = maximum entropy)
- Compare entropy before and after decoding
- Flag high-entropy decoded output (likely encrypted or compressed, not just encoded)
**Interpretation guide:**
- 01: highly structured/repetitive (like all zeros)
- 35: English text, source code
- 67: compressed data, some binary formats
- 7.58: encrypted data, random bytes
**Why this matters:** If you peel 3 encoding layers and the output has entropy 7.9, it's almost certainly encrypted — further encoding analysis won't help. If entropy is 4.5, it's probably readable text. Entropy is the first triage check in malware analysis.
### 12. Integrate with CyberChef Recipes
CyberChef (GCHQ's open-source data analysis tool) uses "recipes" — JSON descriptions of operation chains. Build import/export compatibility.
**What to do:**
- Parse CyberChef recipe JSON format
- Map CyberChef operations to b64tool formats (e.g., "To Base64" → base64 encode)
- Execute a recipe as a chain command
- Export peel results as a CyberChef recipe (reverse operations)
**Example:**
```bash
b64tool recipe --import recipe.json "input data"
b64tool peel "encoded_data" --export-recipe result.json
```
**Why this matters:** CyberChef is ubiquitous in CTF competitions and security operations centers. Being able to exchange recipes between tools demonstrates real interoperability. It also means your tool can leverage the thousands of existing CyberChef recipes shared by the community.

View File

@ -0,0 +1,116 @@
# ©AngelaMos | 2026
# pyproject.toml
[project]
name = "b64tool"
version = "0.1.0"
description = "Multi-format encoding/decoding CLI with recursive layer detection for security analysis."
readme = "README.md"
requires-python = ">=3.14"
license = { text = "MIT" }
authors = [{ name = "CarterPerez-dev", email = "support@certgames.com" }]
keywords = [
"base64",
"encoding",
"decoding",
"security",
"cli",
"cybersecurity",
"forensics",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.14",
"Topic :: Security",
"Topic :: Utilities",
]
dependencies = [
"typer>=0.21.1,<0.22.0",
"rich>=14.3.2,<15.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.2,<10.0.0",
"pytest-cov>=7.0.0,<8.0.0",
"hypothesis>=6.130.0,<7.0.0",
"mypy>=1.19.1,<2.0.0",
"ruff>=0.15.0,<0.16.0",
"pylint>=4.0.4,<5.0.0",
"pre-commit>=4.5.1,<5.0.0",
]
[project.urls]
Homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main"
Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/base64-tool"
Issues = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/issues"
[project.scripts]
b64tool = "base64_tool.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/base64_tool"]
[tool.ruff]
target-version = "py314"
line-length = 89
src = ["src"]
[tool.ruff.lint]
select = [
"E",
"W",
"F",
"B",
"C4",
"UP",
"ARG",
"SIM",
"PTH",
"RUF",
"S",
"N",
]
ignore = [
"E501",
"B008",
"S101",
"S104",
"S105",
"ARG001",
"N999",
"N818",
"RUF005",
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
[tool.pylint.main]
py-version = "3.14"
jobs = 4
persistent = true
suggestion-mode = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q"
filterwarnings = ["ignore::DeprecationWarning"]

View File

@ -0,0 +1,15 @@
"""
©AngelaMos | 2026
__init__.py
Multi-format encoder/decoder with recursive layer peeling
"""
__version__ = "0.1.0"
__author__ = "CarterPerez-dev"

View File

@ -0,0 +1,298 @@
"""
©AngelaMos | 2026
cli.py
"""
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

View File

@ -0,0 +1,78 @@
"""
©AngelaMos | 2026
constants.py
"""
from enum import StrEnum
from typing import Final
class EncodingFormat(StrEnum):
BASE64 = "base64"
BASE64URL = "base64url"
BASE32 = "base32"
HEX = "hex"
URL = "url"
class ExitCode:
SUCCESS: Final[int] = 0
ERROR: Final[int] = 1
INVALID_INPUT: Final[int] = 2
PEEL_MAX_DEPTH: Final[int] = 20
MIN_INPUT_LENGTH: Final[int] = 4
PREVIEW_LENGTH: Final[int] = 72
CONFIDENCE_THRESHOLD: Final[float] = 0.6
PRINTABLE_RATIO_THRESHOLD: Final[float] = 0.8
class ScoreWeight:
DECODE_SUCCESS: Final[float] = 0.15
PRINTABLE_RESULT: Final[float] = 0.15
LONGER_INPUT: Final[float] = 0.05
B64_BASE: Final[float] = 0.4
B64_VALID_PADDING: Final[float] = 0.1
B64_SPECIAL_CHARS: Final[float] = 0.1
B64_MIXED_CASE: Final[float] = 0.1
B64_NO_SIGNAL_PENALTY: Final[float] = 0.2
B64URL_BASE: Final[float] = 0.3
B64URL_SAFE_CHARS: Final[float] = 0.25
B32_BASE: Final[float] = 0.35
B32_VALID_PADDING: Final[float] = 0.1
B32_UPPERCASE: Final[float] = 0.1
HEX_BASE: Final[float] = 0.3
HEX_SEPARATOR_PRESENT: Final[float] = 0.2
HEX_ALPHA_CHARS: Final[float] = 0.1
HEX_NO_ALPHA_PENALTY: Final[float] = 0.15
HEX_CONSISTENT_CASE: Final[float] = 0.1
HEX_DECODE_SUCCESS: Final[float] = 0.1
URL_BASE: Final[float] = 0.3
URL_RATIO_MULTIPLIER: Final[float] = 0.4
URL_RATIO_CAP: Final[float] = 0.35
URL_DECODE_CHANGED: Final[float] = 0.15
BASE64_CHARSET: Final[
frozenset[str]
] = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
BASE64URL_CHARSET: Final[
frozenset[str]
] = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=")
BASE32_CHARSET: Final[frozenset[str]] = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=")
HEX_CHARSET: Final[frozenset[str]] = frozenset("0123456789abcdefABCDEF")
HEX_SEPARATORS: Final[frozenset[str]] = frozenset(" :.-")

View File

@ -0,0 +1,231 @@
"""
©AngelaMos | 2026
detector.py
"""
import re
from collections.abc import Callable
from dataclasses import dataclass
from base64_tool.constants import (
BASE32_CHARSET,
BASE64_CHARSET,
BASE64URL_CHARSET,
CONFIDENCE_THRESHOLD,
EncodingFormat,
HEX_CHARSET,
HEX_SEPARATORS,
MIN_INPUT_LENGTH,
ScoreWeight as W,
)
from base64_tool.encoders import try_decode
from base64_tool.utils import is_printable_text
@dataclass(frozen=True, slots=True)
class DetectionResult:
format: EncodingFormat
confidence: float
decoded: bytes | None
def _score_base64(data: str) -> float:
stripped = "".join(data.split())
if len(stripped) < MIN_INPUT_LENGTH:
return 0.0
if not all(c in BASE64_CHARSET for c in stripped):
return 0.0
if len(stripped) % 4 != 0:
return 0.0
score = W.B64_BASE
content = stripped.rstrip("=")
padding = len(stripped) - len(content)
if padding <= 2:
score += W.B64_VALID_PADDING
if any(c in stripped for c in "+/"):
score += W.B64_SPECIAL_CHARS
has_upper = any(c.isupper() for c in content)
has_lower = any(c.islower() for c in content)
if has_upper and has_lower:
score += W.B64_MIXED_CASE
elif not has_upper and not any(c in stripped for c in "+/="):
score -= W.B64_NO_SIGNAL_PENALTY
if len(stripped) >= 8:
score += W.LONGER_INPUT
decoded = try_decode(stripped, EncodingFormat.BASE64)
if decoded is None:
return 0.0
score += W.DECODE_SUCCESS
if is_printable_text(decoded):
score += W.PRINTABLE_RESULT
return min(score, 1.0)
def _score_base64url(data: str) -> float:
stripped = "".join(data.split())
if len(stripped) < MIN_INPUT_LENGTH:
return 0.0
if not all(c in BASE64URL_CHARSET for c in stripped):
return 0.0
score = W.B64URL_BASE
has_url_chars = any(c in stripped for c in "-_")
has_std_chars = any(c in stripped for c in "+/")
if has_url_chars and not has_std_chars:
score += W.B64URL_SAFE_CHARS
elif not has_url_chars:
return 0.0
decoded = try_decode(stripped, EncodingFormat.BASE64URL)
if decoded is None:
return 0.0
score += W.DECODE_SUCCESS
if is_printable_text(decoded):
score += W.PRINTABLE_RESULT
return min(score, 1.0)
def _score_base32(data: str) -> float:
stripped = "".join(data.split()).upper()
if len(stripped) < MIN_INPUT_LENGTH:
return 0.0
if not all(c in BASE32_CHARSET for c in stripped):
return 0.0
if len(stripped) % 8 != 0:
return 0.0
score = W.B32_BASE
valid_pad_counts = frozenset({0, 1, 3, 4, 6})
padding = len(stripped) - len(stripped.rstrip("="))
if padding in valid_pad_counts:
score += W.B32_VALID_PADDING
if data == data.upper():
score += W.B32_UPPERCASE
decoded = try_decode(stripped, EncodingFormat.BASE32)
if decoded is None:
return 0.0
score += W.DECODE_SUCCESS
if is_printable_text(decoded):
score += W.PRINTABLE_RESULT
return min(score, 1.0)
def _score_hex(data: str) -> float:
stripped = data.strip()
if len(stripped) < MIN_INPUT_LENGTH:
return 0.0
hex_only = stripped
for sep in HEX_SEPARATORS:
hex_only = hex_only.replace(sep, "")
if not hex_only:
return 0.0
if not all(c in HEX_CHARSET for c in hex_only):
return 0.0
if len(hex_only) % 2 != 0:
return 0.0
score = W.HEX_BASE
has_separators = any(sep in stripped for sep in HEX_SEPARATORS)
if has_separators:
score += W.HEX_SEPARATOR_PRESENT
has_alpha = any(c in "abcdefABCDEF" for c in hex_only)
if has_alpha:
score += W.HEX_ALPHA_CHARS
else:
score -= W.HEX_NO_ALPHA_PENALTY
is_consistent_case = (
hex_only == hex_only.lower() or hex_only == hex_only.upper()
)
if is_consistent_case:
score += W.HEX_CONSISTENT_CASE
if len(hex_only) >= 8:
score += W.LONGER_INPUT
decoded = try_decode(stripped, EncodingFormat.HEX)
if decoded is None:
return 0.0
score += W.HEX_DECODE_SUCCESS
if is_printable_text(decoded):
score += W.PRINTABLE_RESULT
return min(score, 1.0)
_URL_PATTERN = re.compile(r"%[0-9a-fA-F]{2}")
def _score_url(data: str) -> float:
if len(data) < MIN_INPUT_LENGTH:
return 0.0
matches = _URL_PATTERN.findall(data)
if not matches:
return 0.0
encoded_char_count = len(matches) * 3
ratio = encoded_char_count / len(data)
score = W.URL_BASE + min(ratio * W.URL_RATIO_MULTIPLIER, W.URL_RATIO_CAP)
decoded = try_decode(data, EncodingFormat.URL)
if decoded is not None:
decoded_text = decoded.decode("utf-8", errors="replace")
if decoded_text != data:
score += W.URL_DECODE_CHANGED
return min(score, 1.0)
_SCORERS: dict[EncodingFormat, Callable[[str], float]] = {
EncodingFormat.BASE64: _score_base64,
EncodingFormat.BASE64URL: _score_base64url,
EncodingFormat.BASE32: _score_base32,
EncodingFormat.HEX: _score_hex,
EncodingFormat.URL: _score_url,
}
def score_all_formats(data: str) -> dict[EncodingFormat, float]:
return {fmt: scorer(data) for fmt, scorer in _SCORERS.items()}
def detect_encoding(data: str) -> list[DetectionResult]:
results: list[DetectionResult] = []
for fmt, confidence in score_all_formats(data).items():
if confidence >= CONFIDENCE_THRESHOLD:
decoded = try_decode(data, fmt)
results.append(
DetectionResult(
format=fmt,
confidence=round(confidence, 2),
decoded=decoded,
)
)
results.sort(key=lambda r: r.confidence, reverse=True)
return results
def detect_best(data: str) -> DetectionResult | None:
results = detect_encoding(data)
return results[0] if results else None

View File

@ -0,0 +1,107 @@
"""
©AngelaMos | 2026
encoders.py
"""
import base64 as b64
import binascii
from collections.abc import Callable
from urllib.parse import (
quote,
quote_plus,
unquote,
unquote_plus,
)
from base64_tool.constants import EncodingFormat
type EncoderFn = Callable[[bytes], str]
type DecoderFn = Callable[[str], bytes]
def encode_base64(data: bytes) -> str:
return b64.b64encode(data).decode("ascii")
def decode_base64(data: str) -> bytes:
cleaned = "".join(data.split())
return b64.b64decode(cleaned, validate=True)
def encode_base64url(data: bytes) -> str:
return b64.urlsafe_b64encode(data).decode("ascii")
def decode_base64url(data: str) -> bytes:
cleaned = "".join(data.split())
return b64.urlsafe_b64decode(cleaned)
def encode_base32(data: bytes) -> str:
return b64.b32encode(data).decode("ascii")
def decode_base32(data: str) -> bytes:
cleaned = "".join(data.split()).upper()
return b64.b32decode(cleaned)
def encode_hex(data: bytes) -> str:
return data.hex()
def decode_hex(data: str) -> bytes:
cleaned = data.strip()
for sep in (" ", ":", "-", "."):
cleaned = cleaned.replace(sep, "")
return bytes.fromhex(cleaned)
def encode_url(data: bytes, *, form: bool = False) -> str:
text = data.decode("utf-8")
if form:
return quote_plus(text)
return quote(text, safe="")
def decode_url(data: str, *, form: bool = False) -> bytes:
if form:
return unquote_plus(data).encode("utf-8")
return unquote(data).encode("utf-8")
ENCODER_REGISTRY: dict[
EncodingFormat,
tuple[EncoderFn, DecoderFn],
] = {
EncodingFormat.BASE64: (encode_base64, decode_base64),
EncodingFormat.BASE64URL: (encode_base64url, decode_base64url),
EncodingFormat.BASE32: (encode_base32, decode_base32),
EncodingFormat.HEX: (encode_hex, decode_hex),
EncodingFormat.URL: (
lambda data: encode_url(data),
lambda data: decode_url(data),
),
}
def encode(data: bytes, fmt: EncodingFormat) -> str:
encoder_fn, _ = ENCODER_REGISTRY[fmt]
return encoder_fn(data)
def decode(data: str, fmt: EncodingFormat) -> bytes:
_, decoder_fn = ENCODER_REGISTRY[fmt]
return decoder_fn(data)
def try_decode(data: str, fmt: EncodingFormat) -> bytes | None:
try:
return decode(data, fmt)
except (
ValueError,
binascii.Error,
UnicodeDecodeError,
UnicodeEncodeError,
):
return None

View File

@ -0,0 +1,221 @@
"""
©AngelaMos | 2026
formatter.py
"""
import sys
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from base64_tool.constants import (
CONFIDENCE_THRESHOLD,
EncodingFormat,
PREVIEW_LENGTH,
)
from base64_tool.detector import DetectionResult
from base64_tool.peeler import PeelResult
from base64_tool.utils import safe_bytes_preview
console = Console(stderr=True)
def is_piped() -> bool:
return not sys.stdout.isatty()
def write_raw(text: str) -> None:
sys.stdout.write(text)
sys.stdout.flush()
def print_encoded(result: str, fmt: EncodingFormat) -> None:
if is_piped():
write_raw(result)
return
panel = Panel(
Text(result, style="green"),
title=f"[bold cyan]{fmt.value}[/bold cyan] encoded",
border_style="cyan",
)
console.print(panel)
def print_decoded(result: bytes) -> None:
preview = safe_bytes_preview(result, length=4096)
if is_piped():
write_raw(preview)
return
panel = Panel(
Text(preview, style="green"),
title="[bold cyan]Decoded[/bold cyan]",
border_style="cyan",
)
console.print(panel)
def print_score_breakdown(
scores: dict[EncodingFormat, float],
) -> None:
table = Table(
title="Score Breakdown",
show_header=True,
header_style="bold magenta",
)
table.add_column("Format", style="cyan", min_width=10)
table.add_column(
"Score",
justify="right",
min_width=8,
)
table.add_column("Status", min_width=10)
sorted_scores = sorted(
scores.items(), key=lambda x: x[1], reverse=True,
)
for fmt, score in sorted_scores:
color = _confidence_color(score)
if score >= CONFIDENCE_THRESHOLD:
status = "[green]detected[/green]"
elif score > 0:
status = "[yellow]below threshold[/yellow]"
else:
status = "[dim]no match[/dim]"
table.add_row(
fmt.value,
f"[{color}]{score:.0%}[/{color}]",
status,
)
console.print(table)
def print_detection(
results: list[DetectionResult],
*,
verbose_scores: dict[EncodingFormat, float] | None = None,
) -> None:
if verbose_scores is not None:
print_score_breakdown(verbose_scores)
console.print()
if not results:
console.print("[yellow]No encoding format detected.[/yellow]")
return
table = Table(
title="Detection Results",
show_header=True,
header_style="bold magenta",
)
table.add_column("Format", style="cyan", min_width=10)
table.add_column(
"Confidence",
justify="right",
style="green",
min_width=12,
)
table.add_column("Decoded Preview", style="dim")
for result in results:
confidence_str = f"{result.confidence:.0%}"
preview = ""
if result.decoded is not None:
preview = safe_bytes_preview(
result.decoded,
PREVIEW_LENGTH,
)
table.add_row(
result.format.value,
confidence_str,
preview,
)
console.print(table)
def print_peel_result(
result: PeelResult,
*,
verbose: bool = False,
) -> None:
if not result.success:
console.print("[yellow]No encoding layers detected.[/yellow]")
return
layer_count = len(result.layers)
suffix = "s" if layer_count > 1 else ""
console.print()
console.print(
f"[bold cyan]Peeled {layer_count} encoding "
f"layer{suffix}[/bold cyan]"
)
console.print()
for layer in result.layers:
color = _confidence_color(layer.confidence)
console.print(
f" [bold]Layer {layer.depth}[/bold] "
f"[cyan]{layer.format.value}[/cyan] "
f"[{color}]{layer.confidence:.0%}[/{color}]"
)
console.print(f" [dim]{layer.decoded_preview}[/dim]")
if verbose and layer.all_scores:
console.print()
print_score_breakdown(dict(layer.all_scores))
console.print()
console.print()
preview = safe_bytes_preview(result.final_output, length=4096)
panel = Panel(
Text(preview, style="bold green"),
title="[bold]Final Output[/bold]",
border_style="green",
subtitle=(f"[dim]{layer_count} layer{suffix} peeled[/dim]"),
)
console.print(panel)
def print_chain_result(
steps: list[tuple[EncodingFormat, str]],
final: str,
) -> None:
if is_piped():
write_raw(final)
return
console.print()
console.print("[bold cyan]Encoding Chain[/bold cyan]")
console.print()
for i, (fmt, intermediate) in enumerate(steps):
marker = "start" if i == 0 else "step"
arrow = f" [{marker}] " if i == 0 else " -> "
truncated = intermediate[:PREVIEW_LENGTH]
ellipsis = "..." if len(intermediate) > PREVIEW_LENGTH else ""
console.print(
f"{arrow}[cyan]{fmt.value}[/cyan] "
f"[dim]{truncated}{ellipsis}[/dim]"
)
console.print()
panel = Panel(
Text(final, style="green"),
title="[bold]Chain Result[/bold]",
border_style="cyan",
subtitle=f"[dim]{len(steps)} steps[/dim]",
)
console.print(panel)
def _confidence_color(confidence: float) -> str:
if confidence >= 0.9:
return "green"
if confidence >= 0.7:
return "yellow"
return "red"

View File

@ -0,0 +1,82 @@
"""
©AngelaMos | 2026
peeler.py
"""
from dataclasses import dataclass
from base64_tool.constants import (
CONFIDENCE_THRESHOLD,
PEEL_MAX_DEPTH,
EncodingFormat,
)
from base64_tool.detector import detect_best, score_all_formats
from base64_tool.utils import safe_bytes_preview, truncate
@dataclass(frozen=True, slots=True)
class PeelLayer:
depth: int
format: EncodingFormat
confidence: float
encoded_preview: str
decoded_preview: str
all_scores: tuple[tuple[EncodingFormat, float], ...] = ()
@dataclass(frozen=True, slots=True)
class PeelResult:
layers: tuple[PeelLayer, ...]
final_output: bytes
success: bool
def peel(
data: str,
*,
max_depth: int = PEEL_MAX_DEPTH,
threshold: float = CONFIDENCE_THRESHOLD,
verbose: bool = False,
) -> PeelResult:
layers: list[PeelLayer] = []
current_text = data
current_bytes = data.encode("utf-8")
for depth in range(max_depth):
detection = detect_best(current_text)
if detection is None:
break
if detection.confidence < threshold:
break
if detection.decoded is None:
break
scores = (
tuple(score_all_formats(current_text).items())
if verbose
else ()
)
decoded_bytes = detection.decoded
layer = PeelLayer(
depth=depth + 1,
format=detection.format,
confidence=detection.confidence,
encoded_preview=truncate(current_text),
decoded_preview=safe_bytes_preview(decoded_bytes),
all_scores=scores,
)
layers.append(layer)
current_bytes = decoded_bytes
try:
current_text = decoded_bytes.decode("utf-8")
except (UnicodeDecodeError, ValueError):
break
return PeelResult(
layers=tuple(layers),
final_output=current_bytes,
success=len(layers) > 0,
)

View File

@ -0,0 +1,68 @@
"""
©AngelaMos | 2026
utils.py
"""
import sys
from pathlib import Path
import typer
def resolve_input_bytes(
data: str | None,
file: Path | None,
) -> bytes:
if file is not None:
if not file.exists():
raise typer.BadParameter(f"File not found: {file}")
return file.read_bytes()
if data is not None:
return data.encode("utf-8")
if not sys.stdin.isatty():
return sys.stdin.buffer.read()
raise typer.BadParameter(
"No input provided. Pass an argument, use --file, or pipe stdin."
)
def resolve_input_text(
data: str | None,
file: Path | None,
) -> str:
if file is not None:
if not file.exists():
raise typer.BadParameter(f"File not found: {file}")
return file.read_text("utf-8").strip()
if data is not None:
return data.strip()
if not sys.stdin.isatty():
return sys.stdin.read().strip()
raise typer.BadParameter(
"No input provided. Pass an argument, use --file, or pipe stdin."
)
def truncate(text: str, length: int = 72) -> str:
if len(text) <= length:
return text
return text[: length] + "..."
def safe_bytes_preview(data: bytes, length: int = 72) -> str:
try:
text = data.decode("utf-8")
return truncate(text, length)
except (UnicodeDecodeError, ValueError):
return truncate(data.hex(), length)
def is_printable_text(data: bytes, threshold: float = 0.8) -> bool:
try:
text = data.decode("utf-8")
except (UnicodeDecodeError, ValueError):
return False
if not text:
return False
printable_count = sum(1 for c in text if c.isprintable() or c in "\n\r\t")
return (printable_count / len(text)) >= threshold

View File

@ -0,0 +1,4 @@
"""
©AngelaMos | 2026
__init__.py
"""

View File

@ -0,0 +1,173 @@
"""
©AngelaMos | 2026
test_cli.py
"""
from typer.testing import CliRunner
from base64_tool.cli import app
runner = CliRunner()
class TestEncodeCommand:
def test_encode_base64(self) -> None:
result = runner.invoke(app, ["encode", "Hello World"])
assert result.exit_code == 0
assert "SGVsbG8gV29ybGQ=" in result.output
def test_encode_hex(self) -> None:
result = runner.invoke(
app,
["encode",
"Hello",
"--format",
"hex"],
)
assert result.exit_code == 0
assert "48656c6c6f" in result.output
def test_encode_base32(self) -> None:
result = runner.invoke(
app,
["encode",
"Hello",
"--format",
"base32"],
)
assert result.exit_code == 0
assert "JBSWY3DP" in result.output
def test_encode_url(self) -> None:
result = runner.invoke(
app,
["encode",
"hello world&test",
"--format",
"url"],
)
assert result.exit_code == 0
assert "%20" in result.output or "hello" in result.output
def test_encode_empty_string(self) -> None:
result = runner.invoke(app, ["encode", ""])
assert result.exit_code == 0
class TestDecodeCommand:
def test_decode_base64(self) -> None:
result = runner.invoke(
app,
["decode",
"SGVsbG8gV29ybGQ="],
)
assert result.exit_code == 0
assert "Hello World" in result.output
def test_decode_hex(self) -> None:
result = runner.invoke(
app,
["decode",
"48656c6c6f",
"--format",
"hex"],
)
assert result.exit_code == 0
assert "Hello" in result.output
def test_decode_invalid_base64(self) -> None:
result = runner.invoke(
app,
["decode",
"!!!invalid!!!"],
)
assert result.exit_code != 0
class TestDetectCommand:
def test_detect_base64(self) -> None:
result = runner.invoke(
app,
["detect",
"SGVsbG8gV29ybGQ="],
)
assert result.exit_code == 0
assert "base64" in result.output.lower()
def test_detect_hex(self) -> None:
result = runner.invoke(
app,
["detect",
"48656c6c6f20576f726c64"],
)
assert result.exit_code == 0
assert "hex" in result.output.lower()
def test_detect_no_match(self) -> None:
result = runner.invoke(
app,
["detect",
"just plain text"],
)
assert result.exit_code == 0
assert "no encoding" in result.output.lower()
class TestPeelCommand:
def test_peel_single_layer(self) -> None:
result = runner.invoke(
app,
["peel",
"SGVsbG8gV29ybGQ="],
)
assert result.exit_code == 0
assert "layer" in result.output.lower()
def test_peel_no_encoding(self) -> None:
result = runner.invoke(
app,
["peel",
"hello world"],
)
assert result.exit_code == 0
class TestChainCommand:
def test_chain_single_step(self) -> None:
result = runner.invoke(
app,
["chain",
"Hello",
"--steps",
"base64"],
)
assert result.exit_code == 0
assert "SGVsbG8=" in result.output
def test_chain_multiple_steps(self) -> None:
result = runner.invoke(
app,
["chain",
"Hi",
"--steps",
"base64,hex"],
)
assert result.exit_code == 0
def test_chain_invalid_format(self) -> None:
result = runner.invoke(
app,
["chain",
"test",
"--steps",
"fake"],
)
assert result.exit_code != 0
class TestVersionFlag:
def test_version_output(self) -> None:
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert "b64tool" in result.output

View File

@ -0,0 +1,107 @@
"""
©AngelaMos | 2026
test_detector.py
"""
from base64_tool.constants import EncodingFormat
from base64_tool.detector import detect_best, detect_encoding
class TestDetectBase64:
def test_standard_base64(self) -> None:
result = detect_best("SGVsbG8gV29ybGQ=")
assert result is not None
assert result.format == EncodingFormat.BASE64
assert result.confidence >= 0.7
def test_base64_no_padding(self) -> None:
result = detect_best("SGVsbG8gV29ybGQh")
assert result is not None
assert result.format == EncodingFormat.BASE64
def test_base64_with_plus_slash(self) -> None:
result = detect_best("dGVzdC9wYXRoK3F1ZXJ5")
assert result is not None
assert result.format == EncodingFormat.BASE64
class TestDetectBase64Url:
def test_url_safe_chars(self) -> None:
result = detect_best("dGVzdC1kYXRhX3ZhbHVl")
assert result is not None
assert result.format in (
EncodingFormat.BASE64URL,
EncodingFormat.BASE64,
)
class TestDetectBase32:
def test_standard_base32(self) -> None:
result = detect_best("JBSWY3DPEBLW64TMMQ======")
assert result is not None
assert result.format == EncodingFormat.BASE32
assert result.confidence >= 0.6
def test_base32_uppercase(self) -> None:
result = detect_best("JBSWY3DP")
assert result is not None
assert result.format == EncodingFormat.BASE32
class TestDetectHex:
def test_hex_with_letters(self) -> None:
result = detect_best("48656c6c6f20576f726c64")
assert result is not None
assert result.format == EncodingFormat.HEX
assert result.confidence >= 0.6
def test_hex_with_colons(self) -> None:
result = detect_best("48:65:6c:6c:6f:20:57:6f:72:6c:64")
assert result is not None
assert result.format == EncodingFormat.HEX
def test_pure_digits_not_detected(self) -> None:
result = detect_best("1234567890")
if result is not None and result.format == EncodingFormat.HEX:
assert result.confidence < 0.7
class TestDetectUrl:
def test_url_encoded(self) -> None:
result = detect_best("hello%20world%21%40%23")
assert result is not None
assert result.format == EncodingFormat.URL
def test_heavily_encoded(self) -> None:
result = detect_best("%48%65%6C%6C%6F%20%57%6F%72%6C%64")
assert result is not None
assert result.format == EncodingFormat.URL
assert result.confidence >= 0.6
class TestDetectMultiple:
def test_returns_sorted_by_confidence(self) -> None:
results = detect_encoding("SGVsbG8gV29ybGQ=")
if len(results) > 1:
confidences = [r.confidence for r in results]
assert confidences == sorted(confidences, reverse = True)
def test_no_match_returns_empty(self) -> None:
results = detect_encoding("hello world")
assert results == []
def test_short_string_returns_empty(self) -> None:
results = detect_encoding("ab")
assert results == []
class TestDetectBest:
def test_returns_highest_confidence(self) -> None:
result = detect_best("SGVsbG8gV29ybGQ=")
assert result is not None
all_results = detect_encoding("SGVsbG8gV29ybGQ=")
if all_results:
assert result.confidence == all_results[0].confidence
def test_no_match_returns_none(self) -> None:
assert detect_best("not encoded at all!") is None

View File

@ -0,0 +1,160 @@
"""
©AngelaMos | 2026
test_encoders.py
"""
import binascii
import pytest
from base64_tool.constants import EncodingFormat
from base64_tool.encoders import (
decode,
decode_base32,
decode_base64,
decode_base64url,
decode_hex,
decode_url,
encode,
encode_base32,
encode_base64,
encode_base64url,
encode_hex,
encode_url,
try_decode,
)
class TestBase64:
def test_encode_simple_text(self) -> None:
assert encode_base64(b"Hello World") == "SGVsbG8gV29ybGQ="
def test_decode_simple_text(self) -> None:
assert decode_base64("SGVsbG8gV29ybGQ=") == b"Hello World"
def test_roundtrip(self) -> None:
original = b"The quick brown fox jumps over the lazy dog"
assert decode_base64(encode_base64(original)) == original
def test_encode_empty(self) -> None:
assert encode_base64(b"") == ""
def test_decode_empty(self) -> None:
assert decode_base64("") == b""
def test_encode_binary_data(self) -> None:
data = bytes(range(256))
assert decode_base64(encode_base64(data)) == data
def test_decode_with_whitespace(self) -> None:
encoded = "SGVs\nbG8g\nV29y\nbGQ="
assert decode_base64(encoded) == b"Hello World"
def test_decode_invalid_raises(self) -> None:
with pytest.raises((ValueError, binascii.Error)):
decode_base64("!!!invalid!!!")
def test_encode_unicode(self) -> None:
data = "Hello 世界".encode()
decoded = decode_base64(encode_base64(data))
assert decoded == data
class TestBase64Url:
def test_encode_with_url_chars(self) -> None:
data = b"\xfb\xff\xfe"
encoded = encode_base64url(data)
assert "+" not in encoded
assert "/" not in encoded
def test_decode_url_safe(self) -> None:
result = decode_base64url(encode_base64url(b"test/path+query"))
assert result == b"test/path+query"
def test_roundtrip(self) -> None:
original = b"https://example.com?token=abc+def/ghi"
assert decode_base64url(encode_base64url(original)) == original
class TestBase32:
def test_encode_simple(self) -> None:
assert encode_base32(b"Hello") == "JBSWY3DP"
def test_decode_simple(self) -> None:
assert decode_base32("JBSWY3DP") == b"Hello"
def test_roundtrip(self) -> None:
original = b"Base32 encoding test"
assert decode_base32(encode_base32(original)) == original
def test_decode_lowercase_accepted(self) -> None:
assert decode_base32("jbswy3dp") == b"Hello"
def test_decode_with_padding(self) -> None:
assert decode_base32("JBSWY3DPEBLW64TMMQ======") == b"Hello World"
class TestHex:
def test_encode_simple(self) -> None:
assert encode_hex(b"\xca\xfe") == "cafe"
def test_decode_simple(self) -> None:
assert decode_hex("cafe") == b"\xca\xfe"
def test_decode_with_colons(self) -> None:
assert decode_hex("ca:fe:ba:be") == b"\xca\xfe\xba\xbe"
def test_decode_with_spaces(self) -> None:
assert decode_hex("ca fe ba be") == b"\xca\xfe\xba\xbe"
def test_decode_with_dashes(self) -> None:
assert decode_hex("ca-fe-ba-be") == b"\xca\xfe\xba\xbe"
def test_decode_uppercase(self) -> None:
assert decode_hex("CAFE") == b"\xca\xfe"
def test_roundtrip(self) -> None:
original = b"Hello World"
assert decode_hex(encode_hex(original)) == original
class TestUrl:
def test_encode_special_chars(self) -> None:
result = encode_url(b"hello world&foo=bar")
assert " " not in result
assert "&" not in result
def test_decode_percent_encoded(self) -> None:
assert decode_url("hello%20world") == b"hello world"
def test_roundtrip(self) -> None:
original = b"path/to/file?key=value&other=test"
assert decode_url(encode_url(original)) == original
def test_form_encode_space_as_plus(self) -> None:
result = encode_url(b"hello world", form = True)
assert "+" in result
assert "%20" not in result
def test_form_decode_plus_as_space(self) -> None:
assert decode_url("hello+world", form = True) == b"hello world"
class TestRegistryDispatch:
@pytest.mark.parametrize("fmt", list(EncodingFormat))
def test_encode_decode_roundtrip(
self,
fmt: EncodingFormat,
) -> None:
original = b"roundtrip test data"
encoded = encode(original, fmt)
decoded = decode(encoded, fmt)
assert decoded == original
def test_try_decode_valid(self) -> None:
result = try_decode("SGVsbG8=", EncodingFormat.BASE64)
assert result == b"Hello"
def test_try_decode_invalid_returns_none(self) -> None:
result = try_decode("!!!bad!!!", EncodingFormat.BASE64)
assert result is None

View File

@ -0,0 +1,74 @@
"""
©AngelaMos | 2026
test_peeler.py
"""
from base64_tool.constants import EncodingFormat
from base64_tool.encoders import encode
from base64_tool.peeler import peel
class TestSingleLayer:
def test_peel_base64(self) -> None:
encoded = encode(b"Hello World", EncodingFormat.BASE64)
result = peel(encoded)
assert result.success is True
assert len(result.layers) == 1
assert result.layers[0].format == EncodingFormat.BASE64
assert result.final_output == b"Hello World"
def test_peel_hex(self) -> None:
encoded = encode(b"Hello World", EncodingFormat.HEX)
result = peel(encoded)
assert result.success is True
assert len(result.layers) >= 1
assert result.final_output == b"Hello World"
def test_peel_base32(self) -> None:
encoded = encode(b"Hello World", EncodingFormat.BASE32)
result = peel(encoded)
assert result.success is True
assert len(result.layers) >= 1
class TestMultiLayer:
def test_base64_then_hex(self) -> None:
step1 = encode(b"secret payload", EncodingFormat.BASE64)
step2 = encode(step1.encode("utf-8"), EncodingFormat.HEX)
result = peel(step2)
assert result.success is True
assert len(result.layers) >= 2
assert b"secret payload" in result.final_output
def test_base64_double_encoded(self) -> None:
step1 = encode(b"double layer", EncodingFormat.BASE64)
step2 = encode(step1.encode("utf-8"), EncodingFormat.BASE64)
result = peel(step2)
assert result.success is True
assert len(result.layers) >= 2
class TestPeelEdgeCases:
def test_plaintext_no_layers(self) -> None:
result = peel("just plain text")
assert result.success is False
assert len(result.layers) == 0
def test_max_depth_respected(self) -> None:
encoded = encode(b"data", EncodingFormat.BASE64)
result = peel(encoded, max_depth = 0)
assert len(result.layers) == 0
def test_empty_string(self) -> None:
result = peel("")
assert result.success is False
def test_layer_metadata_populated(self) -> None:
encoded = encode(b"test data", EncodingFormat.BASE64)
result = peel(encoded)
if result.success and result.layers:
layer = result.layers[0]
assert layer.depth == 1
assert layer.confidence > 0
assert len(layer.encoded_preview) > 0
assert len(layer.decoded_preview) > 0

View File

@ -0,0 +1,109 @@
"""
©AngelaMos | 2026
test_properties.py
"""
from hypothesis import given, settings, strategies as st
from base64_tool.constants import EncodingFormat
from base64_tool.encoders import (
decode,
decode_base32,
decode_base64,
decode_base64url,
decode_hex,
decode_url,
encode,
encode_base32,
encode_base64,
encode_base64url,
encode_hex,
encode_url,
)
class TestBase64Properties:
@given(st.binary())
def test_roundtrip(self, data: bytes) -> None:
assert decode_base64(encode_base64(data)) == data
@given(st.binary(min_size=1))
def test_encoded_is_ascii(self, data: bytes) -> None:
encoded = encode_base64(data)
encoded.encode("ascii")
@given(st.binary())
def test_encoded_length_is_multiple_of_4(self, data: bytes) -> None:
encoded = encode_base64(data)
if encoded:
assert len(encoded) % 4 == 0
class TestBase64UrlProperties:
@given(st.binary())
def test_roundtrip(self, data: bytes) -> None:
assert decode_base64url(encode_base64url(data)) == data
@given(st.binary(min_size=1))
def test_no_standard_base64_chars(self, data: bytes) -> None:
encoded = encode_base64url(data)
assert "+" not in encoded
assert "/" not in encoded
class TestBase32Properties:
@given(st.binary())
def test_roundtrip(self, data: bytes) -> None:
assert decode_base32(encode_base32(data)) == data
@given(st.binary(min_size=1))
def test_encoded_is_uppercase(self, data: bytes) -> None:
encoded = encode_base32(data)
assert encoded == encoded.upper()
@given(st.binary())
def test_encoded_length_is_multiple_of_8(self, data: bytes) -> None:
encoded = encode_base32(data)
if encoded:
assert len(encoded) % 8 == 0
class TestHexProperties:
@given(st.binary())
def test_roundtrip(self, data: bytes) -> None:
assert decode_hex(encode_hex(data)) == data
@given(st.binary(min_size=1))
def test_encoded_length_is_double(self, data: bytes) -> None:
assert len(encode_hex(data)) == len(data) * 2
@given(st.binary())
def test_encoded_is_hex_chars_only(self, data: bytes) -> None:
encoded = encode_hex(data)
assert all(c in "0123456789abcdef" for c in encoded)
class TestUrlProperties:
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
@settings(max_examples=200)
def test_roundtrip(self, text: str) -> None:
data = text.encode("utf-8")
assert decode_url(encode_url(data)) == data
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
@settings(max_examples=200)
def test_form_roundtrip(self, text: str) -> None:
data = text.encode("utf-8")
assert decode_url(encode_url(data, form=True), form=True) == data
class TestCrossFormatProperties:
@given(st.binary(min_size=1, max_size=256))
def test_all_formats_roundtrip(self, data: bytes) -> None:
for fmt in (
EncodingFormat.BASE64,
EncodingFormat.BASE64URL,
EncodingFormat.BASE32,
EncodingFormat.HEX,
):
assert decode(encode(data, fmt), fmt) == data

View File

@ -0,0 +1,514 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "astroid"
version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
]
[[package]]
name = "b64tool"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "rich" },
{ name = "typer" },
]
[package.optional-dependencies]
dev = [
{ name = "hypothesis" },
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pylint" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.130.0,<7.0.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1,<2.0.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1,<5.0.0" },
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4,<5.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10.0.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0,<8.0.0" },
{ name = "rich", specifier = ">=14.3.2,<15.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0,<0.16.0" },
{ name = "typer", specifier = ">=0.21.1,<0.22.0" },
]
provides-extras = ["dev"]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" },
{ url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" },
{ url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" },
{ url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" },
{ url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" },
{ url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" },
{ url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" },
{ url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" },
{ url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" },
{ url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" },
{ url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" },
{ url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" },
{ url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" },
{ url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" },
{ url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" },
{ url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" },
{ url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" },
]
[[package]]
name = "dill"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "filelock"
version = "3.20.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
]
[[package]]
name = "hypothesis"
version = "6.151.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/d7/c40dcd401cc360d8d084e584ffb7ab17255fde22e2b9cf2b53bf25aed629/hypothesis-6.151.5.tar.gz", hash = "sha256:ae3a0622f9693e6b19c697777c2c266c02801f9769ab7c2c37b7ec83d4743783", size = 475923, upload-time = "2026-02-03T19:33:55.845Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d9/53a8b53e75279a953fae608bd01025d9afcf393406c0da1dda1b7f5693c5/hypothesis-6.151.5-py3-none-any.whl", hash = "sha256:c0e15c91fa0e67bc0295551ef5041bebad42753b7977a610cd7a6ec1ad04ef13", size = 543338, upload-time = "2026-02-03T19:33:54.583Z" },
]
[[package]]
name = "identify"
version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
]
[[package]]
name = "librt"
version = "0.7.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" },
{ url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" },
{ url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" },
{ url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" },
{ url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" },
{ url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" },
{ url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" },
{ url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" },
{ url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" },
{ url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" },
{ url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" },
{ url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" },
{ url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" },
{ url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" },
{ url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" },
{ url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" },
{ url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mccabe"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pylint"
version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "dill" },
{ name = "isort" },
{ name = "mccabe" },
{ name = "platformdirs" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "rich"
version = "14.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
]
[[package]]
name = "ruff"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "tomlkit"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
]
[[package]]
name = "typer"
version = "0.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "virtualenv"
version = "20.36.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" },
]

@ -1 +1 @@
Subproject commit 0eec274fba59ddd373080ae961dd4497eaf2e454
Subproject commit ecbb534e85e8e381e6e89aece4b786db8f7ad172