diff --git a/.protocolbox/SKILL.md b/.protocolbox/SKILL.md index 595573a..f62cfb0 100644 --- a/.protocolbox/SKILL.md +++ b/.protocolbox/SKILL.md @@ -2,7 +2,7 @@ name: protocolbox description: > Standard Library of verified tools for AI Agents. - Tools: scrape(url), heal_json(str), generate_invoice(dict). + Tools: scrape(url), heal_json(str). --- # ProtocolBox @@ -31,5 +31,3 @@ uv run protocolbox start - **scrape(url: str) -> str** — Fetch a web page and return clean Markdown. - **heal_json(broken_json: str) -> dict** — Fix malformed JSON from LLM output. -- **generate_invoice(data: dict) -> str** — Generate a PDF invoice. - Requires `client_name` and `total` in data. diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b4a28..02fb6eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.4] - 2026-02-15 + +### Added +- **Web Search** (`web_search`): Privacy-focused web search using DuckDuckGo with Markdown-formatted results. +- **Safe Math** (`safe_math`): Secure mathematical expression evaluator using `ast.parse` (no `eval()`). Supports arithmetic and `math` functions. +- **Get Time** (`get_time`): Real-world time retrieval for any timezone using `pytz`, returns ISO 8601 format. +- **Get Transcript** (`get_transcript`): YouTube video transcript fetcher via `youtube-transcript-api`. +- **Memory** (`remember` / `recall`): Persistent local key-value store using `~/.protocolbox/memory.json`. +- **Tests**: 184 new edge-case tests (283 total) covering all new tools — security, error handling, unicode, and boundary conditions. + +### Fixed +- **YouTube Tool**: Migrated to `youtube-transcript-api` v1.x instance-based API (`api.fetch()` instead of removed `get_transcript()` class method). + +### Dependencies +- Added `duckduckgo-search`, `youtube-transcript-api`, and `pytz`. + +## [0.1.3] - 2026-02-15 + +### Removed +- **Invoice Tool**: Removed `generate_invoice()` tool and all related code, tests, and documentation. +- **Dependency**: Removed `reportlab` from project dependencies (was only used by the invoice tool). + ## [0.1.2] - 2026-02-14 ### Fixed @@ -34,9 +56,6 @@ ProtocolBox v0.1.0 establishes the "Standard Library for the Agentic Web," provi - `heal_json(broken_json)`: - deterministic recovery of malformed JSON from LLM outputs. - Fixes trailing commas, missing quotes, unclosed brackets, and truncated strings. - - `generate_invoice(data)`: - - Generates professional PDF invoices from structured Python dictionaries. - - Supports line items, tax calculations, and custom notes. - **Developer Experience** - **CLI**: `protocolbox init` for environment setup and `protocolbox start` for running the server. @@ -61,4 +80,3 @@ ProtocolBox v0.1.0 establishes the "Standard Library for the Agentic Web," provi - All dependencies pinned via `uv.lock`. - No external API key requirements for core tools. -- Sandboxed PDF generation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b09052a..2a6421f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,11 @@ protocolbox/ │ ├── utils.py # Shared utilities (HTTP client) │ ├── scraper.py # scrape() tool │ ├── json_healer.py # heal_json() tool -│ └── invoice.py # generate_invoice() tool +│ ├── search.py # web_search() tool +│ ├── math_utils.py # safe_math() tool +│ ├── time_utils.py # get_time() tool +│ ├── youtube.py # get_transcript() tool +│ └── memory.py # remember() + recall() tools ├── tests/ # Test suite (mirrors tool structure) ├── docs/ # Documentation & landing page ├── pyproject.toml # Project config diff --git a/README.md b/README.md index 036a83c..ab35957 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # ProtocolBox 📦 -[![License: MIT](https://img.shields.io/badge/License-MIT-white.svg)](LICENSE) -[![Python 3.11+](https://img.shields.io/badge/python-3.11+-white.svg)](https://python.org) -[![Ruff](https://img.shields.io/badge/linting-ruff-white.svg)](https://docs.astral.sh/ruff/) -[![MCP](https://img.shields.io/badge/protocol-MCP-white.svg)](https://modelcontextprotocol.io/) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](LICENSE) +[![Build Status](https://img.shields.io/github/actions/workflow/status/ianuragbhatt/protocolbox/ci.yml?branch=master&style=for-the-badge)](https://github.com/ianuragbhatt/protocolbox/actions) +[![Code Style: Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg?style=for-the-badge)](https://github.com/astral-sh/ruff) +[![MCP Compliant](https://img.shields.io/badge/MCP-Compliant-orange?style=for-the-badge)](https://modelcontextprotocol.io/) +[![Built for Antigravity](https://img.shields.io/badge/Built%20for-Antigravity-purple?style=for-the-badge)](https://protocolbox.in) > **The Standard Library for the Agentic Web.** > https://protocolbox.in @@ -24,13 +25,18 @@ protocolbox init ## 🛠️ Tools -ProtocolBox currently exports 3 core tools optimized for agent workflows: +ProtocolBox currently exports 8 tools optimized for agent workflows: | Tool | Signature | Description | | :--- | :--- | :--- | | **Scrape** | `scrape(url: str) -> str` | Fetches a webpage and converts it to clean, token-saving Markdown. Removes ads, scripts, and clutter automatically. | -| **Heal JSON** | `heal_json(json_str: str) -> dict` | repairs malformed JSON strings often produced by LLMs (trailing commas, missing quotes, etc.) into valid Python dictionaries. | -| **Invoice** | `generate_invoice(data: dict) -> str` | Generates a professional PDF invoice from structured data in milliseconds. | +| **Heal JSON** | `heal_json(json_str: str) -> dict` | Repairs malformed JSON strings often produced by LLMs (trailing commas, missing quotes, etc.) into valid Python dictionaries. | +| **Web Search** | `web_search(query: str, max_results: int) -> str` | Privacy-focused web search using DuckDuckGo. Returns formatted Markdown results. | +| **Safe Math** | `safe_math(expression: str) -> str` | Securely evaluates mathematical expressions without `eval()`. Supports arithmetic and common math functions. | +| **Get Time** | `get_time(timezone: str) -> str` | Returns the current real-world time in any timezone (ISO 8601 format). | +| **Get Transcript** | `get_transcript(video_url: str) -> str` | Fetches the English transcript of a YouTube video as clean text. | +| **Remember** | `remember(key: str, value: str) -> str` | Stores a key-value pair in persistent local memory. | +| **Recall** | `recall(key: str) -> str` | Retrieves a value from persistent local memory by key. | ## ⚡ Usage @@ -54,7 +60,14 @@ protocolbox/ │ ├── server.py # FastMCP server │ ├── cli.py # CLI entry point │ └── tools/ # Tool implementations -├── tests/ # 115+ edge-case tests +│ ├── scraper.py # scrape() +│ ├── json_healer.py# heal_json() +│ ├── search.py # web_search() +│ ├── math_utils.py # safe_math() +│ ├── time_utils.py # get_time() +│ ├── youtube.py # get_transcript() +│ └── memory.py # remember() + recall() +├── tests/ # 280+ edge-case tests ├── docs/ # Documentation site └── pyproject.toml # Project config ``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d7e5802 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,34 @@ +# ProtocolBox Roadmap 🗺️ + +This document outlines the development trajectory for ProtocolBox. We prioritize tools that provide high-leverage capabilities to AI Agents (scrapers, browsers, sandbox execution). + +## ✅ Completed (v0.1.x) + +- [x] **Core Tools**: `scrape(url)`, `heal_json(broken_json)` — web scraping and JSON repair. +- [x] **Daily Drivers** (v0.1.4): `web_search`, `safe_math`, `get_time`, `get_transcript`, `remember`, `recall`. +- [x] **CLI**: `protocolbox init` and `protocolbox start` commands. +- [x] **CI/CD**: GitHub Actions, Ruff linting, automated PyPI publishing. +- [x] **Test Suite**: 280+ edge-case tests with 100% pass rate. + +## 🚀 Q1 2026 Objectives (v0.2.0) + +### New Tools +- [ ] **`browser`**: Headless browser control for interacting with dynamic JS-heavy sites (beyond simple scraping). +- [ ] **`filesystem`**: Safe, sandboxed file I/O permissions for agents to read/write their own workspace. + +### Infrastructure +- [ ] **Docker Image**: Official `protocolbox/server` image for easy deployment in containerized agent swarms. +- [ ] **Auth**: Simple Bearer token authentication for the MCP server. + +## 🔮 Future Concepts (v0.3.0+) + +- **Agent Sandbox**: A secure Python execution environment (REPL) for agents to run generated code safely. +- **Vector Memory**: Built-in simple RAG interface for agents to store/retrieve context across sessions. +- **Multi-Modal**: Tools for resizing/converting images and processing audio. + +## 🤝 How to contribute + +See a feature here you want? +1. Check [Issues](https://github.com/ianuragbhatt/protocolbox/issues) to see if it's already in progress. +2. Comment on the issue or open a new one expressing interest. +3. Submit a PR! diff --git a/docs/index.html b/docs/index.html index 8338b3b..eed6020 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,292 +4,917 @@ + ProtocolBox — The Standard Library for AI Agents - ProtocolBox + content="ProtocolBox is an open-source collection of 8 verified MCP tools for AI Agents. Scrape the web, search, do math, and more — all without hallucinations."> -
- - - - - - - Star on GitHub - ... - - + +
+
+
+ Open Source · MCP Compliant · Python 3.11+ +
+ +

+ Give your AI agent
reliable tools. +

+ +

+ ProtocolBox is an open-source Python library of 8 verified MCP tools that let AI agents + scrape the web, search, do math, fetch transcripts, and remember context — + without hallucinating or wasting tokens. +

-
- $ pip install protocolbox - +
+ $ pip install protocolbox +
-
-
-
scrape(url)
-
Fetch web pages as token-efficient Markdown. Strips ads, scripts, and clutter - automatically.
+
+
+
8
+
Tools
-
-
heal_json(str)
-
Repair malformed LLM outputs. Fixes trailing commas, missing quotes, and - truncated JSON.
+
+
283
+
Tests
-
-
generate_invoice(dict)
-
Create professional PDF invoices instantly from structured data.
+
+
100%
+
Pass Rate
+
+
+
0
+
API Keys
+
+
+
+ +
+ + +
+
+
+

Up and running in 60 seconds

+

Install the package, initialize the config for your agent, and start the MCP server.

+
+ +
+
+
1
+

Install

+

One command installs all 8 tools. No external API keys needed.

+ pip install protocolbox +
+
+
2
+

Initialize

+

Generate the MCP config file for your AI agent (Claude, Cursor, etc.).

+ protocolbox init +
+
+
3
+

Start

+

Launch the MCP server. Your agent can now call any tool instantly.

+ protocolbox start +
+
+ +
- -
+ + +
+
+
+

Web Tools

+ 3 tools +
+
+
+
scrape
+
(url: str) → str
+
Fetches a webpage and converts it to clean Markdown. Strips ads, scripts, + navigation, and clutter — 90% token reduction.
+
+
+
web_search
+
(query: str, max_results: int) → str
+
Privacy-focused search via DuckDuckGo. Returns formatted Markdown with + titles, links, and snippets. No API key.
+
+
+
get_transcript
+
(video_url: str) → str
+
Fetches the English transcript of any YouTube video as clean text. + Supports standard, short, and embed URLs.
+
+
+
+ + +
+
+
+

Logic & Data

+ 2 tools +
+
+
+
safe_math
+
(expression: str) → str
+
Securely evaluates math expressions using AST parsing — zero + eval(). Supports arithmetic, sqrt, floor, ceil, sin, cos, tan, log.
+
+
+
heal_json
+
(broken_json: str) → dict
+
Repairs malformed JSON produced by LLMs. Fixes trailing commas, unquoted + keys, single quotes, and truncated output.
+
+
+
+ + +
+
+
+

Utilities

+ 3 tools +
+
+
+
get_time
+
(timezone: str) → str
+
Returns the current time in any timezone, formatted as ISO 8601. Supports + 500+ timezones.
+
+
+
remember
+
(key: str, value: str) → str
+
Stores a key-value pair in persistent local memory. Data survives across + sessions.
+
+
+
recall
+
(key: str) → str
+
Retrieves a value from persistent memory. Perfect for maintaining agent + context across conversations.
+
+
+
+ + + +
+ + +
+

Ready to build?

+

ProtocolBox is free, open-source, and MIT licensed.

