Compare commits

..
21 Commits
Author SHA1 Message Date
venus cd7ef151c4 adding more features inclided steg analysis, decoding engines, and more
tests
2026-07-19 00:13:13 -05:00
venus c48e4343dc [GEMINI] Write tests for steganography crack module 2026-07-19 00:11:53 -05:00
venus d31e837b62 [GEMINI] Add test case for unique ROT13 metadata decoding 2026-07-18 23:55:16 -05:00
venus f338ccb071 [GEMINI] Add unit tests for unanchored decoding 2026-07-18 04:31:25 -05:00
venus 1cf9ec4a7c [GEMINI] Add unit tests for recursive nested decoding 2026-07-18 04:22:33 -05:00
venus d474de1317 [GEMINI] Fix base32 payload in decoding test 2026-07-18 04:17:36 -05:00
venus 4c5d966057 [GEMINI] Add tests for base32, binary, url, rot13, and reversed decoders 2026-07-18 04:11:23 -05:00
venus dc58c0f3ca [GEMINI] Write unit tests for decoding.py 2026-07-18 03:55:29 -05:00
venus 7f586830ba [GEMINI] Update tests for empty XMP attributes 2026-07-18 03:43:45 -05:00
venus 7bb0f87808 [GEMINI] Write tests for forensics and stage baseline implementation 2026-07-18 03:28:52 -05:00
venus be58885e4f [GEMINI] Document custom forensics metadata parser architectural decision 2026-07-18 03:23:49 -05:00
venus 3e4a449c41 [GEMINI] Add test cases for Adobe XMP metadata extraction 2026-07-18 03:17:24 -05:00
venus 08533f3ee2 [GEMINI] Record future metadata parsing features in status roadmap and architecture documentation 2026-07-18 03:10:17 -05:00
venus d58587cc65 [GEMINI] Update Core Mandate 7 to explicitly restrict unsolicited testing 2026-07-18 03:08:45 -05:00
venus 72266489da [GEMINI] Make EXIF filename assertion robust to small terminal console wrapping 2026-07-18 03:06:56 -05:00
venus dc72be5a17 [GEMINI] Add test cases for JPEG comment extraction and EXIF warnings 2026-07-18 03:06:00 -05:00
venus 812a493cf1 [GEMINI] Update test_utils.py assertions to align with correct regex patterns suggestions 2026-07-18 02:58:58 -05:00
venus 7f61d5db07 [GEMINI] Update test_commands.py to match dynamic competition name in suggested patterns output 2026-07-18 02:57:11 -05:00
venus 7a301e3a72 Add test cases verifying pure EXIF parsing and inspect -i/-e options 2026-07-18 02:50:15 -05:00
venus b6c73e46f2 Add test cases verifying root inspect and nested forensics inspect CLI execution paths 2026-07-18 02:44:54 -05:00
venus 05144a2dda Update test to expect a trailing newline in ctf flag -p output 2026-07-18 02:40:26 -05:00
34 changed files with 2441 additions and 82 deletions
+10
View File
@@ -50,6 +50,9 @@ Houses the logic for generic CLI context and active competition commands:
### D. Forensics ([forensics.py](file:///home/venus/code/ctf/src/ctf/forensics.py))
Implements specialized forensic inspection utilities registered as a nested subgroup under the CLI:
* **Custom Extractor**: Implements our own pure-Python, zero-dependency parser for standard TIFF/EXIF tags, Adobe XMP XML blocks, Photoshop IPTC IIM records, and JPEG/PNG physical parameters, rather than importing large external libraries.
> [!NOTE]
> We designed and implemented our own custom EXIF scraper/parser from scratch in pure Python rather than importing an external library (like `exifread` or `Pillow`) to maintain zero dependencies and keep the toolchain highly lightweight and customizable.
* `info`: Inspects target file sizes, reads magic bytes, and warns if extensions do not match detected signatures.
* `flag-search`: Extracts printable string sequences (equivalent to GNU `strings`) and matches them against regular expression patterns to find potential flags.
@@ -74,6 +77,13 @@ Implements specialized forensic inspection utilities registered as a nested subg
### A. Forensics Pipeline & Extended Metadata
* **Pipeline Coordination**: A central runner that coordinates file inspections, signature verification, and string searches sequentially on target files.
* **Carving & Extraction Toolkit**: Deep inspection and carving utilities (such as archive extracting, custom chunk/payload carvers, and EXIF/metadata header decoders).
* **Chepy Machine Learning**: Plan to integrate the `chepy_ml` plugin later to perform automated heuristics, entropy analysis, and machine-learning-based format detection on unknown metadata payloads.
* **Targeted Forensic Metadata Parsing**: Implement decoders/parsers for high-signal forensics structures:
* *PNG*: `tEXt`, `zTXt`, `iTXt` metadata chunks.
* *PDF*: `/Info` dictionaries and `/Metadata` XML streams.
* *GIF*: Comment extensions and application loop extensions.
* *ELF/PE*: Executable header offsets, architectures, and section headers.
* *MP3*: ID3v1 and ID3v2 tag blocks (Title, Artist, custom Comments).
### B. Core Solving Infrastructure & QOL
* **Solving Assistants**: Standard templates and modular helper scripts for repeating CTF patterns (e.g., base conversions, cipher decoding, and request generation).
+2 -2
View File
@@ -12,7 +12,7 @@ The primary goal is to use the context of CTF challenges (forensics, crypto, web
4. **Educational Context:** Use the existing scripts and tools in this repository (like `psk_crack.py`, `exploit.sh`, or the `tools/` directory) as examples when explaining technical concepts.
5. **Vim Folding Markers:** Wrap all classes, command groups, subcommands, and functions inside project implementation files in standard Vim/Neovim folding syntax markers (`# {{{ <name>` and `# }}}`).
6. **Matching Test Files:** Every Python implementation file inside `src/` must have a corresponding test file under the `tests/` directory named `test_<filename>.py`.
7. **Write Only, Do Not Run**: The agent must only write code and tests. Do not attempt to run tests or execute command-line scripts; the user will handle all execution, verification, and testing tasks.
7. **Write Only, Do Not Run**: The agent must only write code and tests. Do not attempt to run tests, execute command-line scripts, or execute commands (like pytest) unless the user explicitly asks for execution in their prompt; the user will handle all execution, verification, and testing tasks.
## Interaction Workflow
When discussing new implementations, features, or additions:
@@ -20,7 +20,7 @@ When discussing new implementations, features, or additions:
2. **The Pitch**: Provide a clear, technical pitch detailing the planned code modifications.
3. **Implementation Cycle**: Once the user approves the pitch:
* **Add Test Cases**: Write or update the corresponding test cases in the test files (e.g. `tests/test_forensics.py`) following the SDET instructions.
* **Git Commit Current State**: Create a git commit of the current workspace state (including the new tests) *before* modifying any implementation files.
* **Git Commit Current State**: Create a git commit of the current workspace state (including the new tests) *before* modifying any implementation files. The commit message MUST start with `[GEMINI]` as the author prefix (e.g., `git commit -m "[GEMINI] Write tests for forensics"`).
* **Update the Codebase**: Write/update the actual project implementation files as approved. Do **NOT** create a git commit after writing this implementation code. This ensures all implementation changes remain unstaged so the user can easily run `git diff` to review them.
* **Hand Over**: Present the changes to the user so they can run the tests. Do not run the tests yourself.
+53 -33
View File
@@ -1,38 +1,58 @@
# CTF toolchain for linux
This project is meant to be an ai-enhancing ctf toolchain to track your work and help you faster
The system keeps track of your file history, current ctf, challenge catagory, etc to make it easy to provide AI with the context to give you the right answer
The focus is on building a solid methodology to solve any problem.
# AI-Enhanced CTF Toolchain & Learning Platform
A modern, lightweight Capture The Flag (CTF) utility and context manager written in Python. This tool standardizes solving methodologies, captures solver context, inspects file structures, and automatically intercepts flags in the console stream to facilitate agentic solving workflows.
# Dev flow
## initialize a competition
Take user input (eventually screenshots) and generate a new competition directory with directories for each catagory
## Initialize a challenge
Make a new challenge directory and cd into it
Review recently downloaded files, and add challenge files
## Progress tracker
Generate an active log file from command history
Accept user prompts to inform history
- finished (challenge finished)
- begin route (start a new path of exploration)
- help (prompts AI with challenge and progress to give reliable and relevant help)
- success (current path succeeded with previously run command)
- context (add context at this point in the log)
## Built in scripts and libraries
- built in rapid forensics testing and flag analysis based on context
## Write up aid
Shows all of your quickly commented notes
Creates prompt for AI to generate a write up showing your process
- feedback (generates feedback on your work flow and suggests improvements)
Managed using `uv` as the package manager and built with a modular, pure-Python architecture.
# Misc
the end goal is to create a methodology solid enough to build out a full AI tool chain around it while allowing tight human integration
---
## 🚀 Key Features
## features
- Cyberchef like decoding and magic feature
- regexing input text for flag
- flag selection and filtering with remembered prev. flags
# Tools
- forensics
- forensics tool for basic file analysis
### 1. Real-Time Flag Interception (Stdout Monitor)
* **Automatic Flag Detection**: Standard output stream interceptor scans terminal output in real-time.
* **Persistent Capture**: Detected flags matching the active regular expression format are instantly saved to [config.toml](file:///home/venus/code/ctf/config.toml) (`last_flag`).
### 2. Custom Forensic Inspection
Pure-Python, zero-dependency parsers extract metadata without using heavy external libraries:
* **POSIX Metadata**: Hard link counts, ownership, permission masks, and inode data.
* **Image Metadata**: Scrapes TIFF/EXIF tags, Adobe XMP XML blocks, and Photoshop IPTC IIM records.
* **Chunk/Comment Scrapers**: Parses PNG text chunks (`tEXt`, `zTXt`, `iTXt`) and extracts comments from JPEG and GIF comment blocks.
* **Physical Parameters**: Returns image parameters (width, height, color depth, etc.) for JPEG, PNG, and GIF.
### 3. Recursive Decoding Engine
* **Multi-layer Decoder**: Integrates with `chepy` to identify and decode nested strings.
* **Supported Formats**: Hex, Base64, Base32, Binary, URL percent-encoding, ROT13, and string reversal.
* **Intelligent Filtering**: Prevents spam by only outputting ROT13 and Reversal transformations if they yield valid flag formats.
### 4. Steganography Cracker
* **Steghide Wrapper**: Automatically brute-forces passphrase-protected files using standard dictionary wordlists.
* **Safe Execution**: Runs in isolated temporary sandboxes to keep the workspace clean.
---
## 🛠️ CLI Reference
### Context Commands
* `ctf set-competition <name>`: Configure active competition.
* `ctf set-flag-format`: Set expected flag regex format (supports original flag sample extraction).
* `ctf flag`: Output the last captured flag from the configuration.
### Forensics Commands
* `ctf inspect <file>`: Detailed metadata inspection.
* `-i, --inode`: Output POSIX file attributes only.
* `-e, --exif`: Output EXIF/XMP/IPTC data and decoded hints.
* `-p, --physical`: Output physical parameters of the target media.
* `ctf forensics flag-search <file>`: Extract printable characters (GNU `strings` style) and match them against regular expressions.
### Steganography Commands
* `ctf steg crack-steghide <file> -w <wordlist>`: Brute-force passphrase-protected files.
* `-o, --output <path>`: Write the extracted payload directly to a file on success.
---
## 🧪 Development & Testing
This project is built following strict **SDET workflows** to ensure pipeline correctness:
* **Sandbox Testing**: Unit tests run using a dedicated sandboxed environment under [tests/env](file:///home/venus/code/ctf/tests/env).
* **Testing Commands**: Use `uv run pytest` to execute the full integration test suite.
* **Dependency Management**: Dependencies are locked in [uv.lock](file:///home/venus/code/ctf/uv.lock) and managed in [pyproject.toml](file:///home/venus/code/ctf/pyproject.toml).
+1 -1
View File
@@ -4,7 +4,7 @@ competition = "picoctf"
catagory = "textCat"
challenge = "testChal"
flag_format = "picoCTF\\{.*\\}"
last_flag = "picoCTF{flag}"
last_flag = "flag{my_flag_here}"
[Enviroment]
ctf_dir = "/home/venus/ctf"
+7 -1
View File
@@ -21,7 +21,13 @@ This file tracks the completed progress and upcoming development milestones for
### 📅 Phase 1: Functional Forensics Pipeline & Toolkit
* **Pipeline Automation**: Integrate the existing forensics command-line utilities into a cohesive analysis pipeline where files are automatically checked for magic bytes, file extensions, and flag patterns.
* **Metadata & Extraction Toolkit**: Extend forensics tools to extract specific metadata (e.g., EXIF records, archive tables) and automate extraction/carving of nested data structures (e.g., binwalk-like carving, automated unzipping, extraction of hidden payloads).
* **Metadata & Extraction Toolkit**: Extend forensics tools to extract specific metadata and automate extraction/carving of nested data structures (e.g., binwalk-like carving, automated unzipping, extraction of hidden payloads).
* **Targeted Forensic Metadata Parsing**: Implement pure-Python parsers for high-signal forensics structures:
* *PNG Text Chunks*: Extract metadata from `tEXt`, `zTXt`, and `iTXt` blocks.
* *PDF Catalog Info*: Extract `/Info` and `/Metadata` XML properties.
* *GIF Application Extensions*: Extract comments and loop delays.
* *ELF/PE Executable Headers*: Read architecture, entry points, and section identifiers.
* *MP3 ID3 Tags*: Extract Title, Artist, Album, and standard Comment frames.
* **QOL Utilities**: Add standard format outputs (JSON, Rich logs) and automatic logging of analysis artifacts to speed up user-led inspections.
### 📅 Phase 2: Core CTF Solving & Solver QOL
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"toml>=0.10.2",
"click",
"rich",
"chepy",
]
[build-system]
+73 -9
View File
@@ -13,17 +13,18 @@ def forensics_group():
pass
# }}}
# {{{ tf
@forensics_group.command()
def tf():
click.echo("hello from forensics")
# }}}
# {{{ inspect
@forensics_group.command()
@click.argument('path', type=click.Path(exists=True))
def inspect(path):
"""Lists all basic inode metadata about a file"""
@click.option('-i', '--inode', is_flag=True, help="Print only POSIX inode metadata (without EXIF data).")
@click.option('-e', '--exif', is_flag=True, help="Print only EXIF metadata (without POSIX inode metadata).")
@click.option('-p', '--physical', is_flag=True, help="Print only physical image parameters.")
def inspect(path, inode, exif, physical):
"""Lists all basic inode metadata and EXIF data about a file"""
flags = [inode, exif, physical]
if sum(flags) > 1:
raise click.UsageError("Cannot specify more than one of -i/--inode, -e/--exif, and -p/--physical.")
meta = get_metadata(Path(path))
# Present using Rich Table
@@ -31,6 +32,8 @@ def inspect(path):
from rich.table import Table
console = Console()
if not exif and not physical:
table = Table(title=f"Metadata: {meta.filename}", show_header=False)
table.add_column("Key", style="bold cyan")
table.add_column("Value")
@@ -51,8 +54,69 @@ def inspect(path):
if meta.extended_attributes:
xattr_str = ", ".join(f"{k}={v}" for k, v in meta.extended_attributes.items())
table.add_row("Extended Attributes", xattr_str)
console.print(table)
if physical:
if meta.physical_data:
table = Table(title="Physical Metadata", show_header=True)
table.add_column("Attribute", style="bold cyan")
table.add_column("Value")
for k, v in sorted(meta.physical_data.items()):
table.add_row(k, str(v))
console.print(table)
else:
console.print("[bold yellow]No physical metadata found.[/bold yellow]")
if not inode and not exif and not physical:
if meta.physical_data:
table = Table(title="Physical Metadata", show_header=True)
table.add_column("Attribute", style="bold cyan")
table.add_column("Value")
for k, v in sorted(meta.physical_data.items()):
table.add_row(k, str(v))
console.print(table)
exif_displayed = False
if not inode and not physical:
if meta.exif_data:
exif_table = Table(title=f"EXIF Metadata: {meta.filename}", show_header=True)
exif_table.add_column("Tag", style="bold green")
exif_table.add_column("Value")
for tag, val in sorted(meta.exif_data.items()):
if isinstance(val, bytes):
val_str = val.hex().upper()
if len(val_str) > 60:
val_str = val_str[:57] + "..."
else:
val_str = str(val)
exif_table.add_row(tag, val_str)
console.print(exif_table)
exif_displayed = True
if meta.comment:
from rich.panel import Panel
console.print(Panel(
meta.comment,
title=f"[bold green]Comment: {meta.filename}[/bold green]",
border_style="green"
))
exif_displayed = True
if exif and not exif_displayed:
console.print("[bold yellow]No EXIF or comment metadata found.[/bold yellow]")
if meta.decoded_hints:
hints_table = Table(title="Decoded Metadata Hints (Hex/Base64)", show_header=True)
hints_table.add_column("Source Tag", style="bold yellow")
hints_table.add_column("Encoding", style="bold green")
hints_table.add_column("Decoded Value")
for src, dec_dict in sorted(meta.decoded_hints.items()):
for enc, dec_val in sorted(dec_dict.items()):
hints_table.add_row(src, enc, dec_val)
console.print(hints_table)
return meta
# }}}
+68
View File
@@ -0,0 +1,68 @@
# src/ctf/cli_steg.py
# {{{ imports
import click
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from ctf.steg import crack_steghide
# }}}
# {{{ steg_group
@click.group(name="steg")
def steg_group():
""" Steganography tools and solvers """
pass
# }}}
# {{{ crack_steghide_cmd
@steg_group.command(name="crack-steghide")
@click.argument('path', type=click.Path(exists=True))
@click.option('-w', '--wordlist', type=click.Path(exists=True), required=True, help="Path to password wordlist.")
@click.option('-o', '--output', type=click.Path(), help="Path to write the extracted payload (if cracked).")
def crack_steghide_cmd(path, wordlist, output):
"""Attempt to crack steghide passphrases using a wordlist"""
console = Console()
# Read the wordlist
try:
with open(wordlist, "r", errors="ignore") as f:
words = [line.strip() for line in f if line.strip()]
except Exception as e:
console.print(f"[bold red]Failed to read wordlist: {e}[/bold red]")
return
console.print(f"[*] Cracking {path} using {len(words)} passwords...")
result = crack_steghide(Path(path), words)
if result.success:
console.print(Panel(
f"[bold green]✓ Successfully cracked![/bold green]\n"
f"[bold cyan]Password:[/bold cyan] {result.password}\n"
f"[bold cyan]Payload Size:[/bold cyan] {len(result.payload)} bytes",
title="Crack Result",
border_style="green"
))
# If output destination is supplied, write payload; otherwise print preview
if output:
try:
out_path = Path(output).resolve()
out_path.write_bytes(result.payload)
console.print(f"[green]✓ Extracted payload written to {out_path}[/green]")
except Exception as e:
console.print(f"[bold red]Failed to write output file: {e}[/bold red]")
else:
# Show a safe, printable preview of the payload
try:
preview = result.payload.decode("utf-8")
# Truncate if long
if len(preview) > 300:
preview = preview[:300] + "\n..."
console.print(Panel(preview, title="Payload Preview", border_style="blue"))
except UnicodeDecodeError:
# Binary payload
hex_preview = result.payload.hex()[:100] + "..."
console.print(f"[yellow]Binary payload (hex preview): {hex_preview}[/yellow]")
else:
console.print(f"[bold red]✗ Cracking failed: {result.error_message}[/bold red]")
# }}}
+119
View File
@@ -0,0 +1,119 @@
# src/ctf/decoding.py
# {{{ imports
from chepy import Chepy
import re
from typing import Dict, Set
# }}}
# {{{ is_valid_flag
def is_valid_flag(s: str, original: str = "") -> bool:
"""
Checks if the string contains a flag matching the active pattern or a generic pattern.
If original is provided, ensures that the decoded string contains a new unique flag
that was not already present in the original string.
"""
try:
from ctf.utils import load_config
config = load_config()
flag_format = config.get("Competition", {}).get("flag_format", "")
except Exception:
flag_format = ""
patterns = []
if flag_format:
patterns.append(flag_format.strip("^$"))
patterns.append(r"(?i)[a-z0-9_-]+{[a-z0-9_!@#$%^&*()\-+=]+}")
decoded_flags = set()
for pattern in patterns:
for match in re.finditer(pattern, s):
decoded_flags.add(match.group(0))
if not decoded_flags:
return False
if original:
original_flags = set()
for pattern in patterns:
for match in re.finditer(pattern, original):
original_flags.add(match.group(0))
# If there are no new flags in decoded_flags, return False
if not (decoded_flags - original_flags):
return False
return True
# }}}
# {{{ attempt_decode
def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_check=None, validator=None) -> str | None:
"""Helper to run regex matching, dynamic Chepy decoding, and string printability checks."""
# Find all matches sequentially and try decoding
for match in re.finditer(pattern, val):
matched_val = match.group(0)
if len_check and not len_check(matched_val):
continue
try:
chepy_inst = Chepy(matched_val)
method = getattr(chepy_inst, method_name)
decoded = method().state
if isinstance(decoded, bytes):
dec_str = decoded.decode("utf-8")
elif isinstance(decoded, str):
dec_str = decoded
else:
dec_str = ""
if dec_str.strip() and all(32 <= ord(c) < 127 or c in "\r\n\t" for c in dec_str):
if validator and not validator(dec_str, val):
continue
return dec_str
except Exception:
pass
return None
# }}}
# {{{ try_decode_metadata
def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]:
"""Attempts to decode a metadata value using defined formats recursively."""
if seen is None:
seen = set()
results = {}
if not isinstance(val, str) or not val.strip() or max_depth <= 0:
return results
cleaned_val = val.strip()
if cleaned_val in seen:
return results
seen.add(cleaned_val)
# We define our decoders here using unanchored patterns. Note: for rot13 and reversed, we only allow them
# if they produce a valid flag to avoid spamming everyday metadata fields.
formats = [
("hex", r"[0-9a-fA-F]{4,}", "from_hex", lambda s: len(s) % 2 == 0, None),
("base64", r"[A-Za-z0-9+/=]{4,}", "from_base64", lambda s: len(s) % 4 == 0, None),
("base32", r"[A-Za-z2-7=]{4,}", "from_base32", lambda s: len(s) % 8 == 0, None),
("binary", r"[01]{8,}", "from_binary", lambda s: len(s) % 8 == 0, None),
("url", r"(?:%[0-9a-fA-F]{2})+", "from_url_encoding", None, None),
("rot13", r"[\x20-\x7E\s]{4,}", "rot_13", None, is_valid_flag),
("reversed", r"[\x20-\x7E\s]{4,}", "reverse", None, is_valid_flag),
]
for name, pattern, method, len_check, validator in formats:
decoded_val = attempt_decode(cleaned_val, name, pattern, method, len_check, validator)
if decoded_val is not None:
chain_name = f"{current_chain}->{name}" if current_chain else name
results[chain_name] = decoded_val
# Recurse with copy of seen to allow different branching paths to process same strings
nested_results = try_decode_metadata(
decoded_val,
current_chain=chain_name,
max_depth=max_depth - 1,
seen=set(seen)
)
results.update(nested_results)
return results
# }}}
+784 -2
View File
@@ -7,7 +7,9 @@ from pathlib import Path
import stat
import os
import sys
from typing import List, Dict
import struct
import xml.etree.ElementTree as ET
from typing import List, Dict, Any
try:
import pwd
@@ -16,6 +18,7 @@ except ImportError:
pwd = None
grp = None
# {{{ Common Signatures
COMMON_SIGNATURES = {
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
@@ -33,6 +36,7 @@ COMMON_SIGNATURES = {
b"BM": ("BMP Image", [".bmp"]),
b"ID3": ("MP3 Audio", [".mp3"]),
}
# }}}
# {{{ FileMetadata
@dataclass
@@ -65,6 +69,18 @@ class FileMetadata:
# Task 6: Extended Attributes
extended_attributes: Dict[str, str] = field(default_factory=dict)
# EXIF Data
exif_data: Dict[str, Any] = field(default_factory=dict)
# Physical Image Data
physical_data: Dict[str, Any] = field(default_factory=dict)
# Decoded metadata hints
decoded_hints: Dict[str, Dict[str, str]] = field(default_factory=dict)
# JPEG/PNG Comments
comment: str = ""
# }}}
# {{{ get_metadata
@@ -146,6 +162,42 @@ def get_metadata(path: Path) -> FileMetadata:
except OSError:
pass
exif_data = get_exif(p)
comment = get_comment(p)
physical_data = {}
try:
with open(p, "rb") as f:
file_bytes = f.read()
if file_bytes.startswith(b"\xff\xd8\xff"):
physical_data = get_jpeg_physical(file_bytes)
elif file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
physical_data = get_png_physical(file_bytes)
elif file_bytes.startswith(b"GIF87a") or file_bytes.startswith(b"GIF89a"):
physical_data = get_gif_physical(file_bytes)
except Exception:
pass
decoded_hints = {}
try:
from ctf.decoding import try_decode_metadata
if comment:
dec = try_decode_metadata(comment)
if dec:
decoded_hints["Comment"] = dec
for tag, val in exif_data.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"EXIF:{tag}"] = dec
for attr, val in extended_attributes.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"xattr:{attr}"] = dec
except Exception:
pass
return FileMetadata(
filename=p.name,
size=size,
@@ -162,7 +214,737 @@ def get_metadata(path: Path) -> FileMetadata:
hard_links=hard_links,
inode=inode,
device=device,
extended_attributes=extended_attributes
extended_attributes=extended_attributes,
exif_data=exif_data,
physical_data=physical_data,
decoded_hints=decoded_hints,
comment=comment
)
# }}}
# {{{ EXIF tags
EXIF_TAGS = {
0x010e: "ImageDescription",
0x010f: "Make",
0x0110: "Model",
0x0112: "Orientation",
0x011a: "XResolution",
0x011b: "YResolution",
0x0128: "ResolutionUnit",
0x0131: "Software",
0x0132: "DateTime",
0x013b: "Artist",
0x8298: "Copyright",
0x829a: "ExposureTime",
0x829d: "FNumber",
0x8822: "ExposureProgram",
0x8825: "GPSInfo",
0x8827: "ISOSpeedRatings",
0x9000: "ExifVersion",
0x9003: "DateTimeOriginal",
0x9004: "DateTimeDigitized",
0x9201: "ShutterSpeedValue",
0x9202: "ApertureValue",
0x9204: "ExposureBiasValue",
0x9205: "MaxApertureValue",
0x9207: "MeteringMode",
0x9208: "LightSource",
0x9209: "Flash",
0x920a: "FocalLength",
0x9286: "UserComment",
0xa000: "FlashpixVersion",
0xa001: "ColorSpace",
0xa002: "ExifImageWidth",
0xa003: "ExifImageHeight",
0xa402: "ExposureMode",
0xa403: "WhiteBalance",
0xa405: "FocalLengthIn35mmFilm",
0xa406: "SceneCaptureType",
}# }}}
# {{{ parse_tiff
def parse_tiff(data: bytes) -> Dict[str, Any]:
"""Parses raw TIFF data block into EXIF tag dictionary."""
tags = {}
if len(data) < 8:
return tags
byte_order = data[0:2]
if byte_order == b"II":
endian = "<"
elif byte_order == b"MM":
endian = ">"
else:
return tags
magic = struct.unpack(f"{endian}H", data[2:4])[0]
if magic != 42:
return tags
ifd_offset = struct.unpack(f"{endian}I", data[4:8])[0]
try:
while ifd_offset != 0 and ifd_offset < len(data):
if ifd_offset + 2 > len(data):
break
num_entries = struct.unpack(f"{endian}H", data[ifd_offset:ifd_offset+2])[0]
entry_offset = ifd_offset + 2
for _ in range(num_entries):
if entry_offset + 12 > len(data):
break
tag, field_type, count, val_offset = struct.unpack(
f"{endian}HHII", data[entry_offset:entry_offset+12]
)
entry_offset += 12
# Resolve value based on type and count
val = None
type_sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8}
size = type_sizes.get(field_type, 1) * count
if size <= 4:
raw_val = struct.pack(f"{endian}I", val_offset)[:size]
else:
if val_offset + size <= len(data):
raw_val = data[val_offset:val_offset+size]
else:
raw_val = b""
if field_type == 2: # ASCII
val = raw_val.split(b"\x00")[0].decode("utf-8", errors="ignore")
elif field_type == 3: # SHORT
fmt = f"{endian}" + "H" * count
if len(raw_val) >= 2 * count:
val = struct.unpack(fmt, raw_val[:2*count])
if count == 1:
val = val[0]
elif field_type == 4: # LONG
fmt = f"{endian}" + "I" * count
if len(raw_val) >= 4 * count:
val = struct.unpack(fmt, raw_val[:4*count])
if count == 1:
val = val[0]
elif field_type == 5: # RATIONAL
fmt = f"{endian}" + "II" * count
if len(raw_val) >= 8 * count:
unpacked = struct.unpack(fmt, raw_val[:8*count])
rationals = [f"{unpacked[i*2]}/{unpacked[i*2+1]}" for i in range(count)]
val = rationals[0] if count == 1 else rationals
elif field_type == 7: # UNDEFINED
val = raw_val
elif field_type == 9: # SLONG
fmt = f"{endian}" + "i" * count
if len(raw_val) >= 4 * count:
val = struct.unpack(fmt, raw_val[:4*count])
if count == 1:
val = val[0]
elif field_type == 10: # SRATIONAL
fmt = f"{endian}" + "ii" * count
if len(raw_val) >= 8 * count:
unpacked = struct.unpack(fmt, raw_val[:8*count])
rationals = [f"{unpacked[i*2]}/{unpacked[i*2+1]}" for i in range(count)]
val = rationals[0] if count == 1 else rationals
else:
val = raw_val
tag_name = EXIF_TAGS.get(tag, f"Tag_{hex(tag)}")
tags[tag_name] = val
if entry_offset + 4 > len(data):
break
ifd_offset = struct.unpack(f"{endian}I", data[entry_offset:entry_offset+4])[0]
except Exception:
pass
return tags
# }}}
# {{{ extract_jpeg_exif
def extract_jpeg_exif(data: bytes) -> bytes:
"""Extracts raw Exif/TIFF payload from JPEG APP1 segments."""
if not data.startswith(b"\xff\xd8\xff"):
return b""
idx = 2
while idx < len(data) - 4:
if data[idx] == 0xff:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
# Markers without length parameters
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
length = struct.unpack(">H", data[idx+2:idx+4])[0]
if marker == 0xe1: # APP1
app1_data = data[idx+4:idx+4+length-2]
if app1_data.startswith(b"Exif\x00\x00"):
return app1_data[6:]
idx += 2 + length
else:
idx += 1
return b""
# }}}
# {{{ extract_png_exif
def extract_png_exif(data: bytes) -> bytes:
"""Extracts raw Exif/TIFF payload from PNG eXIf chunks."""
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return b""
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
if chunk_type == b"eXIf":
return data[idx+8:idx+8+length]
elif chunk_type == b"IEND":
break
idx += 12 + length
return b""
# }}}
# {{{ get_exif
def get_exif(path: Path) -> Dict[str, Any]:
"""Reads file, extracts Exif and XMP segments, and parses them to a tag dictionary."""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return {}
tags = {}
# 1. Parse standard EXIF
exif_data = b""
if data.startswith(b"\xff\xd8\xff"):
exif_data = extract_jpeg_exif(data)
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
exif_data = extract_png_exif(data)
if exif_data:
try:
tags.update(parse_tiff(exif_data))
except Exception:
pass
# 2. Parse XMP (Adobe)
xmp_str = ""
if data.startswith(b"\xff\xd8\xff"):
xmp_str = extract_jpeg_xmp(data)
if xmp_str:
try:
tags.update(parse_xmp(xmp_str))
except Exception:
pass
# 3. Parse IPTC (Photoshop APP13) if JPEG
if data.startswith(b"\xff\xd8\xff"):
try:
tags.update(extract_jpeg_iptc(data))
except Exception:
pass
# 4. Parse PNG text chunks if PNG
if data.startswith(b"\x89PNG\r\n\x1a\n"):
try:
tags.update(parse_png_text_chunks(data))
except Exception:
pass
return tags
# }}}
# {{{ extract_jpeg_comment
def extract_jpeg_comment(data: bytes) -> str:
"""Extracts raw comment string from JPEG COM (0xfe) segments."""
if not data.startswith(b"\xff\xd8\xff"):
return ""
idx = 2
while idx < len(data) - 4:
if data[idx] == 0xff:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
length = struct.unpack(">H", data[idx+2:idx+4])[0]
if marker == 0xfe: # COM
comment_data = data[idx+4:idx+4+length-2]
return comment_data.decode("utf-8", errors="ignore")
idx += 2 + length
else:
idx += 1
return ""
# }}}
# {{{ get_comment
def get_comment(path: Path) -> str:
"""Reads file, checks headers, and extracts JPEG COM / GIF comments."""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return ""
if data.startswith(b"\xff\xd8\xff"):
return extract_jpeg_comment(data)
elif data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
return extract_gif_comments(data)
return ""
# }}}
#{{{ NS_map
NS_MAP = {
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
"http://creativecommons.org/ns#": "cc",
"http://purl.org/dc/elements/1.1/": "dc",
"http://ns.adobe.com/xap/1.0/": "xmp",
"http://ns.adobe.com/exif/1.0/": "exif",
"http://ns.adobe.com/tiff/1.0/": "tiff",
}
#}}}
# {{{ parse_xmp
def parse_xmp(xmp_str: str) -> Dict[str, Any]:
"""Parses Adobe XMP XML string and returns namespace-prefixed tags."""
metadata = {}
try:
start_idx = xmp_str.find("<x:xmpmeta")
if start_idx == -1:
return metadata
end_idx = xmp_str.find("</x:xmpmeta>")
if end_idx == -1:
return metadata
xml_data = xmp_str[start_idx:end_idx + len("</x:xmpmeta>")]
root = ET.fromstring(xml_data)
def get_clean_name(tag: str) -> str:
if "}" in tag:
ns, name = tag.split("}")
ns = ns.lstrip("{")
prefix = NS_MAP.get(ns, ns.split("/")[-1].split("#")[0])
return f"{prefix}:{name}"
return tag
for elem in root.iter():
clean_tag = get_clean_name(elem.tag)
if clean_tag == "rdf:Description":
for k, v in elem.attrib.items():
clean_k = get_clean_name(k)
if clean_k not in ("rdf:about", "rdf:Description"):
metadata[clean_k] = v
continue
if clean_tag in ("rdf:RDF", "x:xmpmeta"):
continue
val = None
if elem.text and elem.text.strip():
val = elem.text.strip()
else:
attribs = {get_clean_name(k): v for k, v in elem.attrib.items()}
attribs.pop("rdf:about", None)
if "rdf:resource" in attribs:
val = attribs["rdf:resource"]
elif attribs:
val = ", ".join(f"{k} | {v}" if v else k for k, v in sorted(attribs.items()))
if val:
metadata[clean_tag] = val
except Exception:
pass
return metadata
# }}}
# {{{ extract_jpeg_xmp
def extract_jpeg_xmp(data: bytes) -> str:
"""Extracts XMP XML string from JPEG APP1 segments."""
if not data.startswith(b"\xff\xd8\xff"):
return ""
idx = 2
while idx < len(data) - 4:
if data[idx] == 0xff:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
length = struct.unpack(">H", data[idx+2:idx+4])[0]
if marker == 0xe1: # APP1
app1_data = data[idx+4:idx+4+length-2]
if app1_data.startswith(b"http://ns.adobe.com/xap/1.0/\x00"):
return app1_data[29:].decode("utf-8", errors="ignore")
idx += 2 + length
else:
idx += 1
return ""
# }}}
# {{{ get_jpeg_physical
def get_jpeg_physical(data: bytes) -> Dict[str, Any]:
"""Parses JPEG APP0 and SOF segments for physical dimensions and metadata."""
physical = {}
if not data.startswith(b"\xff\xd8\xff"):
return physical
idx = 2
width = None
height = None
encoding_process = None
jfif_version = None
while idx < len(data) - 4:
if data[idx] == 0xff:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
length = struct.unpack(">H", data[idx+2:idx+4])[0]
segment_data = data[idx+4:idx+4+length-2]
# APP0 (JFIF)
if marker == 0xe0 and segment_data.startswith(b"JFIF\x00"):
if len(segment_data) >= 7:
major = segment_data[5]
minor = segment_data[6]
jfif_version = f"{major}.{minor:02d}"
physical["JFIF Version"] = jfif_version
# SOF markers
elif marker in (0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf):
if len(segment_data) >= 5:
precision = segment_data[0]
height = struct.unpack(">H", segment_data[1:3])[0]
width = struct.unpack(">H", segment_data[3:5])[0]
SOF_MARKERS = {
0xc0: "Baseline DCT, Huffman coding",
0xc1: "Extended Sequential DCT, Huffman coding",
0xc2: "Progressive DCT, Huffman coding",
0xc3: "Lossless, Huffman coding",
0xc5: "Differential Sequential DCT, Huffman coding",
0xc6: "Differential Progressive DCT, Huffman coding",
0xc7: "Differential Lossless, Huffman coding",
0xc9: "Extended Sequential DCT, Arithmetic coding",
0xca: "Progressive DCT, Arithmetic coding",
0xcb: "Lossless, Arithmetic coding",
0xcd: "Differential Sequential DCT, Arithmetic coding",
0xce: "Differential Progressive DCT, Arithmetic coding",
0xcf: "Differential Lossless, Arithmetic coding",
}
encoding_process = SOF_MARKERS.get(marker, "Unknown")
idx += 2 + length
else:
idx += 1
if width is not None and height is not None:
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
if encoding_process is not None:
physical["Encoding Process"] = encoding_process
return physical
# }}}
# {{{ get_png_physical
def get_png_physical(data: bytes) -> Dict[str, Any]:
"""Parses PNG IHDR and pHYs chunks for physical properties."""
physical = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return physical
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"IHDR":
if len(chunk_data) >= 13:
width = struct.unpack(">I", chunk_data[0:4])[0]
height = struct.unpack(">I", chunk_data[4:8])[0]
bit_depth = chunk_data[8]
color_type = chunk_data[9]
compression = chunk_data[10]
filter_method = chunk_data[11]
interlace = chunk_data[12]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Bit Depth"] = f"{bit_depth} bits/sample"
color_types = {
0: "Grayscale",
2: "Truecolor",
3: "Indexed-color",
4: "Grayscale with Alpha",
6: "Truecolor with Alpha"
}
physical["Color Type"] = color_types.get(color_type, f"Unknown ({color_type})")
if compression == 0:
physical["Encoding Process"] = "Deflate/Inflate"
else:
physical["Encoding Process"] = f"Unknown compression ({compression})"
interlace_methods = {
0: "Noninterlaced",
1: "Adam7 Interlace"
}
physical["Interlace Method"] = interlace_methods.get(interlace, f"Unknown ({interlace})")
elif chunk_type == b"pHYs":
if len(chunk_data) >= 9:
x_res = struct.unpack(">I", chunk_data[0:4])[0]
y_res = struct.unpack(">I", chunk_data[4:8])[0]
unit = chunk_data[8]
unit_str = " meters" if unit == 1 else " (unknown unit)"
physical["Pixels Per Unit X"] = f"{x_res}{unit_str}"
physical["Pixels Per Unit Y"] = f"{y_res}{unit_str}"
elif chunk_type == b"IEND":
break
idx += 12 + length
return physical
# }}}
# {{{ get_gif_physical
def get_gif_physical(data: bytes) -> Dict[str, Any]:
"""Parses GIF logical screen descriptor for physical size."""
physical = {}
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return physical
if len(data) >= 10:
width = struct.unpack("<H", data[6:8])[0]
height = struct.unpack("<H", data[8:10])[0]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Encoding Process"] = "LZW"
return physical
# }}}
# {{{ extract_jpeg_iptc
def extract_jpeg_iptc(data: bytes) -> Dict[str, str]:
"""Extracts IPTC/NAA metadata (Record 2) from Photoshop APP13 segments."""
iptc_metadata = {}
if not data.startswith(b"\xff\xd8\xff"):
return iptc_metadata
IPTC_TAGS = {
5: "ObjectName",
25: "Keywords",
40: "SpecialInstructions",
80: "By-line",
85: "By-lineTitle",
90: "City",
95: "Province-State",
101: "Country-PrimaryLocationName",
105: "Headline",
110: "Credit",
115: "Source",
116: "CopyrightNotice",
120: "Caption-Abstract",
122: "Writer-Editor"
}
idx = 2
while idx < len(data) - 4:
if data[idx] == 0xff:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
length = struct.unpack(">H", data[idx+2:idx+4])[0]
if marker == 0xed: # APP13 Photoshop block
app13_data = data[idx+4:idx+4+length-2]
if app13_data.startswith(b"Photoshop 3.0\x00"):
offset = 14
while offset < len(app13_data) - 12:
if app13_data[offset:offset+4] == b"8BIM":
offset += 4
irb_id = app13_data[offset:offset+2]
offset += 2
name_len = app13_data[offset]
offset += 1
name = app13_data[offset:offset+name_len]
offset += name_len
if (name_len + 1) % 2 != 0:
offset += 1
if offset + 4 > len(app13_data):
break
size = struct.unpack(">I", app13_data[offset:offset+4])[0]
offset += 4
if offset + size > len(app13_data):
break
irb_data = app13_data[offset:offset+size]
offset += size
if size % 2 != 0:
offset += 1
if irb_id == b"\x04\x04":
iptc_offset = 0
while iptc_offset < len(irb_data) - 5:
if irb_data[iptc_offset] == 0x1c:
record = irb_data[iptc_offset+1]
dataset = irb_data[iptc_offset+2]
ds_size = struct.unpack(">H", irb_data[iptc_offset+3:iptc_offset+5])[0]
iptc_offset += 5
if iptc_offset + ds_size <= len(irb_data):
ds_data = irb_data[iptc_offset:iptc_offset+ds_size]
iptc_offset += ds_size
if record == 2:
tag_name = IPTC_TAGS.get(dataset, f"IPTC_2_{dataset}")
val = ds_data.decode("utf-8", errors="ignore")
iptc_metadata[tag_name] = val
else:
break
else:
iptc_offset += 1
idx += 2 + length
else:
idx += 1
return iptc_metadata
# }}}
# {{{ parse_png_text_chunks
def parse_png_text_chunks(data: bytes) -> Dict[str, str]:
"""Decompresses and extracts standard tEXt/zTXt/iTXt PNG text metadata."""
import zlib
text_metadata = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return text_metadata
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"tEXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
val = parts[1].decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"zTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) > 1:
comp_method = remaining[0]
comp_text = remaining[1:]
if comp_method == 0:
try:
val = zlib.decompress(comp_text).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
elif chunk_type == b"iTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) >= 2:
comp_flag = remaining[0]
comp_method = remaining[1]
rem = remaining[2:]
parts2 = rem.split(b"\x00", 1)
if len(parts2) == 2:
lang_tag = parts2[0].decode("utf-8", errors="ignore")
rem = parts2[1]
parts3 = rem.split(b"\x00", 1)
if len(parts3) == 2:
trans_key = parts3[0].decode("utf-8", errors="ignore")
text_bytes = parts3[1]
if comp_flag == 1 and comp_method == 0:
try:
val = zlib.decompress(text_bytes).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
else:
val = text_bytes.decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"IEND":
break
idx += 12 + length
return text_metadata
# }}}
# {{{ extract_gif_comments
def extract_gif_comments(data: bytes) -> str:
"""Extracts comments from GIF comment extensions (\x21\xfe) sequentially."""
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return ""
comments = []
idx = 6
if idx + 7 > len(data):
return ""
packed_byte = data[idx+4]
global_color_table_present = bool(packed_byte & 0x80)
global_color_table_size = 2 ** ((packed_byte & 0x07) + 1)
idx += 7
if global_color_table_present:
idx += 3 * global_color_table_size
while idx < len(data) - 2:
intro = data[idx]
if intro == 0x21: # Extension Introducer
ext_label = data[idx+1]
idx += 2
if ext_label == 0xfe: # Comment Extension
comment_parts = []
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
if idx + block_len <= len(data):
comment_parts.append(data[idx:idx+block_len].decode("utf-8", errors="ignore"))
idx += block_len
else:
break
comments.append("".join(comment_parts))
else:
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x2c: # Image Descriptor
if idx + 10 > len(data):
break
packed = data[idx+9]
local_table = bool(packed & 0x80)
local_table_size = 2 ** ((packed & 0x07) + 1)
idx += 10
if local_table:
idx += 3 * local_table_size
idx += 1
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x3b: # Trailer
break
else:
idx += 1
return "\n".join(comments)
# }}}
+10 -4
View File
@@ -2,7 +2,8 @@
# Parses and calls commands
from ctf.commands import basic_group
from ctf.cli_forensics import forensics_group
from ctf.cli_forensics import forensics_group, inspect
from ctf.cli_steg import steg_group
import click
import sys
import re
@@ -64,6 +65,7 @@ class FlagDetectorStream:
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
def flag_cmd(plain):
"""Retrieve the last detected flag from config."""
# TODO add support for multiple flags and flag selection
global _active_detector
if _active_detector is not None:
_active_detector.detecting = False
@@ -76,7 +78,7 @@ def flag_cmd(plain):
click.echo("No flag has been detected yet.")
return
if plain:
click.echo(last_flag, nl=False)
click.echo(last_flag)
else:
click.echo(f"Last detected flag: {last_flag}")
finally:
@@ -84,15 +86,19 @@ def flag_cmd(plain):
_active_detector.detecting = True
# }}}
# {{{ main
def main():
# {{{ cli
@click.group()
def cli(): pass
cli.add_command(forensics_group)
cli.add_command(basic_group)
cli.add_command(flag_cmd)
cli.add_command(inspect)
cli.add_command(steg_group)
# }}}
# {{{ main
def main():
from ctf.utils import load_config
config = load_config("/home/venus/code/ctf/config.toml")
flag_format = config.get("Competition", {}).get("flag_format", "")
+49
View File
@@ -0,0 +1,49 @@
# src/ctf/steg.py
# {{{ imports
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
# }}}
# {{{ SteghideCrackResult
@dataclass
class SteghideCrackResult:
success: bool
password: str | None = None
payload: bytes | None = None
error_message: str | None = None
# }}}
# {{{ crack_steghide
def crack_steghide(file_path: Path, wordlist: list[str]) -> SteghideCrackResult:
"""Tries to extract data from a file using steghide with a wordlist."""
if not shutil.which("steghide"):
return SteghideCrackResult(success=False, error_message="steghide executable not found on system PATH.")
file_path = Path(file_path).resolve()
if not file_path.exists():
return SteghideCrackResult(success=False, error_message=f"File not found: {file_path}")
for password in wordlist:
password = password.strip()
with tempfile.TemporaryDirectory() as tmpdir:
out_file = Path(tmpdir) / "extracted_payload"
cmd = ["steghide", "extract", "-sf", str(file_path), "-p", password, "-xf", str(out_file), "-f"]
try:
# Capture standard outputs; timeout to prevent indefinite hangs
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
if result.returncode == 0 and out_file.exists():
return SteghideCrackResult(
success=True,
password=password,
payload=out_file.read_bytes()
)
except subprocess.TimeoutExpired:
pass
except Exception as e:
return SteghideCrackResult(success=False, error_message=str(e))
return SteghideCrackResult(success=False, error_message="Password not found in wordlist.")
# }}}
+1
View File
@@ -0,0 +1 @@
picoCTF{h1dd3n_1n_1m4g3_5d4cba73}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+2
View File
@@ -0,0 +1,2 @@
‰PNG


