feat: did a couple things idk

This commit is contained in:
CarterPerez-dev 2026-02-28 14:08:36 -05:00
parent 65e01d055c
commit f9b5a614d3
314 changed files with 7119 additions and 974 deletions

View File

@ -1,7 +1,7 @@
# ©AngelaMos | 2026
# .gitignore
# Planning docs
# dev docs
.angelusvigil/
# Environment

View File

@ -52,7 +52,7 @@ async def _get_active_models(
Query all active model metadata records
"""
query = select(ModelMetadata).where(
ModelMetadata.is_active == True # noqa: E712
ModelMetadata.is_active == True # type: ignore[arg-type] # noqa: E712
)
rows = (await session.execute(query)).scalars().all()
return [{

View File

@ -6,13 +6,14 @@ inference.py
import json
import logging
from pathlib import Path
from typing import Any
import numpy as np
try:
import onnxruntime as ort
except ImportError:
ort = None # type: ignore[assignment]
ort = None
logger = logging.getLogger(__name__)
@ -33,9 +34,9 @@ class InferenceEngine:
"""
def __init__(self, model_dir: str) -> None:
self._ae_session: ort.InferenceSession | None = None # type: ignore[union-attr]
self._rf_session: ort.InferenceSession | None = None # type: ignore[union-attr]
self._if_session: ort.InferenceSession | None = None # type: ignore[union-attr]
self._ae_session: ort.InferenceSession | None = None
self._rf_session: ort.InferenceSession | None = None
self._if_session: ort.InferenceSession | None = None
self._scaler_center: np.ndarray | None = None
self._scaler_scale: np.ndarray | None = None
self._threshold: float = 0.0
@ -127,12 +128,12 @@ class InferenceEngine:
"""
if self._scaler_center is None or self._scaler_scale is None:
return batch
return (batch - self._scaler_center) / self._scaler_scale
return (batch - self._scaler_center) / self._scaler_scale # type: ignore[no-any-return]
@staticmethod
def _extract_rf_proba(
ort_output: list | np.ndarray
) -> np.ndarray: # type: ignore[type-arg]
ort_output: list[Any] | np.ndarray
) -> np.ndarray:
"""
Extract attack probability from skl2onnx RF output format.

View File

@ -93,6 +93,8 @@ CATEGORICAL_ENCODERS: dict[str, dict[str, int]] = {
"file_extension": EXTENSION_MAP,
}
WINDOWED_FEATURE_NAMES: list[str] = FEATURE_ORDER[23:]
BOOLEAN_FEATURES: frozenset[str] = frozenset({
"has_encoded_chars",
"has_double_encoding",

View File

@ -9,6 +9,7 @@ import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@ -24,6 +25,9 @@ from app.core.redis_manager import redis_manager
from app.models import model_metadata as _model_metadata_reg # noqa: F401
from app.models import threat_event as _threat_event_reg # noqa: F401
if TYPE_CHECKING:
from app.core.detection.inference import InferenceEngine
logger = logging.getLogger(__name__)
@ -110,7 +114,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
logger.info("AngelusVigil shut down cleanly")
def _load_inference_engine() -> object | None:
def _load_inference_engine() -> InferenceEngine | None:
"""
Attempt to load the ONNX inference engine from the
configured model directory, returning None if ML

View File

@ -18,7 +18,6 @@ class ModelMetadata(TimestampedModel, table=True):
__table_args__ = (Index(
"idx_model_metadata_active",
"model_type",
unique=True,
postgresql_where=text("is_active = TRUE"),
), )

View File

@ -3,6 +3,8 @@
main.py
"""
import asyncio
import dataclasses
from pathlib import Path
import typer
@ -22,6 +24,44 @@ DEFAULT_EXPERIMENT_NAME = "angelusvigil-training"
DEFAULT_SERVER_URL = "http://localhost:8000"
async def _write_metadata(
model_dir: Path,
training_samples: int,
metrics: dict[str, object],
mlflow_run_id: str | None,
threshold: float | None,
) -> None:
"""
Persist training metadata to the database
"""
from app.config import settings
from ml.metadata import save_model_metadata
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
engine = create_async_engine(settings.database_url)
try:
factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with factory() as session:
await save_model_metadata(
session,
model_dir=model_dir,
training_samples=training_samples,
metrics=metrics,
mlflow_run_id=mlflow_run_id,
threshold=threshold,
)
finally:
await engine.dispose()
@app.command()
def serve(
host: str = typer.Option("0.0.0.0", help="Bind address"),
@ -92,9 +132,10 @@ def train(
)
raise typer.Exit(code=1)
from ml.data_loader import load_csic_dataset
from ml.data_loader import load_csic_dataset, load_csic_normal
normal_path = csic_dir / "normalTrafficTraining.txt"
normal_test_path = csic_dir / "normalTrafficTest.txt"
attack_path = csic_dir / "anomalousTrafficTest.txt"
typer.echo(f"Loading CSIC data from {csic_dir}")
X_csic, y_csic = load_csic_dataset(
@ -106,6 +147,14 @@ def train(
f" CSIC: {len(X_csic)} samples"
)
if normal_test_path.exists():
X_extra, y_extra = load_csic_normal(normal_test_path)
X_parts.append(X_extra)
y_parts.append(y_extra)
typer.echo(
f" CSIC normal test: {len(X_extra)} samples"
)
if synthetic_normal > 0 or synthetic_attack > 0:
from ml.synthetic import generate_mixed_dataset
@ -143,6 +192,25 @@ def train(
)
result = orch.run(X, y)
try:
metrics: dict[str, object] = (
dataclasses.asdict(result.ensemble_metrics)
if result.ensemble_metrics else {}
)
asyncio.run(_write_metadata(
Path(output_dir),
int(len(X)),
metrics,
result.mlflow_run_id,
result.ae_metrics.get("ae_threshold"),
))
typer.echo(" Model metadata saved to database")
except Exception as exc:
typer.echo(
f" Warning: could not save metadata to DB: {exc}",
err=True,
)
typer.echo(f"Models exported to {output_dir}")
if result.ensemble_metrics is not None:
typer.echo(

View File

@ -51,13 +51,13 @@ class ThreatAutoencoder(nn.Module):
"""
Compress input through the encoder to the 6-dim bottleneck.
"""
return self.encoder(x)
return self.encoder(x) # type: ignore[no-any-return]
def decode(self, z: Tensor) -> Tensor:
"""
Reconstruct input from the bottleneck representation.
"""
return self.decoder(z)
return self.decoder(z) # type: ignore[no-any-return]
def forward(self, x: Tensor) -> Tensor:
"""

View File

@ -13,6 +13,7 @@ import numpy as np
from app.core.features.encoder import encode_for_inference
from app.core.features.extractor import extract_request_features
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
from app.core.ingestion.parsers import ParsedLogEntry
logger = logging.getLogger(__name__)
@ -26,21 +27,6 @@ _DEFAULT_IP = "192.168.1.100"
_DEFAULT_UA = ("Mozilla/5.0 (compatible; Konqueror/3.5; Linux)"
" KHTML/3.5.8 (like Gecko)")
_WINDOWED_FEATURE_NAMES: list[str] = [
"req_count_1m",
"req_count_5m",
"req_count_10m",
"error_rate_5m",
"unique_paths_5m",
"unique_uas_10m",
"method_entropy_5m",
"avg_response_size_5m",
"status_diversity_5m",
"path_depth_variance_5m",
"inter_request_time_mean",
"inter_request_time_std",
]
@dataclass
class CSICRequest:
@ -192,7 +178,7 @@ def load_csic_dataset(
entry = csic_to_parsed_entry(req)
features = extract_request_features(entry)
for name in _WINDOWED_FEATURE_NAMES:
for name in WINDOWED_FEATURE_NAMES:
features[name] = 0.0
vector = encode_for_inference(features)
@ -211,3 +197,32 @@ def load_csic_dataset(
)
return X, y
def load_csic_normal(
path: Path,
) -> tuple[np.ndarray, np.ndarray]:
"""
Load a CSIC 2010 normal traffic file and return (X, y) arrays
with all labels set to 0
"""
reqs = parse_csic_file(path, label=0)
vectors: list[list[float]] = []
for req in reqs:
entry = csic_to_parsed_entry(req)
features = extract_request_features(entry)
for name in WINDOWED_FEATURE_NAMES:
features[name] = 0.0
vectors.append(encode_for_inference(features))
X = np.array(vectors, dtype=np.float32)
y = np.zeros(len(vectors), dtype=np.int32)
logger.info(
"Loaded %d normal samples from %s",
len(vectors),
path.name,
)
return X, y

View File

@ -7,7 +7,8 @@ import hashlib
import logging
import sys
from pathlib import Path
from urllib.request import urlretrieve
import httpx
logger = logging.getLogger(__name__)
@ -26,24 +27,6 @@ FILES = [
MIN_FILE_BYTES = 1_000_000
def _progress_hook(
block_num: int,
block_size: int,
total_size: int,
) -> None:
"""
Print download progress to stdout
"""
downloaded = block_num * block_size
if total_size > 0:
pct = min(downloaded * 100 / total_size, 100)
sys.stdout.write(f"\r {pct:.0f}%")
else:
mb = downloaded / 1_048_576
sys.stdout.write(f"\r {mb:.1f} MB")
sys.stdout.flush()
def _compute_sha256(path: Path) -> str:
"""
Compute SHA-256 hash of a file
@ -73,7 +56,31 @@ def download_csic(output_dir: Path = DATASET_DIR, ) -> None:
print(f"Downloading {filename}...")
try:
urlretrieve(url, dest, reporthook=_progress_hook)
with httpx.stream(
"GET",
url,
follow_redirects=True,
) as response:
response.raise_for_status()
total = int(
response.headers.get("content-length", 0)
)
downloaded = 0
with open(dest, "wb") as f:
for chunk in response.iter_bytes(
chunk_size=65536
):
f.write(chunk)
downloaded += len(chunk)
if total > 0:
pct = min(
downloaded * 100 / total, 100
)
sys.stdout.write(f"\r {pct:.0f}%")
else:
mb = downloaded / 1_048_576
sys.stdout.write(f"\r {mb:.1f} MB")
sys.stdout.flush()
print()
except Exception as exc:
logger.error(

View File

@ -34,7 +34,7 @@ def export_autoencoder(
torch.onnx.export(
model,
dummy,
dummy, # type: ignore[arg-type]
str(path),
opset_version=opset,
export_params=True,

View File

@ -50,11 +50,11 @@ async def save_model_metadata(
result = await session.execute(
select(ModelMetadata).where(
ModelMetadata.model_type == model_type,
ModelMetadata.is_active == True, # noqa: E712
ModelMetadata.model_type == model_type, # type: ignore[arg-type]
ModelMetadata.is_active == True, # type: ignore[arg-type] # noqa: E712
))
for old in result.scalars().all():
await session.delete(old)
old.is_active = False
await session.flush()
row = ModelMetadata(

View File

@ -5,8 +5,10 @@ orchestrator.py
import json
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
@ -133,13 +135,24 @@ class TrainingOrchestrator:
"ensemble_roc_auc": ensemble.roc_auc,
})
passed = ensemble.passed_gates
except Exception:
except Exception as exc:
logger.exception("Ensemble validation failed")
print(
f" WARNING: validation raised"
f" {type(exc).__name__}: {exc}",
file=sys.stderr,
)
ensemble = None
passed = False
for artifact in self._output_dir.iterdir():
experiment.log_artifact(artifact)
for name in (
AE_FILENAME,
RF_FILENAME,
IF_FILENAME,
SCALER_FILENAME,
THRESHOLD_FILENAME,
):
experiment.log_artifact(self._output_dir / name)
run_id = experiment.run_id
@ -159,7 +172,7 @@ class TrainingOrchestrator:
mlflow_run_id=run_id,
)
def _train_ae(self, X_normal: np.ndarray) -> dict:
def _train_ae(self, X_normal: np.ndarray) -> dict[str, Any]:
"""
Train the autoencoder on normal-only data
"""
@ -174,7 +187,7 @@ class TrainingOrchestrator:
batch_size=self._batch_size,
)
def _train_rf(self, X: np.ndarray, y: np.ndarray) -> dict:
def _train_rf(self, X: np.ndarray, y: np.ndarray) -> dict[str, Any]:
"""
Train the random forest classifier
"""
@ -184,7 +197,7 @@ class TrainingOrchestrator:
)
return train_random_forest(X, y)
def _train_if(self, X_normal: np.ndarray) -> dict:
def _train_if(self, X_normal: np.ndarray) -> dict[str, Any]:
"""
Train the isolation forest on normal-only data
"""
@ -196,9 +209,9 @@ class TrainingOrchestrator:
def _export_models(
self,
ae_result: dict,
rf_result: dict,
if_result: dict,
ae_result: dict[str, Any],
rf_result: dict[str, Any],
if_result: dict[str, Any],
) -> None:
"""
Export all 3 models to ONNX and save scaler/threshold

View File

@ -46,7 +46,7 @@ class FeatureScaler:
"""
if not self._fitted or self._scaler is None:
raise RuntimeError("Scaler has not been fitted")
return self._scaler.transform(X).astype(np.float32)
return self._scaler.transform(X).astype(np.float32) # type: ignore[no-any-return]
def inverse_transform(self, X: np.ndarray) -> np.ndarray:
"""
@ -54,7 +54,7 @@ class FeatureScaler:
"""
if not self._fitted or self._scaler is None:
raise RuntimeError("Scaler has not been fitted")
return self._scaler.inverse_transform(X).astype(np.float32)
return self._scaler.inverse_transform(X).astype(np.float32) # type: ignore[no-any-return]
def fit_transform(self, X: np.ndarray) -> np.ndarray:
"""
@ -74,14 +74,14 @@ class FeatureScaler:
"scale": self._scaler.scale_.tolist(),
"n_features": int(self._scaler.n_features_in_),
}
Path(path).write_text(json.dumps(data, indent=2))
Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")
@classmethod
def load_json(cls, path: Path | str) -> FeatureScaler:
"""
Reconstruct a fitted scaler from a JSON file.
"""
data = json.loads(Path(path).read_text())
data = json.loads(Path(path).read_text(encoding="utf-8"))
scaler = cls()
scaler._scaler = RobustScaler()
scaler._scaler.center_ = np.array(data["center"], dtype=np.float64)

View File

@ -11,6 +11,7 @@ import numpy as np
from app.core.features.encoder import encode_for_inference
from app.core.features.extractor import extract_request_features
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
from app.core.ingestion.parsers import ParsedLogEntry
logger = logging.getLogger(__name__)
@ -189,21 +190,6 @@ ATTACK_PATHS: list[str] = [
"/debug",
]
_WINDOWED_FEATURE_NAMES: list[str] = [
"req_count_1m",
"req_count_5m",
"req_count_10m",
"error_rate_5m",
"unique_paths_5m",
"unique_uas_10m",
"method_entropy_5m",
"avg_response_size_5m",
"status_diversity_5m",
"path_depth_variance_5m",
"inter_request_time_mean",
"inter_request_time_std",
]
def _random_ip() -> str:
"""
@ -212,13 +198,6 @@ def _random_ip() -> str:
return f"{random.randint(1, 223)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}"
def _random_private_ip() -> str:
"""
Generate a random private IP address
"""
return f"10.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}"
def _random_timestamp() -> datetime:
"""
Generate a random timestamp within the last 24 hours
@ -270,7 +249,6 @@ def generate_sqli_requests(n: int, ) -> list[ParsedLogEntry]:
status_code=random.choice([200, 500]),
response_size=random.randint(0, 5000),
user_agent=random.choice(NORMAL_UAS),
ip=_random_private_ip(),
))
else:
entries.append(
@ -281,7 +259,6 @@ def generate_sqli_requests(n: int, ) -> list[ParsedLogEntry]:
status_code=random.choice([200, 403]),
response_size=random.randint(0, 2000),
user_agent=random.choice(NORMAL_UAS),
ip=_random_private_ip(),
))
return entries
@ -302,7 +279,6 @@ def generate_xss_requests(n: int, ) -> list[ParsedLogEntry]:
status_code=200,
response_size=random.randint(500, 5000),
user_agent=random.choice(NORMAL_UAS),
ip=_random_private_ip(),
))
return entries
@ -322,7 +298,6 @@ def generate_traversal_requests(n: int, ) -> list[ParsedLogEntry]:
status_code=random.choice([200, 403, 404]),
response_size=random.randint(0, 1000),
user_agent=random.choice(NORMAL_UAS),
ip=_random_private_ip(),
))
return entries
@ -343,7 +318,6 @@ def generate_log4shell_requests(n: int, ) -> list[ParsedLogEntry]:
status_code=200,
response_size=random.randint(0, 2000),
user_agent=payload,
ip=_random_private_ip(),
))
return entries
@ -364,7 +338,6 @@ def generate_ssrf_requests(n: int, ) -> list[ParsedLogEntry]:
status_code=random.choice([200, 302]),
response_size=random.randint(0, 3000),
user_agent=random.choice(NORMAL_UAS),
ip=_random_private_ip(),
))
return entries
@ -384,7 +357,6 @@ def generate_scanner_requests(n: int, ) -> list[ParsedLogEntry]:
status_code=random.choice([200, 301, 403, 404]),
response_size=random.randint(0, 500),
user_agent=random.choice(SCANNER_UAS),
ip=_random_private_ip(),
))
return entries
@ -417,7 +389,7 @@ def _entries_to_vectors(entries: list[ParsedLogEntry], ) -> list[list[float]]:
vectors: list[list[float]] = []
for entry in entries:
features = extract_request_features(entry)
for name in _WINDOWED_FEATURE_NAMES:
for name in WINDOWED_FEATURE_NAMES:
features[name] = 0.0
vectors.append(encode_for_inference(features))
return vectors

View File

@ -71,7 +71,7 @@ def train_autoencoder(
reconstructed = model(batch)
loss = torch.nn.functional.mse_loss(reconstructed, batch)
optimizer.zero_grad()
loss.backward()
loss.backward() # type: ignore[no-untyped-call]
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
epoch_loss += loss.item()

View File

@ -135,6 +135,7 @@ ignore = [
[tool.mypy]
python_version = "3.14"
strict = true
exclude = ["^alembic/"]
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
@ -151,6 +152,14 @@ ignore_missing_imports = true
module = "app.models.*"
disable_error_code = ["call-arg", "misc"]
[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "alembic.*"
ignore_errors = true
[tool.pylint.main]
py-version = "3.12"
jobs = 4
@ -175,6 +184,7 @@ ignore-paths = [
"^.venv/.*",
"^build/.*",
"^dist/.*",
"^tests/.*",
]
[tool.pylint.messages_control]
@ -201,13 +211,18 @@ disable = [
"C0415",
"E1102",
"R1732",
"C0121",
"E0601",
"E0602",
"R0902",
]
[tool.pylint.design]
max-args = 12
max-attributes = 10
max-attributes = 12
max-locals = 35
max-positional-arguments = 12
max-statements = 60
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

@ -4,6 +4,7 @@ test_cli.py
"""
import re
from unittest import mock
from typer.testing import CliRunner
@ -105,3 +106,38 @@ class TestCLICommands:
],
)
assert result.exit_code != 0
class TestCLITrainMetadata:
"""
Test metadata persistence wiring in the train command
"""
def test_train_warns_when_db_unavailable(self) -> None:
"""
train exits 0 and emits a warning when DB write fails
"""
mock_result = mock.MagicMock()
mock_result.ensemble_metrics = None
mock_result.passed_gates = True
mock_result.mlflow_run_id = None
mock_result.ae_metrics = {}
with mock.patch(
"ml.orchestrator.TrainingOrchestrator"
) as mock_class:
mock_class.return_value.run.return_value = mock_result
result = runner.invoke(
app,
[
"train",
"--synthetic-normal",
"5",
"--synthetic-attack",
"3",
],
)
assert result.exit_code == 0
output = _clean(result.output)
assert "warning" in output or "metadata" in output

View File

@ -192,3 +192,35 @@ class TestSaveModelMetadata:
assert len(active_rows) == 3
assert all(r.training_samples == 600 for r in active_rows)
@pytest.mark.asyncio
async def test_previous_inactive_rows_preserved(
self,
db_session: AsyncSession,
model_artifacts: Path,
) -> None:
"""
Old model rows are deactivated, not deleted, after a new save
"""
await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=500,
metrics={"f1": 0.9},
)
(model_artifacts / "ae.onnx").write_bytes(b"new-ae-data")
await save_model_metadata(
db_session,
model_dir=model_artifacts,
training_samples=600,
metrics={"f1": 0.95},
)
result = await db_session.execute(select(ModelMetadata))
all_rows = result.scalars().all()
inactive = [r for r in all_rows if not r.is_active]
assert len(all_rows) == 6
assert len(inactive) == 3
assert all(r.training_samples == 500 for r in inactive)

View File

@ -7,6 +7,7 @@ import numpy as np
from app.core.features.encoder import encode_for_inference
from app.core.features.extractor import extract_request_features
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
from app.core.ingestion.parsers import ParsedLogEntry
from ml.synthetic import (
generate_log4shell_requests,
@ -19,22 +20,6 @@ from ml.synthetic import (
generate_xss_requests,
)
_WINDOWED_FEATURE_NAMES: list[str] = [
"req_count_1m",
"req_count_5m",
"req_count_10m",
"error_rate_5m",
"unique_paths_5m",
"unique_uas_10m",
"method_entropy_5m",
"avg_response_size_5m",
"status_diversity_5m",
"path_depth_variance_5m",
"inter_request_time_mean",
"inter_request_time_std",
]
class TestGenerators:
"""
Test individual attack and normal traffic generators
@ -152,7 +137,7 @@ class TestGenerators:
for gen in generators:
for entry in gen(5):
features = extract_request_features(entry)
for name in _WINDOWED_FEATURE_NAMES:
for name in WINDOWED_FEATURE_NAMES:
features[name] = 0.0
vector = encode_for_inference(features)
assert len(vector) == 35

View File

@ -1,6 +1,19 @@
"""
AngelaMos | 2025
app.py
Example FastAPI app demonstrating all three rate limiting patterns
Shows how to wire up fastapi-420 in a real application. Uses
ScopedRateLimiter for auth endpoints with strict brute-force
protection, the @limiter.limit() decorator for public endpoints,
and RateLimitDep dependency injection for one-off limits. Includes
lifespan setup with init()/close() and Redis storage configuration.
Defines 12 routes across auth, public, and user endpoint groups.
Connects to:
__init__.py - imports RateLimiter, settings, ScopedRateLimiter
types.py - imports Algorithm, FingerprintLevel
"""
from __future__ import annotations

View File

@ -2,6 +2,23 @@
AngelaMos | 2025
__init__.py
Public API surface for fastapi-420
Re-exports all user-facing classes, functions, and types into
a single top-level namespace so consumers can write
"from fastapi_420 import RateLimiter" instead of reaching into
submodules. The __all__ list defines the 28 public names that
make up the library's API.
Connects to:
config.py - re-exports settings classes and get_settings()
defense/__init__.py - re-exports CircuitBreaker, LayeredDefense
dependencies.py - re-exports all DI integration
exceptions.py - re-exports all exception classes
limiter.py - re-exports RateLimiter
middleware.py - re-exports both middleware classes
types.py - re-exports all enums and data types

View File

@ -1,6 +1,22 @@
"""
AngelaMos | 2025
__init__.py
Algorithm subpackage with factory function
Provides create_algorithm() which maps an Algorithm enum value
to the corresponding implementation class. Re-exports all three
algorithm classes for direct import.
Key exports:
create_algorithm() - factory that builds algorithm instances
FixedWindowAlgorithm, SlidingWindowAlgorithm, TokenBucketAlgorithm
Connects to:
base.py - re-exports BaseAlgorithm
fixed_window.py - re-exports FixedWindowAlgorithm
sliding_window.py - re-exports SlidingWindowAlgorithm
token_bucket.py - re-exports TokenBucketAlgorithm
"""
from fastapi_420.algorithms.base import BaseAlgorithm

View File

@ -1,6 +1,22 @@
"""
AngelaMos | 2025
base.py
Abstract base class for rate limiting algorithms
Defines the interface that all algorithm implementations must
follow: a name property, a check() method that decides whether
a request is allowed, and a get_current_usage() method for
reporting. Each algorithm receives a storage backend and operates
against it, but the base class doesn't care which backend it is.
Key exports:
BaseAlgorithm - ABC with name, check(), get_current_usage()
Connects to:
fixed_window.py - subclasses BaseAlgorithm
sliding_window.py - subclasses BaseAlgorithm
token_bucket.py - subclasses BaseAlgorithm
"""
# pylint: disable=unnecessary-ellipsis

View File

@ -1,6 +1,19 @@
"""
AngelaMos | 2025
fixed_window.py
Fixed window counter rate limiting algorithm
The simplest algorithm. Divides time into fixed-size windows and
counts requests in each. Has the well-known boundary burst problem
where a client can make 2x the limit by timing requests at the
edge of two adjacent windows. Includes a special codepath for
Redis that uses the atomic increment_fixed_window() Lua script
instead of the generic sliding window storage methods.
Connects to:
base.py - extends BaseAlgorithm
redis_backend.py - checks isinstance for Redis-specific path
"""
from __future__ import annotations

View File

@ -1,6 +1,17 @@
"""
AngelaMos | 2025
sliding_window.py
Sliding window counter rate limiting algorithm
The recommended default for production. Uses weighted interpolation
between two adjacent fixed windows to approximate a true sliding
window with ~99.997% accuracy. Gets O(1) memory per client (just
two counters) compared to O(n) for a sorted-set sliding log.
Eliminates the boundary burst problem that fixed windows have.
Connects to:
base.py - extends BaseAlgorithm
"""
from __future__ import annotations

View File

@ -1,6 +1,17 @@
"""
AngelaMos | 2025
token_bucket.py
Token bucket rate limiting algorithm
Allows controlled bursting up to bucket capacity while enforcing
an average rate over time. Tokens refill at a constant rate, and
each request consumes one token. Good for APIs where occasional
traffic spikes are acceptable as long as the average stays within
bounds. The bucket capacity sets the maximum burst size.
Connects to:
base.py - extends BaseAlgorithm
"""
from __future__ import annotations

View File

@ -1,6 +1,23 @@
"""
AngelaMos | 2025
config.py
Pydantic-settings configuration with RATELIMIT_ env prefix
All rate limiter settings are validated here and loaded from
environment variables. The top-level RateLimiterSettings nests
three sub-configs (storage, fingerprint, defense) and includes
model validators that resolve shorthand like algorithm names
and rule strings into their typed equivalents.
Key exports:
StorageSettings - Redis URL, max keys, backend selection
FingerprintSettings - extractor toggles and fingerprint level
RateLimiterSettings - main config that composes all sub-configs
get_settings() - cached singleton factory
Connects to:
types.py - imports Algorithm, DefenseMode, FingerprintLevel, RateLimitRule
"""
from __future__ import annotations

View File

@ -1,6 +1,12 @@
"""
AngelaMos | 2025
__init__.py
Defense subpackage re-exports
Connects to:
circuit_breaker.py - re-exports CircuitBreaker
layers.py - re-exports LayeredDefense, LayerResult
"""
from fastapi_420.defense.circuit_breaker import CircuitBreaker

View File

@ -1,6 +1,23 @@
"""
AngelaMos | 2025
circuit_breaker.py
Global circuit breaker for API-wide DDoS protection
Tracks total request volume across all clients. When the count
exceeds a threshold within a time window, the circuit opens and
blocks all incoming requests until a recovery period passes.
After recovery, enters a half-open state that lets a limited
number of requests through to test if conditions have improved.
If those succeed, the circuit closes again and normal traffic
resumes.
Key exports:
CircuitBreaker - tracks state with check(), record_request(),
reset(), and exposes is_open and current_state
Connects to:
types.py - imports CircuitState, DefenseMode
"""
from __future__ import annotations

View File

@ -1,6 +1,26 @@
"""
AngelaMos | 2025
layers.py
Three-layer defense system for graduated rate limiting
Layer 1 is per-user per-endpoint (individual abuse). Layer 2 is
per-endpoint global (endpoint-level flood). Layer 3 is the global
circuit breaker (full API DDoS). Each layer checks independently
and the first rejection wins. Defense modes control bypass logic:
adaptive mode lets authenticated users through when limits hit,
lockdown mode restricts to known-good and authenticated clients
only.
Key exports:
LayerResult - result from a single defense layer check
LayeredDefense - orchestrates all three layers via
check_all_layers()
Connects to:
circuit_breaker.py - uses CircuitBreaker for layer 3
exceptions.py - raises EnhanceYourCalm on rejection
types.py - imports DefenseContext, DefenseMode, Layer, others
"""
from __future__ import annotations

View File

@ -1,6 +1,27 @@
"""
AngelaMos | 2025
dependencies.py
FastAPI dependency injection integration for rate limiting
Provides three patterns for wiring rate limits into FastAPI routes.
RateLimitDep is a callable class you pass to Depends() for
per-route limits. create_rate_limit_dep() is a factory that
builds those callables. ScopedRateLimiter groups endpoints by
prefix (like "/auth") with shared limits and burst overrides.
Also provides a global limiter singleton via set_global_limiter()
and get_limiter().
Key exports:
RateLimitDep - callable dependency for per-route limits
create_rate_limit_dep() - factory for RateLimitDep instances
ScopedRateLimiter - per-prefix endpoint group limiter
set_global_limiter() / get_limiter() - singleton management
require_rate_limit() - simple dependency using defaults
Connects to:
limiter.py - imports RateLimiter
types.py - imports RateLimitResult, RateLimitRule
"""
from __future__ import annotations

View File

@ -1,6 +1,26 @@
"""
AngelaMos | 2025
exceptions.py
Exception hierarchy for rate limiting failures
The signature exception is EnhanceYourCalm, which returns HTTP 420
("Enhance Your Calm", from the Twitter API) when a client exceeds
their limit. Below that, domain-specific errors cover storage
failures, fingerprint extraction problems, circuit breaker trips,
and configuration mistakes. Each carries context about which layer
or storage backend triggered it.
Key exports:
HTTP_420_ENHANCE_YOUR_CALM - status code constant (420)
EnhanceYourCalm - the HTTP 420 response exception
RateLimitExceeded - internal limit exceeded (pre-HTTP)
StorageError, StorageConnectionError - backend failures
CircuitBreakerOpen - global circuit breaker tripped
ConfigurationError, InvalidRuleError - bad config/rules
Connects to:
types.py - imports Layer, StorageType for error context
"""
from __future__ import annotations

View File

@ -1,6 +1,14 @@
"""
AngelaMos | 2025
__init__.py
Fingerprinting subpackage re-exports
Connects to:
ip.py - re-exports IPExtractor
headers.py - re-exports HeadersExtractor
auth.py - re-exports AuthExtractor
composite.py - re-exports CompositeFingerprinter
"""
from fastapi_420.fingerprinting.auth import AuthExtractor

View File

@ -1,6 +1,18 @@
"""
AngelaMos | 2025
auth.py
Authentication identifier extraction from requests
Pulls client identity from auth mechanisms in priority order:
JWT Bearer tokens (with optional signature verification), API
keys (from header or query param), and session cookies. When a
token is found, it can be SHA256-hashed for privacy so the
rate limiter tracks identity without storing raw credentials.
Key exports:
AuthExtractor - extracts auth identifiers with extract()
and checks authentication status via is_authenticated()
"""
from __future__ import annotations

View File

@ -1,6 +1,25 @@
"""
AngelaMos | 2025
composite.py
Combines IP, header, and auth extractors into one fingerprint
The CompositeFingerprinter runs whichever extractors are enabled
for the configured fingerprint level (STRICT uses all three,
NORMAL skips headers, RELAXED uses IP only, CUSTOM lets you pick).
Also pulls TLS/JA3 fingerprint and geo ASN data from proxy headers
when available. The output FingerprintData produces a composite
key used as the rate limit bucket identifier.
Key exports:
CompositeFingerprinter - orchestrates all extractors, built
from settings via from_settings() classmethod
Connects to:
ip.py - uses IPExtractor for IP extraction
headers.py - uses HeadersExtractor for header fingerprinting
auth.py - uses AuthExtractor for auth identity
config.py - reads FingerprintSettings (TYPE_CHECKING)
"""
from __future__ import annotations

View File

@ -1,6 +1,19 @@
"""
AngelaMos | 2025
headers.py
HTTP header fingerprinting for client identification
Extracts browser-identifying headers (user-agent, accept-language,
accept-encoding) and computes a SHA256 hash across a set of
fingerprint-relevant headers. Optionally includes header ordering
in the hash, which is browser-specific and difficult to spoof
since different HTTP implementations send headers in different
orders.
Key exports:
HeadersExtractor - extracts individual headers and computes
a composite fingerprint hash via extract_all()
"""
from __future__ import annotations

View File

@ -1,6 +1,20 @@
"""
AngelaMos | 2025
ip.py
Client IP extraction and normalization from HTTP requests
Handles the tricky parts of identifying clients by IP. IPv6
addresses get normalized to /64 prefixes because end users
typically control an entire /64 block and can rotate within it.
IPv4-mapped IPv6 addresses get unwrapped to plain IPv4.
For proxied requests, parses X-Forwarded-For using the
rightmost-trusted approach (trusting the entry closest to
your infrastructure, not the client-supplied leftmost one).
Key exports:
IPExtractor - extracts and normalizes client IPs with
extract(), is_ipv6(), and is_private()
"""
from __future__ import annotations

View File

@ -1,6 +1,27 @@
"""
AngelaMos | 2025
limiter.py
Main RateLimiter class that orchestrates the library
This is the central entry point. RateLimiter wires together the
storage backend, algorithm, and fingerprinter on init(), then
exposes two ways to enforce limits: a limit() decorator for
routes and a check() method for manual use. Handles fail-open
logic so a storage outage degrades to allowing requests rather
than crashing the API. Builds composite rate limit keys from
the client fingerprint, endpoint path, and layer.
Key exports:
RateLimiter - main class with init(), close(), limit(),
check(), and settings/is_initialized properties
Connects to:
config.py - reads RateLimiterSettings via get_settings()
exceptions.py - raises EnhanceYourCalm, catches StorageError
algorithms/__init__.py - calls create_algorithm()
fingerprinting/__init__.py - uses CompositeFingerprinter
storage/__init__.py - calls create_storage(), uses MemoryStorage
"""
from __future__ import annotations

View File

@ -1,6 +1,23 @@
"""
AngelaMos | 2025
middleware.py
ASGI middleware for automatic rate limiting across all routes
Two middleware classes for different throttling strategies.
RateLimitMiddleware applies hard limits and returns HTTP 420
when exceeded, with support for path inclusion/exclusion lists
and path-specific limit overrides. SlowDownMiddleware takes a
softer approach, adding progressive delays to responses as
clients approach their limit instead of blocking them outright.
Key exports:
RateLimitMiddleware - hard blocking with HTTP 420 responses
SlowDownMiddleware - gradual throttling via response delays
Connects to:
exceptions.py - uses HTTP_420_ENHANCE_YOUR_CALM, EnhanceYourCalm
limiter.py - imports RateLimiter
"""
from __future__ import annotations

View File

@ -1,6 +1,20 @@
"""
AngelaMos | 2025
__init__.py
Storage subpackage with factory and union type
Defines the Storage type alias (MemoryStorage | RedisStorage)
used throughout the library for type annotations. Provides
create_storage() which builds the right backend from settings.
Key exports:
Storage - TypeAlias for MemoryStorage | RedisStorage
create_storage() - factory that builds a storage backend
Connects to:
memory.py - re-exports MemoryStorage
redis_backend.py - re-exports RedisStorage
"""
from __future__ import annotations

View File

@ -1,6 +1,22 @@
"""
AngelaMos | 2025
memory.py
In-memory storage backend using OrderedDict for LRU eviction
Stores rate limit state in process memory, protected by an
asyncio.Lock for concurrency safety. Implements sliding window
tracking with dual-window weighted interpolation, and token
bucket with refill-on-access. Runs a background cleanup task
that periodically sweeps expired entries. When max keys is
reached, the least recently used entries get evicted first.
Key exports:
MemoryStorage - full storage backend with from_settings(),
increment(), consume_token(), close(), health_check()
Connects to:
types.py - imports WindowState, TokenBucketState, StorageType
"""
from __future__ import annotations

View File

@ -1,6 +1,24 @@
"""
AngelaMos | 2025
redis_backend.py
Redis storage backend using atomic Lua scripts
All rate limit operations (sliding window increment, fixed window
increment, token bucket consume) run as Lua scripts inside Redis
for race-condition-free atomic execution. Scripts are loaded from
disk on first use, and their SHA1 hashes are cached for EVALSHA
calls. If Redis flushes its script cache, NOSCRIPT errors trigger
automatic reload and retry.
Key exports:
RedisStorage - Redis-backed storage with from_settings(),
connect(), increment(), increment_fixed_window(),
consume_token(), close(), health_check()
Connects to:
exceptions.py - raises StorageConnectionError, StorageError
types.py - imports WindowState, TokenBucketState, StorageType
"""
from __future__ import annotations

View File

@ -1,6 +1,22 @@
"""
AngelaMos | 2025
types.py
Core type definitions for the rate limiting library
Every enum, dataclass, and Protocol in the library lives here.
This is the foundation layer with zero internal dependencies,
so every other module can import from it without circular issues.
Defines the three algorithm choices, fingerprint levels, defense
modes, and the storage/algorithm/fingerprinter Protocols that
backends must satisfy.
Key exports:
Algorithm, FingerprintLevel, DefenseMode - behavior enums
RateLimitResult - frozen result from a limit check
RateLimitRule - frozen rule with parse() for "100/minute" strings
FingerprintData - extracted client identity fields
StorageBackend, Fingerprinter, RateLimitAlgorithm - Protocols
"""
# pylint: disable=unnecessary-ellipsis

View File

@ -1,6 +1,19 @@
"""
AngelaMos | 2025
conftest.py
Shared pytest fixtures and test factories for the test suite
Defines constants for test data (IPs, tokens, endpoints, window
sizes), nine factory classes for building mock objects
(RequestFactory, FingerprintFactory, RuleFactory, ResultFactory,
and others), pytest fixtures for every major component, and helper
functions for common assertions like checking rate limit headers
and exhausting a client's limit budget.
Tests:
Provides fixtures for all source modules including storage,
algorithms, fingerprinting, defense, limiter, and middleware
"""
from __future__ import annotations

View File

@ -1,6 +1,15 @@
"""
AngelaMos | 2025
test_algorithms.py
Tests for rate limiting algorithms and the factory function
Tests:
create_algorithm() factory mapping
SlidingWindowAlgorithm - first request, limit enforcement, keys
TokenBucketAlgorithm - bursting, refill, capacity
FixedWindowAlgorithm - counting, boundary behavior
Cross-algorithm behavioral comparison
"""
from __future__ import annotations

View File

@ -1,6 +1,15 @@
"""
AngelaMos | 2025
test_fingerprinting.py
Tests for all fingerprinting components
Tests:
IPExtractor - IPv4, IPv6 /64 normalization, mapped addresses,
X-Forwarded-For parsing, X-Real-IP, private detection
HeadersExtractor - individual headers, composite hash, ordering
AuthExtractor - JWT, API key, session cookie, priority chain
CompositeFingerprinter - all levels, composite key generation
"""
from __future__ import annotations

View File

@ -1,6 +1,17 @@
"""
AngelaMos | 2025
test_integration.py
End-to-end integration tests across all integration patterns
Tests:
RateLimitMiddleware - path exclusion, limit enforcement, headers
@limiter.limit() decorator - basic flow and rejection
RateLimitDep dependency injection - per-route limits
ScopedRateLimiter - prefix matching and burst overrides
SlowDownMiddleware - progressive delay behavior
Concurrent requests and multi-client IP independence
Algorithm-specific integration (sliding, token, fixed)
"""
from __future__ import annotations

View File

@ -1,6 +1,16 @@
"""
AngelaMos | 2025
test_limiter.py
Tests for the RateLimiter class
Tests:
Initialization (default, custom settings, provided storage)
check() method (first request, multiple rules, raise behavior,
endpoint/user independence, custom key_func, auto-init)
limit() decorator (basic, enforcement, multiple rules)
Fail-open behavior when storage is unavailable
Settings access and idempotent init/close
"""
from __future__ import annotations

View File

@ -1,6 +1,17 @@
"""
AngelaMos | 2025
test_storage.py
Tests for MemoryStorage and the create_storage() factory
Tests:
Basic lifecycle (init, close, health check)
Sliding window increment and state retrieval
Token bucket consume, refill, and state
LRU eviction when max keys is reached
Background cleanup of expired entries
create_storage() factory function
Concurrent access safety under asyncio
"""
from __future__ import annotations

View File

@ -1,6 +1,16 @@
"""
AngelaMos | 2025
test_types.py
Tests for all type definitions in types.py
Tests:
Enum values for Algorithm, FingerprintLevel, DefenseMode, others
RateLimitRule.parse() with all time units, whitespace, case
RateLimitResult header generation
FingerprintData composite key generation at all levels
WindowState weighted count math
TokenBucketState, CircuitState, DefenseContext, RateLimitKey
"""
from __future__ import annotations

View File

@ -259,20 +259,20 @@ wheels = [
[[package]]
name = "fakeredis"
version = "2.33.0"
version = "2.34.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "redis" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" }
sdist = { url = "https://files.pythonhosted.org/packages/11/40/fd09efa66205eb32253d2b2ebc63537281384d2040f0a88bcd2289e120e4/fakeredis-2.34.1.tar.gz", hash = "sha256:4ff55606982972eecce3ab410e03d746c11fe5deda6381d913641fbd8865ea9b", size = 177315, upload-time = "2026-02-25T13:17:51.315Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" },
{ url = "https://files.pythonhosted.org/packages/49/b5/82f89307d0d769cd9bf46a54fb9136be08e4e57c5570ae421db4c9a2ba62/fakeredis-2.34.1-py3-none-any.whl", hash = "sha256:0107ec99d48913e7eec2a5e3e2403d1bd5f8aa6489d1a634571b975289c48f12", size = 122160, upload-time = "2026-02-25T13:17:49.701Z" },
]
[[package]]
name = "fastapi"
version = "0.128.7"
version = "0.134.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@ -281,9 +281,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/15/647ea81cb73b55b48fb095158a9cd64e42e9e4f1d34dbb5cc4a4939779d6/fastapi-0.134.0.tar.gz", hash = "sha256:3122b1ea0dbeaab48b5976e80b99ca7eda02be154bf03e126a33220e73255a9a", size = 385667, upload-time = "2026-02-27T21:18:12.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e6/fd49c28a54b7d6f5c64045155e40f6cff9ed4920055043fb5ac7969f7f2f/fastapi-0.134.0-py3-none-any.whl", hash = "sha256:f4e7214f24b2262258492e05c48cf21125e4ffc427e30dd32fb4f74049a3d56a", size = 110404, upload-time = "2026-02-27T21:18:10.809Z" },
]
[package.optional-dependencies]
@ -331,13 +331,13 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.1.0" },
{ name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.33.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.128.7" },
{ name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.34.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" },
{ name = "pydantic", specifier = ">=2.12.5,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.13.0" },
{ name = "pyjwt", specifier = ">=2.11.0" },
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" },
{ name = "pylint-per-file-ignores", marker = "extra == 'dev'", specifier = ">=3.2.0" },
@ -345,8 +345,8 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "redis", specifier = ">=7.1.1" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" },
{ name = "redis", specifier = ">=7.2.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.1" },
{ name = "time-machine", marker = "extra == 'dev'", specifier = ">=3.2.0" },
{ name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6.0.20241004" },
]
@ -802,16 +802,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.12.0"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
@ -975,11 +975,11 @@ wheels = [
[[package]]
name = "redis"
version = "7.1.1"
version = "7.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
{ url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" },
]
[[package]]
@ -1049,27 +1049,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.0"
version = "0.15.4"
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" }
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
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" },
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]]

View File

@ -1,6 +1,28 @@
"""
©AngelaMos | 2026
cli.py
Typer CLI application with five encoding subcommands
Defines the b64tool Typer app and its five commands: encode, decode,
detect, peel, and chain. Each command resolves its input from a
positional argument, --file, or stdin, delegates to the appropriate
logic module, then passes results to formatter.py for display. The
chain command applies a comma-separated sequence of formats in order,
passing each encoded output as the next step's input.
Key exports:
app - The Typer application instance registered as the CLI entry point
Connects to:
__init__.py - imports __version__
constants.py - imports EncodingFormat, ExitCode, PEEL_MAX_DEPTH
encoders.py - imports encode, decode, encode_url, decode_url
detector.py - imports detect_encoding, score_all_formats
peeler.py - imports peel
formatter.py - imports all print_* functions
utils.py - imports resolve_input_bytes, resolve_input_text
test_cli.py - exercises all commands via Typer's CliRunner
"""
from pathlib import Path

View File

@ -1,6 +1,28 @@
"""
©AngelaMos | 2026
constants.py
Encoding format definitions, scoring weights, and shared constants
Defines the EncodingFormat and ExitCode enums, numeric thresholds
used by the detector (confidence, printable ratio, min input length),
character set frozensets for charset membership tests, and the
ScoreWeight class that holds every per-format confidence score
contribution. All values shared across the package live here.
Key exports:
EncodingFormat - StrEnum of supported formats (base64, base64url, base32, hex, url)
ExitCode - CLI exit codes for success, error, and invalid input
ScoreWeight - Per-format scoring weights used by detector.py
BASE64_CHARSET, BASE64URL_CHARSET, BASE32_CHARSET, HEX_CHARSET - Valid character sets
CONFIDENCE_THRESHOLD, PEEL_MAX_DEPTH, PREVIEW_LENGTH - Shared thresholds
Connects to:
encoders.py - imports EncodingFormat
detector.py - imports EncodingFormat, ScoreWeight, charsets, thresholds
peeler.py - imports EncodingFormat, PEEL_MAX_DEPTH, CONFIDENCE_THRESHOLD
formatter.py - imports EncodingFormat, CONFIDENCE_THRESHOLD, PREVIEW_LENGTH
cli.py - imports EncodingFormat, ExitCode, PEEL_MAX_DEPTH
"""
from enum import StrEnum

View File

@ -1,6 +1,30 @@
"""
©AngelaMos | 2026
detector.py
Format detection via per-format confidence scoring
Runs each input string through five scoring functions (one per
supported format) that check charset membership, structural constraints
like padding alignment, and whether the decoded result is printable
text. Scores are clamped to [0.0, 1.0]. Results above
CONFIDENCE_THRESHOLD are returned as DetectionResult instances,
sorted by confidence descending.
Key exports:
DetectionResult - Frozen dataclass with format, confidence, and decoded bytes
detect_encoding() - Returns all formats that exceed the confidence threshold
detect_best() - Returns the single highest-confidence result, or None
score_all_formats() - Returns raw confidence scores for every format
Connects to:
constants.py - imports charsets, thresholds, ScoreWeight, EncodingFormat
encoders.py - imports try_decode
utils.py - imports is_printable_text
peeler.py - imports detect_best, score_all_formats
formatter.py - imports DetectionResult
cli.py - imports detect_encoding, score_all_formats
test_detector.py - tests detection accuracy per format
"""
import re

View File

@ -1,6 +1,29 @@
"""
©AngelaMos | 2026
encoders.py
Encode and decode functions for all five supported formats
Provides individual encode/decode functions for base64, base64url,
base32, hex, and URL percent-encoding, plus a dispatch registry
(ENCODER_REGISTRY) that maps each EncodingFormat to its function pair.
The top-level encode(), decode(), and try_decode() functions route
calls through the registry and handle all common decoding exceptions.
Key exports:
encode() - Encode bytes to string for a given format
decode() - Decode string to bytes for a given format
try_decode() - Like decode() but returns None on failure instead of raising
ENCODER_REGISTRY - Dict mapping EncodingFormat to (encoder, decoder) function pairs
EncoderFn, DecoderFn - Type aliases for encoder and decoder callables
Connects to:
constants.py - imports EncodingFormat
detector.py - imports try_decode
cli.py - imports encode, decode, encode_url, decode_url
test_encoders.py - tests all functions directly
test_properties.py - property-based roundtrip tests
test_peeler.py - imports encode to build test inputs
"""
import base64 as b64

View File

@ -1,6 +1,30 @@
"""
©AngelaMos | 2026
formatter.py
Rich terminal output for all CLI commands
Handles all display logic for encoded strings, decoded bytes,
detection tables, peel layer summaries, and chain step results.
Detects whether stdout is a TTY or a pipe and switches between
Rich-formatted panels and raw text output. All Rich output goes
to stderr so piped stdout stays machine-readable.
Key exports:
print_encoded() - Displays an encoded string in a Rich panel or raw to stdout
print_decoded() - Displays decoded bytes as text or hex fallback
print_detection() - Renders a confidence table for detect results
print_peel_result() - Renders each peel layer and a final output panel
print_chain_result() - Renders each encoding step and the final chain result
print_score_breakdown() - Renders per-format score table for verbose mode
is_piped() - Returns True when stdout is not a TTY
Connects to:
constants.py - imports CONFIDENCE_THRESHOLD, PREVIEW_LENGTH, EncodingFormat
detector.py - imports DetectionResult
peeler.py - imports PeelResult
utils.py - imports safe_bytes_preview
cli.py - imports all print_* functions
"""
import sys

View File

@ -1,6 +1,27 @@
"""
©AngelaMos | 2026
peeler.py
Recursive multi-layer encoding detection and decoding
Iteratively calls detect_best() on the current text, decodes one
layer at a time, and continues until no encoding is detected, the
decoded output is not valid UTF-8, or max_depth is reached. Each
iteration produces a PeelLayer record capturing the format, confidence,
and previews. The complete result is returned as an immutable PeelResult.
Key exports:
PeelLayer - Frozen dataclass for a single decoded layer (depth, format, confidence, previews)
PeelResult - Frozen dataclass with all layers, final bytes output, and success flag
peel() - Main entry point for recursive decoding
Connects to:
constants.py - imports PEEL_MAX_DEPTH, CONFIDENCE_THRESHOLD, EncodingFormat
detector.py - imports detect_best, score_all_formats
utils.py - imports safe_bytes_preview, truncate
formatter.py - imports PeelResult
cli.py - imports peel
test_peeler.py - tests single and multi-layer peeling
"""
from dataclasses import dataclass

View File

@ -1,6 +1,27 @@
"""
©AngelaMos | 2026
utils.py
Input resolution and string/bytes utility functions
Handles the three input sources the CLI accepts: a positional
argument, a --file path, or piped stdin. Also provides truncate()
for capping display strings, safe_bytes_preview() for converting
raw bytes to a readable preview, and is_printable_text() for
checking whether decoded bytes look like human-readable output.
Key exports:
resolve_input_bytes() - Returns raw bytes from argument, file, or stdin
resolve_input_text() - Returns decoded text from argument, file, or stdin
truncate() - Truncates a string with "..." if it exceeds the length limit
safe_bytes_preview() - Converts bytes to UTF-8 string or hex fallback
is_printable_text() - Returns True if bytes decode to mostly printable characters
Connects to:
detector.py - imports is_printable_text
peeler.py - imports safe_bytes_preview, truncate
formatter.py - imports safe_bytes_preview
cli.py - imports resolve_input_bytes, resolve_input_text
"""
import sys

View File

@ -1,6 +1,24 @@
"""
©AngelaMos | 2026
test_cli.py
Integration tests for all five CLI commands via Typer's CliRunner
Invokes each CLI command end-to-end without spawning subprocesses and
verifies exit codes and output content. Covers encode, decode, detect,
peel, and chain along with the --version flag and error paths (invalid
format, bad input).
Tests:
TestEncodeCommand - base64, hex, base32, url, empty input
TestDecodeCommand - base64, hex, invalid input returns non-zero exit
TestDetectCommand - base64 detection, hex detection, no-match message
TestPeelCommand - single layer, plain text with no layers
TestChainCommand - single step, multiple steps, unknown format error
TestVersionFlag - version string present in output
Connects to:
cli.py - imports app (the Typer application under test)
"""
from typer.testing import CliRunner
@ -13,11 +31,17 @@ runner = CliRunner()
class TestEncodeCommand:
def test_encode_base64(self) -> None:
"""
Checks that the encode command outputs the correct base64 string
"""
result = runner.invoke(app, ["encode", "Hello World"])
assert result.exit_code == 0
assert "SGVsbG8gV29ybGQ=" in result.output
def test_encode_hex(self) -> None:
"""
Checks that the encode command outputs the correct hex string with --format hex
"""
result = runner.invoke(
app,
["encode",
@ -29,6 +53,9 @@ class TestEncodeCommand:
assert "48656c6c6f" in result.output
def test_encode_base32(self) -> None:
"""
Checks that the encode command outputs the correct base32 string with --format base32
"""
result = runner.invoke(
app,
["encode",
@ -40,6 +67,9 @@ class TestEncodeCommand:
assert "JBSWY3DP" in result.output
def test_encode_url(self) -> None:
"""
Checks that the encode command percent-encodes special characters with --format url
"""
result = runner.invoke(
app,
["encode",
@ -51,12 +81,18 @@ class TestEncodeCommand:
assert "%20" in result.output or "hello" in result.output
def test_encode_empty_string(self) -> None:
"""
Checks that encoding an empty string succeeds without error
"""
result = runner.invoke(app, ["encode", ""])
assert result.exit_code == 0
class TestDecodeCommand:
def test_decode_base64(self) -> None:
"""
Checks that the decode command recovers 'Hello World' from a known base64 string
"""
result = runner.invoke(
app,
["decode",
@ -66,6 +102,9 @@ class TestDecodeCommand:
assert "Hello World" in result.output
def test_decode_hex(self) -> None:
"""
Checks that the decode command recovers 'Hello' from a hex string
"""
result = runner.invoke(
app,
["decode",
@ -77,6 +116,9 @@ class TestDecodeCommand:
assert "Hello" in result.output
def test_decode_invalid_base64(self) -> None:
"""
Checks that decoding garbage input exits with a non-zero code
"""
result = runner.invoke(
app,
["decode",
@ -87,6 +129,9 @@ class TestDecodeCommand:
class TestDetectCommand:
def test_detect_base64(self) -> None:
"""
Checks that the detect command identifies base64 in its output
"""
result = runner.invoke(
app,
["detect",
@ -96,6 +141,9 @@ class TestDetectCommand:
assert "base64" in result.output.lower()
def test_detect_hex(self) -> None:
"""
Checks that the detect command identifies hex in its output
"""
result = runner.invoke(
app,
["detect",
@ -105,6 +153,9 @@ class TestDetectCommand:
assert "hex" in result.output.lower()
def test_detect_no_match(self) -> None:
"""
Checks that the detect command reports no encoding found for plain text
"""
result = runner.invoke(
app,
["detect",
@ -116,6 +167,9 @@ class TestDetectCommand:
class TestPeelCommand:
def test_peel_single_layer(self) -> None:
"""
Checks that the peel command reports at least one layer for a base64 string
"""
result = runner.invoke(
app,
["peel",
@ -125,6 +179,9 @@ class TestPeelCommand:
assert "layer" in result.output.lower()
def test_peel_no_encoding(self) -> None:
"""
Checks that the peel command exits cleanly when no encoding is found
"""
result = runner.invoke(
app,
["peel",
@ -135,6 +192,9 @@ class TestPeelCommand:
class TestChainCommand:
def test_chain_single_step(self) -> None:
"""
Checks that the chain command applies one base64 step correctly
"""
result = runner.invoke(
app,
["chain",
@ -146,6 +206,9 @@ class TestChainCommand:
assert "SGVsbG8=" in result.output
def test_chain_multiple_steps(self) -> None:
"""
Checks that the chain command applies two steps in sequence without error
"""
result = runner.invoke(
app,
["chain",
@ -156,6 +219,9 @@ class TestChainCommand:
assert result.exit_code == 0
def test_chain_invalid_format(self) -> None:
"""
Checks that an unknown format name causes the chain command to exit with an error
"""
result = runner.invoke(
app,
["chain",
@ -168,6 +234,9 @@ class TestChainCommand:
class TestVersionFlag:
def test_version_output(self) -> None:
"""
Checks that --version prints the tool name and exits cleanly
"""
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert "b64tool" in result.output

View File

@ -1,6 +1,26 @@
"""
©AngelaMos | 2026
test_detector.py
Tests for format detection accuracy and edge cases in detector.py
Verifies that detect_best() and detect_encoding() correctly identify
each supported format from realistic input strings, that multi-format
results are sorted by confidence, and that unrecognized or too-short
inputs return empty results or None.
Tests:
TestDetectBase64 - standard padding, no padding, +/ characters
TestDetectBase64Url - URL-safe -_ character detection
TestDetectBase32 - uppercase inputs, standard padding
TestDetectHex - alpha hex chars, colon-separated, pure digits (low confidence)
TestDetectUrl - percent sequences, heavily encoded strings
TestDetectMultiple - sort order, no-match plain text, short string
TestDetectBest - highest confidence selection, None on no match
Connects to:
detector.py - imports detect_best, detect_encoding
constants.py - imports EncodingFormat
"""
from base64_tool.constants import EncodingFormat
@ -9,17 +29,26 @@ from base64_tool.detector import detect_best, detect_encoding
class TestDetectBase64:
def test_standard_base64(self) -> None:
"""
Checks that a padded base64 string is detected as base64 with high confidence
"""
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:
"""
Checks that base64 without trailing = padding is still detected
"""
result = detect_best("SGVsbG8gV29ybGQh")
assert result is not None
assert result.format == EncodingFormat.BASE64
def test_base64_with_plus_slash(self) -> None:
"""
Checks that base64 containing + and / characters is detected correctly
"""
result = detect_best("dGVzdC9wYXRoK3F1ZXJ5")
assert result is not None
assert result.format == EncodingFormat.BASE64
@ -27,6 +56,9 @@ class TestDetectBase64:
class TestDetectBase64Url:
def test_url_safe_chars(self) -> None:
"""
Checks that a base64url string with - and _ is detected as base64url or base64
"""
result = detect_best("dGVzdC1kYXRhX3ZhbHVl")
assert result is not None
assert result.format in (
@ -37,12 +69,18 @@ class TestDetectBase64Url:
class TestDetectBase32:
def test_standard_base32(self) -> None:
"""
Checks that a padded uppercase base32 string is detected with sufficient confidence
"""
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:
"""
Checks that a short uppercase base32 string is detected
"""
result = detect_best("JBSWY3DP")
assert result is not None
assert result.format == EncodingFormat.BASE32
@ -50,17 +88,26 @@ class TestDetectBase32:
class TestDetectHex:
def test_hex_with_letters(self) -> None:
"""
Checks that a hex string containing alpha characters is detected as hex
"""
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:
"""
Checks that colon-separated hex bytes are detected as hex
"""
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:
"""
Checks that a digit-only string is not confidently detected as hex
"""
result = detect_best("1234567890")
if result is not None and result.format == EncodingFormat.HEX:
assert result.confidence < 0.7
@ -68,11 +115,17 @@ class TestDetectHex:
class TestDetectUrl:
def test_url_encoded(self) -> None:
"""
Checks that a string with percent-encoded characters is detected as URL encoding
"""
result = detect_best("hello%20world%21%40%23")
assert result is not None
assert result.format == EncodingFormat.URL
def test_heavily_encoded(self) -> None:
"""
Checks that a heavily percent-encoded string is detected with sufficient confidence
"""
result = detect_best("%48%65%6C%6C%6F%20%57%6F%72%6C%64")
assert result is not None
assert result.format == EncodingFormat.URL
@ -81,22 +134,34 @@ class TestDetectUrl:
class TestDetectMultiple:
def test_returns_sorted_by_confidence(self) -> None:
"""
Checks that multiple detection results come back sorted highest confidence first
"""
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:
"""
Checks that plain unencoded text returns no detection results
"""
results = detect_encoding("hello world")
assert results == []
def test_short_string_returns_empty(self) -> None:
"""
Checks that strings below the minimum length return no results
"""
results = detect_encoding("ab")
assert results == []
class TestDetectBest:
def test_returns_highest_confidence(self) -> None:
"""
Checks that detect_best returns the same result as the first item from detect_encoding
"""
result = detect_best("SGVsbG8gV29ybGQ=")
assert result is not None
all_results = detect_encoding("SGVsbG8gV29ybGQ=")
@ -104,4 +169,7 @@ class TestDetectBest:
assert result.confidence == all_results[0].confidence
def test_no_match_returns_none(self) -> None:
"""
Checks that detect_best returns None when nothing is detected
"""
assert detect_best("not encoded at all!") is None

View File

@ -1,6 +1,25 @@
"""
©AngelaMos | 2026
test_encoders.py
Unit tests for all encoder and decoder functions in encoders.py
Tests each format with known-good inputs, full roundtrips, whitespace
tolerance in decoders, binary and unicode data, and invalid input
rejection. Also covers the ENCODER_REGISTRY dispatch via encode() and
decode(), and the try_decode() safe wrapper.
Tests:
TestBase64 - encode, decode, roundtrip, whitespace, binary, unicode, invalid input
TestBase64Url - URL-safe character guarantees and roundtrip
TestBase32 - encode, decode, lowercase tolerance, padding
TestHex - encode, decode, colon/space/dash separator variants
TestUrl - percent-encoding, form-encoding (plus signs), roundtrip
TestRegistryDispatch - parametrized roundtrip for all formats, try_decode
Connects to:
encoders.py - all functions under test
constants.py - imports EncodingFormat
"""
import binascii
@ -27,34 +46,61 @@ from base64_tool.encoders import (
class TestBase64:
def test_encode_simple_text(self) -> None:
"""
Checks that 'Hello World' encodes to the known base64 string
"""
assert encode_base64(b"Hello World") == "SGVsbG8gV29ybGQ="
def test_decode_simple_text(self) -> None:
"""
Checks that a known base64 string decodes back to 'Hello World'
"""
assert decode_base64("SGVsbG8gV29ybGQ=") == b"Hello World"
def test_roundtrip(self) -> None:
"""
Encodes then decodes an ASCII sentence and checks it matches the original
"""
original = b"The quick brown fox jumps over the lazy dog"
assert decode_base64(encode_base64(original)) == original
def test_encode_empty(self) -> None:
"""
Checks that encoding empty bytes produces an empty string
"""
assert encode_base64(b"") == ""
def test_decode_empty(self) -> None:
"""
Checks that decoding an empty string produces empty bytes
"""
assert decode_base64("") == b""
def test_encode_binary_data(self) -> None:
"""
Encodes all 256 byte values and checks the roundtrip is lossless
"""
data = bytes(range(256))
assert decode_base64(encode_base64(data)) == data
def test_decode_with_whitespace(self) -> None:
"""
Checks that a base64 string split across newlines still decodes correctly
"""
encoded = "SGVs\nbG8g\nV29y\nbGQ="
assert decode_base64(encoded) == b"Hello World"
def test_decode_invalid_raises(self) -> None:
"""
Checks that decoding garbage characters raises an exception
"""
with pytest.raises((ValueError, binascii.Error)):
decode_base64("!!!invalid!!!")
def test_encode_unicode(self) -> None:
"""
Checks that multi-byte unicode survives a base64 roundtrip
"""
data = "Hello 世界".encode()
decoded = decode_base64(encode_base64(data))
assert decoded == data
@ -62,81 +108,141 @@ class TestBase64:
class TestBase64Url:
def test_encode_with_url_chars(self) -> None:
"""
Checks that URL-safe base64 never emits + or / characters
"""
data = b"\xfb\xff\xfe"
encoded = encode_base64url(data)
assert "+" not in encoded
assert "/" not in encoded
def test_decode_url_safe(self) -> None:
"""
Checks that data containing / and + round-trips through URL-safe base64
"""
result = decode_base64url(encode_base64url(b"test/path+query"))
assert result == b"test/path+query"
def test_roundtrip(self) -> None:
"""
Encodes then decodes a URL string and checks it matches the original
"""
original = b"https://example.com?token=abc+def/ghi"
assert decode_base64url(encode_base64url(original)) == original
class TestBase32:
def test_encode_simple(self) -> None:
"""
Checks that 'Hello' encodes to its known base32 representation
"""
assert encode_base32(b"Hello") == "JBSWY3DP"
def test_decode_simple(self) -> None:
"""
Checks that a known base32 string decodes to 'Hello'
"""
assert decode_base32("JBSWY3DP") == b"Hello"
def test_roundtrip(self) -> None:
"""
Encodes then decodes a short sentence and checks it matches the original
"""
original = b"Base32 encoding test"
assert decode_base32(encode_base32(original)) == original
def test_decode_lowercase_accepted(self) -> None:
"""
Checks that lowercase base32 input is accepted and decoded correctly
"""
assert decode_base32("jbswy3dp") == b"Hello"
def test_decode_with_padding(self) -> None:
"""
Checks that a padded base32 string decodes to 'Hello World'
"""
assert decode_base32("JBSWY3DPEBLW64TMMQ======") == b"Hello World"
class TestHex:
def test_encode_simple(self) -> None:
"""
Checks that bytes \\xca\\xfe encode to the hex string 'cafe'
"""
assert encode_hex(b"\xca\xfe") == "cafe"
def test_decode_simple(self) -> None:
"""
Checks that 'cafe' decodes to the bytes \\xca\\xfe
"""
assert decode_hex("cafe") == b"\xca\xfe"
def test_decode_with_colons(self) -> None:
"""
Checks that colon-separated hex decodes correctly
"""
assert decode_hex("ca:fe:ba:be") == b"\xca\xfe\xba\xbe"
def test_decode_with_spaces(self) -> None:
"""
Checks that space-separated hex decodes correctly
"""
assert decode_hex("ca fe ba be") == b"\xca\xfe\xba\xbe"
def test_decode_with_dashes(self) -> None:
"""
Checks that dash-separated hex decodes correctly
"""
assert decode_hex("ca-fe-ba-be") == b"\xca\xfe\xba\xbe"
def test_decode_uppercase(self) -> None:
"""
Checks that uppercase hex input decodes correctly
"""
assert decode_hex("CAFE") == b"\xca\xfe"
def test_roundtrip(self) -> None:
"""
Encodes then decodes a known string and checks no data is lost
"""
original = b"Hello World"
assert decode_hex(encode_hex(original)) == original
class TestUrl:
def test_encode_special_chars(self) -> None:
"""
Checks that spaces and ampersands are percent-encoded
"""
result = encode_url(b"hello world&foo=bar")
assert " " not in result
assert "&" not in result
def test_decode_percent_encoded(self) -> None:
"""
Checks that %20 decodes to a space
"""
assert decode_url("hello%20world") == b"hello world"
def test_roundtrip(self) -> None:
"""
Encodes then decodes a URL with a query string and checks it matches the original
"""
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:
"""
Checks that form encoding turns spaces into + rather than %20
"""
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:
"""
Checks that form decoding turns + back into a space
"""
assert decode_url("hello+world", form = True) == b"hello world"
@ -146,15 +252,24 @@ class TestRegistryDispatch:
self,
fmt: EncodingFormat,
) -> None:
"""
Checks that all registered formats produce a lossless roundtrip via encode() and decode()
"""
original = b"roundtrip test data"
encoded = encode(original, fmt)
decoded = decode(encoded, fmt)
assert decoded == original
def test_try_decode_valid(self) -> None:
"""
Checks that try_decode returns the correct bytes for valid input
"""
result = try_decode("SGVsbG8=", EncodingFormat.BASE64)
assert result == b"Hello"
def test_try_decode_invalid_returns_none(self) -> None:
"""
Checks that try_decode returns None instead of raising for garbage input
"""
result = try_decode("!!!bad!!!", EncodingFormat.BASE64)
assert result is None

View File

@ -1,6 +1,23 @@
"""
©AngelaMos | 2026
test_peeler.py
Tests for single-layer and multi-layer decoding in peeler.py
Verifies that peel() strips one encoding layer correctly, handles
stacked encodings (base64 over hex, double base64), respects the
max_depth limit, and populates layer metadata accurately including
depth index, format, confidence, and preview strings.
Tests:
TestSingleLayer - base64, hex, base32 single-layer peeling
TestMultiLayer - base64+hex and double-base64 stacked encodings
TestPeelEdgeCases - plain text input, max_depth=0, empty string, layer metadata fields
Connects to:
peeler.py - imports peel
encoders.py - imports encode to construct layered test inputs
constants.py - imports EncodingFormat
"""
from base64_tool.constants import EncodingFormat
@ -10,6 +27,9 @@ from base64_tool.peeler import peel
class TestSingleLayer:
def test_peel_base64(self) -> None:
"""
Checks that a single base64 layer is peeled and the original bytes recovered
"""
encoded = encode(b"Hello World", EncodingFormat.BASE64)
result = peel(encoded)
assert result.success is True
@ -18,6 +38,9 @@ class TestSingleLayer:
assert result.final_output == b"Hello World"
def test_peel_hex(self) -> None:
"""
Checks that a single hex layer is peeled and the original bytes recovered
"""
encoded = encode(b"Hello World", EncodingFormat.HEX)
result = peel(encoded)
assert result.success is True
@ -25,6 +48,9 @@ class TestSingleLayer:
assert result.final_output == b"Hello World"
def test_peel_base32(self) -> None:
"""
Checks that a single base32 layer is successfully detected and peeled
"""
encoded = encode(b"Hello World", EncodingFormat.BASE32)
result = peel(encoded)
assert result.success is True
@ -33,6 +59,9 @@ class TestSingleLayer:
class TestMultiLayer:
def test_base64_then_hex(self) -> None:
"""
Checks that two stacked layers (base64 inside hex) are both peeled
"""
step1 = encode(b"secret payload", EncodingFormat.BASE64)
step2 = encode(step1.encode("utf-8"), EncodingFormat.HEX)
result = peel(step2)
@ -41,6 +70,9 @@ class TestMultiLayer:
assert b"secret payload" in result.final_output
def test_base64_double_encoded(self) -> None:
"""
Checks that base64 applied twice is unwrapped through both layers
"""
step1 = encode(b"double layer", EncodingFormat.BASE64)
step2 = encode(step1.encode("utf-8"), EncodingFormat.BASE64)
result = peel(step2)
@ -50,20 +82,32 @@ class TestMultiLayer:
class TestPeelEdgeCases:
def test_plaintext_no_layers(self) -> None:
"""
Checks that plain text with no encoding returns a failed peel with zero layers
"""
result = peel("just plain text")
assert result.success is False
assert len(result.layers) == 0
def test_max_depth_respected(self) -> None:
"""
Checks that setting max_depth=0 prevents any layers from being peeled
"""
encoded = encode(b"data", EncodingFormat.BASE64)
result = peel(encoded, max_depth = 0)
assert len(result.layers) == 0
def test_empty_string(self) -> None:
"""
Checks that an empty string results in a failed peel
"""
result = peel("")
assert result.success is False
def test_layer_metadata_populated(self) -> None:
"""
Checks that a successful peel populates depth, confidence, and preview fields
"""
encoded = encode(b"test data", EncodingFormat.BASE64)
result = peel(encoded)
if result.success and result.layers:

View File

@ -1,6 +1,26 @@
"""
©AngelaMos | 2026
test_properties.py
Property-based roundtrip and structural invariant tests using Hypothesis
Generates arbitrary binary and text inputs to verify that every
encode/decode pair is lossless and that encoded output meets format
constraints: ASCII-only, correct length multiples, correct character
sets, and URL form-encoding symmetry. Cross-format roundtrips are
verified in a single parametrized test.
Tests:
TestBase64Properties - roundtrip, ASCII output, length multiple of 4
TestBase64UrlProperties - roundtrip, no +/ characters in output
TestBase32Properties - roundtrip, uppercase output, length multiple of 8
TestHexProperties - roundtrip, output length exactly double, lowercase hex chars only
TestUrlProperties - roundtrip and form encoding roundtrip (200 examples each)
TestCrossFormatProperties - all non-URL formats pass roundtrip for arbitrary binary
Connects to:
encoders.py - all encode/decode functions under test
constants.py - imports EncodingFormat
"""
from hypothesis import given, settings, strategies as st
@ -25,15 +45,24 @@ from base64_tool.encoders import (
class TestBase64Properties:
@given(st.binary())
def test_roundtrip(self, data: bytes) -> None:
"""
Checks that arbitrary binary data survives a base64 encode-decode cycle unchanged
"""
assert decode_base64(encode_base64(data)) == data
@given(st.binary(min_size=1))
def test_encoded_is_ascii(self, data: bytes) -> None:
"""
Checks that base64 output is always valid ASCII
"""
encoded = encode_base64(data)
encoded.encode("ascii")
@given(st.binary())
def test_encoded_length_is_multiple_of_4(self, data: bytes) -> None:
"""
Checks that base64 output length is always a multiple of 4
"""
encoded = encode_base64(data)
if encoded:
assert len(encoded) % 4 == 0
@ -42,10 +71,16 @@ class TestBase64Properties:
class TestBase64UrlProperties:
@given(st.binary())
def test_roundtrip(self, data: bytes) -> None:
"""
Checks that arbitrary binary data survives a base64url encode-decode cycle unchanged
"""
assert decode_base64url(encode_base64url(data)) == data
@given(st.binary(min_size=1))
def test_no_standard_base64_chars(self, data: bytes) -> None:
"""
Checks that base64url output never contains + or /
"""
encoded = encode_base64url(data)
assert "+" not in encoded
assert "/" not in encoded
@ -54,15 +89,24 @@ class TestBase64UrlProperties:
class TestBase32Properties:
@given(st.binary())
def test_roundtrip(self, data: bytes) -> None:
"""
Checks that arbitrary binary data survives a base32 encode-decode cycle unchanged
"""
assert decode_base32(encode_base32(data)) == data
@given(st.binary(min_size=1))
def test_encoded_is_uppercase(self, data: bytes) -> None:
"""
Checks that base32 output is always uppercase
"""
encoded = encode_base32(data)
assert encoded == encoded.upper()
@given(st.binary())
def test_encoded_length_is_multiple_of_8(self, data: bytes) -> None:
"""
Checks that base32 output length is always a multiple of 8
"""
encoded = encode_base32(data)
if encoded:
assert len(encoded) % 8 == 0
@ -71,14 +115,23 @@ class TestBase32Properties:
class TestHexProperties:
@given(st.binary())
def test_roundtrip(self, data: bytes) -> None:
"""
Checks that arbitrary binary data survives a hex encode-decode cycle unchanged
"""
assert decode_hex(encode_hex(data)) == data
@given(st.binary(min_size=1))
def test_encoded_length_is_double(self, data: bytes) -> None:
"""
Checks that hex output is exactly twice the length of the input
"""
assert len(encode_hex(data)) == len(data) * 2
@given(st.binary())
def test_encoded_is_hex_chars_only(self, data: bytes) -> None:
"""
Checks that hex output contains only lowercase hex characters
"""
encoded = encode_hex(data)
assert all(c in "0123456789abcdef" for c in encoded)
@ -87,12 +140,18 @@ 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:
"""
Checks that arbitrary UTF-8 text survives a URL encode-decode cycle unchanged
"""
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:
"""
Checks that arbitrary UTF-8 text survives a form-encoded URL roundtrip unchanged
"""
data = text.encode("utf-8")
assert decode_url(encode_url(data, form=True), form=True) == data
@ -100,6 +159,9 @@ class TestUrlProperties:
class TestCrossFormatProperties:
@given(st.binary(min_size=1, max_size=256))
def test_all_formats_roundtrip(self, data: bytes) -> None:
"""
Checks that arbitrary binary data roundtrips losslessly through all non-URL formats
"""
for fmt in (
EncodingFormat.BASE64,
EncodingFormat.BASE64URL,

View File

@ -1,6 +1,22 @@
"""
AngelaMos | 2026
__main__.py
FastAPI application factory and server entry point
create_app assembles the FastAPI instance with CORS middleware, health
and root endpoints, and all three routers. The lifespan handler
initializes the database and creates the shared registry,
task_manager, and ops_manager singletons stored on app.state.
Connects to:
config.py - reads all settings
database.py - calls init_db()
beacon/registry.py - creates BeaconRegistry singleton
beacon/tasking.py - creates TaskManager singleton
beacon/router.py - mounts beacon WebSocket router
ops/manager.py - creates OpsManager singleton
ops/router.py - mounts operator WebSocket and REST routers
"""
import logging

View File

@ -1,6 +1,20 @@
"""
AngelaMos | 2026
registry.py
Tracks active beacon WebSocket connections with SQLite persistence
BeaconRegistry holds an in-memory dict of live connections keyed by
beacon ID alongside the SQLite beacons table. register/unregister
update both stores. is_active and get_connection read from memory;
get_all and get_one query the database.
Connects to:
core/models.py - uses BeaconMeta, BeaconRecord
beacon/router.py - registers and unregisters connections
ops/router.py - reads beacon list and active status
__main__.py - creates singleton on startup
tests/test_registry.py - tests all registry methods
"""
from datetime import UTC, datetime

View File

@ -1,6 +1,23 @@
"""
AngelaMos | 2026
router.py
WebSocket endpoint that manages the full beacon connection lifecycle
The /ws/beacon handler validates the REGISTER handshake, then runs
two concurrent coroutines: one pushing queued tasks to the beacon and
one processing incoming RESULT and HEARTBEAT messages. On disconnect,
it cleans up the registry and queue and broadcasts the event to
operators.
Connects to:
beacon/registry.py - registers, unregisters, updates heartbeat
beacon/tasking.py - dequeues tasks, stores results
config.py - reads XOR_KEY
core/models.py - uses BeaconMeta, TaskResult
core/protocol.py - calls pack, unpack
database.py - calls get_db()
ops/manager.py - broadcasts beacon events
"""
import asyncio

View File

@ -1,6 +1,20 @@
"""
AngelaMos | 2026
tasking.py
Per-beacon asyncio task queues backed by SQLite persistence
TaskManager maintains an asyncio.Queue per beacon for pending tasks.
submit writes to SQLite and enqueues; get_next blocks until a task is
available; store_result persists the result and marks the task
completed; get_history returns tasks joined with their results.
Connects to:
core/models.py - uses TaskRecord, TaskResult
beacon/router.py - calls get_next, store_result, remove_queue
ops/router.py - calls submit, get_history
__main__.py - creates singleton on startup
tests/test_tasking.py - tests all task lifecycle methods
"""
import asyncio

View File

@ -1,6 +1,17 @@
"""
AngelaMos | 2026
config.py
Application settings loaded from environment variables and a .env file
Defines the Settings class using pydantic-settings, covering server
host/port, database path, XOR encryption key, CORS origins, and log
level. The module-level settings singleton is shared across the app.
Connects to:
database.py - reads DATABASE_PATH
beacon/router.py - reads XOR_KEY
__main__.py - reads HOST, PORT, APP_NAME, LOG_LEVEL
"""
from functools import lru_cache

View File

@ -1,6 +1,22 @@
"""
AngelaMos | 2026
encoding.py
XOR cipher and Base64 encoding pipeline for obfuscating C2 traffic
Provides the low-level encoding primitives used on both the server and
implant sides. encode applies a repeating XOR with the shared key then
Base64-encodes the result; decode reverses the operation. This is
traffic obfuscation, not encryption.
Key exports:
xor_bytes - byte-level XOR with repeating key
encode - plaintext string to XOR+Base64 string
decode - XOR+Base64 string back to plaintext
Connects to:
protocol.py - calls encode and decode
tests/test_encoding.py - tests all three functions
"""
import base64

View File

@ -1,6 +1,24 @@
"""
AngelaMos | 2026
models.py
Pydantic models and command types shared across the C2 server
Defines the data types used throughout the server: CommandType (the
supported C2 commands mapped to MITRE ATT&CK techniques), beacon
registration and storage models, and the full task lifecycle from
request to result.
Key exports:
CommandType - enum of supported beacon commands
BeaconMeta, BeaconRecord - beacon registration and database types
TaskRequest, TaskRecord, TaskResult - task lifecycle types
Connects to:
beacon/registry.py - uses BeaconMeta, BeaconRecord
beacon/tasking.py - uses TaskRecord, TaskResult
beacon/router.py - uses BeaconMeta, TaskResult
ops/router.py - uses CommandType, TaskRecord
"""
from datetime import UTC, datetime

View File

@ -1,6 +1,24 @@
"""
AngelaMos | 2026
protocol.py
Protocol envelope types and pack/unpack for all WebSocket messages
Defines the shared message format for beacon/server communication.
MessageType enumerates the five protocol states. pack serializes a
Message to an encoded string; unpack decodes and validates the result,
raising ValueError on any malformed input.
Key exports:
MessageType - REGISTER, HEARTBEAT, TASK, RESULT, ERROR
Message - protocol envelope model
pack - serialize and encode a Message to a wire string
unpack - decode and validate a raw wire string into a Message
Connects to:
encoding.py - calls encode and decode
beacon/router.py - calls pack, unpack
tests/test_protocol.py - tests pack/unpack roundtrips
"""
import binascii

View File

@ -1,6 +1,21 @@
"""
AngelaMos | 2026
database.py
SQLite schema definition and async connection management
Holds the three-table schema (beacons, tasks, task_results) as a SQL
string and exposes init_db for first-run setup and get_db as an async
context manager. WAL mode and foreign keys are enabled on every
connection.
Connects to:
config.py - reads DATABASE_PATH via settings
beacon/router.py - calls get_db()
ops/router.py - calls get_db()
__main__.py - calls init_db()
tests/test_registry.py - imports SCHEMA
tests/test_tasking.py - imports SCHEMA
"""
from collections.abc import AsyncIterator

View File

@ -1,6 +1,18 @@
"""
AngelaMos | 2026
manager.py
Fan-out broadcaster for operator WebSocket connections
OpsManager keeps a set of active operator WebSocket connections.
connect accepts a new connection; broadcast serializes an event dict
as JSON and sends it to all connected operators, silently dropping
any connections that fail to send.
Connects to:
beacon/router.py - broadcast called for beacon connect/disconnect/result
ops/router.py - connect, disconnect, and broadcast called per operator
__main__.py - creates singleton on startup
"""
import json

View File

@ -1,6 +1,25 @@
"""
AngelaMos | 2026
router.py
Operator interface combining a WebSocket dashboard channel and REST beacon routes
The /ws/operator WebSocket sends the current beacon list on connect
and accepts submit_task messages from the operator. Three REST routes
cover beacon listing, single-beacon lookup, and task history. All
handlers share the registry, task manager, and ops manager from
app.state.
Key exports:
ws_router - WebSocket router mounted at /ws/operator
rest_router - REST routes for /beacons and /beacons/{id}
Connects to:
beacon/registry.py - reads beacon list and active status
beacon/tasking.py - submits tasks and fetches history
core/models.py - uses CommandType, TaskRecord
database.py - calls get_db()
ops/manager.py - manages operator connections
"""
import json

View File

@ -1,6 +1,12 @@
"""
AngelaMos | 2026
conftest.py
Shared pytest fixtures for the backend test suite
Provides two fixtures: tmp_db_path supplies a temp SQLite path for
test isolation, and test_settings builds a Settings instance pointed
at that path with a known XOR key.
"""
from pathlib import Path

View File

@ -1,6 +1,15 @@
"""
AngelaMos | 2026
test_app.py
Integration tests for the FastAPI HTTP endpoints
Spins up the full application using ASGITransport and verifies the
health check, root info endpoint, empty beacon list, and 404 handling
for unknown beacon IDs.
Tests:
__main__.py - create_app, /health, /, /beacons, /beacons/{id}
"""
from pathlib import Path

View File

@ -1,6 +1,15 @@
"""
AngelaMos | 2026
test_encoding.py
Unit tests for the XOR cipher and Base64 encoding pipeline
Verifies xor_bytes correctness and idempotency, encode/decode
roundtrips for ASCII and Unicode payloads, and that mismatched keys
produce different ciphertext.
Tests:
core/encoding.py - xor_bytes, encode, decode
"""

View File

@ -1,6 +1,15 @@
"""
AngelaMos | 2026
test_protocol.py
Unit tests for protocol message pack/unpack roundtrips and validation
Verifies that all five MessageType values survive pack -> unpack, and
that unpack raises ValueError for invalid base64, malformed JSON,
missing fields, and unknown message types.
Tests:
core/protocol.py - pack, unpack, Message, MessageType
"""
import pytest

View File

@ -1,6 +1,16 @@
"""
AngelaMos | 2026
test_registry.py
Unit tests for BeaconRegistry connection tracking and persistence
Verifies that registering a beacon adds it to both the in-memory
store and SQLite, that unregistering removes it from memory while
preserving the database record, and that query methods return correct
results.
Tests:
beacon/registry.py - BeaconRegistry
"""
from pathlib import Path

View File

@ -1,6 +1,15 @@
"""
AngelaMos | 2026
test_tasking.py
Unit tests for TaskManager queue behavior and SQLite persistence
Verifies task submission, FIFO ordering, blocking get_next behavior,
result storage and status transitions, task history queries, and queue
cleanup on beacon disconnect.
Tests:
beacon/tasking.py - TaskManager
"""
import asyncio

View File

@ -1,6 +1,19 @@
"""
AngelaMos | 2026
beacon.py
Standalone implant that connects to the C2 server and executes tasks
Self-contained beacon implant. Connects to the server over WebSocket,
sends a REGISTER message with host metadata, then concurrently runs a
heartbeat loop and a task receive loop. Handles all C2 commands
locally (shell, sysinfo, proclist, upload, download, screenshot,
keylogging, persistence, sleep configuration) and sends results back.
Reconnects with exponential backoff on failure.
Key exports:
BeaconConfig - runtime configuration dataclass
main - async entry point for the beacon loop
"""
import asyncio

View File

@ -1,6 +1,15 @@
// ===========================
// © AngelaMos | 2026
// App.tsx
//
// Root application component wrapping the router and toast notifications
//
// Renders RouterProvider with the configured browser router and a
// Sonner Toaster styled to the dark C2 theme. This is the single
// component mounted by main.tsx.
//
// Connects to:
// routers.tsx - provides the router instance
// ===========================
import { RouterProvider } from 'react-router-dom'

View File

@ -1,6 +1,21 @@
// ===================
// © AngelaMos | 2026
// config.ts
//
// Client-side URL constants for API endpoints, WebSocket paths, and routes
//
// All hard-coded paths live here so components never construct URLs
// directly. API_ENDPOINTS covers REST paths, WS_ENDPOINTS covers the
// operator WebSocket, ROUTES covers client-side navigation paths, and
// STORAGE_KEYS names the localStorage persistence key.
//
// Connects to:
// core/ws.ts - reads WS_ENDPOINTS.OPERATOR
// core/lib/shell.ui.store.ts - reads STORAGE_KEYS.UI
// core/app/routers.tsx - reads ROUTES.DASHBOARD
// core/app/shell.tsx - reads ROUTES.DASHBOARD
// pages/dashboard/index.tsx - reads ROUTES.SESSION
// pages/session/index.tsx - reads ROUTES.DASHBOARD
// ===================
export const API_BASE = '/api'

View File

@ -1,6 +1,18 @@
// ===================
// © AngelaMos | 2026
// routers.tsx
//
// React Router browser router with Shell layout and lazy page routes
//
// All routes nest under the Shell layout element. Dashboard and Session
// pages are lazily imported so they split into separate chunks at build
// time.
//
// Connects to:
// config.ts - reads ROUTES.DASHBOARD for the dashboard path
// core/app/shell.tsx - Shell is the parent layout element
// pages/dashboard/index.tsx - lazy-loaded for /
// pages/session/index.tsx - lazy-loaded for /session/:id
// ===================
import { createBrowserRouter, type RouteObject } from 'react-router-dom'

View File

@ -1,6 +1,22 @@
// ===========================
// © AngelaMos | 2026
// shell.tsx
//
// Application shell with collapsible sidebar, top header, and page outlet
//
// Renders the persistent layout used by every page: a sidebar with
// navigation links, a header showing the current page title, and an
// Outlet for child routes. Sidebar open/collapsed state comes from
// useUIStore. An ErrorBoundary and Suspense wrap the Outlet to handle
// lazy-loaded pages and runtime errors gracefully.
//
// Key components:
// Shell - layout wrapper rendered as the parent route for all pages
//
// Connects to:
// config.ts - reads ROUTES.DASHBOARD for the nav link
// core/lib/index.ts - imports useUIStore
// core/app/routers.tsx - Shell is the element for the root route
// ===========================
import { Suspense } from 'react'

View File

@ -1,6 +1,20 @@
// ===================
// © AngelaMos | 2026
// shell.ui.store.ts
//
// Zustand store for sidebar open and collapsed UI state
//
// Manages two boolean flags: sidebarOpen (transient, not persisted)
// and sidebarCollapsed (persisted to localStorage). Exposes toggle
// and setter actions alongside selector hooks for each flag.
//
// Key exports:
// useUIStore - full Zustand store with sidebar state and actions
// useSidebarOpen, useSidebarCollapsed - selector hooks
//
// Connects to:
// config.ts - reads STORAGE_KEYS.UI for the localStorage key
// core/app/shell.tsx - reads and mutates sidebar state
// ===================
import { create } from 'zustand'

View File

@ -1,6 +1,24 @@
// ===================
// © AngelaMos | 2026
// types.ts
//
// Zod schemas and TypeScript types for all C2 data structures
//
// Mirrors the server-side Pydantic models: CommandType, BeaconRecord,
// TaskRecord, and TaskResult. Also defines the full set of WebSocket
// message schemas and WsServerMessage, a discriminated union that
// covers every event the operator channel can emit.
//
// Key exports:
// CommandType - enum of supported beacon commands
// BeaconRecord, TaskRecord, TaskResult - core C2 data models
// WsServerMessage - discriminated union of all server message types
// parseServerMessage - parse and validate a raw WebSocket string
//
// Connects to:
// core/ws.ts - imports all types and parseServerMessage
// pages/dashboard/index.tsx - imports BeaconRecord
// pages/session/index.tsx - imports CommandType, TaskResult
// ===================
import { z } from 'zod/v4'

View File

@ -1,6 +1,25 @@
// ===================
// © AngelaMos | 2026
// ws.ts
//
// Zustand store and WebSocket hook for the live operator C2 connection
//
// useC2Store holds all live beacon state: beacon map, task results, and
// local-to-server task ID mapping. useOperatorSocket manages the
// /ws/operator WebSocket, dispatches incoming server messages into the
// store, and reconnects with exponential backoff on disconnect.
// sendTask sends a submit_task message to the server.
//
// Key exports:
// useC2Store - Zustand store for all C2 state and actions
// useOperatorSocket - hook that owns the WebSocket lifecycle
// useBeacons, useBeacon, useTaskResults, useTaskIdMap, useIsConnected - selectors
//
// Connects to:
// config.ts - reads WS_ENDPOINTS.OPERATOR
// core/types.ts - imports BeaconRecord, TaskResult, parseServerMessage
// pages/dashboard/index.tsx - calls useBeacons, useIsConnected, useOperatorSocket
// pages/session/index.tsx - calls useBeacon, useOperatorSocket, useTaskResults, useTaskIdMap
// ===================
import { useEffect, useRef } from 'react'

View File

@ -1,6 +1,8 @@
// ===========================
// © AngelaMos | 2026
// main.tsx
//
// React entry point that mounts App into the DOM root
// ===========================
import { StrictMode } from 'react'

View File

@ -1,6 +1,21 @@
// ===========================
// © AngelaMos | 2026
// pages/dashboard/index.tsx
//
// Operator dashboard listing all known beacons with live status
//
// Renders a table of every beacon the C2 server has seen. Active status
// and last-seen timestamps update in real time via the operator
// WebSocket. Clicking a row navigates to the session page for that
// beacon. An empty state is shown when no beacons are registered.
//
// Key components:
// Component (Dashboard) - lazy-loaded route component for /
//
// Connects to:
// config.ts - reads ROUTES.SESSION for row navigation
// core/types.ts - imports BeaconRecord
// core/ws.ts - calls useBeacons, useIsConnected, useOperatorSocket
// ===========================
import { useEffect, useState } from 'react'

View File

@ -1,6 +1,22 @@
// ===========================
// © AngelaMos | 2026
// pages/session/index.tsx
//
// Interactive terminal session for issuing commands to a single beacon
//
// Renders a terminal-style interface for a specific beacon: command
// input with history navigation and Tab autocomplete, quick-action
// buttons for sysinfo/proclist/screenshot, and a scrolling output panel
// that updates as task results arrive. Screenshots render inline as
// images; all other output renders as preformatted text.
//
// Key components:
// Component (Session) - lazy-loaded route component for /session/:id
//
// Connects to:
// config.ts - reads ROUTES.DASHBOARD for the back link
// core/types.ts - imports CommandType, TaskResult
// core/ws.ts - calls useBeacon, useOperatorSocket, useTaskResults, useTaskIdMap
// ===========================
import { useCallback, useEffect, useRef, useState } from 'react'

View File

@ -1 +1,24 @@
.venv
# 2026 | ©AngelaMos LLC
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.venv/
venv/
*.venv
.coverage
htmlcov/
.tox/
.dmypy.json
dmypy.json
.ruff_cache/
.mypy_cache/
.pytest_cache/

View File

@ -21,6 +21,15 @@
------------------
Public surface of the caesar_cipher package
Re-exports CaesarCipher and FrequencyAnalyzer so callers can import
directly from the package root. Sets the package version string.
Connects to:
cipher.py - imports CaesarCipher
analyzer.py - imports FrequencyAnalyzer
"""
from caesar_cipher.cipher import CaesarCipher

View File

@ -1,6 +1,17 @@
"""
AngelaMos | 2026
©AngelaMos | 2026
analyzer.py
Statistical frequency analysis for ranking Caesar cipher brute-force results
Provides the FrequencyAnalyzer class that scores decryption candidates
by comparing their letter distributions against expected English frequency
percentages using a chi-squared test. Lower scores indicate text that
more closely matches natural English.
Connects to:
constants.py - imports ENGLISH_LETTER_FREQUENCIES
main.py - crack command passes CaesarCipher.crack() output to rank_candidates()
"""
from collections import Counter

View File

@ -1,6 +1,18 @@
"""
AngelaMos | 2026
©AngelaMos | 2026
cipher.py
Caesar cipher with encrypt, decrypt, and brute-force crack methods
Provides the CaesarCipher class that performs letter shifting for a given
key while preserving case, spaces, and punctuation. The static crack()
method generates all 26 possible decryptions without needing the key,
leaving ranking to the analyzer layer.
Connects to:
constants.py - imports UPPERCASE_LETTERS, LOWERCASE_LETTERS, ALPHABET_SIZE
main.py - all three CLI commands instantiate CaesarCipher
analyzer.py - crack() output is passed to FrequencyAnalyzer for ranking
"""
from caesar_cipher.constants import (

View File

@ -1,6 +1,17 @@
"""
AngelaMos | 2026
©AngelaMos | 2026
constants.py
Shared constants for the Caesar cipher: alphabet definitions and English letter frequency data
Defines the letter sets used by the cipher for case-aware shifting,
the expected frequency percentages for each English letter (used by
frequency analysis to score decryption candidates), and the chi-squared
threshold for filtering results.
Connects to:
cipher.py - imports UPPERCASE_LETTERS, LOWERCASE_LETTERS, ALPHABET_SIZE
analyzer.py - imports ENGLISH_LETTER_FREQUENCIES
"""
import string

View File

@ -1,6 +1,21 @@
"""
AngelaMos | 2026
©AngelaMos | 2026
main.py
CLI entry point with encrypt, decrypt, and crack commands via Typer
Wires together input reading, cipher operations, and formatted Rich output
for three commands. The encrypt and decrypt commands require a key; crack
tries all 26 shifts and displays the ranked results as a table with the
best match highlighted.
Key exports:
app - Typer application, registered as the caesar-cipher CLI entry point
Connects to:
cipher.py - instantiates CaesarCipher for all three commands
analyzer.py - crack command uses FrequencyAnalyzer to rank candidates
utils.py - imports read_input, validate_key, write_output
"""
from pathlib import Path

View File

@ -1,6 +1,15 @@
"""
AngelaMos | 2026
©AngelaMos | 2026
utils.py
Input/output helpers and key validation shared across CLI commands
Handles the three sources of input (positional argument, file, or stdin)
and routes output to either stdout or a file. Also validates that the
shift key is in the acceptable range before the cipher is constructed.
Connects to:
main.py - imports read_input, write_output, validate_key
"""
import sys

View File

@ -1,6 +1,18 @@
"""
AngelaMos | 2026
©AngelaMos | 2026
test_analyzer.py
Tests for FrequencyAnalyzer covering chi-squared scoring and candidate ranking
Tests:
chi-squared scoring distinguishes English text from gibberish
empty string returns infinity
rank_candidates() orders results by score ascending
end-to-end crack-and-rank against a known plaintext
Connects to:
analyzer.py - the class under test
cipher.py - crack() output used as input to rank_candidates()
"""
from caesar_cipher.analyzer import FrequencyAnalyzer
@ -9,28 +21,43 @@ from caesar_cipher.cipher import CaesarCipher
class TestFrequencyAnalyzer:
def test_calculate_chi_squared_english_text(self) -> None:
"""
Confirms real English text scores below a reasonable chi-squared threshold
"""
analyzer = FrequencyAnalyzer()
english_text = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
score = analyzer.calculate_chi_squared(english_text)
assert score < 150
def test_calculate_chi_squared_gibberish(self) -> None:
"""
Confirms text made of rare letters scores above the threshold
"""
analyzer = FrequencyAnalyzer()
gibberish = "ZZZZZ QQQQQ XXXXX"
score = analyzer.calculate_chi_squared(gibberish)
assert score > 100
def test_calculate_chi_squared_empty_string(self) -> None:
"""
Confirms an empty string returns infinity since there are no letters to score
"""
analyzer = FrequencyAnalyzer()
assert analyzer.calculate_chi_squared("") == float("inf")
def test_score_text_lowercase(self) -> None:
"""
Confirms score_text returns a non-negative float for lowercase input
"""
analyzer = FrequencyAnalyzer()
score = analyzer.score_text("hello world")
assert isinstance(score, float)
assert score >= 0
def test_rank_candidates_orders_by_score(self) -> None:
"""
Confirms candidates are sorted so the most English-like text ranks first
"""
analyzer = FrequencyAnalyzer()
candidates = [
(0,
@ -48,6 +75,9 @@ class TestFrequencyAnalyzer:
assert ranked[1][2] < ranked[2][2]
def test_rank_candidates_with_actual_cipher(self) -> None:
"""
Encrypts known text, brute-forces all shifts, and confirms the correct key ranks first
"""
cipher = CaesarCipher(key = 3)
plaintext = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
ciphertext = cipher.encrypt(plaintext)
@ -61,6 +91,9 @@ class TestFrequencyAnalyzer:
assert best_text == plaintext
def test_rank_candidates_empty_list(self) -> None:
"""
Confirms an empty candidate list returns an empty ranked list
"""
analyzer = FrequencyAnalyzer()
ranked = analyzer.rank_candidates([])
assert ranked == []

Some files were not shown because too many files have changed in this diff Show More