+
+ + + + + View on GitHub + + + Read llms.txt → + +
+
+ + + + \ No newline at end of file diff --git a/docs/llms.txt b/docs/llms.txt index 810f9ba..50c156a 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,7 +1,7 @@ # ProtocolBox — The Standard Library for AI Agents > Project: ProtocolBox -> Version: 0.1.0 +> Version: 0.1.4 > Website: https://protocolbox.in > Repo: https://github.com/ianuragbhatt/protocolbox @@ -71,34 +71,109 @@ Returns: `{"name": "Alice", "age": 30}` --- -### generate_invoice(data: dict) -> str +### web_search(query: str, max_results: int = 3) -> str -Generate a professional PDF invoice from structured data. +Search the web using DuckDuckGo and return formatted Markdown results. +Privacy-focused — no API key required. -Required fields in `data`: -- `client_name` (str): Name of the client. -- `total` (float): Invoice total amount. +Use this tool for: +- Real-time web grounding to verify facts. +- Finding current information, documentation, or references. + +Example: +```json +{"query": "Python MCP protocol", "max_results": 5} +``` + +Returns: Markdown with numbered results (title, link, snippet). + +--- -Optional fields: -- `invoice_number` (str): Custom invoice number. -- `items` (list): Line items with `description`, `qty`, `price`. -- `currency` (str): Currency symbol (default: "$"). -- `notes` (str): Additional notes. +### safe_math(expression: str) -> str + +Securely evaluate a mathematical expression without using eval(). +Uses AST parsing for safety. Supports +, -, *, /, **, % and functions: sqrt, floor, ceil, abs, sin, cos, tan, log. + +Use this tool for: +- Performing calculations safely within agent workflows. +- Evaluating user-provided math expressions without code injection risk. Example: ```json -{ - "data": { - "client_name": "Acme Corp", - "total": 1500.00, - "items": [ - {"description": "Consulting", "qty": 10, "price": 150.00} - ] - } -} -``` - -Returns: File path to the generated PDF (e.g., `/tmp/invoice_abc123.pdf`). +{"expression": "sqrt(16) + 2 ** 3"} +``` + +Returns: `"12.0"` + +--- + +### get_time(timezone: str = "UTC") -> str + +Get the current real-world time in a specified timezone. +Returns time in ISO 8601 format (YYYY-MM-DD HH:MM:SS TZ). + +Use this tool for: +- Getting the current time in any timezone. +- Scheduling or time-aware agent decisions. + +Example: +```json +{"timezone": "Asia/Kolkata"} +``` + +Returns: `"2026-02-15 23:30:00 IST"` + +--- + +### get_transcript(video_url: str) -> str + +Fetch the English transcript of a YouTube video as clean text. +Supports standard, short (youtu.be), and embed YouTube URLs. + +Use this tool for: +- Summarizing YouTube video content. +- Extracting information from video tutorials or lectures. + +Example: +```json +{"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"} +``` + +Returns: The full transcript as a single text string. + +--- + +### remember(key: str, value: str) -> str + +Store a key-value pair in persistent local memory (~/.protocolbox/memory.json). + +Use this tool for: +- Saving user preferences or context across sessions. +- Persisting data that the agent needs to recall later. + +Example: +```json +{"key": "user_name", "value": "Alice"} +``` + +Returns: `"Remembered: 'user_name' has been saved."` + +--- + +### recall(key: str) -> str + +Retrieve a value from persistent local memory by key. + +Use this tool for: +- Retrieving previously stored preferences or context. +- Accessing persistent data from earlier sessions. + +Example: +```json +{"key": "user_name"} +``` + +Returns: `"Alice"` (or a "not found" message if the key doesn't exist). --- diff --git a/pyproject.toml b/pyproject.toml index 858cd2d..233877f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protocolbox" -version = "0.1.2" +version = "0.1.4" description = "The Standard Library for the Agentic Web — verified MCP tools for any AI Agent." readme = "README.md" authors = [ @@ -17,9 +17,11 @@ dependencies = [ "mcp>=0.1.0", "html2text", "json_repair", - "reportlab", "httpx", "typer", + "duckduckgo-search", + "youtube-transcript-api", + "pytz", ] [project.urls] @@ -49,3 +51,4 @@ select = ["E", "F", "I"] [tool.pytest.ini_options] testpaths = ["tests"] +addopts = "-q --tb=short --no-header" diff --git a/src/protocolbox/__init__.py b/src/protocolbox/__init__.py index 47372e5..74934a0 100644 --- a/src/protocolbox/__init__.py +++ b/src/protocolbox/__init__.py @@ -1,3 +1,3 @@ """ProtocolBox — The Standard Library for the Agentic Web.""" -__version__ = "0.1.0" +__version__ = "0.1.4" diff --git a/src/protocolbox/cli.py b/src/protocolbox/cli.py index fcaf9c5..f1f2e99 100644 --- a/src/protocolbox/cli.py +++ b/src/protocolbox/cli.py @@ -15,7 +15,7 @@ name: protocolbox description: > Standard Library of verified tools for AI Agents. - Tools: scrape(url), heal_json(str), generate_invoice(dict). + Tools: scrape(url), heal_json(str). --- # ProtocolBox @@ -44,8 +44,6 @@ - **scrape(url: str) -> str** — Fetch a web page and return clean Markdown. - **heal_json(broken_json: str) -> dict** — Fix malformed JSON from LLM output. -- **generate_invoice(data: dict) -> str** — Generate a PDF invoice. - Requires `client_name` and `total` in data. """ diff --git a/src/protocolbox/server.py b/src/protocolbox/server.py index 0d6745c..84f47b8 100644 --- a/src/protocolbox/server.py +++ b/src/protocolbox/server.py @@ -6,9 +6,13 @@ # Import tools so they register with the MCP server. # Each tool module uses the `mcp` instance via import. -import protocolbox.tools.invoice # noqa: F401, E402 import protocolbox.tools.json_healer # noqa: F401, E402 +import protocolbox.tools.math_utils # noqa: F401, E402 +import protocolbox.tools.memory # noqa: F401, E402 import protocolbox.tools.scraper # noqa: F401, E402 +import protocolbox.tools.search # noqa: F401, E402 +import protocolbox.tools.time_utils # noqa: F401, E402 +import protocolbox.tools.youtube # noqa: F401, E402 def main() -> None: diff --git a/src/protocolbox/tools/__init__.py b/src/protocolbox/tools/__init__.py index 24b1308..9bdcb0a 100644 --- a/src/protocolbox/tools/__init__.py +++ b/src/protocolbox/tools/__init__.py @@ -1,7 +1,20 @@ """ProtocolBox Tools — the standard library of MCP tools.""" -from protocolbox.tools.invoice import generate_invoice from protocolbox.tools.json_healer import heal_json +from protocolbox.tools.math_utils import safe_math +from protocolbox.tools.memory import recall, remember from protocolbox.tools.scraper import scrape +from protocolbox.tools.search import web_search +from protocolbox.tools.time_utils import get_time +from protocolbox.tools.youtube import get_transcript -__all__ = ["scrape", "heal_json", "generate_invoice"] +__all__ = [ + "scrape", + "heal_json", + "web_search", + "safe_math", + "get_time", + "get_transcript", + "remember", + "recall", +] diff --git a/src/protocolbox/tools/invoice.py b/src/protocolbox/tools/invoice.py deleted file mode 100644 index e41f39d..0000000 --- a/src/protocolbox/tools/invoice.py +++ /dev/null @@ -1,129 +0,0 @@ -"""pb_invoice — Generate PDF invoices from structured data.""" - -import uuid -from typing import Any - -from reportlab.lib.pagesizes import A4 -from reportlab.lib.units import mm -from reportlab.pdfgen import canvas - -from protocolbox.server import mcp - - -@mcp.tool() -def generate_invoice(data: dict[str, Any]) -> str: - """Generate a PDF invoice from structured data. - - The data dict must contain at minimum: - - client_name (str): Name of the client. - - total (float | int): Invoice total amount. - - Optional fields: - - invoice_number (str): Custom invoice number. - - items (list[dict]): Line items with 'description', 'qty', 'price'. - - currency (str): Currency symbol (default: "$"). - - notes (str): Additional notes for the invoice. - - Args: - data: Dictionary containing invoice details. - - Returns: - The file path to the generated PDF, or an error message. - """ - # --- Validate required fields --- - client_name = data.get("client_name") - total = data.get("total") - - if not client_name: - return "Error: 'client_name' is required in the data dict." - if total is None: - return "Error: 'total' is required in the data dict." - - try: - total = float(total) - except (TypeError, ValueError): - return "Error: 'total' must be a number." - - # --- Set up defaults --- - invoice_number = data.get("invoice_number", f"INV-{uuid.uuid4().hex[:8].upper()}") - currency = data.get("currency", "$") - items: list[dict[str, Any]] = data.get("items", []) - notes: str = data.get("notes", "") - - # --- Generate PDF --- - file_path = f"/tmp/invoice_{uuid.uuid4().hex}.pdf" - c = canvas.Canvas(file_path, pagesize=A4) - width, height = A4 - - # Header - c.setFont("Helvetica-Bold", 24) - c.drawString(30 * mm, height - 30 * mm, "INVOICE") - - c.setFont("Helvetica", 10) - c.drawString(30 * mm, height - 40 * mm, f"Invoice #: {invoice_number}") - - # Client info - c.setFont("Helvetica-Bold", 12) - c.drawString(30 * mm, height - 55 * mm, "Bill To:") - c.setFont("Helvetica", 11) - c.drawString(30 * mm, height - 62 * mm, str(client_name)) - - # Line items - y_pos = height - 80 * mm - - if items: - # Table header - c.setFont("Helvetica-Bold", 10) - c.drawString(30 * mm, y_pos, "Description") - c.drawString(120 * mm, y_pos, "Qty") - c.drawString(145 * mm, y_pos, "Price") - c.drawString(170 * mm, y_pos, "Subtotal") - y_pos -= 7 * mm - - # Draw a line - c.line(30 * mm, y_pos + 3 * mm, 190 * mm, y_pos + 3 * mm) - - c.setFont("Helvetica", 10) - for item in items: - desc = str(item.get("description", "—")) - qty = item.get("qty", 1) - price = item.get("price", 0) - subtotal = float(qty) * float(price) - - c.drawString(30 * mm, y_pos, desc[:40]) - c.drawString(120 * mm, y_pos, str(qty)) - c.drawString(145 * mm, y_pos, f"{currency}{price:.2f}") - c.drawString(170 * mm, y_pos, f"{currency}{subtotal:.2f}") - y_pos -= 6 * mm - - y_pos -= 5 * mm - - # Total - c.line(30 * mm, y_pos + 3 * mm, 190 * mm, y_pos + 3 * mm) - c.setFont("Helvetica-Bold", 14) - c.drawString(145 * mm, y_pos - 5 * mm, f"Total: {currency}{total:.2f}") - y_pos -= 20 * mm - - # Notes - if notes: - c.setFont("Helvetica-Oblique", 9) - c.drawString(30 * mm, y_pos, f"Notes: {notes}") - - # Footer - c.setFont("Helvetica", 8) - c.drawString(30 * mm, 15 * mm, "Generated by ProtocolBox — protocolbox.in") - - c.save() - - return file_path - - -def _validate_invoice_data(data: dict[str, Any]) -> str | None: - """Validate invoice data, returning an error message or None.""" - if not isinstance(data, dict): - return "Error: Input must be a dictionary." - if "client_name" not in data: - return "Error: 'client_name' is required." - if "total" not in data: - return "Error: 'total' is required." - return None diff --git a/src/protocolbox/tools/math_utils.py b/src/protocolbox/tools/math_utils.py new file mode 100644 index 0000000..49505ec --- /dev/null +++ b/src/protocolbox/tools/math_utils.py @@ -0,0 +1,111 @@ +"""pb_safe_math — Secure mathematical expression evaluator (no eval).""" + +import ast +import math +import operator +from typing import Union + +from protocolbox.server import mcp + +# Allowed binary operators. +_BINARY_OPS: dict[type, object] = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.Pow: operator.pow, + ast.Mod: operator.mod, +} + +# Allowed unary operators. +_UNARY_OPS: dict[type, object] = { + ast.UAdd: operator.pos, + ast.USub: operator.neg, +} + +# Allowed math functions (name → callable). +_MATH_FUNCS: dict[str, object] = { + "sqrt": math.sqrt, + "floor": math.floor, + "ceil": math.ceil, + "abs": abs, + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "log": math.log, +} + +# Type alias for numeric results. +Numeric = Union[int, float] + + +def _safe_eval(node: ast.AST) -> Numeric: + """Recursively evaluate an AST node using only allowed operations. + + Args: + node: An AST node from a parsed math expression. + + Returns: + The numeric result of the expression. + + Raises: + ValueError: If the expression contains disallowed operations. + """ + if isinstance(node, ast.Expression): + return _safe_eval(node.body) + + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return node.value + + if isinstance(node, ast.UnaryOp): + op_func = _UNARY_OPS.get(type(node.op)) + if op_func is None: + raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}") + return op_func(_safe_eval(node.operand)) # type: ignore[operator] + + if isinstance(node, ast.BinOp): + op_func = _BINARY_OPS.get(type(node.op)) + if op_func is None: + raise ValueError(f"Unsupported operator: {type(node.op).__name__}") + left = _safe_eval(node.left) + right = _safe_eval(node.right) + return op_func(left, right) # type: ignore[operator] + + if isinstance(node, ast.Call): + if not isinstance(node.func, ast.Name): + raise ValueError("Only simple function calls are allowed.") + func_name = node.func.id + func = _MATH_FUNCS.get(func_name) + if func is None: + raise ValueError( + f"Unknown function: {func_name}. " + f"Allowed: {', '.join(sorted(_MATH_FUNCS))}." + ) + args = [_safe_eval(arg) for arg in node.args] + return func(*args) # type: ignore[operator] + + raise ValueError( + f"Unsupported expression type: {type(node).__name__}. " + "Only numbers, basic operators, and math functions are allowed." + ) + + +@mcp.tool() +def safe_math(expression: str) -> str: + """Safely evaluate a mathematical expression without using eval(). + + Supports basic arithmetic (+, -, *, /, **, %) and math functions + (sqrt, floor, ceil, abs, sin, cos, tan, log). + + Args: + expression: A string containing a math expression, e.g. "sqrt(16) + 2**3". + + Returns: + The result as a string, or an error message if evaluation fails. + """ + try: + tree = ast.parse(expression.strip(), mode="eval") + result = _safe_eval(tree) + return str(result) + except (ValueError, TypeError, SyntaxError, ZeroDivisionError) as e: + return f"Error: Could not evaluate '{expression}'. {e}" diff --git a/src/protocolbox/tools/memory.py b/src/protocolbox/tools/memory.py new file mode 100644 index 0000000..ae18efe --- /dev/null +++ b/src/protocolbox/tools/memory.py @@ -0,0 +1,81 @@ +"""pb_memory — Persistent key-value memory store for Agents.""" + +import json +from pathlib import Path +from typing import Any + +from protocolbox.server import mcp + +# Memory file location: ~/.protocolbox/memory.json +_MEMORY_DIR = Path.home() / ".protocolbox" +_MEMORY_FILE = _MEMORY_DIR / "memory.json" + + +def _load_memory() -> dict[str, Any]: + """Load the memory store from disk. + + Returns: + The memory dictionary, or an empty dict if the file doesn't exist. + """ + if not _MEMORY_FILE.exists(): + return {} + try: + return json.loads(_MEMORY_FILE.read_text(encoding="utf-8")) # type: ignore[no-any-return] + except (json.JSONDecodeError, OSError): + return {} + + +def _save_memory(data: dict[str, Any]) -> None: + """Save the memory store to disk. + + Creates the parent directory if it doesn't exist. + + Args: + data: The memory dictionary to persist. + """ + _MEMORY_DIR.mkdir(parents=True, exist_ok=True) + _MEMORY_FILE.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + + +@mcp.tool() +def remember(key: str, value: str) -> str: + """Store a key-value pair in persistent memory. + + Data is saved to ~/.protocolbox/memory.json and persists across + sessions. + + Args: + key: The identifier for the stored value. + value: The data to store. + + Returns: + A confirmation message. + """ + try: + data = _load_memory() + data[key] = value + _save_memory(data) + return f"Remembered: '{key}' has been saved." + except OSError as e: + return f"Error: Could not save memory. {type(e).__name__}: {e}" + + +@mcp.tool() +def recall(key: str) -> str: + """Retrieve a value from persistent memory by key. + + Args: + key: The identifier to look up. + + Returns: + The stored value, or a "not found" message. + """ + try: + data = _load_memory() + if key in data: + return str(data[key]) + return f"Memory not found: No value stored for key '{key}'." + except OSError as e: + return f"Error: Could not read memory. {type(e).__name__}: {e}" diff --git a/src/protocolbox/tools/search.py b/src/protocolbox/tools/search.py new file mode 100644 index 0000000..2b25784 --- /dev/null +++ b/src/protocolbox/tools/search.py @@ -0,0 +1,41 @@ +"""pb_web_search — Privacy-focused web search via DuckDuckGo.""" + +from duckduckgo_search import DDGS + +from protocolbox.server import mcp + + +@mcp.tool() +def web_search(query: str, max_results: int = 3) -> str: + """Search the web using DuckDuckGo and return formatted results. + + Allows an Agent to query the live web for up-to-date information + without tracking or ads. + + Args: + query: The search query string. + max_results: Maximum number of results to return (default 3). + + Returns: + A Markdown-formatted string of search results, or an error message. + """ + try: + with DDGS() as ddgs: + results = list(ddgs.text(query, max_results=max_results)) + + if not results: + return f"No results found for: {query}" + + lines: list[str] = [f"## Search Results for: {query}\n"] + for i, r in enumerate(results, 1): + title = r.get("title", "No title") + link = r.get("href", "") + snippet = r.get("body", "No description available.") + lines.append(f"### {i}. {title}") + lines.append(f"**Link:** {link}") + lines.append(f"{snippet}\n") + + return "\n".join(lines) + + except Exception as e: + return f"Error: Web search failed. {type(e).__name__}: {e}" diff --git a/src/protocolbox/tools/time_utils.py b/src/protocolbox/tools/time_utils.py new file mode 100644 index 0000000..0302c3f --- /dev/null +++ b/src/protocolbox/tools/time_utils.py @@ -0,0 +1,44 @@ +"""pb_get_time — Real-world time with timezone support.""" + +from datetime import datetime + +import pytz + +from protocolbox.server import mcp + +# Common timezones shown in error messages. +_COMMON_TIMEZONES = [ + "UTC", + "US/Eastern", + "US/Central", + "US/Pacific", + "Europe/London", + "Europe/Berlin", + "Asia/Kolkata", + "Asia/Tokyo", + "Australia/Sydney", +] + + +@mcp.tool() +def get_time(timezone: str = "UTC") -> str: + """Get the current real-world time in a specified timezone. + + Returns the time in ISO 8601 format (YYYY-MM-DD HH:MM:SS TZ). + + Args: + timezone: A valid timezone string (default "UTC"). + Examples: "US/Eastern", "Asia/Kolkata", "Europe/London". + + Returns: + The current time as a formatted string, or an error message + with a list of common timezones if the input is invalid. + """ + try: + tz = pytz.timezone(timezone) + except pytz.exceptions.UnknownTimeZoneError: + tz_list = ", ".join(_COMMON_TIMEZONES) + return f"Error: Unknown timezone '{timezone}'. Common timezones: {tz_list}." + + now = datetime.now(tz) + return now.strftime("%Y-%m-%d %H:%M:%S %Z") diff --git a/src/protocolbox/tools/youtube.py b/src/protocolbox/tools/youtube.py new file mode 100644 index 0000000..aa26780 --- /dev/null +++ b/src/protocolbox/tools/youtube.py @@ -0,0 +1,64 @@ +"""pb_get_transcript — YouTube video transcript fetcher.""" + +import re + +from youtube_transcript_api import YouTubeTranscriptApi + +from protocolbox.server import mcp + +# Regex to extract Video ID from various YouTube URL formats. +_VIDEO_ID_PATTERN = re.compile( + r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)" + r"([a-zA-Z0-9_-]{11})" +) + + +def _extract_video_id(url: str) -> str | None: + """Extract the 11-character video ID from a YouTube URL. + + Supports: + - https://www.youtube.com/watch?v=VIDEO_ID + - https://youtu.be/VIDEO_ID + - https://www.youtube.com/embed/VIDEO_ID + + Args: + url: A YouTube video URL. + + Returns: + The video ID string, or None if extraction fails. + """ + match = _VIDEO_ID_PATTERN.search(url) + return match.group(1) if match else None + + +@mcp.tool() +def get_transcript(video_url: str) -> str: + """Fetch the English transcript of a YouTube video. + + Extracts the video ID from the URL, fetches the transcript, + and returns it as a single clean block of text. + + Args: + video_url: A YouTube video URL + (e.g. "https://www.youtube.com/watch?v=dQw4w9WgXcQ"). + + Returns: + The transcript text, or an error message if fetching fails. + """ + video_id = _extract_video_id(video_url) + if not video_id: + return ( + f"Error: Could not extract video ID from '{video_url}'. " + "Please provide a valid YouTube URL." + ) + + try: + api = YouTubeTranscriptApi() + transcript = api.fetch(video_id, languages=["en"]) + text = " ".join(snippet.text for snippet in transcript) + return text.strip() + except Exception as e: + return ( + f"Error: Could not fetch transcript for video '{video_id}'. " + f"{type(e).__name__}: {e}" + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dfd0d12 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,96 @@ +"""Custom pytest plugin for clean, organized test output.""" + +import os +from collections import defaultdict + +# ANSI color codes +GREEN = "\033[92m" +RED = "\033[91m" +YELLOW = "\033[93m" +BOLD = "\033[1m" +DIM = "\033[2m" +RESET = "\033[0m" + +# Module-level storage for results +_results: dict = defaultdict(lambda: {"passed": 0, "failed": 0, "errors": []}) +_use_color: bool = hasattr(os, "isatty") and os.isatty(1) + + +def pytest_runtest_logreport(report): + """Collect results per file during test execution.""" + if report.when != "call": + return + + file_key = report.nodeid.split("::")[0] + + if report.passed: + _results[file_key]["passed"] += 1 + elif report.failed: + _results[file_key]["failed"] += 1 + test_name = report.nodeid.split("::")[-1] + short_reason = str(report.longrepr).strip().split("\n")[-1] + _results[file_key]["errors"].append((test_name, short_reason)) + + +def pytest_terminal_summary(terminalreporter): + """Print a clean, organized summary at the end.""" + if not _results: + return + + def c(text, color): + return f"{color}{text}{RESET}" if _use_color else text + + total_passed = 0 + total_failed = 0 + max_name_len = max( + len(os.path.basename(f).replace("test_", "").replace(".py", "")) + for f in _results + ) + + terminalreporter.write_line("") + terminalreporter.write_line(c(" Test Results", BOLD)) + terminalreporter.write_line(c(" " + "─" * 50, DIM)) + + for filepath in sorted(_results): + data = _results[filepath] + passed = data["passed"] + failed = data["failed"] + total = passed + failed + total_passed += passed + total_failed += failed + + name = os.path.basename(filepath).replace("test_", "").replace(".py", "") + name_display = name.ljust(max_name_len) + + if failed == 0: + status = c("✓ PASS", GREEN) + count = c(f"{passed}/{total}", GREEN) + else: + status = c("✗ FAIL", RED) + count = c(f"{passed}/{total}", RED) + + terminalreporter.write_line(f" {status} {name_display} {count}") + + for test_name, reason in data["errors"]: + terminalreporter.write_line( + f" {c('└─', DIM)} {c(test_name, YELLOW)}" + ) + terminalreporter.write_line(f" {c(reason, RED)}") + + grand_total = total_passed + total_failed + terminalreporter.write_line(c(" " + "─" * 50, DIM)) + + if total_failed == 0: + terminalreporter.write_line( + f" {c('✓', GREEN)} {c(f'{total_passed}/{grand_total} passed', BOLD)}" + ) + else: + terminalreporter.write_line( + f" {c(f'{total_passed} passed', GREEN)}" + f" · {c(f'{total_failed} failed', RED)}" + f" {c(f'({grand_total} total)', DIM)}" + ) + terminalreporter.write_line("") + + # Clear for next run + _results.clear() diff --git a/tests/test_cli.py b/tests/test_cli.py index 0200848..8d4e646 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -73,7 +73,6 @@ def test_skill_content(self, tmp_path: Path) -> None: assert "protocolbox" in content assert "scrape" in content assert "heal_json" in content - assert "generate_invoice" in content def test_creates_parent_dirs(self, tmp_path: Path) -> None: """Should create parent directories if needed.""" diff --git a/tests/test_invoice.py b/tests/test_invoice.py deleted file mode 100644 index 34b2956..0000000 --- a/tests/test_invoice.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Tests for the pb_invoice tool — comprehensive edge-case coverage.""" - -import os - -from protocolbox.tools.invoice import generate_invoice - - -def _cleanup(path: str) -> None: - """Remove a generated file if it exists.""" - if os.path.exists(path): - os.remove(path) - - -class TestInvoiceBasic: - """Core happy-path invoice generation tests.""" - - def test_basic_invoice(self) -> None: - """Minimal valid invoice generates a PDF.""" - result = generate_invoice( - { - "client_name": "Acme Corp", - "total": 1500.00, - } - ) - assert result.startswith("/tmp/invoice_") - assert result.endswith(".pdf") - assert os.path.exists(result) - _cleanup(result) - - def test_invoice_with_items(self) -> None: - """Invoice with line items generates a PDF.""" - result = generate_invoice( - { - "client_name": "Widgets Inc", - "total": 300.00, - "items": [ - {"description": "Widget A", "qty": 2, "price": 100}, - {"description": "Widget B", "qty": 1, "price": 100}, - ], - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_invoice_with_all_fields(self) -> None: - """All optional fields should generate a PDF.""" - result = generate_invoice( - { - "client_name": "Full Corp", - "total": 999.99, - "invoice_number": "INV-CUSTOM-001", - "currency": "€", - "items": [ - {"description": "Service", "qty": 1, "price": 999.99}, - ], - "notes": "Payment due within 30 days.", - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_pdf_file_is_not_empty(self) -> None: - """Generated PDF should have content (non-zero size).""" - result = generate_invoice( - { - "client_name": "Size Test", - "total": 42.00, - } - ) - assert os.path.getsize(result) > 0 - _cleanup(result) - - def test_each_call_produces_unique_file(self) -> None: - """Two calls should produce different file paths.""" - r1 = generate_invoice({"client_name": "A", "total": 1}) - r2 = generate_invoice({"client_name": "B", "total": 2}) - assert r1 != r2 - assert os.path.exists(r1) - assert os.path.exists(r2) - _cleanup(r1) - _cleanup(r2) - - -class TestInvoiceValidation: - """Input validation and error handling.""" - - def test_missing_client_name(self) -> None: - result = generate_invoice({"total": 100}) - assert "Error" in result - assert "client_name" in result - - def test_missing_total(self) -> None: - result = generate_invoice({"client_name": "Test"}) - assert "Error" in result - assert "total" in result - - def test_empty_data(self) -> None: - result = generate_invoice({}) - assert "Error" in result - - def test_invalid_total_string(self) -> None: - """Non-numeric total string should return error.""" - result = generate_invoice( - { - "client_name": "Test", - "total": "not_a_number", - } - ) - assert "Error" in result - - def test_invalid_total_none(self) -> None: - """None total should trigger missing-total error.""" - result = generate_invoice( - { - "client_name": "Test", - "total": None, - } - ) - assert "Error" in result - - def test_empty_client_name(self) -> None: - """Empty string client name should return error.""" - result = generate_invoice( - { - "client_name": "", - "total": 100, - } - ) - assert "Error" in result - - def test_error_returns_string_not_path(self) -> None: - """Errors should be plain strings, not file paths.""" - result = generate_invoice({}) - assert not result.startswith("/tmp/") - - -class TestInvoiceEdgeCases: - """Unusual and boundary condition inputs.""" - - def test_zero_total(self) -> None: - """Zero total should generate a valid PDF.""" - result = generate_invoice( - { - "client_name": "Zero Corp", - "total": 0, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_negative_total(self) -> None: - """Negative total (credit note) should generate a PDF.""" - result = generate_invoice( - { - "client_name": "Credit Corp", - "total": -150.00, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_very_large_total(self) -> None: - """Very large total should not crash.""" - result = generate_invoice( - { - "client_name": "Big Corp", - "total": 999_999_999.99, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_integer_total(self) -> None: - """Integer total should work (not just float).""" - result = generate_invoice( - { - "client_name": "Int Corp", - "total": 500, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_string_numeric_total(self) -> None: - """String that looks like a number should work.""" - result = generate_invoice( - { - "client_name": "Str Corp", - "total": "250.50", - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_unicode_client_name(self) -> None: - """Unicode client name should not crash.""" - result = generate_invoice( - { - "client_name": "株式会社テスト", - "total": 100, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_special_chars_in_client_name(self) -> None: - """Special characters in client name.""" - result = generate_invoice( - { - "client_name": "O'Brien & Sons ", - "total": 200, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_very_long_client_name(self) -> None: - """Very long client name should not crash.""" - result = generate_invoice( - { - "client_name": "A" * 500, - "total": 100, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_many_line_items(self) -> None: - """A large number of line items should not crash.""" - items = [ - {"description": f"Item {i}", "qty": 1, "price": 10.0} for i in range(100) - ] - result = generate_invoice( - { - "client_name": "Bulk Corp", - "total": 1000.0, - "items": items, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_empty_items_list(self) -> None: - """Empty items list should generate a valid PDF.""" - result = generate_invoice( - { - "client_name": "Empty Items", - "total": 50, - "items": [], - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_items_with_missing_fields(self) -> None: - """Items missing optional fields should use defaults.""" - result = generate_invoice( - { - "client_name": "Sparse Corp", - "total": 100, - "items": [ - {"description": "Partial item"}, - {}, - ], - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_custom_currency_symbols(self) -> None: - """Various currency symbols should work.""" - for symbol in ["$", "€", "£", "¥", "₹", "₿"]: - result = generate_invoice( - { - "client_name": "Currency Test", - "total": 100, - "currency": symbol, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_long_notes(self) -> None: - """Very long notes text should not crash.""" - result = generate_invoice( - { - "client_name": "Notes Corp", - "total": 100, - "notes": "This is a note. " * 100, - } - ) - assert os.path.exists(result) - _cleanup(result) - - def test_extra_unknown_fields_ignored(self) -> None: - """Unknown fields in data should be silently ignored.""" - result = generate_invoice( - { - "client_name": "Extra Corp", - "total": 100, - "foo": "bar", - "nested": {"deep": True}, - } - ) - assert os.path.exists(result) - _cleanup(result) diff --git a/tests/test_math_utils.py b/tests/test_math_utils.py new file mode 100644 index 0000000..444aa79 --- /dev/null +++ b/tests/test_math_utils.py @@ -0,0 +1,269 @@ +"""Tests for the pb_safe_math tool — comprehensive security and edge-case coverage.""" + +import math + +from protocolbox.tools.math_utils import safe_math + + +class TestSafeMathBasicArithmetic: + """Basic arithmetic operations.""" + + def test_addition(self) -> None: + assert safe_math("2 + 3") == "5" + + def test_subtraction(self) -> None: + assert safe_math("10 - 4") == "6" + + def test_multiplication(self) -> None: + assert safe_math("6 * 7") == "42" + + def test_division(self) -> None: + assert safe_math("10 / 4") == "2.5" + + def test_power(self) -> None: + assert safe_math("2 ** 10") == "1024" + + def test_modulo(self) -> None: + assert safe_math("17 % 5") == "2" + + def test_integer_result(self) -> None: + assert safe_math("3 + 4") == "7" + + def test_float_result(self) -> None: + assert safe_math("1 / 3") == str(1 / 3) + + def test_negative_numbers(self) -> None: + assert safe_math("-5 + 3") == "-2" + + def test_unary_positive(self) -> None: + assert safe_math("+5") == "5" + + def test_unary_negative(self) -> None: + assert safe_math("-42") == "-42" + + +class TestSafeMathComplexExpressions: + """Complex and compound expressions.""" + + def test_order_of_operations(self) -> None: + assert safe_math("2 + 3 * 4") == "14" + + def test_parenthesized_expression(self) -> None: + assert safe_math("(2 + 3) * 4") == "20" + + def test_nested_parentheses(self) -> None: + assert safe_math("((1 + 2) * (3 + 4))") == "21" + + def test_mixed_operators(self) -> None: + assert safe_math("10 + 5 * 2 - 3 / 1") == "17.0" + + def test_chained_powers(self) -> None: + # 2 ** 3 ** 2 = 2 ** 9 = 512 (right-associative) + assert safe_math("2 ** 3 ** 2") == "512" + + def test_float_literal(self) -> None: + assert safe_math("3.14 * 2") == str(3.14 * 2) + + def test_large_numbers(self) -> None: + result = safe_math("999999999 * 999999999") + assert result == str(999999999 * 999999999) + + def test_very_small_float(self) -> None: + result = safe_math("0.0001 * 0.0001") + assert float(result) == 0.0001 * 0.0001 + + +class TestSafeMathFunctions: + """Math module function tests.""" + + def test_sqrt(self) -> None: + assert safe_math("sqrt(16)") == "4.0" + + def test_sqrt_non_perfect(self) -> None: + assert safe_math("sqrt(2)") == str(math.sqrt(2)) + + def test_floor(self) -> None: + assert safe_math("floor(3.7)") == "3" + + def test_floor_negative(self) -> None: + assert safe_math("floor(-3.2)") == "-4" + + def test_ceil(self) -> None: + assert safe_math("ceil(3.1)") == "4" + + def test_ceil_negative(self) -> None: + assert safe_math("ceil(-3.7)") == "-3" + + def test_abs_positive(self) -> None: + assert safe_math("abs(5)") == "5" + + def test_abs_negative(self) -> None: + assert safe_math("abs(-5)") == "5" + + def test_sin_zero(self) -> None: + assert safe_math("sin(0)") == "0.0" + + def test_cos_zero(self) -> None: + assert safe_math("cos(0)") == "1.0" + + def test_tan_zero(self) -> None: + assert safe_math("tan(0)") == "0.0" + + def test_log_natural(self) -> None: + assert safe_math("log(1)") == "0.0" + + def test_log_e(self) -> None: + result = float(safe_math("log(2.718281828)")) + assert abs(result - 1.0) < 0.0001 + + def test_function_with_expression_arg(self) -> None: + assert safe_math("sqrt(4 + 12)") == "4.0" + + def test_function_in_expression(self) -> None: + assert safe_math("sqrt(16) + 2 ** 3") == "12.0" + + def test_nested_function_calls(self) -> None: + assert safe_math("abs(floor(-3.7))") == "4" + + +class TestSafeMathSecurity: + """Security tests — ensure no code injection is possible.""" + + def test_rejects_import(self) -> None: + result = safe_math("__import__('os')") + assert "Error" in result + + def test_rejects_eval(self) -> None: + result = safe_math("eval('1+1')") + assert "Error" in result + + def test_rejects_exec(self) -> None: + result = safe_math("exec('print(1)')") + assert "Error" in result + + def test_rejects_open(self) -> None: + result = safe_math("open('/etc/passwd')") + assert "Error" in result + + def test_rejects_os_system(self) -> None: + result = safe_math("os.system('ls')") + assert "Error" in result + + def test_rejects_attribute_access(self) -> None: + result = safe_math("(1).__class__") + assert "Error" in result + + def test_rejects_lambda(self) -> None: + result = safe_math("(lambda: 1)()") + assert "Error" in result + + def test_rejects_list_comprehension(self) -> None: + result = safe_math("[x for x in range(10)]") + assert "Error" in result + + def test_rejects_string_literals(self) -> None: + result = safe_math("'hello'") + assert "Error" in result + + def test_rejects_unknown_function(self) -> None: + result = safe_math("print(42)") + assert "Error" in result + assert "Unknown function" in result + + def test_rejects_dunder_methods(self) -> None: + result = safe_math("__builtins__") + assert "Error" in result + + def test_no_eval_used_in_implementation(self) -> None: + """Verify the source code does not use Python's builtin eval().""" + from pathlib import Path + + import protocolbox.tools.math_utils as mod + + source = Path(mod.__file__).read_text(encoding="utf-8") + # Remove all _safe_eval references and docstrings to avoid false positives. + cleaned = source.replace("_safe_eval", "") + # Remove docstrings (triple-quoted strings). + import re + + cleaned = re.sub(r'""".*?"""', "", cleaned, flags=re.DOTALL) + cleaned = re.sub(r"'''.*?'''", "", cleaned, flags=re.DOTALL) + assert "eval(" not in cleaned + + +class TestSafeMathErrors: + """Error conditions and invalid inputs.""" + + def test_division_by_zero(self) -> None: + result = safe_math("1 / 0") + assert "Error" in result + + def test_modulo_by_zero(self) -> None: + result = safe_math("10 % 0") + assert "Error" in result + + def test_sqrt_negative(self) -> None: + result = safe_math("sqrt(-1)") + assert "Error" in result + + def test_log_zero(self) -> None: + result = safe_math("log(0)") + assert "Error" in result + + def test_log_negative(self) -> None: + result = safe_math("log(-1)") + assert "Error" in result + + def test_empty_string(self) -> None: + result = safe_math("") + assert "Error" in result + + def test_whitespace_only(self) -> None: + result = safe_math(" ") + assert "Error" in result + + def test_invalid_syntax(self) -> None: + result = safe_math("2 +* 3") + assert "Error" in result + + def test_unmatched_parentheses(self) -> None: + result = safe_math("(2 + 3") + assert "Error" in result + + def test_garbage_input(self) -> None: + result = safe_math("not a math expression") + assert "Error" in result + + def test_return_type_always_string(self) -> None: + """Return type should always be str, even on errors.""" + inputs = ["2 + 3", "1/0", "garbage", "", "sqrt(4)"] + for inp in inputs: + assert isinstance(safe_math(inp), str), f"Failed for: {inp!r}" + + +class TestSafeMathEdgeCases: + """Edge cases and unusual inputs.""" + + def test_whitespace_around_expression(self) -> None: + assert safe_math(" 2 + 3 ") == "5" + + def test_zero(self) -> None: + assert safe_math("0") == "0" + + def test_negative_zero(self) -> None: + result = float(safe_math("-0")) + assert result == 0.0 + + def test_very_large_power(self) -> None: + """Large exponentials should still evaluate (Python handles big ints).""" + result = safe_math("2 ** 100") + assert result == str(2**100) + + def test_float_precision(self) -> None: + """Standard float arithmetic precision.""" + result = safe_math("0.1 + 0.2") + assert float(result) == 0.1 + 0.2 + + def test_integer_division_gives_float(self) -> None: + """Python true division should return float.""" + assert safe_math("7 / 2") == "3.5" diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..23ed5bd --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,236 @@ +"""Tests for the pb_memory tool — comprehensive persistence and edge-case coverage.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from protocolbox.tools.memory import _load_memory, _save_memory, recall, remember + + +@pytest.fixture() +def tmp_memory(tmp_path: Path): + """Redirect memory storage to a temp directory for test isolation.""" + memory_dir = tmp_path / ".protocolbox" + memory_file = memory_dir / "memory.json" + with ( + patch("protocolbox.tools.memory._MEMORY_DIR", memory_dir), + patch("protocolbox.tools.memory._MEMORY_FILE", memory_file), + ): + yield memory_file + + +class TestRememberBasic: + """Basic happy-path tests for remember().""" + + def test_remember_returns_confirmation(self, tmp_memory: Path) -> None: + result = remember("name", "Alice") + assert "Remembered" in result + assert "name" in result + + def test_remember_creates_file(self, tmp_memory: Path) -> None: + remember("key", "value") + assert tmp_memory.exists() + + def test_remember_stores_correct_value(self, tmp_memory: Path) -> None: + remember("color", "blue") + data = json.loads(tmp_memory.read_text()) + assert data["color"] == "blue" + + def test_remember_multiple_keys(self, tmp_memory: Path) -> None: + remember("a", "1") + remember("b", "2") + remember("c", "3") + data = json.loads(tmp_memory.read_text()) + assert data == {"a": "1", "b": "2", "c": "3"} + + def test_remember_overwrites_existing_key(self, tmp_memory: Path) -> None: + remember("key", "old") + remember("key", "new") + data = json.loads(tmp_memory.read_text()) + assert data["key"] == "new" + + def test_remember_return_type_is_string(self, tmp_memory: Path) -> None: + assert isinstance(remember("k", "v"), str) + + +class TestRecallBasic: + """Basic happy-path tests for recall().""" + + def test_recall_existing_key(self, tmp_memory: Path) -> None: + remember("name", "Bob") + result = recall("name") + assert result == "Bob" + + def test_recall_returns_correct_value(self, tmp_memory: Path) -> None: + remember("score", "100") + assert recall("score") == "100" + + def test_recall_missing_key(self, tmp_memory: Path) -> None: + result = recall("nonexistent") + assert "not found" in result.lower() or "Memory not found" in result + + def test_recall_missing_key_includes_key_name(self, tmp_memory: Path) -> None: + result = recall("my_special_key") + assert "my_special_key" in result + + def test_recall_return_type_is_string(self, tmp_memory: Path) -> None: + assert isinstance(recall("anything"), str) + + +class TestMemoryPersistence: + """Test that memory persists correctly between operations.""" + + def test_round_trip(self, tmp_memory: Path) -> None: + remember("round_trip", "data") + assert recall("round_trip") == "data" + + def test_multiple_round_trips(self, tmp_memory: Path) -> None: + for i in range(10): + remember(f"key_{i}", f"value_{i}") + for i in range(10): + assert recall(f"key_{i}") == f"value_{i}" + + def test_overwrite_and_recall(self, tmp_memory: Path) -> None: + remember("version", "1") + remember("version", "2") + assert recall("version") == "2" + + def test_recall_after_multiple_writes(self, tmp_memory: Path) -> None: + """Writing different keys should not affect earlier keys.""" + remember("first", "A") + remember("second", "B") + remember("third", "C") + assert recall("first") == "A" + + def test_json_file_is_valid_json(self, tmp_memory: Path) -> None: + remember("test", "data") + data = json.loads(tmp_memory.read_text(encoding="utf-8")) + assert isinstance(data, dict) + + def test_json_file_is_pretty_printed(self, tmp_memory: Path) -> None: + """The JSON file should be human-readable (indented).""" + remember("test", "data") + content = tmp_memory.read_text(encoding="utf-8") + assert "\n" in content # Indented JSON has newlines + + +class TestMemoryEdgeCases: + """Edge cases and unusual inputs.""" + + def test_empty_key(self, tmp_memory: Path) -> None: + """An empty string key should still work.""" + remember("", "empty_key_value") + assert recall("") == "empty_key_value" + + def test_empty_value(self, tmp_memory: Path) -> None: + """An empty string value should be stored.""" + remember("empty_val", "") + assert recall("empty_val") == "" + + def test_unicode_key(self, tmp_memory: Path) -> None: + remember("名前", "太郎") + assert recall("名前") == "太郎" + + def test_unicode_value(self, tmp_memory: Path) -> None: + remember("emoji", "🚀🎉") + assert recall("emoji") == "🚀🎉" + + def test_very_long_key(self, tmp_memory: Path) -> None: + long_key = "k" * 1000 + remember(long_key, "long") + assert recall(long_key) == "long" + + def test_very_long_value(self, tmp_memory: Path) -> None: + long_value = "v" * 10000 + remember("long_val", long_value) + assert recall("long_val") == long_value + + def test_special_chars_in_key(self, tmp_memory: Path) -> None: + remember("key/with.special!chars@#$", "special") + assert recall("key/with.special!chars@#$") == "special" + + def test_special_chars_in_value(self, tmp_memory: Path) -> None: + remember("special_val", "quotes \"and\" 'single' & ") + assert recall("special_val") == "quotes \"and\" 'single' & " + + def test_multiline_value(self, tmp_memory: Path) -> None: + """Multiline strings should be preserved.""" + value = "line1\nline2\nline3" + remember("multi", value) + assert recall("multi") == value + + def test_json_like_value(self, tmp_memory: Path) -> None: + """A JSON string as a value should be stored as-is.""" + value = '{"nested": "json"}' + remember("json_val", value) + assert recall("json_val") == value + + def test_whitespace_key(self, tmp_memory: Path) -> None: + """Keys with whitespace should work.""" + remember(" spaced key ", "value") + assert recall(" spaced key ") == "value" + + +class TestMemoryCorruptedFile: + """Tests for corrupted or missing memory file.""" + + def test_recall_when_no_file_exists(self, tmp_memory: Path) -> None: + """Recall should return not-found when no file exists.""" + result = recall("anything") + assert "not found" in result.lower() or "Memory not found" in result + + def test_remember_creates_directory(self, tmp_memory: Path) -> None: + """The .protocolbox directory should be created on first write.""" + remember("first", "write") + assert tmp_memory.parent.is_dir() + + def test_corrupted_json_file_recover(self, tmp_memory: Path) -> None: + """A corrupted memory file should not crash recall.""" + tmp_memory.parent.mkdir(parents=True, exist_ok=True) + tmp_memory.write_text("not valid json {{{", encoding="utf-8") + result = recall("key") + assert "not found" in result.lower() or "Memory not found" in result + + def test_corrupted_json_file_remember_recovers(self, tmp_memory: Path) -> None: + """Remember should overwrite a corrupted memory file.""" + tmp_memory.parent.mkdir(parents=True, exist_ok=True) + tmp_memory.write_text("broken!!!", encoding="utf-8") + result = remember("key", "value") + assert "Remembered" in result + assert recall("key") == "value" + + def test_empty_json_file(self, tmp_memory: Path) -> None: + """An empty file should not crash.""" + tmp_memory.parent.mkdir(parents=True, exist_ok=True) + tmp_memory.write_text("", encoding="utf-8") + result = recall("key") + assert "not found" in result.lower() or "Memory not found" in result + + +class TestLoadSaveMemoryHelpers: + """Direct tests for _load_memory and _save_memory.""" + + def test_load_empty(self, tmp_memory: Path) -> None: + assert _load_memory() == {} + + def test_save_then_load(self, tmp_memory: Path) -> None: + _save_memory({"a": "1", "b": "2"}) + assert _load_memory() == {"a": "1", "b": "2"} + + def test_save_creates_dir(self, tmp_memory: Path) -> None: + _save_memory({"test": "data"}) + assert tmp_memory.parent.is_dir() + + def test_load_corrupted_returns_empty(self, tmp_memory: Path) -> None: + tmp_memory.parent.mkdir(parents=True, exist_ok=True) + tmp_memory.write_text("corrupted", encoding="utf-8") + assert _load_memory() == {} + + def test_save_overwrites_completely(self, tmp_memory: Path) -> None: + _save_memory({"first": "1"}) + _save_memory({"second": "2"}) + result = _load_memory() + assert "first" not in result + assert result == {"second": "2"} diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..a6c3b32 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,232 @@ +"""Tests for the pb_web_search tool — comprehensive edge-case coverage.""" + +from unittest.mock import MagicMock, patch + +from protocolbox.tools.search import web_search + + +def _mock_ddgs_results(results: list[dict]) -> MagicMock: + """Create a mock DDGS context manager that returns given results.""" + mock_ddgs = MagicMock() + mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs) + mock_ddgs.__exit__ = MagicMock(return_value=False) + mock_ddgs.text = MagicMock(return_value=results) + return mock_ddgs + + +class TestWebSearchBasic: + """Basic happy-path search tests.""" + + @patch("protocolbox.tools.search.DDGS") + def test_returns_markdown_with_results(self, mock_ddgs_cls: MagicMock) -> None: + """A successful search should return formatted Markdown.""" + mock_ddgs_cls.return_value = _mock_ddgs_results( + [ + { + "title": "Example Page", + "href": "https://example.com", + "body": "An example result.", + } + ] + ) + result = web_search("test query") + assert "## Search Results for: test query" in result + assert "Example Page" in result + assert "https://example.com" in result + assert "An example result." in result + + @patch("protocolbox.tools.search.DDGS") + def test_returns_multiple_results(self, mock_ddgs_cls: MagicMock) -> None: + """Multiple results should be numbered correctly.""" + mock_ddgs_cls.return_value = _mock_ddgs_results( + [ + {"title": "First", "href": "https://a.com", "body": "Desc A"}, + {"title": "Second", "href": "https://b.com", "body": "Desc B"}, + {"title": "Third", "href": "https://c.com", "body": "Desc C"}, + ] + ) + result = web_search("multi query") + assert "### 1. First" in result + assert "### 2. Second" in result + assert "### 3. Third" in result + + @patch("protocolbox.tools.search.DDGS") + def test_respects_max_results(self, mock_ddgs_cls: MagicMock) -> None: + """The max_results parameter should be passed to DDGS.""" + mock_ddgs_cls.return_value = _mock_ddgs_results([]) + web_search("query", max_results=5) + ddgs_instance = mock_ddgs_cls.return_value.__enter__.return_value + ddgs_instance.text.assert_called_once_with("query", max_results=5) + + @patch("protocolbox.tools.search.DDGS") + def test_default_max_results_is_three(self, mock_ddgs_cls: MagicMock) -> None: + """Default max_results should be 3.""" + mock_ddgs_cls.return_value = _mock_ddgs_results([]) + web_search("query") + ddgs_instance = mock_ddgs_cls.return_value.__enter__.return_value + ddgs_instance.text.assert_called_once_with("query", max_results=3) + + @patch("protocolbox.tools.search.DDGS") + def test_return_type_is_string(self, mock_ddgs_cls: MagicMock) -> None: + """Return type should always be str.""" + mock_ddgs_cls.return_value = _mock_ddgs_results( + [{"title": "T", "href": "h", "body": "b"}] + ) + assert isinstance(web_search("test"), str) + + +class TestWebSearchNoResults: + """Tests for when no results are found.""" + + @patch("protocolbox.tools.search.DDGS") + def test_no_results_message(self, mock_ddgs_cls: MagicMock) -> None: + """An empty result list should return a no-results message.""" + mock_ddgs_cls.return_value = _mock_ddgs_results([]) + result = web_search("obscure query xyz") + assert "No results found for: obscure query xyz" in result + + @patch("protocolbox.tools.search.DDGS") + def test_no_results_is_still_string(self, mock_ddgs_cls: MagicMock) -> None: + """No-results output should still be a string.""" + mock_ddgs_cls.return_value = _mock_ddgs_results([]) + assert isinstance(web_search("nothing"), str) + + +class TestWebSearchMissingFields: + """Tests for results with missing fields.""" + + @patch("protocolbox.tools.search.DDGS") + def test_missing_title_uses_fallback(self, mock_ddgs_cls: MagicMock) -> None: + """A result without 'title' should use fallback text.""" + mock_ddgs_cls.return_value = _mock_ddgs_results( + [{"href": "https://x.com", "body": "Some snippet"}] + ) + result = web_search("query") + assert "No title" in result + + @patch("protocolbox.tools.search.DDGS") + def test_missing_body_uses_fallback(self, mock_ddgs_cls: MagicMock) -> None: + """A result without 'body' should use fallback text.""" + mock_ddgs_cls.return_value = _mock_ddgs_results( + [{"title": "Title", "href": "https://x.com"}] + ) + result = web_search("query") + assert "No description available." in result + + @patch("protocolbox.tools.search.DDGS") + def test_missing_href_uses_empty(self, mock_ddgs_cls: MagicMock) -> None: + """A result without 'href' should not crash.""" + mock_ddgs_cls.return_value = _mock_ddgs_results( + [{"title": "Title", "body": "Description"}] + ) + result = web_search("query") + assert "Title" in result + assert "**Link:**" in result + + @patch("protocolbox.tools.search.DDGS") + def test_completely_empty_result_dict(self, mock_ddgs_cls: MagicMock) -> None: + """An empty result dict should use all fallbacks.""" + mock_ddgs_cls.return_value = _mock_ddgs_results([{}]) + result = web_search("query") + assert "No title" in result + assert "No description available." in result + + +class TestWebSearchErrors: + """Error handling tests.""" + + @patch("protocolbox.tools.search.DDGS") + def test_connection_error_returns_error_message( + self, mock_ddgs_cls: MagicMock + ) -> None: + """Network errors should return a descriptive error string.""" + mock_ddgs = MagicMock() + mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs) + mock_ddgs.__exit__ = MagicMock(return_value=False) + mock_ddgs.text.side_effect = ConnectionError("No internet") + mock_ddgs_cls.return_value = mock_ddgs + result = web_search("test") + assert "Error" in result + assert "ConnectionError" in result + + @patch("protocolbox.tools.search.DDGS") + def test_timeout_error_returns_error_message( + self, mock_ddgs_cls: MagicMock + ) -> None: + """Timeout should return a descriptive error string.""" + mock_ddgs = MagicMock() + mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs) + mock_ddgs.__exit__ = MagicMock(return_value=False) + mock_ddgs.text.side_effect = TimeoutError("Timed out") + mock_ddgs_cls.return_value = mock_ddgs + result = web_search("test") + assert "Error" in result + + @patch("protocolbox.tools.search.DDGS") + def test_unexpected_exception_handled(self, mock_ddgs_cls: MagicMock) -> None: + """Unexpected exception types should be caught gracefully.""" + mock_ddgs = MagicMock() + mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs) + mock_ddgs.__exit__ = MagicMock(return_value=False) + mock_ddgs.text.side_effect = RuntimeError("Unexpected") + mock_ddgs_cls.return_value = mock_ddgs + result = web_search("test") + assert "Error" in result + assert "RuntimeError" in result + + @patch("protocolbox.tools.search.DDGS") + def test_error_message_never_contains_html(self, mock_ddgs_cls: MagicMock) -> None: + """Error messages should be plain text.""" + mock_ddgs = MagicMock() + mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs) + mock_ddgs.__exit__ = MagicMock(return_value=False) + mock_ddgs.text.side_effect = Exception("err") + mock_ddgs_cls.return_value = mock_ddgs + result = web_search("test") + assert "<" not in result + + +class TestWebSearchEdgeCases: + """Edge case inputs.""" + + @patch("protocolbox.tools.search.DDGS") + def test_unicode_query(self, mock_ddgs_cls: MagicMock) -> None: + """Unicode characters in query should be handled.""" + mock_ddgs_cls.return_value = _mock_ddgs_results( + [{"title": "結果", "href": "https://x.com", "body": "日本語の結果"}] + ) + result = web_search("日本語テスト") + assert "日本語テスト" in result + assert "結果" in result + + @patch("protocolbox.tools.search.DDGS") + def test_emoji_in_query(self, mock_ddgs_cls: MagicMock) -> None: + """Emoji in query should not crash.""" + mock_ddgs_cls.return_value = _mock_ddgs_results([]) + result = web_search("🚀 rocket") + assert isinstance(result, str) + + @patch("protocolbox.tools.search.DDGS") + def test_very_long_query(self, mock_ddgs_cls: MagicMock) -> None: + """A very long query should not crash.""" + mock_ddgs_cls.return_value = _mock_ddgs_results([]) + long_query = "a " * 500 + result = web_search(long_query) + assert isinstance(result, str) + + @patch("protocolbox.tools.search.DDGS") + def test_special_characters_in_query(self, mock_ddgs_cls: MagicMock) -> None: + """Special characters (quotes, ampersands) should not crash.""" + mock_ddgs_cls.return_value = _mock_ddgs_results([]) + result = web_search('python "best practices" & tips <2026>') + assert isinstance(result, str) + + @patch("protocolbox.tools.search.DDGS") + def test_max_results_one(self, mock_ddgs_cls: MagicMock) -> None: + """max_results=1 should work correctly.""" + mock_ddgs_cls.return_value = _mock_ddgs_results( + [{"title": "Only", "href": "https://x.com", "body": "One result"}] + ) + result = web_search("query", max_results=1) + assert "### 1. Only" in result + assert "### 2." not in result diff --git a/tests/test_server.py b/tests/test_server.py index e14056b..74039c1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -27,12 +27,27 @@ def test_scrape_registered(self) -> None: def test_heal_json_registered(self) -> None: assert "heal_json" in self._tool_names() - def test_generate_invoice_registered(self) -> None: - assert "generate_invoice" in self._tool_names() + def test_web_search_registered(self) -> None: + assert "web_search" in self._tool_names() - def test_exactly_three_tools(self) -> None: - """There should be exactly 3 registered tools.""" - assert len(self._tool_names()) == 3 + def test_safe_math_registered(self) -> None: + assert "safe_math" in self._tool_names() + + def test_get_time_registered(self) -> None: + assert "get_time" in self._tool_names() + + def test_get_transcript_registered(self) -> None: + assert "get_transcript" in self._tool_names() + + def test_remember_registered(self) -> None: + assert "remember" in self._tool_names() + + def test_recall_registered(self) -> None: + assert "recall" in self._tool_names() + + def test_exactly_eight_tools(self) -> None: + """There should be exactly 8 registered tools.""" + assert len(self._tool_names()) == 8 def test_no_duplicate_tools(self) -> None: """No tool name should appear more than once.""" @@ -56,10 +71,35 @@ def test_heal_json_has_description(self) -> None: assert tools["heal_json"].description assert len(tools["heal_json"].description) > 10 - def test_generate_invoice_has_description(self) -> None: + def test_web_search_has_description(self) -> None: + tools = self._tools_by_name() + assert tools["web_search"].description + assert len(tools["web_search"].description) > 10 + + def test_safe_math_has_description(self) -> None: + tools = self._tools_by_name() + assert tools["safe_math"].description + assert len(tools["safe_math"].description) > 10 + + def test_get_time_has_description(self) -> None: + tools = self._tools_by_name() + assert tools["get_time"].description + assert len(tools["get_time"].description) > 10 + + def test_get_transcript_has_description(self) -> None: + tools = self._tools_by_name() + assert tools["get_transcript"].description + assert len(tools["get_transcript"].description) > 10 + + def test_remember_has_description(self) -> None: + tools = self._tools_by_name() + assert tools["remember"].description + assert len(tools["remember"].description) > 10 + + def test_recall_has_description(self) -> None: tools = self._tools_by_name() - assert tools["generate_invoice"].description - assert len(tools["generate_invoice"].description) > 10 + assert tools["recall"].description + assert len(tools["recall"].description) > 10 class TestPackageImports: @@ -68,18 +108,28 @@ class TestPackageImports: def test_version_available(self) -> None: from protocolbox import __version__ - assert __version__ == "0.1.0" + assert __version__ == "0.1.4" def test_tools_importable_from_package(self) -> None: from protocolbox.tools import ( - generate_invoice, + get_time, + get_transcript, heal_json, + recall, + remember, + safe_math, scrape, + web_search, ) assert callable(scrape) assert callable(heal_json) - assert callable(generate_invoice) + assert callable(web_search) + assert callable(safe_math) + assert callable(get_time) + assert callable(get_transcript) + assert callable(remember) + assert callable(recall) def test_server_importable(self) -> None: from protocolbox.server import main diff --git a/tests/test_time_utils.py b/tests/test_time_utils.py new file mode 100644 index 0000000..d567ae9 --- /dev/null +++ b/tests/test_time_utils.py @@ -0,0 +1,167 @@ +"""Tests for the pb_get_time tool — comprehensive timezone edge-case coverage.""" + +import re +from unittest.mock import patch + +from protocolbox.tools.time_utils import get_time + + +class TestGetTimeBasic: + """Basic happy-path time tests.""" + + def test_default_timezone_is_utc(self) -> None: + """Default call should return UTC time.""" + result = get_time() + assert "UTC" in result + + def test_utc_format_matches_iso8601(self) -> None: + """Output should match YYYY-MM-DD HH:MM:SS TZ pattern.""" + result = get_time("UTC") + pattern = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \w+" + assert re.match(pattern, result), f"Format mismatch: {result}" + + def test_return_type_is_string(self) -> None: + assert isinstance(get_time(), str) + + def test_return_is_not_empty(self) -> None: + assert len(get_time()) > 10 + + +class TestGetTimeValidTimezones: + """Test various valid timezone strings.""" + + def test_us_eastern(self) -> None: + result = get_time("US/Eastern") + assert "E" in result # EST or EDT + + def test_us_pacific(self) -> None: + result = get_time("US/Pacific") + assert "P" in result # PST or PDT + + def test_us_central(self) -> None: + result = get_time("US/Central") + assert "C" in result # CST or CDT + + def test_europe_london(self) -> None: + result = get_time("Europe/London") + assert isinstance(result, str) + assert len(result) > 10 + + def test_europe_berlin(self) -> None: + result = get_time("Europe/Berlin") + assert isinstance(result, str) + + def test_asia_kolkata(self) -> None: + result = get_time("Asia/Kolkata") + assert "IST" in result + + def test_asia_tokyo(self) -> None: + result = get_time("Asia/Tokyo") + assert "JST" in result + + def test_australia_sydney(self) -> None: + result = get_time("Australia/Sydney") + assert isinstance(result, str) + + def test_utc_explicit(self) -> None: + result = get_time("UTC") + assert "UTC" in result + + +class TestGetTimeInvalidTimezones: + """Invalid timezone handling.""" + + def test_invalid_timezone_returns_error(self) -> None: + result = get_time("Invalid/Timezone") + assert "Error" in result + assert "Unknown timezone" in result + + def test_invalid_timezone_includes_input(self) -> None: + result = get_time("Mars/Olympus") + assert "Mars/Olympus" in result + + def test_invalid_timezone_lists_common_timezones(self) -> None: + """Error should include common timezone suggestions.""" + result = get_time("Nowhere/Place") + assert "UTC" in result + assert "US/Eastern" in result + assert "Asia/Kolkata" in result + + def test_empty_string_timezone(self) -> None: + """Empty timezone string should return an error.""" + result = get_time("") + assert "Error" in result + + def test_numeric_timezone(self) -> None: + """A purely numeric string should return an error.""" + result = get_time("12345") + assert "Error" in result + + def test_special_characters_timezone(self) -> None: + """Special characters should return an error.""" + result = get_time("!@#$%") + assert "Error" in result + + def test_partial_timezone(self) -> None: + """A partial timezone like 'US' alone should return an error.""" + result = get_time("US") + assert "Error" in result + + +class TestGetTimeEdgeCases: + """Edge cases and unusual inputs.""" + + def test_case_sensitivity(self) -> None: + """A misspelled timezone should fail.""" + result = get_time("Americaa/New_Yorrk") + assert "Error" in result + + def test_timezone_with_spaces(self) -> None: + """Timezone with spaces should fail.""" + result = get_time("US / Eastern") + assert "Error" in result + + def test_gmt_timezone(self) -> None: + """GMT should be a valid timezone.""" + result = get_time("GMT") + assert "Error" not in result + assert "GMT" in result + + @patch("protocolbox.tools.time_utils.datetime") + def test_midnight_time(self, mock_datetime) -> None: + """Midnight should format correctly.""" + from datetime import datetime + + import pytz + + utc = pytz.UTC + fixed_time = datetime(2026, 1, 1, 0, 0, 0, tzinfo=utc) + mock_datetime.now.return_value = fixed_time + result = get_time("UTC") + assert "2026-01-01 00:00:00" in result + + @patch("protocolbox.tools.time_utils.datetime") + def test_end_of_day_time(self, mock_datetime) -> None: + """23:59:59 should format correctly.""" + from datetime import datetime + + import pytz + + utc = pytz.UTC + fixed_time = datetime(2026, 12, 31, 23, 59, 59, tzinfo=utc) + mock_datetime.now.return_value = fixed_time + result = get_time("UTC") + assert "2026-12-31 23:59:59" in result + + def test_all_common_timezones_are_valid(self) -> None: + """Every timezone listed in the error message should be valid.""" + from protocolbox.tools.time_utils import _COMMON_TIMEZONES + + for tz in _COMMON_TIMEZONES: + result = get_time(tz) + assert "Error" not in result, f"Common timezone '{tz}' failed" + + def test_error_return_type_is_string(self) -> None: + """Even on error, return type should be str.""" + result = get_time("Fake/Zone") + assert isinstance(result, str) diff --git a/tests/test_youtube.py b/tests/test_youtube.py new file mode 100644 index 0000000..77ca31e --- /dev/null +++ b/tests/test_youtube.py @@ -0,0 +1,281 @@ +"""Tests for the pb_get_transcript tool — comprehensive edge-case coverage.""" + +from unittest.mock import MagicMock, patch + +from protocolbox.tools.youtube import _extract_video_id, get_transcript + + +class TestExtractVideoIdStandard: + """Test video ID extraction from standard YouTube URLs.""" + + def test_standard_url(self) -> None: + url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + def test_standard_url_without_www(self) -> None: + url = "https://youtube.com/watch?v=dQw4w9WgXcQ" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + def test_http_url(self) -> None: + url = "http://www.youtube.com/watch?v=dQw4w9WgXcQ" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + def test_url_with_extra_params(self) -> None: + url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120&list=PLtest" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + def test_url_with_feature_param(self) -> None: + url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=shared" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + +class TestExtractVideoIdShort: + """Test video ID extraction from short youtu.be URLs.""" + + def test_short_url(self) -> None: + url = "https://youtu.be/dQw4w9WgXcQ" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + def test_short_url_with_timestamp(self) -> None: + url = "https://youtu.be/dQw4w9WgXcQ?t=60" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + def test_short_url_http(self) -> None: + url = "http://youtu.be/dQw4w9WgXcQ" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + +class TestExtractVideoIdEmbed: + """Test video ID extraction from embed URLs.""" + + def test_embed_url(self) -> None: + url = "https://www.youtube.com/embed/dQw4w9WgXcQ" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + def test_embed_url_with_params(self) -> None: + url = "https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1" + assert _extract_video_id(url) == "dQw4w9WgXcQ" + + +class TestExtractVideoIdInvalid: + """Test invalid URLs that should fail extraction.""" + + def test_empty_string(self) -> None: + assert _extract_video_id("") is None + + def test_random_url(self) -> None: + assert _extract_video_id("https://example.com") is None + + def test_youtube_homepage(self) -> None: + assert _extract_video_id("https://www.youtube.com") is None + + def test_youtube_channel(self) -> None: + assert _extract_video_id("https://www.youtube.com/@username") is None + + def test_youtube_playlist(self) -> None: + url = "https://www.youtube.com/playlist?list=PLtest123" + assert _extract_video_id(url) is None + + def test_malformed_url(self) -> None: + assert _extract_video_id("not a url at all") is None + + def test_partial_video_id(self) -> None: + """Video ID must be exactly 11 characters.""" + url = "https://www.youtube.com/watch?v=short" + assert _extract_video_id(url) is None + + def test_none_like_input(self) -> None: + """A URL with no recognizable pattern.""" + assert _extract_video_id("youtube.com") is None + + +class TestExtractVideoIdEdgeCases: + """Edge cases for video ID extraction.""" + + def test_id_with_hyphens(self) -> None: + url = "https://youtu.be/abc-def_123" + assert _extract_video_id(url) == "abc-def_123" + + def test_id_with_underscores(self) -> None: + url = "https://youtu.be/___________" + assert _extract_video_id(url) == "___________" + + def test_id_all_numbers(self) -> None: + url = "https://youtu.be/12345678901" + assert _extract_video_id(url) == "12345678901" + + +def _make_mock_transcript(segments: list[dict]) -> MagicMock: + """Create a mock FetchedTranscript that is iterable over snippet objects.""" + snippets = [] + for seg in segments: + snippet = MagicMock() + snippet.text = seg["text"] + snippets.append(snippet) + mock_transcript = MagicMock() + mock_transcript.__iter__ = MagicMock(return_value=iter(snippets)) + return mock_transcript + + +class TestGetTranscriptBasic: + """Basic happy-path transcript tests.""" + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_returns_joined_text(self, mock_api_cls: MagicMock) -> None: + """Transcript segments should be joined into a single string.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript( + [ + {"text": "Hello world."}, + {"text": "This is a test."}, + {"text": "Thank you."}, + ] + ) + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert "Hello world." in result + assert "This is a test." in result + assert "Thank you." in result + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_segments_separated_by_spaces(self, mock_api_cls: MagicMock) -> None: + """Segments should be joined with spaces.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript( + [ + {"text": "Word1"}, + {"text": "Word2"}, + ] + ) + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert "Word1 Word2" in result + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_return_type_is_string(self, mock_api_cls: MagicMock) -> None: + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript([{"text": "Test"}]) + assert isinstance(get_transcript("https://youtu.be/dQw4w9WgXcQ"), str) + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_requests_english_language(self, mock_api_cls: MagicMock) -> None: + """The API should be called with languages=['en'].""" + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript([{"text": "T"}]) + get_transcript("https://youtu.be/dQw4w9WgXcQ") + mock_api.fetch.assert_called_once_with("dQw4w9WgXcQ", languages=["en"]) + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_strips_leading_trailing_whitespace(self, mock_api_cls: MagicMock) -> None: + """Output should be stripped.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript([{"text": " Hello "}]) + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert result == "Hello" + + +class TestGetTranscriptErrors: + """Error handling tests.""" + + def test_invalid_url_returns_error(self) -> None: + result = get_transcript("https://example.com/not-youtube") + assert "Error" in result + assert "Could not extract video ID" in result + + def test_empty_url_returns_error(self) -> None: + result = get_transcript("") + assert "Error" in result + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_api_exception_returns_error(self, mock_api_cls: MagicMock) -> None: + """API exceptions should be caught and returned as error strings.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.side_effect = Exception("Transcript not available") + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert "Error" in result + assert "dQw4w9WgXcQ" in result + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_no_transcript_available(self, mock_api_cls: MagicMock) -> None: + """When no transcript exists, should return error.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.side_effect = Exception("No transcripts available") + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert "Error" in result + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_network_error_returns_error(self, mock_api_cls: MagicMock) -> None: + """Network errors should be caught.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.side_effect = ConnectionError("No internet") + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert "Error" in result + + def test_error_includes_url_on_bad_input(self) -> None: + """Error message should include the invalid URL.""" + bad_url = "https://notyoutube.com/video" + result = get_transcript(bad_url) + assert bad_url in result + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_error_return_type_is_string(self, mock_api_cls: MagicMock) -> None: + mock_api = mock_api_cls.return_value + mock_api.fetch.side_effect = Exception("fail") + assert isinstance(get_transcript("https://youtu.be/dQw4w9WgXcQ"), str) + + +class TestGetTranscriptEdgeCases: + """Edge cases and unusual inputs.""" + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_empty_transcript(self, mock_api_cls: MagicMock) -> None: + """An empty transcript list should return an empty string.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript([]) + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert result == "" + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_single_segment(self, mock_api_cls: MagicMock) -> None: + """A single segment should work correctly.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript([{"text": "Only segment"}]) + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert result == "Only segment" + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_unicode_transcript(self, mock_api_cls: MagicMock) -> None: + """Unicode characters should be preserved.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript( + [ + {"text": "日本語テスト"}, + {"text": "🚀 emoji"}, + ] + ) + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert "日本語テスト" in result + assert "🚀" in result + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_very_long_transcript(self, mock_api_cls: MagicMock) -> None: + """A very long transcript should not crash.""" + mock_api = mock_api_cls.return_value + segments = [{"text": f"Segment {i}"} for i in range(1000)] + mock_api.fetch.return_value = _make_mock_transcript(segments) + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert "Segment 0" in result + assert "Segment 999" in result + assert isinstance(result, str) + + @patch("protocolbox.tools.youtube.YouTubeTranscriptApi") + def test_segments_with_special_characters(self, mock_api_cls: MagicMock) -> None: + """Special characters (HTML entities, etc.) should pass through.""" + mock_api = mock_api_cls.return_value + mock_api.fetch.return_value = _make_mock_transcript( + [ + {"text": "Tom & Jerry"}, + {"text": "Price: $5 < $10"}, + ] + ) + result = get_transcript("https://youtu.be/dQw4w9WgXcQ") + assert "Tom & Jerry" in result + assert "$5" in result diff --git a/uv.lock b/uv.lock index 243b9fc..bd8a1f3 100644 --- a/uv.lock +++ b/uv.lock @@ -274,6 +274,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "duckduckgo-search" +version = "8.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "lxml" }, + { name = "primp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/ef/07791a05751e6cc9de1dd49fb12730259ee109b18e6d097e25e6c32d5617/duckduckgo_search-8.1.1.tar.gz", hash = "sha256:9da91c9eb26a17e016ea1da26235d40404b46b0565ea86d75a9f78cc9441f935", size = 22868, upload-time = "2025-07-06T15:30:59.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/72/c027b3b488b1010cf71670032fcf7e681d44b81829d484bb04e31a949a8d/duckduckgo_search-8.1.1-py3-none-any.whl", hash = "sha256:f48adbb06626ee05918f7e0cef3a45639e9939805c4fc179e68c48a12f1b5062", size = 18932, upload-time = "2025-07-06T15:30:58.339Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -383,6 +406,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -439,112 +564,65 @@ wheels = [ ] [[package]] -name = "pillow" -version = "12.1.1" +name = "pluggy" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, + { 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 = "pluggy" -version = "1.6.0" +name = "primp" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/60/ea0822d275847ed266d694662cef1863c37d3c1752f4286c4baae5297d3f/primp-1.0.0.tar.gz", hash = "sha256:09fc1ff6009220247d723792794e514782e1ab7e9ba5e2547272a07afed5ca86", size = 973426, upload-time = "2026-02-13T15:32:49.846Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/443244fb49e2f421dafadd689361777d48b07f0ea7d18b34e72a38a3ef44/primp-1.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6af2343ac655d409ec70c3eeb7c2283de509b663aeb6b3e34e39e1331c82daf6", size = 3893122, upload-time = "2026-02-13T15:33:07.596Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/aa765143ce632bcf5e3cfa8bd41e2032f8d12695754564b5059821b2b41a/primp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:25f21400ff236b0e1db5d4db7db66965f63b64898103384e916ecef575ab3395", size = 3655128, upload-time = "2026-02-13T15:32:41.147Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d7/5e9e320441a7c0ffef24ce55fd2922aacd003e6713633d1d0732fe964ff6/primp-1.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd09660db079903031be91e04af2dcf42457bd739e6f328c7b2364e38061876", size = 3792951, upload-time = "2026-02-13T15:32:56.186Z" }, + { url = "https://files.pythonhosted.org/packages/36/f2/1130fad846f08bbf104a64232ef4f58ae5b5c4b2c64d6a73b1f4245607e0/primp-1.0.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6e756480c9dd585b20927c2a0c1d0c42cbcb5866ed1e741a8f93163e6f905e6c", size = 3440111, upload-time = "2026-02-13T15:32:57.523Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e5/a3e0ba7f4a0409ba615098bda35a1276ebf992d2bd7a8f635c8349e77276/primp-1.0.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b75a10ead2872dee9be9c60c07e8fce5328c88ed251e3fdbd29a7d2d73ab512a", size = 3651920, upload-time = "2026-02-13T15:32:48.511Z" }, + { url = "https://files.pythonhosted.org/packages/80/02/10cfc095e958e498171977068ebcabddaa8dabd7835725482b8c0eefec19/primp-1.0.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ea1a0b1d4c2a65efd5f22bc42bc0133ebf359f70dd155847cbebf8015fb05a1", size = 3922305, upload-time = "2026-02-13T15:33:23.231Z" }, + { url = "https://files.pythonhosted.org/packages/89/00/947c74646825d38d7f5c5fc5a7f2474f30767ea9817f9a7742f95ac99e45/primp-1.0.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1abd58a2bf0a2f062edc51a3684f8b9d0170348a96afdd3915f02f498c661228", size = 3811925, upload-time = "2026-02-13T15:33:04.976Z" }, + { url = "https://files.pythonhosted.org/packages/65/34/0f788310dd2903be8b49d9396ad4fa7deb1f5ab6419a2a7ea9014380f52f/primp-1.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52506249b8132eb386e90349f9fbbcf6b39e36523d61f92a0e8c557e32f71ef2", size = 4009948, upload-time = "2026-02-13T15:32:43.88Z" }, + { url = "https://files.pythonhosted.org/packages/44/35/9a3147377764380fa9940d4cfc328b5a31a1a1c72d2cbbdaa188ab8ea296/primp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b7f24c3a67aab0517ba4f6e743dfced331198062ff8e31df692381e60a17b775", size = 3970643, upload-time = "2026-02-13T15:33:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/df/a9/396511a300bc44de4213198f10a21337fcb3f43e4553ece9a17b1a48e1df/primp-1.0.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0cf76f39d5820a2607a2dd25c074ceb8efa741bc311552218156c53b1002ec25", size = 3668236, upload-time = "2026-02-13T15:33:00.299Z" }, + { url = "https://files.pythonhosted.org/packages/2b/44/f1f4a6223dbfa8c72d37286b4bf9a2bb06241c9bac7ce95c5acc03069fec/primp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3414a4bbe37e909a45c0fea04104bd23165d81b94f3d68bfe9a11ba18c462b39", size = 3776956, upload-time = "2026-02-13T15:33:08.969Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9e/b6cb2c19abaeea0ade9256c296340b79dee0084bffcbaadceeebaf75c691/primp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3487e5269dc6d840035d59a8e5afbba99b5736da848664b71356681a837c3a8b", size = 4262036, upload-time = "2026-02-13T15:33:21.939Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/bf5a730384f338be7a52e5976c0f7ea8e00f8f078a80bd51fa15a61cd35a/primp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:0c44e8dccfcd2dd3fb3467d44836445039a013704ea869340bf67a444cbf3f36", size = 3185054, upload-time = "2026-02-13T15:33:15.486Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0b/92d644fbbf97f8fca2959c388f0ed50abd9ea1d17c3ad9b5b0e364fa8d37/primp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:705fb755f5461b551925de7546f3fea5b657fc44fee136498bed492bf5051864", size = 3512508, upload-time = "2026-02-13T15:32:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/efd595743e3b8b0477f44194f6a22fe0d7118b76e9b01167b0921a160d91/primp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:4e080ad054df4c325c434acf613d9cae54278e8141fa116452ec18bf576672a8", size = 3560136, upload-time = "2026-02-13T15:32:50.901Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/e3ee3836154f849086e5a29db7ec95bf805c0143266d59868c2eff0528df/primp-1.0.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6853b719f511ed09dc3673e54cd489b4ed35b0f769428dc79b3c54c446aafd22", size = 3890886, upload-time = "2026-02-13T15:33:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/4ea190b844557e919a84d3851d49407303d145dfe93cab67d2ed7268c6fa/primp-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3d072d1e3c84068b5727426500210e33241ef97844fe781d9817094fdfc6b128", size = 3653937, upload-time = "2026-02-13T15:33:13.803Z" }, + { url = "https://files.pythonhosted.org/packages/be/51/bb861bcc45b6761b4dcc3b41a1ce6eecea9ccf4e9786d545f28313540259/primp-1.0.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ef28f8d6b89c5daf651dc7c7560b4914647bfe73b9a3847e2ae5ed0ff7d8bcf", size = 3792475, upload-time = "2026-02-13T15:33:27.419Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/f87d652aa13a1b1bba9f576c04732319ecf75075e3b26bf91ad47eab00d3/primp-1.0.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04a0d9d88cdce7ab685b4657cfe07d603a85118ec48a09015fa66eadad156c44", size = 3443247, upload-time = "2026-02-13T15:32:46.793Z" }, + { url = "https://files.pythonhosted.org/packages/31/f5/623885d04702523201639af3d011efb2eaed0dff9200a78db609b570c4c6/primp-1.0.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0ad2255403b155d93cf5cb7f6e807e26dc10c49071e0bac888c2c0e14801b82", size = 3651674, upload-time = "2026-02-13T15:33:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/0b/17/b45e7e79cf3c5de7aaf23bf38167243c4df017997d954dd903a479f474d8/primp-1.0.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3e7ccbe4746163f14b984523ac49ce3eed923fbe672c4c08480fa13217c2357", size = 3918929, upload-time = "2026-02-13T15:32:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/fb/00/f5f58ef9856d99cf52e59f9034b27dc2659430be3257ecb890f1b4fccb17/primp-1.0.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63a1d34732c2e6282e5e30f5d425eaa28ca417d74accda92908fdb8c944ff319", size = 3814485, upload-time = "2026-02-13T15:33:16.917Z" }, + { url = "https://files.pythonhosted.org/packages/b0/93/5e82f1fb2fd026d21c645b80da90f29f3afb6f1990120dcff8662c4f4b6e/primp-1.0.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d90e61f173e661ed8e21d8cd6534c586ad1d25565a0bac539a6a2d5e990439e0", size = 4014672, upload-time = "2026-02-13T15:33:26.083Z" }, + { url = "https://files.pythonhosted.org/packages/03/d7/6f1739043c84e772b45c51d2a1ab8c32727f0db6d41beb1b092a7baa2c02/primp-1.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcb28e07bc250b8c4762312e952bd84b6b983554fba6cd067f83018bd39a0488", size = 3971122, upload-time = "2026-02-13T15:32:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/47d7101034a36e73bb6976c566c56b54ec46efff1d64ebc07dccf05e51d8/primp-1.0.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8e5b8fa46130d3db33192784d4935fc3f9574f030d0e78d281e90c37cf2507ee", size = 3669273, upload-time = "2026-02-13T15:33:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/86878a9b46fc4bafba454e63b293e779c1ba6f9bf5ffc221f2f3dc70d60e/primp-1.0.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:984ab730449fd2e5f794fd6fad37fed3596432a24435ce2d0363b454503b7846", size = 3776747, upload-time = "2026-02-13T15:33:03.156Z" }, + { url = "https://files.pythonhosted.org/packages/9c/52/7afaf2a232987711863fa1e994cb6908c9dcd550d436578bb6cb63e53a83/primp-1.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2abd6d47ca60028bcc33dc47dd33f355237be80d7889518e44cc4d730c9e45e0", size = 4266058, upload-time = "2026-02-13T15:32:59.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/c2/fd1365ab28c4e15bebd291215c152c9787185a4fade0df780bb5e53d5866/primp-1.0.0-cp314-cp314t-win32.whl", hash = "sha256:39c27d84fd597a43bb291b6928fbaa46d4a7aff0c31ae1a361dccbbd109118a1", size = 3184230, upload-time = "2026-02-13T15:32:45.437Z" }, + { url = "https://files.pythonhosted.org/packages/30/2f/fcb4935ef1b2ba19bafbf050775f402ef30d19c9ba0d83a6328b453436a4/primp-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bc8bac0288fb7ed541c8db4be46c5f2779e4c1b023bf01e46fe4c1405150dbeb", size = 3514652, upload-time = "2026-02-13T15:33:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/49/88/2dbeee5a6c914c36b5dfca6e77913f4a190ac0137db0ea386b9632c16ef0/primp-1.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:117d3eb9c556fe88c8ed0533be80c2495922671e977e3e0e78a6b841014380eb", size = 3553319, upload-time = "2026-02-13T15:33:19.67Z" }, ] [[package]] name = "protocolbox" -version = "0.1.0" +version = "0.1.4" source = { editable = "." } dependencies = [ + { name = "duckduckgo-search" }, { name = "html2text" }, { name = "httpx" }, { name = "json-repair" }, { name = "mcp" }, - { name = "reportlab" }, + { name = "pytz" }, { name = "typer" }, + { name = "youtube-transcript-api" }, ] [package.optional-dependencies] @@ -555,14 +633,16 @@ dev = [ [package.metadata] requires-dist = [ + { name = "duckduckgo-search" }, { name = "html2text" }, { name = "httpx" }, { name = "json-repair" }, { name = "mcp", specifier = ">=0.1.0" }, { name = "pytest", marker = "extra == 'dev'" }, - { name = "reportlab" }, + { name = "pytz" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "typer" }, + { name = "youtube-transcript-api" }, ] provides-extras = ["dev"] @@ -758,6 +838,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -792,16 +881,18 @@ wheels = [ ] [[package]] -name = "reportlab" -version = "4.4.10" +name = "requests" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "certifi" }, { name = "charset-normalizer" }, - { name = "pillow" }, + { name = "idna" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/57/28bfbf0a775b618b6e4d854ef8dd3f5c8988e5d614d8898703502a35f61c/reportlab-4.4.10.tar.gz", hash = "sha256:5cbbb34ac3546039d0086deb2938cdec06b12da3cdb836e813258eb33cd28487", size = 3714962, upload-time = "2026-02-12T10:45:21.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/2e/e1798b8b248e1517e74c6cdf10dd6edd485044e7edf46b5f11ffcc5a0add/reportlab-4.4.10-py3-none-any.whl", hash = "sha256:5abc815746ae2bc44e7ff25db96814f921349ca814c992c7eac3c26029bf7c24", size = 1955400, upload-time = "2026-02-12T10:45:18.828Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -1021,6 +1112,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.40.0" @@ -1033,3 +1133,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e66 wheels = [ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] + +[[package]] +name = "youtube-transcript-api" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/43/4104185a2eaa839daa693b30e15c37e7e58795e8e09ec414f22b3db54bec/youtube_transcript_api-1.2.4.tar.gz", hash = "sha256:b72d0e96a335df599d67cee51d49e143cff4f45b84bcafc202ff51291603ddcd", size = 469839, upload-time = "2026-01-29T09:09:17.088Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/95/129ea37efd6cd6ed00f62baae6543345c677810b8a3bf0026756e1d3cf3c/youtube_transcript_api-1.2.4-py3-none-any.whl", hash = "sha256:03878759356da5caf5edac77431780b91448fb3d8c21d4496015bdc8a7bc43ff", size = 485227, upload-time = "2026-01-29T09:09:15.427Z" }, +]