After

Width:  |  Height:  |  Size: 8 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

+2
View File
@@ -0,0 +1,2 @@
‰PNG


After

Width:  |  Height:  |  Size: 8 B

+2
View File
@@ -0,0 +1,2 @@
‰PNG


After

Width:  |  Height:  |  Size: 8 B

+4
View File
@@ -64,6 +64,10 @@ def test_basic_set_flag_format_original_option_cli():
# We pass -o SKY-1111-000 and input "2" to choose the second suggested pattern
result = runner.invoke(basic_group, ["set-flag-format", "-o", "SKY-1111-000"], input="2\n")
assert result.exit_code == 0
comp_name = original_config.get("Competition", {}).get("competition", "")
if comp_name:
assert f"Suggested regex patterns for competition {comp_name}:" in result.output
else:
assert "Suggested regex patterns:" in result.output
assert "Select a pattern index" in result.output
assert "Flag format set to" in result.output
+135
View File
@@ -0,0 +1,135 @@
# tests/test_decoding.py
from ctf.decoding import try_decode_metadata
# {{{ test_try_decode_metadata_hex
def test_try_decode_metadata_hex():
"""
Verifies that try_decode_metadata successfully decodes standard hex string payloads.
"""
# Hex encoding of "picoCTF{hex_flag_hint}"
hex_str = "7069636f4354467b6865785f666c61675f68696e747d"
res = try_decode_metadata(hex_str)
assert res.get("hex") == "picoCTF{hex_flag_hint}"
# }}}
# {{{ test_try_decode_metadata_base64
def test_try_decode_metadata_base64():
"""
Verifies that try_decode_metadata successfully decodes standard base64 string payloads.
"""
# Base64 encoding of "flag{b64_metadata}"
b64_str = "ZmxhZ3tiNjRfbWV0YWRhdGF9"
res = try_decode_metadata(b64_str)
assert res.get("base64") == "flag{b64_metadata}"
# }}}
# {{{ test_try_decode_metadata_non_decodable
def test_try_decode_metadata_non_decodable():
"""
Verifies that try_decode_metadata returns empty results for invalid or non-printable payloads.
"""
# Invalid characters
res_invalid = try_decode_metadata("invalid!characters")
assert not res_invalid
# Not even length for hex, not modulo 4 for base64
res_odd = try_decode_metadata("1")
assert not res_odd
# Non-printable decoded content (binary raw data)
# Hex: \x01\x02\x03\x04
res_binary = try_decode_metadata("01020304")
assert not res_binary
# {{{ test_try_decode_metadata_base32
def test_try_decode_metadata_base32():
"""
Verifies that try_decode_metadata successfully decodes standard base32 string payloads.
"""
# Base32 encoding of "flag{b32_metadata}"
b32_str = "MZWGCZ33MIZTEX3NMV2GCZDBORQX2==="
res = try_decode_metadata(b32_str)
assert res.get("base32") == "flag{b32_metadata}"
# }}}
# {{{ test_try_decode_metadata_binary
def test_try_decode_metadata_binary():
"""
Verifies that try_decode_metadata successfully decodes binary strings.
"""
# Binary encoding of "flag{bin}"
bin_str = "011001100110110001100001011001110111101101100010011010010110111001111101"
res = try_decode_metadata(bin_str)
assert res.get("binary") == "flag{bin}"
# }}}
# {{{ test_try_decode_metadata_url
def test_try_decode_metadata_url():
"""
Verifies that try_decode_metadata successfully decodes percent/URL encoded payloads.
"""
url_str = "%66%6c%61%67%7b%75%72%6c%7d"
res = try_decode_metadata(url_str)
assert res.get("url") == "flag{url}"
# }}}
# {{{ test_try_decode_metadata_rot13_only_flags
def test_try_decode_metadata_rot13_only_flags():
"""
Verifies that rot13 decodes flags but ignores standard text noise.
"""
# ROT13 of "flag{rot13}" -> "synt{ebg13}"
rot_str = "synt{ebg13}"
res = try_decode_metadata(rot_str)
assert res.get("rot13") == "flag{rot13}"
# ROT13 of "normal text" -> "abezny grkg"
res_noise = try_decode_metadata("abezny grkg")
assert "rot13" not in res_noise
# }}}
# {{{ test_try_decode_metadata_reversed_only_flags
def test_try_decode_metadata_reversed_only_flags():
"""
Verifies that reversed decodes flags but ignores standard text noise.
"""
# Reversed of "flag{rev}" -> "}ver{galf"
rev_str = "}ver{galf"
res = try_decode_metadata(rev_str)
assert res.get("reversed") == "flag{rev}"
# Reversed of "normal text" -> "txet lamron"
res_noise = try_decode_metadata("txet lamron")
assert "reversed" not in res_noise
# {{{ test_try_decode_metadata_nested
def test_try_decode_metadata_nested():
"""
Verifies that try_decode_metadata recursively decodes nested encodings and reports the path.
"""
# Payload: base64(hex("flag{nested_flag}"))
nested_str = "NjY2YzYxNjc3YjZlNjU3Mzc0NjU2NDVmNjY2YzYxNjc3ZA=="
res = try_decode_metadata(nested_str)
assert res.get("base64") == "666c61677b6e65737465645f666c61677d"
assert res.get("base64->hex") == "flag{nested_flag}"
# {{{ test_try_decode_metadata_unanchored
def test_try_decode_metadata_unanchored():
"""
Verifies that try_decode_metadata successfully decodes unanchored substrings (e.g. "steghide:cEF6endvcmQ=").
"""
res = try_decode_metadata("steghide:cEF6endvcmQ=")
assert res.get("base64") == "pAzzword"
# }}}
# {{{ test_try_decode_metadata_rot13_unique_only
def test_try_decode_metadata_rot13_unique_only():
"""
Verifies that rot13 only decodes unique flags and ignores flags already visible.
"""
# Case 1: Flag is encoded and not present in plaintext in the original string.
res = try_decode_metadata("synt{ebg13_havdhr}")
assert res.get("rot13") == "flag{rot13_unique}"
# Case 2: Flag is already present in plaintext in the original string.
res_duplicate = try_decode_metadata("flag{rot13_unique} synt{ebg13_havdhr}")
assert "rot13" not in res_duplicate
# }}}
+362
View File
@@ -248,6 +248,37 @@ def test_forensics_inspect_cli_success():
assert "Detected Type" in result.output
assert "PNG Image" in result.output
def test_root_inspect_cli_success():
"""
Verifies that 'ctf inspect' successfully runs at the root level and prints
the metadata in a formatted table.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "test_file_root.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["inspect", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file_root.png" in result.output
assert "PNG Image" in result.output
def test_nested_inspect_cli_success():
"""
Verifies that 'ctf forensics inspect' successfully runs via the root CLI.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "test_file_nested.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["forensics", "inspect", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file_nested.png" in result.output
assert "PNG Image" in result.output
def test_forensics_inspect_cli_missing_file():
"""
Verifies that the CLI fails gracefully when a non-existent file path is specified.
@@ -271,4 +302,335 @@ def test_forensics_signatures_cli():
# (Old flag-detect tests removed)
def test_pure_exif_parsing():
"""
Verifies that get_metadata successfully parses EXIF fields from a mock PNG.
"""
import struct
tiff_data = (
b"II\x2a\x00\x08\x00\x00\x00" # TIFF Header
b"\x02\x00" # Num entries
b"\x0f\x01\x02\x00\x05\x00\x00\x00\x26\x00\x00\x00" # Entry 1 (Make: offset 38)
b"\x10\x01\x02\x00\x06\x00\x00\x00\x2b\x00\x00\x00" # Entry 2 (Model: offset 43)
b"\x00\x00\x00\x00" # Next IFD offset
b"TEST\x00" # Make value
b"MODEL\x00" # Model value
)
png_header = b"\x89PNG\r\n\x1a\n"
exif_chunk_type = b"eXIf"
exif_chunk_len = struct.pack(">I", len(tiff_data))
exif_chunk_crc = b"\x00\x00\x00\x00"
mock_png = png_header + exif_chunk_len + exif_chunk_type + tiff_data + exif_chunk_crc
test_file = TEST_ENV / "mock_exif_image.png"
with open(test_file, "wb") as f:
f.write(mock_png)
meta = get_metadata(test_file)
assert meta.exif_data.get("Make") == "TEST"
assert meta.exif_data.get("Model") == "MODEL"
def test_exif_cli_options():
"""
Verifies CLI options -i/--inode and -e/--exif work as expected.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_exif_image.png"
# Verify mutual exclusion error
result_err = runner.invoke(cli, ["inspect", "-i", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Verify --inode hides EXIF data table
result_inode = runner.invoke(cli, ["inspect", "-i", str(test_file)])
assert result_inode.exit_code == 0
assert "Metadata: mock_exif_image.png" in result_inode.output
assert "EXIF Metadata" not in result_inode.output
# Verify --exif hides POSIX table but displays EXIF data table
result_exif = runner.invoke(cli, ["inspect", "-e", str(test_file)])
assert result_exif.exit_code == 0
assert "Metadata: mock_exif_image.png" not in result_exif.output
assert "EXIF Metadata" in result_exif.output
assert "mock_exif_image" in result_exif.output
assert "Make" in result_exif.output
assert "TEST" in result_exif.output
def test_jpeg_comment_extraction():
"""
Verifies that get_metadata successfully parses a JPEG comment (COM marker).
"""
import struct
comment_text = b"picoCTF{test_comment_flag}"
comment_len = len(comment_text) + 2
mock_jpeg = (
b"\xff\xd8"
b"\xff\xfe"
+ struct.pack(">H", comment_len)
+ comment_text
+ b"\xff\xd9"
)
test_file = TEST_ENV / "mock_comment.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
meta = get_metadata(test_file)
assert meta.comment == "picoCTF{test_comment_flag}"
def test_exif_cli_options_warning():
"""
Verifies CLI warns when no EXIF or comment is found and -e option is used.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "clean_no_exif.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["inspect", "-e", str(test_file)])
assert result.exit_code == 0
assert "No EXIF or comment metadata found" in result.output
def test_exif_cli_comment_only():
"""
Verifies CLI displays comment when -e is used and only comment is found.
"""
from ctf.main import cli
import struct
runner = CliRunner()
comment_text = b"flag{comment_only}"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text
test_file = TEST_ENV / "comment_only.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["inspect", "-e", str(test_file)])
assert result.exit_code == 0
assert "Comment" in result.output
assert "flag{comment_only}" in result.output
def test_adobe_xmp_extraction():
"""
Verifies that get_metadata successfully parses XMP fields (like license resource).
"""
import struct
xmp_xml = (
b"<x:xmpmeta xmlns:x='adobe:ns:meta/'>\n"
b"<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>\n"
b" <rdf:Description rdf:about='' xmlns:cc='http://creativecommons.org/ns#'>\n"
b" <cc:license rdf:resource='cGljb0NURnt0ZXN0X3htcF9mbGFnfQ=='/>\n"
b" </rdf:Description>\n"
b"</rdf:RDF>\n"
b"</x:xmpmeta>"
)
xmp_prefix = b"http://ns.adobe.com/xap/1.0/\x00"
app1_payload = xmp_prefix + xmp_xml
app1_len = len(app1_payload) + 2
mock_jpeg = (
b"\xff\xd8"
b"\xff\xe1"
+ struct.pack(">H", app1_len)
+ app1_payload
+ b"\xff\xd9"
)
test_file = TEST_ENV / "mock_xmp.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
meta = get_metadata(test_file)
assert meta.exif_data.get("cc:license") == "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ=="
def test_adobe_xmp_cli():
"""
Verifies that the ctf inspect CLI prints the parsed XMP fields.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_xmp.jpg"
result = runner.invoke(cli, ["inspect", "-e", str(test_file)])
assert result.exit_code == 0
assert "EXIF Metadata" in result.output
assert "cc:license" in result.output
assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output
def test_jpeg_physical_parsing():
"""
Verifies that get_metadata successfully parses JPEG physical parameters (JFIF & SOF).
"""
import struct
app0_payload = b"JFIF\x00\x01\x02\x01\x00\x48\x00\x48\x00\x00"
app0_block = b"\xff\xe0" + struct.pack(">H", len(app0_payload) + 2) + app0_payload
sof_payload = b"\x08\x03\xe8\x05\xdc\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01"
sof_block = b"\xff\xc0" + struct.pack(">H", len(sof_payload) + 2) + sof_payload
mock_jpeg = b"\xff\xd8" + app0_block + sof_block + b"\xff\xd9"
test_file = TEST_ENV / "mock_physical.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
meta = get_metadata(test_file)
assert meta.physical_data.get("JFIF Version") == "1.02"
assert meta.physical_data.get("Image Size") == "1500x1000"
assert meta.physical_data.get("Megapixels") == "1.5"
assert meta.physical_data.get("Encoding Process") == "Baseline DCT, Huffman coding"
def test_jpeg_iptc_parsing():
"""
Verifies that get_metadata successfully parses JPEG IPTC metadata from APP13.
"""
import struct
iptc_ds = b"\x1c\x02\x74\x00\x0ePicoCTF Rights"
irb_id = b"\x04\x04"
irb_name = b"\x00\x00"
irb_size = struct.pack(">I", len(iptc_ds))
irb_block = b"8BIM" + irb_id + irb_name + irb_size + iptc_ds
app13_payload = b"Photoshop 3.0\x00" + irb_block
app13_block = b"\xff\xed" + struct.pack(">H", len(app13_payload) + 2) + app13_payload
mock_jpeg = b"\xff\xd8" + app13_block + b"\xff\xd9"
test_file = TEST_ENV / "mock_iptc.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
meta = get_metadata(test_file)
assert meta.exif_data.get("CopyrightNotice") == "PicoCTF Rights"
def test_cli_physical_option():
"""
Verifies the ctf inspect CLI supports -p/--physical and mutual exclusion rules.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_physical.jpg"
# Mutual exclusion check
result_err = runner.invoke(cli, ["inspect", "-p", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Physical view check
result_phys = runner.invoke(cli, ["inspect", "-p", str(test_file)])
assert result_phys.exit_code == 0
assert "Physical Metadata" in result_phys.output
assert "Image Size" in result_phys.output
assert "1500x1000" in result_phys.output
assert "Metadata: mock_physical.jpg" not in result_phys.output
assert "EXIF Metadata" not in result_phys.output
def test_png_text_chunks_decompression():
"""
Verifies that get_metadata successfully parses tEXt and compressed zTXt chunks.
"""
import zlib
import struct
# 1. tEXt chunk: Keyword (Copyright) + NUL + Text (PicoCTF)
text_data = b"Copyright\x00PicoCTF"
text_chunk = struct.pack(">I", len(text_data)) + b"tEXt" + text_data + b"\x00\x00\x00\x00"
# 2. zTXt chunk: Keyword (Author) + NUL + CompMethod(0) + Deflated Text (John Doe)
deflated = zlib.compress(b"John Doe")
ztxt_data = b"Author\x00\x00" + deflated
ztxt_chunk = struct.pack(">I", len(ztxt_data)) + b"zTXt" + ztxt_data + b"\x00\x00\x00\x00"
png_data = b"\x89PNG\r\n\x1a\n" + text_chunk + ztxt_chunk + b"\x00\x00\x00\x00IEND\xaeB`\x82"
test_file = TEST_ENV / "mock_text_chunks.png"
with open(test_file, "wb") as f:
f.write(png_data)
meta = get_metadata(test_file)
assert meta.exif_data.get("Copyright") == "PicoCTF"
assert meta.exif_data.get("Author") == "John Doe"
def test_gif_comment_parsing():
"""
Verifies that get_metadata successfully parses GIF comment blocks.
"""
gif_data = (
b"GIF89a"
b"\x01\x00\x01\x00\x00\x00\x00"
b"\x21\xfe\x0aGIFComment\x00"
b"\x3b"
)
test_file = TEST_ENV / "mock_comment.gif"
with open(test_file, "wb") as f:
f.write(gif_data)
meta = get_metadata(test_file)
assert meta.comment == "GIFComment"
def test_adobe_xmp_formatting():
"""
Verifies that get_metadata parses rdf:Description attributes and formats other
attributes with ' | ' instead of '='.
"""
import struct
xmp_xml = (
b"<x:xmpmeta xmlns:x='adobe:ns:meta/'>\n"
b"<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>\n"
b" <rdf:Description rdf:about='' xmlns:xmp='http://ns.adobe.com/xap/1.0/' xmlns:exif='http://ns.adobe.com/exif/1.0/'>\n"
b" <xmp:CreatorTool>Photoshop</xmp:CreatorTool>\n"
b" <exif:Flash exif:Fired='True' exif:Mode='1'/>\n"
b" <exif:EmptyAttr exif:Value=''/>\n"
b" </rdf:Description>\n"
b"</rdf:RDF>\n"
b"</x:xmpmeta>"
)
xmp_prefix = b"http://ns.adobe.com/xap/1.0/\x00"
app1_payload = xmp_prefix + xmp_xml
app1_len = len(app1_payload) + 2
mock_jpeg = (
b"\xff\xd8"
b"\xff\xe1"
+ struct.pack(">H", app1_len)
+ app1_payload
+ b"\xff\xd9"
)
test_file = TEST_ENV / "mock_xmp_formatting.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
meta = get_metadata(test_file)
assert meta.exif_data.get("xmp:CreatorTool") == "Photoshop"
assert meta.exif_data.get("exif:Flash") == "exif:Fired | True, exif:Mode | 1"
assert meta.exif_data.get("exif:EmptyAttr") == "exif:Value"
assert "rdf:Description" not in meta.exif_data
def test_inspect_decoded_hints_cli():
"""
Verifies that inspect command successfully decodes and prints hex/base64 encoded metadata hints.
"""
from ctf.main import cli
runner = CliRunner()
# Create a mock JPEG with a base64 encoded comment: "ZmxhZ3tiNjRfbWV0YWRhdGF9" -> "flag{b64_metadata}"
import struct
comment_text = b"ZmxhZ3tiNjRfbWV0YWRhdGF9"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + b"\xff\xd9"
test_file = TEST_ENV / "mock_encoded_comment.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["inspect", str(test_file)])
assert result.exit_code == 0
assert "Decoded Metadata Hints" in result.output
assert "Comment" in result.output
assert "base64" in result.output
assert "flag{b64_metadata}" in result.output
+3 -2
View File
@@ -110,13 +110,14 @@ def test_flag_cmd_cli():
assert result_std.exit_code == 0
assert "Last detected flag: flag{test_cli_flag}" in result_std.output
# Test plain flag command output (no flavor text, no newline)
# Test plain flag command output (no flavor text, has newline)
result_plain = runner.invoke(cli, ["flag", "--plain"])
assert result_plain.exit_code == 0
assert result_plain.output == "flag{test_cli_flag}"
assert result_plain.output.strip() == "flag{test_cli_flag}"
finally:
write_config(original_config, str(config_file))
def test_flag_cmd_exemption(capsys):
"""
Verifies that running ctf flag is exempted from triggering the stdout flag warning box.
+158
View File
@@ -0,0 +1,158 @@
# tests/test_steg.py
import shutil
import subprocess
from pathlib import Path
from unittest.mock import patch, MagicMock
import click
from click.testing import CliRunner
import pytest
from ctf.steg import crack_steghide, SteghideCrackResult
def test_crack_steghide_missing_binary():
"""
Verifies that crack_steghide returns failure if steghide is not installed.
"""
with patch("shutil.which", return_value=None):
res = crack_steghide(Path("somefile.jpg"), ["pass1", "pass2"])
assert not res.success
assert "not found on system" in res.error_message
def test_crack_steghide_file_not_found():
"""
Verifies that crack_steghide returns failure if the input file does not exist.
"""
with patch("shutil.which", return_value="steghide"):
res = crack_steghide(Path("non_existent_file_12345.jpg"), ["pass1", "pass2"])
assert not res.success
assert "File not found" in res.error_message
def test_crack_steghide_success():
"""
Verifies that crack_steghide returns success, the password, and payload when cracked.
"""
wordlist = ["wrong1", "correct_pass", "wrong2"]
# We want to mock subprocess.run to write a payload to the out_file (-xf)
# when the correct password is tried.
def mock_run(cmd, *args, **kwargs):
# cmd: ['steghide', 'extract', '-sf', '...', '-p', password, '-xf', out_file, '-f']
password = cmd[5]
out_file = cmd[7]
mock_res = MagicMock()
if password == "correct_pass":
mock_res.returncode = 0
# Write a mock payload file
Path(out_file).write_bytes(b"flag{steghide_cracked_payload}")
else:
mock_res.returncode = 1
return mock_res
with patch("shutil.which", return_value="steghide"), \
patch("subprocess.run", side_effect=mock_run), \
patch("pathlib.Path.exists", return_value=True):
res = crack_steghide(Path("mock_image.jpg"), wordlist)
assert res.success
assert res.password == "correct_pass"
assert res.payload == b"flag{steghide_cracked_payload}"
assert res.error_message is None
def test_crack_steghide_failure():
"""
Verifies that crack_steghide returns failure if the wordlist is exhausted.
"""
wordlist = ["wrong1", "wrong2"]
def mock_run(cmd, *args, **kwargs):
mock_res = MagicMock()
mock_res.returncode = 1
return mock_res
with patch("shutil.which", return_value="steghide"), \
patch("subprocess.run", side_effect=mock_run), \
patch("pathlib.Path.exists", return_value=True):
res = crack_steghide(Path("mock_image.jpg"), wordlist)
assert not res.success
assert res.password is None
assert res.payload is None
assert "Password not found in wordlist" in res.error_message
def test_cli_crack_steghide_success(tmp_path):
"""
Verifies that the crack-steghide CLI command prints success panel and previews payload.
"""
from ctf.main import cli
runner = CliRunner()
# Create temp wordlist file
wordlist_file = tmp_path / "words.txt"
wordlist_file.write_text("pass1\npass2\n")
mock_file = tmp_path / "mock.jpg"
mock_file.write_text("dummy")
mock_result = SteghideCrackResult(
success=True,
password="pass2",
payload=b"flag{cli_steg_preview}"
)
with patch("ctf.cli_steg.crack_steghide", return_value=mock_result):
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file)])
assert res.exit_code == 0
assert "Successfully cracked!" in res.output
assert "pass2" in res.output
assert "flag{cli_steg_preview}" in res.output
def test_cli_crack_steghide_success_output_file(tmp_path):
"""
Verifies that the crack-steghide CLI command writes the payload to a file when specified.
"""
from ctf.main import cli
runner = CliRunner()
wordlist_file = tmp_path / "words.txt"
wordlist_file.write_text("pass1\n")
mock_file = tmp_path / "mock.jpg"
mock_file.write_text("dummy")
out_file = tmp_path / "out.txt"
mock_result = SteghideCrackResult(
success=True,
password="pass1",
payload=b"written_file_content"
)
with patch("ctf.cli_steg.crack_steghide", return_value=mock_result):
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file), "-o", str(out_file)])
assert res.exit_code == 0
assert "Successfully cracked!" in res.output
assert out_file.exists()
assert out_file.read_bytes() == b"written_file_content"
def test_cli_crack_steghide_failure(tmp_path):
"""
Verifies that the crack-steghide CLI command displays correct error message on failure.
"""
from ctf.main import cli
runner = CliRunner()
wordlist_file = tmp_path / "words.txt"
wordlist_file.write_text("pass1\n")
mock_file = tmp_path / "mock.jpg"
mock_file.write_text("dummy")
mock_result = SteghideCrackResult(
success=False,
error_message="Password not found in wordlist."
)
with patch("ctf.cli_steg.crack_steghide", return_value=mock_result):
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file)])
assert res.exit_code == 0
assert "Cracking failed: Password not found in wordlist" in res.output
+3 -3
View File
@@ -69,7 +69,7 @@ def test_suggest_patterns_happy_path_ctf():
# Unanchored options:
assert "[A-Z]{3}\\{[a-z]{4}_[a-z]{4}_\\d{3}\\}" in patterns
assert "[A-Z]+\\{[a-z]+_[a-z]+_\\d+\\}" in patterns
assert "[A-Za-z0-9_\\{\\}]+" in patterns
assert "[A-Za-z0-9_{}]+" in patterns
assert "CTF\\{easy_flag_123\\}" in patterns
# Anchored options:
@@ -131,8 +131,8 @@ def test_suggest_patterns_with_competition_name_non_braced():
assert "SKY-\\d{4}-\\d{3}" in patterns
assert "^SKY-\\d{4}-\\d{3}$" in patterns
assert "SKY-.*" in patterns
assert "^SKY-.*$" in patterns
assert "SKY.*" in patterns
assert "^SKY.*$" in patterns
def test_suggest_patterns_with_competition_name_mismatch():
"""
Generated
+567
View File
@@ -2,6 +2,110 @@ version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "base58"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" },
]
[[package]]
name = "cffi"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
[[package]]
name = "chepy"
version = "7.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "base58" },
{ name = "colorama" },
{ name = "crccheck" },
{ name = "decorator" },
{ name = "docstring-parser" },
{ name = "emoji" },
{ name = "exrex" },
{ name = "fire" },
{ name = "hexdump" },
{ name = "jmespath" },
{ name = "jsonpickle" },
{ name = "lazy-import" },
{ name = "lz4" },
{ name = "msgpack" },
{ name = "parsel" },
{ name = "passlib" },
{ name = "pgpy" },
{ name = "pretty-errors" },
{ name = "prompt-toolkit" },
{ name = "pycipher" },
{ name = "pycryptodome" },
{ name = "pydash" },
{ name = "pyopenssl" },
{ name = "pyperclip" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "setuptools" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/4c/b8bc1607c96e1df206912e4db82ace5adfb2fe9dce5926fd7860d265fa58/chepy-7.6.0.tar.gz", hash = "sha256:f6084689e43d128748c18f79c79d75539abb67ac8245e950b678cfa0da2f5010", size = 161603, upload-time = "2026-04-17T04:04:55.803Z" }
[[package]]
name = "click"
version = "8.4.2"
@@ -23,11 +127,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "crccheck"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/d1/a943f4f1ca899917cc3fe1cb89d59348edd1b407503e4b02608e8d6b421e/crccheck-1.3.1.tar.gz", hash = "sha256:1544c0110bf0a697d875d4f29dc40d7079f9d4d402a9317383f55f90ca72563a", size = 41696, upload-time = "2025-07-10T07:01:08.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/b5/68a9054be852e61f31de1be4e8d95802646b93344ce17c2380a1748706fa/crccheck-1.3.1-py3-none-any.whl", hash = "sha256:1680c9a7bb1ca4bec45fa19b8ca64319f10d2ce4eb8b0d25d51cb99a20ca0108", size = 24608, upload-time = "2025-07-10T07:01:07.25Z" },
]
[[package]]
name = "cryptography"
version = "41.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/b3/13a12ea7edb068de0f62bac88a8ffd92cc2901881b391839851846b84a81/cryptography-41.0.7.tar.gz", hash = "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc", size = 630892, upload-time = "2023-11-28T00:49:41.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/73/5461318abd2fe426855a2f66775c063bbefd377729ece3c3ee048ddf19a5/cryptography-41.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf", size = 5342676, upload-time = "2023-11-28T00:48:49.291Z" },
{ url = "https://files.pythonhosted.org/packages/a9/76/d705397d076fcbf5671544eb72a70b5a5ac83462d23dbd2a365a3bf3692a/cryptography-41.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d", size = 2850425, upload-time = "2023-11-28T00:49:29.113Z" },
{ url = "https://files.pythonhosted.org/packages/68/bb/475658ea92653a894589e657d6cea9ae01354db73405d62126ac5e74e2f8/cryptography-41.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a", size = 4161547, upload-time = "2023-11-28T00:48:38.466Z" },
{ url = "https://files.pythonhosted.org/packages/14/fd/dd5bd6ab0d12476ebca579cbfd48d31bd90fa28fa257b209df585dcf62a0/cryptography-41.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15", size = 4363688, upload-time = "2023-11-28T00:49:34.285Z" },
{ url = "https://files.pythonhosted.org/packages/3e/81/ae2c51ea2b80d57d5756a12df67816230124faea0a762a7a6304fe3c819c/cryptography-41.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a", size = 4148846, upload-time = "2023-11-28T00:49:04.704Z" },
{ url = "https://files.pythonhosted.org/packages/62/bd/69628ab50368b1beb900eb1de5c46f8137169b75b2458affe95f2f470501/cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1", size = 4372805, upload-time = "2023-11-28T00:48:44.452Z" },
{ url = "https://files.pythonhosted.org/packages/b6/4a/1808333c5ea79cb6d51102036cbcf698704b1fc7a5ccd139957aeadd2311/cryptography-41.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157", size = 4255463, upload-time = "2023-11-28T00:48:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/c5/07/826d66b6b03c5bfde8b451bea22c41e68d60aafff0ffa02c5f0819844319/cryptography-41.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406", size = 4441232, upload-time = "2023-11-28T00:49:20.186Z" },
{ url = "https://files.pythonhosted.org/packages/f3/4f/11b739e95598db236013cc9efb4e3d02b51dd0861c85470c3fe42720ef5b/cryptography-41.0.7-cp37-abi3-win32.whl", hash = "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d", size = 2236277, upload-time = "2023-11-28T00:49:09.796Z" },
{ url = "https://files.pythonhosted.org/packages/f6/23/b28f4a03650512efff13a8fcbb977bac178a765c5a887a6720bee13fa85b/cryptography-41.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2", size = 2671839, upload-time = "2023-11-28T00:48:41.223Z" },
]
[[package]]
name = "cssselect"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" },
]
[[package]]
name = "ctf"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "chepy" },
{ name = "click" },
{ name = "platformdirs" },
{ name = "rich" },
@@ -41,6 +185,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "chepy" },
{ name = "click" },
{ name = "platformdirs", specifier = ">=4.9.4" },
{ name = "rich" },
@@ -50,6 +195,60 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "decorator"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
]
[[package]]
name = "docstring-parser"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
]
[[package]]
name = "emoji"
version = "2.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" },
]
[[package]]
name = "exrex"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/9a/580635ea99178aba728de721e88e5a0d18b528805c58e8b26eab87be2a45/exrex-0.12.0.tar.gz", hash = "sha256:3f582add0700d4141e279625252e7a0655f9ad81feab3b8ab9077966668a2c99", size = 19766, upload-time = "2024-11-13T13:46:36.399Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/90/89fdfc6f426cc6414cc8e1c599c4333d800f8f3ff05cb6ce69c8d0eaa245/exrex-0.12.0-py3-none-any.whl", hash = "sha256:1228f2e3afb008cacf6adc3ec20b098f1303886f4382b57cdf3b7259fb7c0ad3", size = 23590, upload-time = "2024-11-13T13:46:34.647Z" },
]
[[package]]
name = "fire"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "termcolor" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/00/f8d10588d2019d6d6452653def1ee807353b21983db48550318424b5ff18/fire-0.7.1.tar.gz", hash = "sha256:3b208f05c736de98fb343310d090dcc4d8c78b2a89ea4f32b837c586270a9cbf", size = 88720, upload-time = "2025-08-16T20:20:24.175Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/4c/93d0f85318da65923e4b91c1c2ff03d8a458cbefebe3bc612a6693c7906d/fire-0.7.1-py3-none-any.whl", hash = "sha256:e43fd8a5033a9001e7e2973bab96070694b9f12f2e0ecf96d4683971b5ab1882", size = 115945, upload-time = "2025-08-16T20:20:22.87Z" },
]
[[package]]
name = "hexdump"
version = "3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/55/b3/279b1d57fa3681725d0db8820405cdcb4e62a9239c205e4ceac4391c78e4/hexdump-3.3.zip", hash = "sha256:d781a43b0c16ace3f9366aade73e8ad3a7bd5137d58f0b45ab2d3f54876f20db", size = 12658, upload-time = "2016-01-22T14:40:19.589Z" }
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -59,6 +258,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "jsonpickle"
version = "4.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/c0/dde9b4b42cc415b9579573f967f12efbb034e427a2a37e93ad5139891d87/jsonpickle-4.1.2.tar.gz", hash = "sha256:8afed18aa189fd81e2e833b426bb4af485594921f0b1d36c2001fc5637a2f210", size = 319120, upload-time = "2026-05-28T03:50:11.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/7b/fd3c7a09649aea9da1d3587aea624d8f9b29963dfd84a1bdb2aa93b36dac/jsonpickle-4.1.2-py3-none-any.whl", hash = "sha256:7ffe34426bc797684dbf1dc84185558bd864cd25b1ff5fb01b7405e392d0a937", size = 47203, upload-time = "2026-05-28T03:50:10.605Z" },
]
[[package]]
name = "lazy-import"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/2e/5378f9b9cbc893826c2ecb022646c97ece9efbaad351adf89425fff33990/lazy_import-0.2.2.tar.gz", hash = "sha256:2149aef8579459407c62cfeccf118527939c9931ace124f355236360644f8a3d", size = 15265, upload-time = "2018-01-22T02:19:32.029Z" }
[[package]]
name = "lxml"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
]
[[package]]
name = "lz4"
version = "4.4.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" },
{ url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" },
{ url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" },
{ url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" },
{ url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" },
{ url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" },
{ url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" },
{ url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
@@ -80,6 +366,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "msgpack"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
]
[[package]]
name = "packaging"
version = "26.1"
@@ -89,6 +401,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]]
name = "parsel"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cssselect" },
{ name = "jmespath" },
{ name = "lxml" },
{ name = "packaging" },
{ name = "w3lib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/c8/4ace3a5c61e39ca21734a5715d0e076eea6200dd8daea2a5b99452f5a0d6/parsel-1.11.0.tar.gz", hash = "sha256:5925fe087eb16fc404a7ed91e31e2c1e2a9b230da4b64f34d81358c0d0e27e88", size = 106849, upload-time = "2026-01-29T07:19:23.388Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/23/4e0dae5e5bee14aea26dba003a682e621563451a20f751ed985810f818b6/parsel-1.11.0-py3-none-any.whl", hash = "sha256:bda82575df1774dd64e1c1396163f3cadb3e383e0f8080d43d45fa6705355daa", size = 14176, upload-time = "2026-01-29T07:19:22.255Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[[package]]
name = "pgpy"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/68/5e285ba9465550a48e837b08b27fa99ef238618f6085a1464afaa4dc8426/PGPy-0.6.0.tar.gz", hash = "sha256:279c2e353f4c3a319f00bd9bd582456e420f8a3ac6de2b4e9731444746828383", size = 603002, upload-time = "2022-11-24T02:35:22.133Z" }
[[package]]
name = "platformdirs"
version = "4.9.6"
@@ -107,6 +454,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pretty-errors"
version = "1.2.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/a7/823f451d48726729b4983072aad7c442f4c02fbaec04b71c1f4c03993964/pretty_errors-1.2.25.tar.gz", hash = "sha256:a16ba5c752c87c263bf92f8b4b58624e3b1e29271a9391f564f12b86e93c6755", size = 20332, upload-time = "2021-11-24T14:32:10.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/8e/2df7467a15eae40e26c476683962fdb810cd1b36676603e2f139b4abbeaf/pretty_errors-1.2.25-py3-none-any.whl", hash = "sha256:8ce68ccd99e0f2a099265c8c1f1c23b7c60a15d69bb08816cb336e237d5dc983", size = 17195, upload-time = "2021-11-24T14:32:07.762Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
name = "pycipher"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/8f/765d9889f76b21bf5327bea518786647e48bec1295a2478dd54e8efdb85d/pycipher-0.5.2.zip", hash = "sha256:e5b3b3ef734604da09e1642f9c496a75df850668aee5b18818a3988eec4cd71b", size = 45497, upload-time = "2016-07-14T05:12:51.907Z" }
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]]
name = "pydash"
version = "8.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/c1/1c55272f49d761cec38ddb80be9817935b9c91ebd6a8988e10f532868d56/pydash-8.0.6.tar.gz", hash = "sha256:b2821547e9723f69cf3a986be4db64de41730be149b2641947ecd12e1e11025a", size = 164338, upload-time = "2026-01-17T16:42:56.576Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/b7/cc5e7974699db40014d58c7dd7c4ad4ffc244d36930dc9ec7d06ee67d7a9/pydash-8.0.6-py3-none-any.whl", hash = "sha256:ee70a81a5b292c007f28f03a4ee8e75c1f5d7576df5457b836ec7ab2839cc5d0", size = 101561, upload-time = "2026-01-17T16:42:55.448Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -116,6 +542,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyopenssl"
version = "23.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/df/75a6525d8988a89aed2393347e9db27a56cb38a3e864314fac223e905aef/pyOpenSSL-23.2.0.tar.gz", hash = "sha256:276f931f55a452e7dea69c7173e984eb2a4407ce413c918aa34b55f82f9b8bac", size = 185132, upload-time = "2023-05-31T03:21:28.901Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/e2/f8b4f1c67933a4907e52228241f4bd52169f3196b70af04403b29c63238a/pyOpenSSL-23.2.0-py3-none-any.whl", hash = "sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2", size = 59036, upload-time = "2023-05-31T03:21:26.479Z" },
]
[[package]]
name = "pyperclip"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
@@ -132,6 +579,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "regex"
version = "2026.7.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/37/451aaddbf50922f34d744ad5ca919ae1fcfac112123885d9728f52a484b3/regex-2026.7.10.tar.gz", hash = "sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135", size = 416282, upload-time = "2026-07-10T19:49:46.267Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/4a/a7fa3ada9bd2d2ce20d56dfceec6b2a51afeed9bf3d8286355ceec5f0628/regex-2026.7.10-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca", size = 497087, upload-time = "2026-07-10T19:48:40.543Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7e/ca0b1a87192e5828dbc16f16ae6caca9b67f25bf729a3348468a5ff52755/regex-2026.7.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042", size = 297307, upload-time = "2026-07-10T19:48:42.213Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6c/fb40bb34275d3cd4d7a376d5fb2ea1f0f4a96fd884fa83c0c4ae869001bf/regex-2026.7.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be", size = 292163, upload-time = "2026-07-10T19:48:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/18/0b/34cbea16c8fea9a18475a7e8f5837c70af451e738bfeb4eb5b029b7dc07a/regex-2026.7.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6", size = 797064, upload-time = "2026-07-10T19:48:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/77/f6805d97f15f5a710bdfd56a768f3468c978239daf9e1b15efd8935e1967/regex-2026.7.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89", size = 866155, upload-time = "2026-07-10T19:48:47.589Z" },
{ url = "https://files.pythonhosted.org/packages/a2/e3/a2a905807bba3bcd90d6ebbb67d27af2adf7d41708175cbc6b956a0c75f1/regex-2026.7.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794", size = 911596, upload-time = "2026-07-10T19:48:49.473Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ca/a3126888b2c6f33c7e29144fedf85f6d5a52a400024fa045ad8fc0550ef1/regex-2026.7.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c", size = 800713, upload-time = "2026-07-10T19:48:51.452Z" },
{ url = "https://files.pythonhosted.org/packages/66/19/9d252fd969f726c8b56b4bacf910811cc70495a110907b3a7ccb96cd9cad/regex-2026.7.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361", size = 777286, upload-time = "2026-07-10T19:48:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/40/7a/5f1bf433fa446ecb3aab87bb402603dc9e171ef8052c1bb8690bb4e255a3/regex-2026.7.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3", size = 785826, upload-time = "2026-07-10T19:48:55.381Z" },
{ url = "https://files.pythonhosted.org/packages/99/ca/69f3a7281d86f1b592338007f3e535cc219d771448e2b61c0b56e4f9d05b/regex-2026.7.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90", size = 860957, upload-time = "2026-07-10T19:48:57.962Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/487ff55c8d515ec9dd60d7ba3c129eeaa9e527358ed9e8a054a9e9430f81/regex-2026.7.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6", size = 765959, upload-time = "2026-07-10T19:49:00.27Z" },
{ url = "https://files.pythonhosted.org/packages/73/e1/fa034e6fa8896a09bd0d5e19c81fdc024411ab37980950a0401dccee8f6d/regex-2026.7.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06", size = 851447, upload-time = "2026-07-10T19:49:02.128Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a5/b9427ed53b0e14c540dc436d56aaf57a19fb9183c6e7abd66f4b4368fbad/regex-2026.7.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8", size = 789418, upload-time = "2026-07-10T19:49:03.949Z" },
{ url = "https://files.pythonhosted.org/packages/ba/52/aab92420c8aa845c7bcbe68dc65023d4a9e9ea785abf0beb2198f0de5ba1/regex-2026.7.10-cp314-cp314-win32.whl", hash = "sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2", size = 272538, upload-time = "2026-07-10T19:49:05.833Z" },
{ url = "https://files.pythonhosted.org/packages/99/16/5c7050e0ef7dd8889441924ff0a2c33b7f0587c0ccb0953fe7ca997d673b/regex-2026.7.10-cp314-cp314-win_amd64.whl", hash = "sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43", size = 280796, upload-time = "2026-07-10T19:49:07.593Z" },
{ url = "https://files.pythonhosted.org/packages/e8/1a/4f6099d2ba271502fdb97e697bae2ed0213c0d87f2273fe7d21e2e401d12/regex-2026.7.10-cp314-cp314-win_arm64.whl", hash = "sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5", size = 281017, upload-time = "2026-07-10T19:49:09.767Z" },
{ url = "https://files.pythonhosted.org/packages/19/02/4061fc71f64703e0df61e782c2894c3fbc089d277767eff6e16099581c73/regex-2026.7.10-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49", size = 501467, upload-time = "2026-07-10T19:49:11.952Z" },
{ url = "https://files.pythonhosted.org/packages/73/a5/8d42b2f3fd672908a05582effd0f88438bf9bb4e8e02d69a62c723e23601/regex-2026.7.10-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f", size = 299700, upload-time = "2026-07-10T19:49:14.067Z" },
{ url = "https://files.pythonhosted.org/packages/65/70/36fa4b46f73d268c0dbe77c40e62da2cd4833ee206d3b2e438c2034e1f36/regex-2026.7.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba", size = 294590, upload-time = "2026-07-10T19:49:15.883Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a7/b6db1823f3a233c2a46f854fdc986f4fd424a84ed557b7751f2998efb266/regex-2026.7.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2", size = 811925, upload-time = "2026-07-10T19:49:17.97Z" },
{ url = "https://files.pythonhosted.org/packages/b6/7d/f8bee4c210c42c7e8b952bb9fb7099dd7fb2f4bd0f33d0d65a8ab08aafc0/regex-2026.7.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067", size = 871257, upload-time = "2026-07-10T19:49:19.943Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/22adf72e614ba0216b996e9aaef5712c23699e360ea127bb3d5ee1a7666f/regex-2026.7.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478", size = 917551, upload-time = "2026-07-10T19:49:22.069Z" },
{ url = "https://files.pythonhosted.org/packages/03/f7/ebc15a39e81e6b58da5f913b91fc293a25c6700d353c14d5cd25fc85712a/regex-2026.7.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c", size = 816436, upload-time = "2026-07-10T19:49:24.131Z" },
{ url = "https://files.pythonhosted.org/packages/5c/33/20bc2bdd57f7e0fcc51be37e4c4d1bca7f0b4af8dc0a148c23220e689da8/regex-2026.7.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70", size = 785935, upload-time = "2026-07-10T19:49:26.265Z" },
{ url = "https://files.pythonhosted.org/packages/b4/51/87ff99c849b56309c40214a72b54b0eef320d0516a8a516970cc8be1b725/regex-2026.7.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f", size = 801494, upload-time = "2026-07-10T19:49:28.493Z" },
{ url = "https://files.pythonhosted.org/packages/16/11/fde67d49083fef489b7e0f841e2e5736516795b166c9867f05956c1e494b/regex-2026.7.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173", size = 866549, upload-time = "2026-07-10T19:49:30.592Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b5/31a156c36acf10181d88f55a66c688d5454a344e53ccc03d49f4a48a2297/regex-2026.7.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48", size = 773089, upload-time = "2026-07-10T19:49:32.661Z" },
{ url = "https://files.pythonhosted.org/packages/27/bb/734e978c904726664df47ae36ce5eca5065de5141185ae46efec063476a2/regex-2026.7.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d", size = 856710, upload-time = "2026-07-10T19:49:35.289Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e5/dc35cea074dbdcb9776c4b0542a3bc326ff08454af0768ef35f3fc66e7fa/regex-2026.7.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be", size = 803621, upload-time = "2026-07-10T19:49:37.704Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b2/124564af46bc0b592785610b3985315610af0a07f4cf21fa36e06c2398dd/regex-2026.7.10-cp314-cp314t-win32.whl", hash = "sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca", size = 274558, upload-time = "2026-07-10T19:49:39.926Z" },
{ url = "https://files.pythonhosted.org/packages/4e/9c/cd813ce9f3404c0443915175c1e339c5afd8fcda04310102eaf233015eef/regex-2026.7.10-cp314-cp314t-win_amd64.whl", hash = "sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb", size = 283687, upload-time = "2026-07-10T19:49:41.872Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d3/3dae6a6ce46144940e64425e32b8573a393a009aeaf75fa6752a35399056/regex-2026.7.10-cp314-cp314t-win_arm64.whl", hash = "sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3", size = 283377, upload-time = "2026-07-10T19:49:43.985Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
@@ -145,6 +658,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "setuptools"
version = "83.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "termcolor"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
]
[[package]]
name = "toml"
version = "0.10.2"
@@ -153,3 +693,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "w3lib"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c0/91/b2eb59c2cf243de5de1e91c963655df78c015509f51297685a8c86a27b8c/w3lib-2.4.1.tar.gz", hash = "sha256:8dd69ee39ff6398d708c793abc779c334a69bac7cee1cdf71736c669ed6be864", size = 48494, upload-time = "2026-03-20T09:50:27.477Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/c3/f8b216cbd742e5b84c40f045204c764ccb7524d2aeab021054ec69446b0a/w3lib-2.4.1-py3-none-any.whl", hash = "sha256:40930132907e68de906a5b89331ab8c8ff4f01bd35b5539ef7896017d814138d", size = 21695, upload-time = "2026-03-20T09:50:26.187Z" },
]
[[package]]
name = "wcwidth"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
]