diff --git a/.gitignore b/.gitignore index d736de38..e7bed02c 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ cython_debug/ # Reference directory - ignore all reference projects reference/ + +# Local legacy backups created during framework migrations +backup/ diff --git a/README.md b/README.md index f88c5a03..b30b2d12 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,14 @@ # Bub -[![Release](https://img.shields.io/github/v/release/bubbuild/bub)](https://github.com/bubbuild/bub/releases) -[![Build status](https://img.shields.io/github/actions/workflow/status/bubbuild/bub/main.yml?branch=main)](https://github.com/bubbuild/bub/actions/workflows/main.yml?query=branch%3Amain) -[![Commit activity](https://img.shields.io/github/commit-activity/m/bubbuild/bub)](https://github.com/bubbuild/bub/graphs/commit-activity) -[![License](https://img.shields.io/github/license/bubbuild/bub)](LICENSE) +Bub is a hook-first AI framework built on `pluggy`: the core stays small and orchestrates turns, while builtins and plugins provide behavior. -> Bub it. Build it. +## Current Implementation -Bub is a collaborative agent for shared delivery workflows, evolving into a framework that helps other agents operate with the same collaboration model. -It is not a personal-assistant shell: it is designed for shared environments where work must be inspectable, handoff-friendly, and operationally reliable. - -> Documentation: - -Built on [Republic](https://github.com/bubbuild/republic), Bub treats context as explicit assembly from verifiable interaction history, rather than opaque inherited state. -This aligns with [Socialized Evaluation](https://psiace.me/posts/im-and-socialized-evaluation/): systems are judged by how well teams can inspect, review, and continue work together. - -## What Bub Provides - -- Multi-operator collaboration in shared delivery environments. -- Explicit command boundaries for predictable execution. -- Verifiable history (`tape`, `anchor`, `handoff`) for audit and continuity. -- Channel-neutral behavior across CLI and message channels. -- Extensible tools and skills with a unified operator-facing workflow. +- CLI bootstrap: `src/bub/__main__.py` (Typer app) +- Turn orchestrator: `src/bub/framework.py` +- Hook contract: `src/bub/hookspecs.py` +- Builtin hooks/runtime: `src/bub/builtin/hook_impl.py` + `src/bub/builtin/engine.py` +- Skill discovery and validation: `src/bub/skills.py` ## Quick Start @@ -29,75 +16,82 @@ This aligns with [Socialized Evaluation](https://psiace.me/posts/im-and-socializ git clone https://github.com/bubbuild/bub.git cd bub uv sync -cp env.example .env +uv run bub --help ``` -Minimal `.env`: - ```bash -BUB_MODEL=openrouter:qwen/qwen3-coder-next -LLM_API_KEY=your_key_here +# Runtime off: falls back to model_output=prompt +BUB_RUNTIME_ENABLED=0 uv run bub run "hello" ``` -Start interactive CLI: - ```bash -uv run bub +# Internal command mode (line starts with ',') +BUB_RUNTIME_ENABLED=0 uv run bub run ",help" ``` -## Interaction Model - -- `hello`: natural language routed to model. -- `,help`: internal command. -- `,git status`: shell command. -- `, ls -la`: shell command (space after comma is optional). - -Common commands: - -```text -,help -,tools -,tool.describe name=fs.read -,skills.list -,skills.describe name=friendly-python -,handoff name=phase-1 summary="bootstrap done" -,anchors -,tape.info -,tape.search query=error -,tape.reset archive=true -,quit +```bash +# Model runtime (hosted providers usually require a key) +BUB_API_KEY=your_key uv run bub run "Summarize this repository" ``` -## Channel Runtime (Optional) +## CLI Commands -Telegram: +- `bub run MESSAGE`: execute one inbound turn and print outbound messages +- `bub hooks`: print hook-to-plugin bindings +- `bub install PLUGIN_SPEC`: install plugin from PyPI or `owner/repo` (GitHub shorthand) -```bash -BUB_TELEGRAM_ENABLED=true -BUB_TELEGRAM_TOKEN=123456:token -BUB_TELEGRAM_ALLOW_FROM='["123456789","your_username"]' -uv run bub message -``` +## Runtime Behavior -Discord: +- Regular text input: uses `run_model`; if runtime is unavailable, output falls back to the prompt text +- Comma commands: `,help`, `,tools`, `,fs.read ...`, etc. +- Unknown comma commands: executed as `bash -lc` in workspace +- Session event log: `.bub/runtime/.jsonl` +- `AGENTS.md`: if present in workspace, appended to runtime system prompt -```bash -BUB_DISCORD_ENABLED=true -BUB_DISCORD_TOKEN=discord_bot_token -BUB_DISCORD_ALLOW_FROM='["123456789012345678","your_discord_name"]' -BUB_DISCORD_ALLOW_CHANNELS='["123456789012345678"]' -uv run bub message +## Skills + +- Discovery roots with deterministic override: + 1. `/.agent/skills` + 2. `~/.agent/skills` + 3. `src/bub_skills` +- Each skill directory must include `SKILL.md` +- Supported frontmatter fields: + - required: `name`, `description` + - optional: `license`, `compatibility`, `metadata`, `allowed-tools` + +## Plugin Development + +Plugins are loaded from Python entry points in `group="bub"`: + +```toml +[project.entry-points."bub"] +my_plugin = "my_package.my_plugin" ``` -## Development +Implement hooks with `@hookimpl` following `BubHookSpecs`. + +## Runtime Environment Variables + +- `BUB_RUNTIME_ENABLED`: `auto` (default), `1`, `0` +- `BUB_MODEL`: default `openrouter:qwen/qwen3-coder-next` +- `BUB_API_KEY`: runtime provider key +- `BUB_API_BASE`: optional provider base URL +- `BUB_RUNTIME_MAX_STEPS`: default `8` +- `BUB_RUNTIME_MAX_TOKENS`: default `1024` +- `BUB_RUNTIME_MODEL_TIMEOUT_SECONDS`: default `90` + +## Documentation + +- `docs/index.md`: overview +- `docs/architecture.md`: lifecycle, precedence, and failure isolation +- `docs/skills.md`: skill discovery and frontmatter constraints +- `docs/cli.md`: CLI usage and comma command mode +- `docs/features.md`: implemented capabilities and limits + +## Development Checks ```bash uv run ruff check . -uv run mypy +uv run mypy src uv run pytest -q -just docs-test ``` - -## License - -[Apache 2.0](./LICENSE) diff --git a/docker-compose.yml b/docker-compose.yml index 68e4edf4..a261c7d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: volumes: - ${BUB_WORKSPACE_PATH:-.}:/workspace - ${BUB_HOME:-${HOME}/.bub}:/data - - ${BUB_AGENT_HOME:-${HOME}/.agent}:/root/.agent + - ${BUB_AGENT_HOME:-${HOME}/.agents}:/root/.agents stdin_open: true tty: true restart: unless-stopped diff --git a/docs/architecture.md b/docs/architecture.md index 1fecdabd..b15fd7d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,49 +1,60 @@ # Architecture -This page is for developers and advanced users who need to understand why Bub behavior is deterministic and how to extend it safely. - -## Core Principles - -1. One session, one append-only tape. -2. Same routing rules for user input and assistant output. -3. Command execution and model reasoning are explicit layers. -4. Phase transitions are represented by `anchor/handoff`, not hidden state jumps. -5. Human and agent operators follow the same collaboration boundaries. - -## Runtime Topology - -```text -input -> InputRouter -> AgentLoop -> ModelRunner -> InputRouter(assistant output) -> ... - \-> direct command response -``` - -Key modules: - -- `src/bub/core/router.py`: command detection, execution, and failure context wrapping. -- `src/bub/core/agent_loop.py`: turn orchestration and stop conditions. -- `src/bub/core/model_runner.py`: bounded model loop and user-driven skill-hint activation. -- `src/bub/tape/service.py`: tape read/write, anchor/handoff, reset, and search. -- `src/bub/tools/*`: unified registry and progressive tool view. - -## Single Turn Flow - -1. `InputRouter.route_user` checks whether input starts with `,`. -2. If command succeeds, return output directly. -3. If command fails, generate a `` block for model context. -4. `ModelRunner` gets assistant output. -5. `route_assistant` applies the same command parsing/execution rules. -6. Loop ends on plain final text, explicit quit, or `max_steps`. - -## Tape, Anchor, Handoff - -- Tape is workspace-level JSONL for replay and audit. -- `handoff` writes an anchor with optional `summary` and `next_steps`. -- `anchors` lists phase boundaries. -- `tape.reset` clears active context (optionally archiving first). - -## Tools and Skills - -- Built-in tools and skills live in one registry. -- System prompt starts with compact tool descriptions. -- Full tool schema is expanded on demand (`tool.describe` or explicit selection). -- `$name` hints progressively expand tool/skill details from either user input or model output. +## Core Components + +- `BubFramework`: creates the plugin manager, loads plugins, and runs `process_inbound()`. +- `BubHookSpecs`: defines all hook contracts (`src/bub/hookspecs.py`). +- `HookRuntime`: executes hooks with sync/async compatibility helpers (`src/bub/hook_runtime.py`). +- `Agent`: builtin model-and-tools runtime (`src/bub/builtin/agent.py`). +- `ChannelManager`: starts channels, buffers inbound messages, and routes outbound messages (`src/bub/channels/manager.py`). + +## Turn Lifecycle + +`BubFramework.process_inbound()` currently executes in this order: + +1. Resolve session via `resolve_session(message)` (fallback to `channel:chat_id` if empty). +2. Initialize state with `_runtime_workspace` from `BubFramework.workspace`. +3. Merge all `load_state(message, session_id)` dicts. +4. Build prompt via `build_prompt(message, session_id, state)` (fallback to inbound `content` if empty). +5. Execute `run_model(prompt, session_id, state)`. +6. Always execute `save_state(...)` in a `finally` block. +7. Render outbound batches via `render_outbound(...)`, then flatten them. +8. If no outbound exists, emit one fallback outbound. +9. Dispatch each outbound via `dispatch_outbound(message)`. + +## Hook Priority Semantics + +- Registration order: +1. Builtin plugin `builtin` +2. External entry points (`group="bub"`) +- Execution order: +1. `HookRuntime` reverses pluggy implementation order, so later-registered plugins run first. +2. `call_first` returns the first non-`None` value. +3. `call_many` collects every implementation return value (including `None`). +- Merge/override details: +1. `load_state` is reversed again before merge so high-priority plugins win on key collisions. +2. `provide_channels` is collected by `BubFramework.get_channels()`, and the first channel name wins, so high-priority plugins can override builtin channel names. + +## Error Behavior + +- For normal hooks, `HookRuntime` does not swallow implementation errors. +- `process_inbound()` catches top-level exceptions, notifies `on_error(stage="turn", ...)`, then re-raises. +- `on_error` itself is observer-safe: one failing observer does not block the others. +- In sync calls (`call_first_sync`/`call_many_sync`), awaitable return values are skipped with a warning. + +## Builtin Runtime Notes + +Builtin `BuiltinImpl` behavior includes: + +- `build_prompt`: supports comma command mode; non-command text may include `context_str`. +- `run_model`: delegates to `Agent.run()`. +- `system_prompt`: combines a default prompt with workspace `AGENTS.md`. +- `register_cli_commands`: installs `run`, `gateway`, `chat`, plus hidden compatibility/diagnostic commands. +- `provide_channels`: returns `telegram` and `cli` channel adapters. +- `provide_tape_store`: returns a file-backed tape store under `~/.bub/tapes`. + +## Boundaries + +- `Envelope` stays intentionally weakly typed (`Any` + accessor helpers). +- There is no globally enforced schema for cross-plugin `state`. +- Runtime behavior in this document is aligned with current source code. diff --git a/docs/assets/images/baby-bub.png b/docs/assets/images/baby-bub.png deleted file mode 100644 index cf16edc2..00000000 Binary files a/docs/assets/images/baby-bub.png and /dev/null differ diff --git a/docs/channels.md b/docs/channels.md deleted file mode 100644 index e235202e..00000000 --- a/docs/channels.md +++ /dev/null @@ -1,34 +0,0 @@ -# Channels - -Bub supports running the same agent loop through channel adapters. -Use channels when you want either local interactive operation or remote operation from mobile/shared team environments. - -## Supported Channels - -- `cli` (local): interactive terminal channel used by `uv run bub chat`. -- [Telegram](telegram.md): direct messages and group chats. -- [Discord](discord.md): servers, channels, and threads. - -## Run Entry - -Start channel mode with: - -```bash -uv run bub message -``` - -If the process exits immediately, check that at least one channel is enabled in `.env`. - -## Session Isolation - -- CLI session key: `cli` or `cli:` (from `--session-id`). -- Telegram session key: `telegram:` -- Discord session key: `discord:` - -This keeps message history isolated per conversation endpoint. - -## Runtime Semantics - -- `uv run bub chat` runs `CliChannel` via `ChannelManager`, sharing the same channel pipeline as Telegram/Discord. -- CLI sets `debounce_enabled = False`, so each input is processed immediately. -- Message channels keep debounce enabled to batch short bursts before model execution. diff --git a/docs/channels/index.md b/docs/channels/index.md new file mode 100644 index 00000000..0206a580 --- /dev/null +++ b/docs/channels/index.md @@ -0,0 +1,47 @@ +# Channels + +Bub uses channel adapters to run the same agent pipeline across different I/O endpoints. + +## Builtin Channels + +- `cli`: local interactive terminal channel (`uv run bub chat`) +- `telegram`: Telegram bot channel (`uv run bub gateway`) + +See [Telegram](telegram.md) for channel-specific configuration and runtime behavior. + +## Run Modes + +Local interactive mode: + +```bash +uv run bub chat +``` + +Channel listener mode (all non-`cli` channels by default): + +```bash +uv run bub gateway +``` + +Enable only Telegram: + +```bash +uv run bub gateway --enable-channel telegram +``` + +## Session Semantics + +- `run` command default session id: `:` +- Telegram channel session id: `telegram:` +- `chat` command default session id: `cli_session` (override with `--session-id`) + +## Debounce Behavior + +- `cli` does not debounce; each input is processed immediately. +- Other channels can debounce and batch inbound messages per session. +- Comma commands (`,` prefix) always bypass debounce and execute immediately. + +## About Discord + +Core Bub does not currently include a builtin Discord adapter. +If you need Discord, implement it in an external plugin via `provide_channels`. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md new file mode 100644 index 00000000..87cd2536 --- /dev/null +++ b/docs/channels/telegram.md @@ -0,0 +1,45 @@ +# Telegram + +Telegram is the builtin remote channel adapter in current core Bub. + +## Configuration + +Environment variables are read by `TelegramSettings` (`src/bub/channels/telegram.py`). + +Required: + +```bash +BUB_TELEGRAM_TOKEN=123456:token +``` + +Optional allowlists (comma-separated): + +```bash +BUB_TELEGRAM_ALLOW_USERS=123456789,your_username +BUB_TELEGRAM_ALLOW_CHATS=123456789,-1001234567890 +``` + +Optional proxy: + +```bash +BUB_TELEGRAM_PROXY=http://127.0.0.1:7890 +``` + +## Message Behavior + +- Session id is `telegram:`. +- `/start` is handled by builtin channel logic. +- `/bub ...` is accepted and normalized to plain prompt content. +- Non-command messages are ingested; active/follow-up behavior is decided by channel filter metadata plus debounce handling. + +## Outbound Behavior + +- Outbound is sent back to Telegram chat via bot API. +- Empty outbound text is ignored. +- If outbound content is JSON, the `"message"` field is used when present. + +## Access Control + +- If `BUB_TELEGRAM_ALLOW_CHATS` is set, non-listed chats are ignored. +- If `BUB_TELEGRAM_ALLOW_USERS` is set, non-listed users are denied. +- In group chats, keep allowlists strict for production bots. diff --git a/docs/cli.md b/docs/cli.md index 13ab6d08..31ed6a3b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,82 +1,78 @@ -# Interactive CLI +# CLI -`uv run bub chat` runs the local `cli` channel adapter (same channel pipeline as other channels, but local-only). +`bub` currently exposes four builtin commands: `run`, `gateway`, `chat`, and the hidden compatibility command `message`. -## Run Commands +## `bub run` -```bash -uv run bub chat -``` - -Optional chat flags: +Run one inbound message through the full framework pipeline and print outbounds. ```bash -uv run bub chat \ - --workspace /path/to/repo \ - --model openrouter:qwen/qwen3-coder-next \ - --max-tokens 1400 \ - --session-id cli-main +uv run bub run "hello" --channel cli --chat-id local ``` -Other run modes: +Common options: -- `uv run bub run "summarize current repo status"`: one-shot message and exit. -- `uv run bub message`: run enabled message channels (Telegram/Discord). -- `uv run bub idle`: run scheduler only (no interactive CLI). +- `--workspace/-w`: workspace root, declared once on the top-level CLI and shared by all subcommands +- `--channel`: source channel (default `cli`) +- `--chat-id`: source endpoint id (default `local`) +- `--sender-id`: sender identity (default `human`) +- `--session-id`: explicit session id (default is `:`) -## How Input Is Interpreted +Comma-prefixed input enters internal command mode: -- Only lines starting with `,` are interpreted as commands. -- Registered names like `,help` are internal commands. -- Other comma-prefixed lines run through shell, for example `,git status`. -- Non-comma input is always treated as natural language. +```bash +uv run bub run ",help" +uv run bub run ",tools" +uv run bub run ",fs.read path=README.md" +``` -This rule is shared by both user input and assistant output. +Unknown comma commands fall back to shell execution: -## Runtime Behavior +```bash +uv run bub run ",echo hello-from-shell" +``` -- `cli` channel disables debounce, so each input is executed immediately. -- Command inputs (`,...`) are executed directly and are not buffered into later batched prompts. +## `bub hooks` -## Shell Mode +Print hook-to-plugin bindings discovered at startup. -Press `Ctrl-X` to toggle between `agent` and `shell` mode. +```bash +uv run bub hooks +``` -- `agent` mode: send input as typed. -- `shell` mode: if input does not start with `,`, Bub auto-normalizes it to `, `. +`hooks` remains available for diagnostics, but it is hidden from the top-level help. -Use shell mode when you want to run multiple shell commands quickly. +## `bub gateway` -## Typical Workflow +Start channel listener mode (defaults to all non-`cli` channels). -1. Check repo status: `,git status` -2. Read files: `,fs.read path=README.md` -3. Edit files: `,fs.edit path=foo.py old=... new=...` -4. Validate: `uv run pytest -q` -5. Mark phase transition: `,tape.handoff name=phase-x summary="tests pass"` +```bash +uv run bub gateway +``` -## Session Context Commands +Enable only selected channels: -```text -,tape.info -,tape.search query=error -,tape.anchors -,tape.reset archive=true +```bash +uv run bub gateway --enable-channel telegram ``` -- `,tape.reset archive=true` archives then clears current tape. -- `,tape.anchors` shows phase boundaries. +`bub message` is kept as a hidden compatibility alias and forwards to the same command implementation. + +## `bub chat` -## One-Shot Examples +Start an interactive REPL session via the `cli` channel. ```bash -uv run bub run ",help" -uv run bub run --tools fs.read,fs.glob --skills friendly-python "inspect Python layout" -uv run bub run --disable-scheduler "quick reasoning task" +uv run bub chat +uv run bub chat --chat-id local --session-id cli:local ``` -## Troubleshooting +## Notes + +- `--workspace` is parsed before the subcommand, for example `uv run bub --workspace /repo chat`. +- `run` prints each outbound as: -- `command not found`: verify whether it should be an internal command (`,help` for list). -- `bub message` exits immediately: no message channel is enabled in `.env`. -- Context is too heavy: add a handoff anchor, then reset tape when needed. +```text +[channel:chat_id] +content +``` diff --git a/docs/core/index.md b/docs/core/index.md new file mode 100644 index 00000000..bfb1064f --- /dev/null +++ b/docs/core/index.md @@ -0,0 +1,13 @@ +# Core Overview + +This section groups core runtime design and behavior. + +## Includes + +- [Architecture](../architecture.md): execution lifecycle, hook precedence, and error semantics. +- [Key Features](../features.md): high-level capability summary and current boundaries. + +## Suggested Reading Order + +1. [Architecture](../architecture.md) +2. [Key Features](../features.md) diff --git a/docs/deployment.md b/docs/deployment.md index cfc79bce..75543756 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,14 +1,14 @@ # Deployment Guide -This page covers production-oriented setups for Bub, including local process management and Docker Compose. +This page covers practical Bub deployment paths based on the current repository behavior. ## 1) Prerequisites - Python 3.12+ - `uv` installed -- A valid model provider key (for example `OPENROUTER_API_KEY` or `LLM_API_KEY`) +- a valid model provider key (for example `OPENROUTER_API_KEY`) -Quick bootstrap: +Bootstrap: ```bash git clone https://github.com/bubbuild/bub.git @@ -17,61 +17,40 @@ uv sync cp env.example .env ``` -Minimum `.env`: +Minimum `.env` example: ```bash BUB_MODEL=openrouter:qwen/qwen3-coder-next OPENROUTER_API_KEY=sk-or-... ``` -## 2) Deployment Modes +## 2) Runtime Modes -Choose one mode based on your operation target: +Choose one command based on your operation target: -1. Interactive local operator: - `uv run bub chat` -2. Channel service (Telegram/Discord): - `uv run bub message` -3. Scheduler-only autonomous mode: - `uv run bub idle` +1. Interactive local operator: `uv run bub chat` +2. Channel listener service: `uv run bub gateway` +3. One-shot task execution: `uv run bub run "summarize this repo"` -One-shot operation: +## 3) Telegram Channel Setup -```bash -uv run bub run "summarize changes in this repo" -``` - -## 3) Message Channel Deployment +Telegram configuration and runtime behavior are documented in: -Enable channels in `.env` first. +- `docs/channels/telegram.md` -Telegram: +Quick start: ```bash -BUB_TELEGRAM_ENABLED=true -BUB_TELEGRAM_TOKEN=123456:token -BUB_TELEGRAM_ALLOW_FROM='["123456789","your_username"]' -BUB_TELEGRAM_ALLOW_CHATS='["123456789","-1001234567890"]' +BUB_TELEGRAM_TOKEN=123456:token uv run bub gateway --enable-channel telegram ``` -Discord: +## 4) Docker Compose -```bash -BUB_DISCORD_ENABLED=true -BUB_DISCORD_TOKEN=discord_bot_token -BUB_DISCORD_ALLOW_FROM='["123456789012345678","your_discord_name"]' -BUB_DISCORD_ALLOW_CHANNELS='["123456789012345678"]' -``` +Repository assets: -Start channel service: - -```bash -uv run bub message -``` - -## 4) Docker Compose Deployment - -The repository already provides `Dockerfile`, `docker-compose.yml`, and `entrypoint.sh`. +- `Dockerfile` +- `docker-compose.yml` +- `entrypoint.sh` Build and run: @@ -80,10 +59,10 @@ docker compose up -d --build docker compose logs -f app ``` -Behavior in container: +Current entrypoint behavior: -- If `/workspace/startup.sh` exists, container starts `bub idle` in background, then executes `startup.sh`. -- Otherwise, container starts `bub message`. +- if `/workspace/startup.sh` exists, entrypoint tries to run `startup.sh` +- otherwise it starts `bub gateway` Default mounts in `docker-compose.yml`: @@ -93,18 +72,16 @@ Default mounts in `docker-compose.yml`: ## 5) Operational Checks -Health checklist: - -1. Process is running: - `ps aux | rg "bub (chat|message|idle)"` -2. Model key is loaded: +1. Verify process: + `ps aux | rg "bub (chat|gateway|run)"` +2. Verify model config: `rg -n "BUB_MODEL|OPENROUTER_API_KEY|LLM_API_KEY" .env` -3. Channel flags are correct: - `rg -n "BUB_TELEGRAM_ENABLED|BUB_DISCORD_ENABLED" .env` -4. Logs show channel startup: - `uv run bub message` and confirm `channel.manager.start` output. +3. Verify Telegram settings: + `rg -n "BUB_TELEGRAM_TOKEN|BUB_TELEGRAM_ALLOW_USERS|BUB_TELEGRAM_ALLOW_CHATS" .env` +4. Verify startup logs: + `uv run bub gateway --enable-channel telegram` -## 6) Safe Upgrade Procedure +## 6) Safe Upgrade ```bash git fetch --all --tags @@ -115,4 +92,4 @@ uv run mypy uv run pytest -q ``` -Then restart your service mode (`chat`, `message`, or container service). +Then restart your service command. diff --git a/docs/discord.md b/docs/discord.md deleted file mode 100644 index 5138b8ef..00000000 --- a/docs/discord.md +++ /dev/null @@ -1,52 +0,0 @@ -# Discord Integration - -Discord allows Bub to run as a remote collaboration endpoint for team channels, threads, and DMs. - -## Configure - -```bash -BUB_DISCORD_ENABLED=true -BUB_DISCORD_TOKEN=discord_bot_token -BUB_DISCORD_ALLOW_FROM='["123456789012345678","your_discord_name"]' -BUB_DISCORD_ALLOW_CHANNELS='["123456789012345678"]' -``` - -Optional: - -```bash -BUB_DISCORD_COMMAND_PREFIX=! -BUB_DISCORD_PROXY=http://127.0.0.1:7890 -``` - -Notes: - -- If `BUB_DISCORD_ALLOW_FROM` is empty, all senders are accepted. -- If `BUB_DISCORD_ALLOW_CHANNELS` is empty, all channels are accepted. -- In production, use strict allowlists. - -## Run - -```bash -uv run bub message -``` - -## Run Behavior - -- Uses `discord.py` bot service loop. -- Each Discord channel maps to `discord:` session key. -- Inbound text enters the same `AgentLoop` used by CLI. -- Outbound immediate output is sent back in-channel (split into chunks when too long). -- Bub processes messages in these cases: - - DM channel - - message includes `bub` - - message starts with `!bub` (or your configured prefix) - - message mentions the bot - - message replies to a bot message - - thread name starts with `bub` - -## Security and Operations - -1. Keep bot token only in `.env` or a secret manager. -2. Restrict `BUB_DISCORD_ALLOW_CHANNELS` and `BUB_DISCORD_ALLOW_FROM`. -3. Confirm the bot has message-content intent enabled in Discord Developer Portal. -4. If no response is observed, verify token, allowlists, intents, and service logs. diff --git a/docs/extension-guide.md b/docs/extension-guide.md new file mode 100644 index 00000000..b6e95712 --- /dev/null +++ b/docs/extension-guide.md @@ -0,0 +1,179 @@ +# Extension Guide + +This guide explains how to implement Bub hooks with `@hookimpl`, and how those implementations are executed in the current runtime. + +## 1) Import And Basic Shape + +Use the marker exported by Bub: + +```python +from bub import hookimpl +``` + +Implement hooks on a plugin object: + +```python +from __future__ import annotations + +from bub import hookimpl + + +class MyPlugin: + @hookimpl + def build_prompt(self, message, session_id, state): + return "custom prompt" + +my_plugin = MyPlugin() +``` + +## 2) Register Plugin Via Entry Points + +Expose your plugin in `pyproject.toml`: + +```toml +[project.entry-points."bub"] +my_plugin = "my_package.plugin:my_plugin" +``` + +`BubFramework.load_hooks()` loads builtin first, then entry points in `group="bub"`. + +## 3) Expose Tools By Importing The Module + +Tools are registered through the `@tool` decorator's import-time side effect. +Your plugin must import the module that contains the `@tool` definitions before the agent starts using them. + +Example: + +```python +from __future__ import annotations + +from bub import hookimpl + +from . import tools # noqa: F401 + + +class MyPlugin: + @hookimpl + def system_prompt(self, prompt, state): + return "extension prompt" +``` + +If that import is missing, the tool module never runs, nothing is inserted into `bub.tools.REGISTRY`, and the tool will not be available to the agent or CLI completion. + +## 4) Ship Skills In Extension Packages + +Extension packages can also ship skills by including a top-level `bub_skills/` directory in the distribution. + +Example layout: + +```text +my-extension/ +├─ src/ +│ ├─ my_extension/ +│ │ └─ plugin.py +│ └─ bub_skills/ +│ └─ my-skill/ +│ └─ SKILL.md +└─ pyproject.toml +``` + +Configure your build backend to include the `bub_skills/` directory in the package data. For example, with `pdm-backend`: + +```toml +[tool.pdm.build] +includes = ["src/"] +``` + +At runtime, Bub discovers builtin skills from `/bub_skills`, so packaged skills in that location are loaded automatically. +These skills use normal precedence rules and can still be overridden by workspace (`.agents/skills`) or user (`~/.agents/skills`) skills. + +## 5) Hook Execution Semantics + +`HookRuntime` drives most framework hooks: + +- `call_first(...)`: execute by priority, return first non-`None` +- `call_many(...)`: execute all, collect all return values (including `None`) +- `call_first_sync(...)` / `call_many_sync(...)`: sync-only bootstrap paths + +Current `process_inbound()` hook usage: + +1. `resolve_session` (`call_first`) +2. `load_state` (`call_many`, then merged by framework) +3. `build_prompt` (`call_first`) +4. `run_model` (`call_first`) +5. `save_state` (`call_many`, always executed in `finally`) +6. `render_outbound` (`call_many`) +7. `dispatch_outbound` (`call_many`, per outbound) + +Other hook consumers: + +- `register_cli_commands`: called by `call_many_sync` +- `provide_channels`: called by `call_many_sync` in `BubFramework.get_channels()` +- `system_prompt`, `provide_tape_store`: consumed by `BubFramework` and the builtin `Agent` + +## 6) Priority And Override Rules + +- Builtin plugin is registered first. +- Later plugins have higher runtime precedence. +- `HookRuntime` reverses pluggy implementation order so later registration runs first. +- For `load_state`, framework re-reverses before merge so high-priority values overwrite low-priority values. + +## 7) Sync vs Async Rules + +- Async hook calls can run both sync and async implementations. +- Sync hook calls skip awaitable return values and log a warning. +- Therefore, keep bootstrap hooks synchronous: + - `register_cli_commands` + - `provide_channels` + - `provide_tape_store` + +## 8) Signature Matching + +`HookRuntime` passes only parameters declared in your function signature. +You can safely omit unused hook arguments. + +Example: + +```python +from bub import hookimpl + + +class SessionPlugin: + @hookimpl + def resolve_session(self, message): + return "my-session" +``` + +## 9) Minimal End-To-End Example + +```python +from __future__ import annotations + +from bub import hookimpl + + +class EchoPlugin: + @hookimpl + def build_prompt(self, message, session_id, state): + return f"[echo] {message['content']}" + + @hookimpl + async def run_model(self, prompt, session_id, state): + return prompt +``` + +Run and verify: + +```bash +uv run bub hooks +uv run bub run "hello" +``` + +Check that your plugin is listed for `build_prompt` / `run_model`, and output reflects your override. + +## 10) Common Pitfalls + +- Defining `@tool` functions without importing the module from your plugin means the tools never register. +- Returning awaitables from hooks invoked via sync paths (`call_many_sync` / `call_first_sync`) causes skip. +- Assuming hook failures are isolated: non-`on_error` hook exceptions propagate and can fail the turn. +- Using stale hook names: always confirm against `src/bub/hookspecs.py`. diff --git a/docs/features.md b/docs/features.md index eb2c5be4..96afd4e2 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,54 +1,35 @@ # Key Features -This page summarizes the capabilities operators rely on most when working with Bub. +## Framework Core -## 1. Deterministic Command Routing +- Hook-first architecture powered by `pluggy`. +- Deterministic turn pipeline in `BubFramework.process_inbound()`. +- Safe fallback to prompt text when `run_model` returns no value (with `on_error` notification). +- Automatic fallback outbound when `render_outbound` produces nothing. -- Command mode is explicit: only line-start `,` triggers command parsing. -- Known names map to internal commands (for example `,help`, `,tools`, `,tape.info`). -- Other comma-prefixed lines run as shell commands (for example `,git status`). +## Runtime And Commands -Why it matters: fewer accidental tool calls and more predictable behavior. +- Builtin CLI commands: `run`, `hooks`, `message`, `chat`. +- Builtin `RuntimeEngine`: + - normal input goes through model + tool loop (Republic) + - comma-prefixed input enters internal command mode (`,help`, `,tools`, `,fs.read`, etc.) + - unknown internal commands fall back to shell execution via the `bash` tool +- Runtime events are persisted to tapes (default under `~/.bub/tapes`). -## 2. Command Failure Recovery +## Channel Capability -- Successful commands return directly. -- Failed commands are wrapped as structured command blocks and sent back to the model loop. +- Builtin channels: `cli` and `telegram`. +- `message` mode runs the same framework pipeline for channel-driven traffic. +- Outbound delivery is routed by `ChannelManager`, keeping business hooks channel-agnostic. -Why it matters: the assistant can debug based on real command output instead of generic guesses. +## Plugin Extensibility -## 3. Verifiable Session History and Context Assembly +- External plugins are loaded via Python entry points (`group="bub"`). +- Later-registered plugins run first and can override builtin behavior. +- Supports both first-result hooks (override style) and broadcast hooks (observer style). -- Bub records session activity as append-only, verifiable history. -- `,tape.anchors` and `,tape.handoff` mark phase transitions. -- `,tape.search` and `,tape.info` help inspect context quickly. +## Current Boundaries -Why it matters: long tasks stay traceable and easier to resume. - -## 4. Unified Tool + Skill View - -- Built-in tools and skills share one registry. -- Prompt includes compact tool descriptions first. -- Tool details expand on explicit selection (for example `,tool.describe name=fs.read`). -- `$name` hints expand details progressively for both tools and skills. -- Hints can come from user input or model output (for example `$fs.read`, `$friendly-python`). - -Why it matters: prompt stays focused while advanced capabilities remain available on demand. - -## 5. Interactive CLI Focused on Real Work - -- Rich interactive shell with history and completions. -- `Ctrl-X` toggles shell mode for faster command execution. -- Same behavior model as channel integrations. - -Why it matters: local debugging and implementation loops are fast and consistent. - -## 6. Message Channel Integration (Telegram + Discord) - -- Optional long-polling Telegram adapter. -- Optional Discord bot adapter. -- Per-chat session isolation (`telegram:`). -- Per-channel session isolation (`discord:`). -- Optional sender/chat allowlist for access control. - -Why it matters: you can continue lightweight operations from mobile or remote environments. +- No strict envelope schema: `Envelope` is intentionally flexible. +- No centralized key contract for shared plugin `state`. +- Core repository does not currently ship a builtin Discord channel adapter. diff --git a/docs/index.md b/docs/index.md index ed34692a..d39c6e23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,39 +1,47 @@ -# Bub Docs +# Bub -Bub is a collaborative agent for shared delivery workflows, evolving into a framework that helps other agents operate with the same collaboration model. -If you only remember one thing from this page, remember this: Bub is built for shared delivery workflows where execution must be inspectable, handoff-friendly, and repeatable. +Bub is a hook-first AI framework for running agent workflows through CLI and message channels. -Under the hood, Bub uses [Republic](https://github.com/bubbuild/republic) to assemble context from traceable history instead of inheriting opaque state. -Its operating philosophy follows [Socialized Evaluation](https://psiace.me/posts/im-and-socialized-evaluation/): quality is judged by whether teams can inspect decisions and continue work safely. +## Quick Start -## What Bub Is (and Is Not) +Install dependencies and create local config: -- Bub is a collaboration agent for human and agent operators. -- Bub is not a personal-assistant-only chat shell. -- Bub keeps command execution explicit, reviewable, and recoverable. +```bash +git clone https://github.com/bubbuild/bub.git +cd bub +uv sync +cp env.example .env +``` -## How Bub Works +Run interactive local chat: -1. Input boundary: only lines starting with `,` are treated as commands. -2. Unified routing: the same routing rules apply to user input and assistant output. -3. Structured fallback: failed commands are returned to the model with execution evidence. -4. Persistent evidence: interaction history is append-only (`tape`) and can be searched. -5. Explicit transitions: `anchor` and `handoff` represent phase changes and responsibility transfer. +```bash +uv run bub chat +``` -## Checklist +Run a one-shot task: -1. Start with model + API key in `.env`. -2. Run `uv run bub` and ask a normal question. -3. Run `,help` and `,tools` to inspect available capabilities. -4. Execute one shell command like `,git status`. -5. Create one handoff: `,tape.handoff name=phase-1 summary="..."`. -6. Verify history using `,tape.info` or `,tape.search query=...`. +```bash +uv run bub run "summarize this repository" +``` -## Where To Read Next +Start channel listener mode: -- [Key Features](features.md): capability-level overview. -- [Interactive CLI](cli.md): interactive workflow and troubleshooting. -- [Architecture](architecture.md): runtime boundaries and internals. -- [Deployment Guide](deployment.md): local and Docker operations. -- [Channels](channels.md): CLI/Telegram/Discord runtime model. -- [Post: Socialized Evaluation and Agent Partnership](posts/2026-03-01-bub-socialized-evaluation-and-agent-partnership.md): project position and principles. +```bash +uv run bub gateway +``` + +## Deployment + +For production setup and operations, read: + +- [Deployment Guide](deployment.md) +- [Channels Overview](channels/index.md) +- [Telegram Channel](channels/telegram.md) + +## Read Next + +- [Core Overview](core/index.md): architecture and capability summary in one place +- [Workflows Overview](workflows/index.md): CLI and skills usage in one place +- [Extension Guide](extension-guide.md): build and publish hook-based extensions +- [Posts](posts/index.md): project notes and updates diff --git a/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md b/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md index a0152412..6af60ff8 100644 --- a/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md +++ b/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md @@ -10,8 +10,6 @@ description: "How Bub draws from modern agent design, and why fixing a single my Bub is a CLI-first AI agent, built to "Bub it. Build it." The project draws direct inspiration from [How to Build an Agent](https://ampcode.com/how-to-build-an-agent) and [Tiny Agents: Building LLM-Powered Agents from Scratch](https://huggingface.co/blog/tiny-agents). Both resources distill the essence of tool-using, loop-based, composable, and extensible agents. -![Baby Bub](../assets/images/baby-bub.png) - But Bub is also a response to the new wave of self-improving, self-hosting agents: think Claude Code, SWE-agent, and the broader "self-bootstrapping" movement. The goal: an agent that can not only help you build, but can help build (and fix) itself. ## Architecture: ReAct Loop, Tools, and CLI diff --git a/docs/posts/index.md b/docs/posts/index.md new file mode 100644 index 00000000..6ce63c5d --- /dev/null +++ b/docs/posts/index.md @@ -0,0 +1,8 @@ +# Posts + +Project posts and long-form notes. + +## Entries + +- [Baby Bub: From Inspiration to Bootstrap Milestone](2025-07-16-baby-bub-bootstrap-milestone.md) +- [Bub: Socialized Evaluation and Agent Partnership](2026-03-01-bub-socialized-evaluation-and-agent-partnership.md) diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 00000000..4d2d5a9a --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,68 @@ +# Skills + +Bub treats skills as discoverable `SKILL.md` documents with validated frontmatter. + +## Minimal Contract + +Each skill directory must contain a `SKILL.md` file: + +```text +my-skill/ +`-- SKILL.md +``` + +Validation rules from `src/bub/skills.py`: + +- `SKILL.md` must start with YAML frontmatter (`--- ... ---`) +- frontmatter must include non-empty `name` and `description` +- directory name must exactly match frontmatter `name` +- `name` must match regex `^[a-z0-9]+(?:-[a-z0-9]+)*$` +- if provided, `metadata` must be a map of `string -> string` + +## Frontmatter Fields + +Currently enforced fields: + +- required: `name`, `description` +- optional with type check: `metadata` + +Other extra keys are allowed but not validated by core. + +## Discovery And Override + +Skills are discovered from three roots in this precedence order: + +1. project: `.agents/skills` +2. user: `~/.agents/skills` +3. builtin: `src/bub_skills` + +If names collide, earlier roots in this list win. + +## Runtime Access + +Builtin command mode can inspect discovered skills: + +```bash +uv run bub run ",skills.list" +uv run bub run ",skills.describe name=my-skill" +``` + +If no valid skills are discovered, `,skills.list` returns `(no skills)`. + +## Authoring Guidance + +- keep `SKILL.md` concise and action-oriented +- keep metadata small and deterministic +- use lowercase kebab-case names for compatibility + +## Optional Script Convention + +For `scripts/*.py`, a practical standalone convention is PEP 723 with `uv`: + +```python +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// +``` diff --git a/docs/telegram.md b/docs/telegram.md deleted file mode 100644 index 04375535..00000000 --- a/docs/telegram.md +++ /dev/null @@ -1,42 +0,0 @@ -# Telegram Integration - -Telegram allows Bub to run as a remote collaboration entry point for lightweight operations. - -## Configure - -```bash -BUB_TELEGRAM_ENABLED=true -BUB_TELEGRAM_TOKEN=123456:token -BUB_TELEGRAM_ALLOW_FROM='["123456789","your_username"]' -BUB_TELEGRAM_ALLOW_CHATS='["123456789","-1001234567890"]' -``` - -Notes: - -- If `BUB_TELEGRAM_ALLOW_FROM` is empty, all senders are accepted. -- If `BUB_TELEGRAM_ALLOW_CHATS` is empty, all chats are accepted. -- If `BUB_TELEGRAM_ALLOW_CHATS` is not empty, only listed `chat_id` values are allowed. -- In production, use a strict allowlist. - -## Run - -```bash -uv run bub message -``` - -## Run Behavior - -- Uses long polling. -- Each Telegram chat maps to `telegram:` session key. -- Inbound text enters the same `AgentLoop` used by CLI. -- Outbound messages are sent by `ChannelManager`. -- Typing indicator is emitted while processing. -- In group chats, Bub only processes messages that mention/reply to the bot. - -## Security and Operations - -1. Keep bot token only in `.env` or a secret manager. -2. Use a dedicated bot account. -3. Keep allowlist updated with valid user IDs/usernames. -4. If no response is observed, check network, token, allowlists, then service/model logs. -5. If `uv run bub message` exits quickly, verify at least one channel is enabled (`BUB_TELEGRAM_ENABLED=true`). diff --git a/docs/workflows/index.md b/docs/workflows/index.md new file mode 100644 index 00000000..e3ad1413 --- /dev/null +++ b/docs/workflows/index.md @@ -0,0 +1,13 @@ +# Workflows Overview + +This section groups day-to-day operator workflows. + +## Includes + +- [CLI](../cli.md): interactive chat, one-shot runs, hook inspection, and channel listener commands. +- [Skills](../skills.md): skill discovery rules, frontmatter validation, and runtime usage. + +## Common Flow + +1. Use [CLI](../cli.md) to run and verify behavior quickly. +2. Add or tune skills following [Skills](../skills.md). diff --git a/entrypoint.sh b/entrypoint.sh index fda64ef3..b24e9ecf 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,16 +2,13 @@ set -eo pipefail -if [ -f "/workspace/bub_hooks.py" ]; then - cp /workspace/bub_hooks.py /app/.venv/lib/python3.12/site-packages/ - echo "Hooks module bub_hooks.py copied to site-packages." - export BUB_HOOKS_MODULE="bub_hooks" +if [ -f "/workspace/bub-reqs.txt" ]; then + echo "Installing additional requirements from /workspace/bub-reqs.txt" + uv pip install -r /workspace/bub-reqs.txt -p /app/.venv/bin/python fi if [ -f "/workspace/startup.sh" ]; then - # Start the idle process in the background - nohup /app/.venv/bin/bub idle >/proc/1/fd/1 2>>/proc/1/fd/2 & exec bash /workspace/startup.sh else - exec /app/.venv/bin/bub message + exec /app/.venv/bin/bub gateway fi diff --git a/env.example b/env.example index a45b158e..a5d5c709 100644 --- a/env.example +++ b/env.example @@ -2,78 +2,51 @@ # Copy to `.env` and fill values. # --------------------------------------------------------------------------- -# Model / provider +# Agent runtime # --------------------------------------------------------------------------- # Republic model format: provider:model_id # Default in code is `openrouter:qwen/qwen3-coder-next`. -BUB_MODEL=openrouter:qwen/qwen3-coder-next +# BUB_MODEL=openrouter:qwen/qwen3-coder-next +# BUB_MAX_STEPS=50 +# BUB_MAX_TOKENS=1024 +# BUB_MODEL_TIMEOUT_SECONDS=300 +# BUB_HOME=~/.bub # --------------------------------------------------------------------------- # API keys (choose one) # --------------------------------------------------------------------------- -# Bub reads keys in this order: -# 1) BUB_API_KEY -# 2) LLM_API_KEY -# 3) OPENROUTER_API_KEY +# `AgentSettings` reads `BUB_API_KEY` and `BUB_API_BASE` directly. +# Provider-specific keys such as `OPENROUTER_API_KEY` may still be used by +# the underlying SDK/provider setup, but are not read by Bub settings itself. # Preferred explicit key # BUB_API_KEY=sk-... -# Generic key (commonly used in this repo) -LLM_API_KEY=sk-... - -# OpenRouter-specific key -# OPENROUTER_API_KEY=sk-or-... # Optional custom API base # BUB_API_BASE=https://openrouter.ai/api/v1 # --------------------------------------------------------------------------- -# Optional Ollama web tools override -# --------------------------------------------------------------------------- -# If BUB_OLLAMA_API_KEY is set, Bub reloads: -# - web.search -> Ollama /web_search API -# - web.fetch -> remains markdown-style fetcher (same output contract) -# -# BUB_OLLAMA_API_KEY=ollama_... -# Optional (default runtime fallback: https://ollama.com/api) -# BUB_OLLAMA_API_BASE=https://ollama.com/api - -# --------------------------------------------------------------------------- -# Runtime behavior +# Channel manager # --------------------------------------------------------------------------- -# BUB_MAX_TOKENS=4096 -# BUB_MAX_STEPS=100 -# BUB_MODEL_TIMEOUT_SECONDS=300 -# BUB_SYSTEM_PROMPT=You are a coding agent. -# BUB_HOME=~/.bub -# BUB_WORKSPACE_PATH=/absolute/path/to/workspace -# BUB_TAPE_NAME=bub +# Comma-separated channel names, or `all`. +# `all` excludes the builtin `cli` channel. +# BUB_ENABLED_CHANNELS=all +# BUB_DEBOUNCE_SECONDS=1.0 +# BUB_MAX_WAIT_SECONDS=10.0 +# BUB_ACTIVE_TIME_WINDOW=60.0 # --------------------------------------------------------------------------- # Telegram channel (optional) # --------------------------------------------------------------------------- -# BUB_TELEGRAM_ENABLED=true # BUB_TELEGRAM_TOKEN=123456:telegram-bot-token -# JSON array recommended: -# BUB_TELEGRAM_ALLOW_FROM='["123456789","my_username"]' -# BUB_TELEGRAM_ALLOW_CHATS='["123456789","-1001234567890"]' +# Comma-separated values: +# BUB_TELEGRAM_ALLOW_USERS=123456789,my_username +# BUB_TELEGRAM_ALLOW_CHATS=123456789,-1001234567890 # BUB_TELEGRAM_PROXY=http://127.0.0.1:1080 -# --------------------------------------------------------------------------- -# Discord channel (optional) -# --------------------------------------------------------------------------- -# BUB_DISCORD_ENABLED=true -# BUB_DISCORD_TOKEN=discord_bot_token -# JSON array recommended: -# BUB_DISCORD_ALLOW_FROM='["123456789012345678","my_discord_name"]' -# BUB_DISCORD_ALLOW_CHANNELS='["123456789012345678"]' -# Optional: -# BUB_DISCORD_COMMAND_PREFIX=! -# BUB_DISCORD_PROXY=http://127.0.0.1:7890 - # --------------------------------------------------------------------------- # Example minimal OpenRouter setup # --------------------------------------------------------------------------- # BUB_MODEL=openrouter:qwen/qwen3-coder-next -# OPENROUTER_API_KEY=sk-or-... +# BUB_API_KEY=sk-or-... diff --git a/mkdocs.yml b/mkdocs.yml index 1fee5e7b..f35424b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,17 +9,23 @@ copyright: Copyright (c) 2026 Bub Build contributors. nav: - Home: index.md - - Key Features: features.md - - Posts: - - "2026-03-01 · Socialized Evaluation and Agent Partnership": posts/2026-03-01-bub-socialized-evaluation-and-agent-partnership.md - - "2025-07-16 · Baby Bub Bootstrap Milestone": posts/2025-07-16-baby-bub-bootstrap-milestone.md - - Deployment: deployment.md - - Architecture: architecture.md + - Core: + - Overview: core/index.md + - Architecture: architecture.md + - Key Features: features.md + - Workflows: + - Overview: workflows/index.md + - CLI: cli.md + - Skills: skills.md - Channels: - - Overview: channels.md - - CLI (Local): cli.md - - Telegram: telegram.md - - Discord: discord.md + - Overview: channels/index.md + - Telegram: channels/telegram.md + - Deployment: deployment.md + - Extension Guide: extension-guide.md + - Posts: + - Overview: posts/index.md + - 2025-07-16 Bootstrap Milestone: posts/2025-07-16-baby-bub-bootstrap-milestone.md + - 2026-03-01 Socialized Evaluation: posts/2026-03-01-bub-socialized-evaluation-and-agent-partnership.md plugins: - search diff --git a/pyproject.toml b/pyproject.toml index 529c2104..16242901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,13 @@ [project] name = "bub" -version = "0.2.3" -description = "Bub it. Build it. A collaborative agent for shared delivery workflows." +version = "0.3.0" +description = "Bub it. Build it. Batteries-included, hook-first AI framework." authors = [ { name = "Chojan Shang", email = "psiace@apache.org" }, { name = "Frost Ming", email = "me@frostming.com" }, - { name = "Hong Yi", email = "zouzou0208@gmail.com" }, + { name = "Yihong", email = "zouzou0208@gmail.com" }, ] readme = "README.md" -keywords = ['python'] requires-python = ">=3.12,<4.0" classifiers = [ "Intended Audience :: Developers", @@ -24,21 +23,15 @@ dependencies = [ "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pyyaml>=6.0.0", + "pluggy>=1.6.0", "typer>=0.9.0", - "republic>=0.5.2", + "republic>=0.5.3", + "any-llm-sdk[anthropic]", "rich>=13.0.0", "prompt-toolkit>=3.0.0", "python-telegram-bot>=21.0", "loguru>=0.7.2", - "telegramify-markdown>=0.5.4", - "apscheduler>=3.11.2", - "aiohttp>=3.13.3", - "rapidfuzz>=3.14.1", - "discord-py>=2.6.4", - "requests>=2.32.5", - "python-dotenv>=1.2.1", - "any-llm-sdk[anthropic,vertexai]>=1.8.0", - "httpx[socks]>=0.28.1", + "rapidfuzz>=3.14.3", ] [project.urls] @@ -47,7 +40,7 @@ Repository = "https://github.com/bubbuild/bub" Documentation = "https://bub.build" [project.scripts] -bub = "bub.cli:app" +bub = "bub.__main__:app" [dependency-groups] dev = [ @@ -65,11 +58,11 @@ dev = [ ] [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["pdm-backend"] +build-backend = "pdm.backend" -[tool.hatch.build.targets.wheel] -packages = ["src/bub"] +[tool.pdm.build] +includes = ["src/bub", "src/bub_skills"] [tool.vulture] ignore_names = ["test_*", "Test*"] @@ -77,7 +70,6 @@ paths = ["src"] [tool.mypy] files = ["src"] -exclude = ['^src/bub/skills/.*$'] disallow_untyped_defs = false disallow_any_unimported = false no_implicit_optional = true @@ -86,6 +78,9 @@ warn_return_any = true warn_unused_ignores = true show_error_codes = true ignore_missing_imports = true +exclude = [ + "src/bub_skills/.*/scripts/.*", +] [tool.pytest.ini_options] testpaths = ["tests"] @@ -94,7 +89,9 @@ testpaths = ["tests"] target-version = "py312" line-length = 120 fix = true -extend-exclude = ["src/bub/skills/**/scripts/*.py"] +extend-exclude = [ + "src/bub_skills/**/scripts/*" +] [tool.ruff.lint] select = [ @@ -142,7 +139,6 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101"] -"src/bub/tools/builtin.py" = ["C901"] [tool.ruff.format] preview = true diff --git a/src/bub/__init__.py b/src/bub/__init__.py index b74281cc..de728f0f 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,6 +1,8 @@ -"""Bub package.""" +"""Bub framework package.""" -from bub.cli import app +from bub.framework import BubFramework +from bub.hookspecs import hookimpl +from bub.tools import tool -__all__ = ["app"] -__version__ = "0.2.3" +__all__ = ["BubFramework", "hookimpl", "tool"] +__version__ = "0.3.0" diff --git a/src/bub/__main__.py b/src/bub/__main__.py new file mode 100644 index 00000000..0a505fd7 --- /dev/null +++ b/src/bub/__main__.py @@ -0,0 +1,27 @@ +"""Bub framework CLI bootstrap.""" + +from __future__ import annotations + +import typer + +from bub.framework import BubFramework + + +def create_cli_app() -> typer.Typer: + framework = BubFramework() + framework.load_hooks() + app = framework.create_cli_app() + + if not app.registered_commands: + + @app.command("help") + def _help() -> None: + typer.echo("No CLI command skills loaded. Install a command skill in .agent/skills.") + + return app + + +app = create_cli_app() + +if __name__ == "__main__": + app() diff --git a/src/bub/app/__init__.py b/src/bub/app/__init__.py deleted file mode 100644 index 08482d1a..00000000 --- a/src/bub/app/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Application runtime package.""" - -from bub.app.bootstrap import build_runtime, get_runtime -from bub.app.runtime import AppRuntime, SessionRuntime - -__all__ = ["AppRuntime", "SessionRuntime", "build_runtime", "get_runtime"] diff --git a/src/bub/app/bootstrap.py b/src/bub/app/bootstrap.py deleted file mode 100644 index 109fba07..00000000 --- a/src/bub/app/bootstrap.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Runtime bootstrap helpers.""" - -from __future__ import annotations - -from pathlib import Path - -from bub.app.runtime import AppRuntime -from bub.config import load_settings - -# Global singleton runtime instance -_runtime: AppRuntime | None = None - - -def get_runtime() -> AppRuntime: - """Get or create the global app runtime.""" - if _runtime is None: - raise RuntimeError("AppRuntime is not initialized. Call build_runtime() first.") - return _runtime - - -def build_runtime( - workspace: Path, - *, - model: str | None = None, - max_tokens: int | None = None, - allowed_tools: set[str] | None = None, - allowed_skills: set[str] | None = None, - enable_scheduler: bool = True, -) -> AppRuntime: - """Build app runtime for one workspace.""" - - global _runtime - settings = load_settings(workspace) - updates: dict[str, object] = {} - if model: - updates["model"] = model - if max_tokens is not None: - updates["max_tokens"] = max_tokens - if updates: - settings = settings.model_copy(update=updates) - _runtime = AppRuntime( - workspace, - settings, - allowed_tools=allowed_tools, - allowed_skills=allowed_skills, - enable_scheduler=enable_scheduler, - ) - return _runtime diff --git a/src/bub/app/jobstore.py b/src/bub/app/jobstore.py deleted file mode 100644 index 7e66e4a1..00000000 --- a/src/bub/app/jobstore.py +++ /dev/null @@ -1,143 +0,0 @@ -import base64 -import json -import pickle -import threading -from datetime import datetime -from pathlib import Path -from typing import Any - -from apscheduler.job import Job -from apscheduler.jobstores.base import BaseJobStore, ConflictingIdError, JobLookupError -from loguru import logger - - -class JSONJobStore(BaseJobStore): - """ - A simple JSON-based job store for APScheduler. - - Jobs are serialized using pickle and stored as base64-encoded strings in a JSON file. - This provides persistence across restarts without requiring SQLAlchemy. - """ - - def __init__(self, file_path: str | Path): - super().__init__() - self.file_path = Path(file_path) - self._lock = threading.RLock() - self._jobs: dict[str, dict[str, Any]] = self._load() - - def _load(self) -> dict[str, dict[str, Any]]: - """Load jobs from JSON file.""" - if self.file_path.exists(): - try: - with open(self.file_path, encoding="utf-8") as f: - loaded_jobs = json.load(f) - return loaded_jobs # type: ignore[no-any-return] - except (OSError, json.JSONDecodeError) as e: - logger.error(f"Error loading job store: {e}") - return {} - - def _save(self): - """Save jobs to JSON file.""" - self.file_path.parent.mkdir(parents=True, exist_ok=True) - try: - with open(self.file_path, "w", encoding="utf-8") as f: - json.dump(self._jobs, f, ensure_ascii=False, indent=2) - except OSError as e: - logger.error(f"Error saving job store: {e}") - - def _serialize_job(self, job: Job) -> dict[str, Any]: - """Serialize a job to a storable format.""" - return { - "id": job.id, - "data": base64.b64encode(pickle.dumps(job)).decode("ascii"), - "next_run_time": (job.next_run_time.isoformat() if job.next_run_time else None), - } - - def _deserialize_job(self, job_data: dict[str, Any]) -> Job | None: - """Deserialize a job from stored format.""" - try: - job = pickle.loads(base64.b64decode(job_data["data"])) # noqa: S301 - job._scheduler = self._scheduler - job._jobstore_alias = self._alias - except Exception as e: - logger.error(f"Error deserializing job {job_data.get('id')}: {e}") - return None - else: - return job - - def shutdown(self): - """Called when the scheduler shuts down.""" - with self._lock: - self._save() - - def lookup_job(self, job_id: str) -> Job | None: - """Look up a job by its ID.""" - with self._lock: - job_data = self._jobs.get(job_id) - if job_data: - return self._deserialize_job(job_data) - return None - - def get_due_jobs(self, now: datetime) -> list[Job]: - """Get jobs that are due to be run.""" - with self._lock: - due_jobs = [] - for job_data in self._jobs.values(): - next_run_time_str = job_data.get("next_run_time") - if next_run_time_str: - next_run_time = datetime.fromisoformat(next_run_time_str) - if next_run_time <= now: - job = self._deserialize_job(job_data) - if job: - due_jobs.append(job) - return due_jobs - - def get_next_run_time(self) -> datetime | None: - """Get the earliest next run time among all jobs.""" - with self._lock: - next_times = [] - for job_data in self._jobs.values(): - next_run_time_str = job_data.get("next_run_time") - if next_run_time_str: - next_times.append(datetime.fromisoformat(next_run_time_str)) - return min(next_times) if next_times else None - - def get_all_jobs(self) -> list[Job]: - """Get all jobs in the store.""" - with self._lock: - jobs = [] - for job_data in self._jobs.values(): - job = self._deserialize_job(job_data) - if job: - jobs.append(job) - return jobs - - def add_job(self, job: Job): - """Add a job to the store.""" - with self._lock: - if job.id in self._jobs: - raise ConflictingIdError(job.id) - self._jobs[job.id] = self._serialize_job(job) - self._save() - - def update_job(self, job: Job): - """Update a job in the store.""" - with self._lock: - if job.id not in self._jobs: - raise JobLookupError(job.id) - self._jobs[job.id] = self._serialize_job(job) - self._save() - - def remove_job(self, job_id: str): - """Remove a job from the store.""" - with self._lock: - if job_id not in self._jobs: - raise JobLookupError(job_id) - del self._jobs[job_id] - self._save() - - def remove_all_jobs(self): - """Remove all jobs from the store.""" - with self._lock: - self._jobs.clear() - self._save() diff --git a/src/bub/app/runtime.py b/src/bub/app/runtime.py deleted file mode 100644 index 2fa31f17..00000000 --- a/src/bub/app/runtime.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Application runtime and session management.""" - -from __future__ import annotations - -import asyncio -import contextlib -import importlib -import os -import signal -from collections.abc import AsyncGenerator -from contextlib import suppress -from dataclasses import dataclass -from hashlib import md5 -from pathlib import Path -from types import SimpleNamespace -from typing import TYPE_CHECKING - -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.schedulers.base import BaseScheduler -from loguru import logger - -from bub.app.jobstore import JSONJobStore -from bub.config.settings import Settings -from bub.core import AgentLoop, InputRouter, LoopResult, ModelRunner -from bub.integrations.republic_client import build_llm, build_tape_store, read_workspace_agents_prompt -from bub.skills.loader import SkillMetadata, discover_skills -from bub.tape import TapeService, default_tape_context -from bub.tools import ProgressiveToolView, ToolRegistry -from bub.tools.builtin import register_builtin_tools - -if TYPE_CHECKING: - from bub.channels.manager import ChannelManager - - -def _session_slug(session_id: str) -> str: - return md5(session_id.encode("utf-8")).hexdigest()[:16] # noqa: S324 - - -@dataclass -class SessionRuntime: - """Runtime state for one deterministic session.""" - - session_id: str - loop: AgentLoop - tape: TapeService - model_runner: ModelRunner - tool_view: ProgressiveToolView - - async def handle_input(self, text: str) -> LoopResult: - await self.tape.ensure_bootstrap_anchor() - with self.tape.fork_tape() as tape: - tape.context = default_tape_context({"session_id": self.session_id}) - return await self.loop.handle_input(text) - - def reset_context(self) -> None: - """Clear volatile in-memory context while keeping the same session identity.""" - self.model_runner.reset_context() - self.tool_view.reset() - - -class AppRuntime: - """Global runtime that manages multiple session loops.""" - - def __init__( - self, - workspace: Path, - settings: Settings, - *, - allowed_tools: set[str] | None = None, - allowed_skills: set[str] | None = None, - enable_scheduler: bool = True, - ) -> None: - self.workspace = workspace.resolve() - self.settings = settings - self._allowed_skills = _normalize_name_set(allowed_skills) - self._allowed_tools = _normalize_name_set(allowed_tools) - self._store = build_tape_store(settings, self.workspace) - self.scheduler = self._default_scheduler() - self._llm = build_llm(settings, self._store) - self._sessions: dict[str, SessionRuntime] = {} - self._active_inputs: set[asyncio.Task[LoopResult]] = set() - self._enable_scheduler = enable_scheduler - - def _default_scheduler(self) -> BaseScheduler: - job_store = JSONJobStore(self.settings.resolve_home() / "jobs.json") - return BackgroundScheduler(daemon=True, jobstores={"default": job_store}) - - def __enter__(self) -> AppRuntime: - if not self.scheduler.running and self._enable_scheduler: - self.scheduler.start() - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - if self.scheduler.running and self._enable_scheduler: - with suppress(Exception): - self.scheduler.shutdown() - - def discover_skills(self) -> list[SkillMetadata]: - discovered = discover_skills(self.workspace) - if self._allowed_skills is None: - return discovered - return [skill for skill in discovered if skill.name.casefold() in self._allowed_skills] - - def get_session(self, session_id: str) -> SessionRuntime: - existing = self._sessions.get(session_id) - if existing is not None: - return existing - - tape_name = f"{self.settings.tape_name}:{_session_slug(session_id)}" - tape = TapeService(self._llm, tape_name, store=self._store) - - registry = ToolRegistry(self._allowed_tools) - register_builtin_tools(registry, workspace=self.workspace, tape=tape, runtime=self) - tool_view = ProgressiveToolView(registry) - router = InputRouter(registry, tool_view, tape, self.workspace) - runner = ModelRunner( - tape=tape, - router=router, - tool_view=tool_view, - tools=registry.model_tools(), - list_skills=self.discover_skills, - model=self.settings.model, - max_steps=self.settings.max_steps, - max_tokens=self.settings.max_tokens, - model_timeout_seconds=self.settings.model_timeout_seconds, - base_system_prompt=self.settings.system_prompt, - get_workspace_system_prompt=lambda: read_workspace_agents_prompt(self.workspace), - ) - loop = AgentLoop(router=router, model_runner=runner, tape=tape) - runtime = SessionRuntime(session_id=session_id, loop=loop, tape=tape, model_runner=runner, tool_view=tool_view) - self._sessions[session_id] = runtime - return runtime - - async def handle_input(self, session_id: str, text: str) -> LoopResult: - session = self.get_session(session_id) - task = asyncio.create_task(session.handle_input(text)) - self._active_inputs.add(task) - try: - return await task - finally: - self._active_inputs.discard(task) - - async def _cancel_active_inputs(self) -> int: - """Cancel all in-flight input tasks and return canceled count.""" - count = 0 - while self._active_inputs: - task = self._active_inputs.pop() - task.cancel() - with suppress(asyncio.CancelledError): - await task - count += 1 - return count - - def reset_session_context(self, session_id: str) -> None: - """Reset volatile context for an already-created session.""" - session = self._sessions.get(session_id) - if session is None: - return - session.reset_context() - - @contextlib.asynccontextmanager - async def graceful_shutdown(self) -> AsyncGenerator[asyncio.Event, None]: - """Run the runtime indefinitely with graceful shutdown.""" - stop_event = asyncio.Event() - loop = asyncio.get_running_loop() - handled_signals: list[signal.Signals] = [] - for sig in (signal.SIGINT, signal.SIGTERM): - try: - loop.add_signal_handler(sig, stop_event.set) - handled_signals.append(sig) - except (NotImplementedError, RuntimeError): - continue - current_task = asyncio.current_task() - future = asyncio.ensure_future(stop_event.wait()) - future.add_done_callback(lambda _, task=current_task: task and task.cancel()) # type: ignore[misc] - try: - yield stop_event - finally: - future.cancel() - cancelled = await self._cancel_active_inputs() - if cancelled: - logger.info("runtime.cancel_inflight count={}", cancelled) - for sig in handled_signals: - with suppress(NotImplementedError, RuntimeError): - loop.remove_signal_handler(sig) - - def install_hooks(self, channel_manager: ChannelManager) -> None: - """Install hooks for cross-cutting concerns like channel integration.""" - - hooks_module_str = os.getenv("BUB_HOOKS_MODULE") - if not hooks_module_str: - return - try: - module = importlib.import_module(hooks_module_str) - except ImportError as e: - raise ImportError(f"Failed to import hooks module '{hooks_module_str}'") from e - if not hasattr(module, "install"): - raise AttributeError(f"Hooks module '{hooks_module_str}' does not have an 'install' function") - hooks_context = SimpleNamespace( - runtime=self, - register_channel=channel_manager.register, - default_channels=channel_manager.default_channels, - ) - module.install(hooks_context) - - -def _normalize_name_set(raw: set[str] | None) -> set[str] | None: - if raw is None: - return None - - normalized = {name.strip().casefold() for name in raw if name.strip()} - return normalized or None diff --git a/src/bub/builtin/__init__.py b/src/bub/builtin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py new file mode 100644 index 00000000..c7b888a2 --- /dev/null +++ b/src/bub/builtin/agent.py @@ -0,0 +1,258 @@ +"""Republic-driven runtime engine to process prompts.""" + +from __future__ import annotations + +import asyncio +import inspect +import re +import shlex +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from functools import cached_property +from pathlib import Path +from typing import Any + +from republic import LLM, AsyncTapeStore, ToolAutoResult, ToolContext +from republic.tape import InMemoryTapeStore, Tape + +from bub.builtin.context import default_tape_context +from bub.builtin.settings import AgentSettings +from bub.builtin.store import ForkTapeStore +from bub.builtin.tape import TapeService +from bub.framework import BubFramework +from bub.skills import discover_skills, render_skills_prompt +from bub.tools import REGISTRY, model_tools, render_tools_prompt +from bub.types import State +from bub.utils import workspace_from_state + +CONTINUE_PROMPT = "Continue the task." +DEFAULT_BUB_HEADERS = {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"} +HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") + + +class Agent: + """Agent that processes prompts using hooks and tools. Backed by republic.""" + + def __init__(self, framework: BubFramework) -> None: + self.settings = _load_runtime_settings() + self.framework = framework + + @cached_property + def tapes(self) -> TapeService: + tape_store = self.framework.get_tape_store() + if tape_store is None: + tape_store = InMemoryTapeStore() + tape_store = ForkTapeStore(tape_store) + llm = _build_llm(self.settings, tape_store) + return TapeService(llm, self.settings.home / "tapes", tape_store) + + async def run(self, *, session_id: str, prompt: str, state: State) -> str: + stripped = prompt.strip() + if not stripped: + return "error: empty prompt" + tape = self.tapes.session_tape(session_id, workspace_from_state(state)) + tape.context.state.update(state) + async with self.tapes.fork_tape(tape.name): + await self.tapes.ensure_bootstrap_anchor(tape.name) + if stripped.startswith(","): + return await self._run_command(tape=tape, line=stripped) + return await self._agent_loop(tape=tape, prompt=stripped) + + async def _run_command(self, tape: Tape, *, line: str) -> str: + line = line[1:].strip() + if not line: + raise ValueError("empty command") + + name, arg_tokens = _parse_internal_command(line) + start = time.monotonic() + context = ToolContext(tape=tape.name, run_id="run_command", state=tape.context.state) + output = "" + status = "ok" + try: + if name not in REGISTRY: + output = await REGISTRY["bash"].run(context=context, cmd=line) + else: + args = _parse_args(arg_tokens) + if REGISTRY[name].context: + args.kwargs["context"] = context + output = REGISTRY[name].run(*args.positional, **args.kwargs) + if inspect.isawaitable(output): + output = await output + except Exception as exc: + status = "error" + output = f"{exc!s}" + raise + else: + return output if isinstance(output, str) else str(output) + finally: + elapsed_ms = int((time.monotonic() - start) * 1000) + output_text = output if isinstance(output, str) else str(output) + + event_payload = { + "raw": line, + "name": name, + "status": status, + "elapsed_ms": elapsed_ms, + "output": output_text, + "date": datetime.now(UTC).isoformat(), + } + await self.tapes.append_event(tape.name, "command", event_payload) + + async def _agent_loop(self, *, tape: Tape, prompt: str) -> str: + next_prompt = prompt + + for step in range(1, self.settings.max_steps + 1): + start = time.monotonic() + await self.tapes.append_event(tape.name, "loop.step.start", {"step": step, "prompt": next_prompt}) + try: + output = await self._run_tools_once(tape=tape, prompt=next_prompt) + except Exception as exc: + elapsed_ms = int((time.monotonic() - start) * 1000) + await self.tapes.append_event( + tape.name, + "loop.step", + { + "step": step, + "elapsed_ms": elapsed_ms, + "status": "error", + "error": f"{exc!s}", + "date": datetime.now(UTC).isoformat(), + }, + ) + raise + + outcome = _resolve_tool_auto_result(output) + elapsed_ms = int((time.monotonic() - start) * 1000) + if outcome.kind == "text": + await self.tapes.append_event( + tape.name, + "loop.step", + { + "step": step, + "elapsed_ms": elapsed_ms, + "status": "ok", + "date": datetime.now(UTC).isoformat(), + }, + ) + return outcome.text + if outcome.kind == "continue": + if "context" in tape.context.state: + next_prompt = f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]" + else: + next_prompt = CONTINUE_PROMPT + await self.tapes.append_event( + tape.name, + "loop.step", + { + "step": step, + "elapsed_ms": elapsed_ms, + "status": "continue", + "date": datetime.now(UTC).isoformat(), + }, + ) + continue + await self.tapes.append_event( + tape.name, + "loop.step", + { + "step": step, + "elapsed_ms": elapsed_ms, + "status": "error", + "error": outcome.error, + "date": datetime.now(UTC).isoformat(), + }, + ) + raise RuntimeError(outcome.error) + + raise RuntimeError(f"max_steps_reached={self.settings.max_steps}") + + def _load_skills_prompt(self, prompt: str, workspace: Path) -> str: + skill_index = {skill.name: skill for skill in discover_skills(workspace)} + expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys()) + return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills) + + async def _run_tools_once(self, *, tape: Tape, prompt: str) -> ToolAutoResult: + extra_options = {"extra_headers": DEFAULT_BUB_HEADERS} if self.settings.model.startswith("openrouter:") else {} + async with asyncio.timeout(self.settings.model_timeout_seconds): + return await tape.run_tools_async( + prompt=prompt, + system_prompt=self._system_prompt(prompt, state=tape.context.state), + max_tokens=self.settings.max_tokens, + tools=model_tools(REGISTRY.values()), + **extra_options, + ) + + def _system_prompt(self, prompt: str, state: State) -> str: + blocks: list[str] = [] + if result := self.framework.get_system_prompt(prompt=prompt, state=state): + blocks.append(result) + tools_prompt = render_tools_prompt(REGISTRY.values()) + if tools_prompt: + blocks.append(tools_prompt) + workspace = workspace_from_state(state) + if skills_prompt := self._load_skills_prompt(prompt, workspace): + blocks.append(skills_prompt) + return "\n\n".join(blocks) + + +@dataclass(frozen=True) +class _ToolAutoOutcome: + kind: str + text: str = "" + error: str = "" + + +def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome: + if output.kind == "text": + return _ToolAutoOutcome(kind="text", text=output.text or "") + if output.kind == "tools" or output.tool_calls or output.tool_results: + return _ToolAutoOutcome(kind="continue") + if output.error is None: + return _ToolAutoOutcome(kind="error", error="tool_auto_error: unknown") + error_kind = getattr(output.error.kind, "value", str(output.error.kind)) + return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}") + + +def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore) -> LLM: + return LLM( + settings.model, + api_key=settings.api_key, + api_base=settings.api_base, + tape_store=tape_store, + context=default_tape_context(), + ) + + +def _load_runtime_settings() -> AgentSettings: + return AgentSettings() + + +@dataclass(frozen=True) +class Args: + positional: list[str] + kwargs: dict[str, Any] + + +def _parse_internal_command(line: str) -> tuple[str, list[str]]: + body = line.strip() + words = shlex.split(body) + if not words: + return "", [] + return words[0], words[1:] + + +def _parse_args(args_tokens: list[str]) -> Args: + positional: list[str] = [] + kwargs: dict[str, str] = {} + first_kwarg = False + for token in args_tokens: + if "=" in token: + key, value = token.split("=", 1) + kwargs[key] = value + first_kwarg = True + elif first_kwarg: + raise ValueError(f"positional argument '{token}' cannot appear after keyword arguments") + else: + positional.append(token) + return Args(positional=positional, kwargs=kwargs) diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py new file mode 100644 index 00000000..2eab2ed5 --- /dev/null +++ b/src/bub/builtin/cli.py @@ -0,0 +1,84 @@ +"""Builtin CLI command adapter.""" + +# ruff: noqa: B008 +from __future__ import annotations + +import asyncio + +import typer + +from bub.channels.message import ChannelMessage +from bub.envelope import field_of +from bub.framework import BubFramework + +app = typer.Typer() + + +def run( + ctx: typer.Context, + message: str = typer.Argument(..., help="Inbound message content"), + channel: str = typer.Option("cli", "--channel", help="Message channel"), + chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), + sender_id: str = typer.Option("human", "--sender-id", help="Sender id"), + session_id: str | None = typer.Option(None, "--session-id", help="Optional session id"), +) -> None: + """Run one inbound message through the framework pipeline.""" + + framework = ctx.ensure_object(BubFramework) + inbound = ChannelMessage( + session_id=f"{channel}:{chat_id}" if session_id is None else session_id, + content=message, + channel=channel, + chat_id=chat_id, + context={"sender_id": sender_id}, + ) + + result = asyncio.run(framework.process_inbound(inbound)) + for outbound in result.outbounds: + rendered = str(field_of(outbound, "content", "")) + target_channel = str(field_of(outbound, "channel", "stdout")) + target_chat = str(field_of(outbound, "chat_id", "local")) + typer.echo(f"[{target_channel}:{target_chat}]\n{rendered}") + + +def list_hooks(ctx: typer.Context) -> None: + """Show hook implementation mapping.""" + framework = ctx.ensure_object(BubFramework) + report = framework.hook_report() + if not report: + typer.echo("(no hook implementations)") + return + for hook_name, adapter_names in report.items(): + typer.echo(f"{hook_name}: {', '.join(adapter_names)}") + + +def gateway( + ctx: typer.Context, + enable_channels: list[str] = typer.Option([], "--enable-channel", help="Channels to enable for CLI (default: all)"), +) -> None: + """Start message listeners(like telegram).""" + from bub.channels.manager import ChannelManager + + framework = ctx.ensure_object(BubFramework) + + manager = ChannelManager(framework, enabled_channels=enable_channels or None) + asyncio.run(manager.listen_and_run()) + + +def chat( + ctx: typer.Context, + chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), + session_id: str | None = typer.Option(None, "--session-id", help="Optional session id"), +) -> None: + """Start a REPL chat session.""" + from bub.channels.manager import ChannelManager + + framework = ctx.ensure_object(BubFramework) + + manager = ChannelManager(framework, enabled_channels=["cli"]) + channel = manager.get_channel("cli") + if channel is None: + typer.echo("CLI channel not found. Please check your hook implementations.") + raise typer.Exit(1) + channel.set_metadata(chat_id=chat_id, session_id=session_id) # type: ignore[attr-defined] + asyncio.run(manager.listen_and_run()) diff --git a/src/bub/tape/context.py b/src/bub/builtin/context.py similarity index 100% rename from src/bub/tape/context.py rename to src/bub/builtin/context.py diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py new file mode 100644 index 00000000..e6c92e5d --- /dev/null +++ b/src/bub/builtin/hook_impl.py @@ -0,0 +1,162 @@ +import sys +from pathlib import Path + +import typer +from loguru import logger +from republic.tape import TapeStore + +from bub.builtin.agent import Agent +from bub.channels.base import Channel +from bub.channels.message import ChannelMessage +from bub.envelope import content_of, field_of +from bub.framework import BubFramework +from bub.hookspecs import hookimpl +from bub.types import Envelope, MessageHandler, State + +AGENTS_FILE_NAME = "AGENTS.md" +DEFAULT_SYSTEM_PROMPT = """\ + +Call tools or use skills to finish the task users assigned. When enough evidence is collected, return plain natural language answer. + + +Excessively long context may cause model call failures. In this case, you MAY use tape.info to the token usage and you SHOULD use tape.handoff tool to shorten the length of the retrieved history. + + +You MUST send message to the corresponding channel before finish when you want to respond. +Route your response to the same channel the message came from. +There is a skill named `{channel}` for each channel that you need to figure out how to send a response to that channel. +## Before finishing ANY response to a channel message: +1. Identify the source channel from the user message metadata +2. Prepare your response text +3. Call the corresponding channel skill to deliver the message +4. ONLY THEN end your turn + +""" + + +class BuiltinImpl: + """Default hook implementations for basic runtime operations.""" + + def __init__(self, framework: BubFramework) -> None: + from bub.builtin import tools # noqa: F401 + + self.framework = framework + self.agent = Agent(framework) + + @hookimpl + def resolve_session(self, message: ChannelMessage) -> str: + session_id = field_of(message, "session_id") + if session_id is not None and str(session_id).strip(): + return str(session_id) + channel = str(field_of(message, "channel", "default")) + chat_id = str(field_of(message, "chat_id", "default")) + return f"{channel}:{chat_id}" + + @hookimpl + async def load_state(self, message: ChannelMessage, session_id: str) -> State: + lifespan = field_of(message, "lifespan") + if lifespan is not None: + await lifespan.__aenter__() + state = {"session_id": session_id, "_runtime_agent": self.agent} + if context := field_of(message, "context_str"): + state["context"] = context + return state + + @hookimpl + async def save_state(self, session_id: str, state: State, message: ChannelMessage, model_output: str) -> None: + tp, value, traceback = sys.exc_info() + lifespan = field_of(message, "lifespan") + if lifespan is not None: + await lifespan.__aexit__(tp, value, traceback) + + @hookimpl + def build_prompt(self, message: ChannelMessage, session_id: str, state: State) -> str: + content = content_of(message) + if content.startswith(","): + message.kind = "command" + return content + context = field_of(message, "context_str") + context_prefix = f"{context}\n---\n" if context else "" + return f"{context_prefix}{content}" + + @hookimpl + async def run_model(self, prompt: str, session_id: str, state: State) -> str: + return await self.agent.run(session_id=session_id, prompt=prompt, state=state) + + @hookimpl + def register_cli_commands(self, app: typer.Typer) -> None: + from bub.builtin import cli + + app.command("run")(cli.run) + app.command("chat")(cli.chat) + app.command("hooks", hidden=True)(cli.list_hooks) + app.command("message", hidden=True)(app.command("gateway")(cli.gateway)) + + def _read_agents_file(self, state: State) -> str: + workspace = state.get("_runtime_workspace", str(Path.cwd())) + prompt_path = Path(workspace) / AGENTS_FILE_NAME + if not prompt_path.is_file(): + return "" + try: + return prompt_path.read_text(encoding="utf-8").strip() + except OSError: + return "" + + @hookimpl + def system_prompt(self, prompt: str, state: State) -> str: + # Read the content of AGENTS.md under workspace + return DEFAULT_SYSTEM_PROMPT + "\n\n" + self._read_agents_file(state) + + @hookimpl + def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: + from bub.channels.cli import CliChannel + from bub.channels.telegram import TelegramChannel + + return [ + TelegramChannel(on_receive=message_handler), + CliChannel(on_receive=message_handler, agent=self.agent), + ] + + @hookimpl + async def on_error(self, stage: str, error: Exception, message: Envelope | None) -> None: + if message is not None: + outbound = ChannelMessage( + session_id=field_of(message, "session_id", "unknown"), + channel=field_of(message, "channel", "default"), + chat_id=field_of(message, "chat_id", "default"), + content=f"An error occurred at stage '{stage}': {error}", + kind="error", + ) + await self.framework._hook_runtime.call_many("dispatch_outbound", message=outbound) + + @hookimpl + async def dispatch_outbound(self, message: Envelope) -> bool: + content = content_of(message) + session_id = field_of(message, "session_id") + if field_of(message, "output_channel") != "cli": + logger.info("session.run.outbound session_id={} content={}", session_id, content) + return await self.framework.dispatch_via_router(message) + + @hookimpl + def render_outbound( + self, + message: Envelope, + session_id: str, + state: State, + model_output: str, + ) -> list[ChannelMessage]: + outbound = ChannelMessage( + session_id=session_id, + channel=field_of(message, "channel", "default"), + chat_id=field_of(message, "chat_id", "default"), + content=model_output, + output_channel=field_of(message, "output_channel", "default"), + kind=field_of(message, "kind", "normal"), + ) + return [outbound] + + @hookimpl + def provide_tape_store(self) -> TapeStore: + from bub.builtin.store import FileTapeStore + + return FileTapeStore(directory=self.agent.settings.home / "tapes") diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py new file mode 100644 index 00000000..7accc943 --- /dev/null +++ b/src/bub/builtin/settings.py @@ -0,0 +1,23 @@ +import pathlib + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" +DEFAULT_MAX_TOKENS = 1024 +DEFAULT_HOME = pathlib.Path.home() / ".bub" + + +class AgentSettings(BaseSettings): + """Configuration settings for the Agent, loaded from environment variables with prefix BUB_ or from a .env file.""" + + model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore", env_file=".env") + + home: pathlib.Path = Field(default=DEFAULT_HOME) + + model: str = DEFAULT_MODEL + api_key: str | None = None + api_base: str | None = None + max_steps: int = 50 + max_tokens: int = DEFAULT_MAX_TOKENS + model_timeout_seconds: int | None = None diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py new file mode 100644 index 00000000..f8827b52 --- /dev/null +++ b/src/bub/builtin/store.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import contextlib +import contextvars +import itertools +import json +import threading +from collections.abc import AsyncGenerator, Iterable +from dataclasses import asdict +from datetime import UTC, datetime +from pathlib import Path +from typing import cast + +from loguru import logger +from republic import AsyncTapeStore, TapeEntry, TapeQuery +from republic.tape import AsyncTapeStoreAdapter, InMemoryQueryMixin, InMemoryTapeStore, TapeStore +from republic.tape.store import is_async_tape_store + +current_store: contextvars.ContextVar[TapeStore] = contextvars.ContextVar("current_store") + + +class ForkTapeStore: + def __init__(self, parent: AsyncTapeStore | TapeStore) -> None: + if is_async_tape_store(parent): + self._parent = parent + else: + self._parent = AsyncTapeStoreAdapter(parent) + + @property + def _current(self) -> TapeStore: + return current_store.get(_emtpy_store) + + async def list_tapes(self) -> list[str]: + return cast(list[str], await self._parent.list_tapes()) + + async def reset(self, tape: str) -> None: + self._current.reset(tape) + await self._parent.reset(tape) + + async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: + try: + parent_entries = await self._parent.fetch_all(query) + except Exception: + parent_entries = [] + this_entries: list[TapeEntry] = [] + if hasattr(self._current, "read"): + for entry in cast(list[TapeEntry], self._current.read(query.tape) or []): + if query._kinds and entry.kind not in query._kinds: + continue + if entry.kind == "anchor": # noqa: SIM102 + if query._after_last or (query._after_anchor and entry.payload.get("name") == query._after_anchor): + this_entries.clear() + parent_entries = [] + this_entries.append(entry) + return itertools.chain(parent_entries, this_entries) + + async def append(self, tape: str, entry: TapeEntry) -> None: + self._current.append(tape, entry) + + @contextlib.asynccontextmanager + async def fork(self, tape: str) -> AsyncGenerator[None, None]: + store = InMemoryTapeStore() + token = current_store.set(store) + try: + yield + finally: + current_store.reset(token) + entries = store.read(tape) + if entries: + count = len(entries) + for entry in entries: + await self._parent.append(tape, entry) + logger.info(f'Merged {count} entries into tape "{tape}"') + + +class EmptyTapeStore: + """Sync TapeStore sentinel that always returns empty results.""" + + def list_tapes(self) -> list[str]: + return [] + + def reset(self, tape: str) -> None: + pass + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: + return [] + + def append(self, tape: str, entry: TapeEntry) -> None: + pass + + +_emtpy_store = EmptyTapeStore() + + +class FileTapeStore(InMemoryQueryMixin): + """TapeStore implementation that persists tapes as JSONL files under a directory.""" + + def __init__(self, directory: Path) -> None: + self._directory = directory + self._directory.mkdir(parents=True, exist_ok=True) + + def _tape_file(self, tape: str) -> TapeFile: + return TapeFile(self._directory / f"{tape}.jsonl") + + def list_tapes(self) -> list[str]: + result: list[str] = [] + for file in self._directory.glob("*.jsonl"): + filename = file.stem + if filename.count("__") != 1: + continue + result.append(filename) + return result + + def reset(self, tape: str) -> None: + self._tape_file(tape).reset() + + def append(self, tape: str, entry: TapeEntry) -> None: + self._tape_file(tape).append(entry) + + def read(self, tape: str) -> list[TapeEntry] | None: + return self._tape_file(tape).read() + + +class TapeFile: + """Helper for one tape file.""" + + def __init__(self, path: Path) -> None: + self.path = path + self._lock = threading.Lock() + self._read_entries: list[TapeEntry] = [] + self._read_offset = 0 + + def _next_id(self) -> int: + if self._read_entries: + return cast(int, self._read_entries[-1].id + 1) + return 1 + + def _reset(self) -> None: + self._read_entries = [] + self._read_offset = 0 + + def reset(self) -> None: + with self._lock: + if self.path.exists(): + self.path.unlink() + self._reset() + + def read(self) -> list[TapeEntry]: + with self._lock: + return self._read_locked() + + def _read_locked(self) -> list[TapeEntry]: + if not self.path.exists(): + self._reset() + return [] + + file_size = self.path.stat().st_size + if file_size < self._read_offset: + # The file was truncated or replaced, so cached entries are stale. + self._reset() + + with self.path.open("r", encoding="utf-8") as handle: + handle.seek(self._read_offset) + for raw_line in handle: + line = raw_line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + entry = self.entry_from_payload(payload) + if entry is not None: + self._read_entries.append(entry) + self._read_offset = handle.tell() + + return list(self._read_entries) + + @staticmethod + def entry_from_payload(payload: object) -> TapeEntry | None: + if not isinstance(payload, dict): + return None + entry_id = payload.get("id") + kind = payload.get("kind") + entry_payload = payload.get("payload") + meta = payload.get("meta") + if not isinstance(entry_id, int): + return None + if not isinstance(kind, str): + return None + if not isinstance(entry_payload, dict): + return None + if not isinstance(meta, dict): + meta = {} + if "date" in payload: + date = payload["date"] + else: + date = datetime.fromtimestamp(payload.get("timestamp", 0.0), tz=UTC).isoformat() + return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), date) + + def append(self, entry: TapeEntry) -> None: + with self._lock: + # Keep cache and offset in sync before allocating new IDs. + self._read_locked() + with self.path.open("a", encoding="utf-8") as handle: + next_id = self._next_id() + stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta)) + handle.write(json.dumps(asdict(stored), ensure_ascii=False) + "\n") + self._read_entries.append(stored) + self._read_offset = handle.tell() diff --git a/src/bub/tape/service.py b/src/bub/builtin/tape.py similarity index 53% rename from src/bub/tape/service.py rename to src/bub/builtin/tape.py index 01a30d57..df85e788 100644 --- a/src/bub/tape/service.py +++ b/src/bub/builtin/tape.py @@ -1,23 +1,23 @@ -"""High-level tape service.""" - -from __future__ import annotations - import contextlib +import hashlib import json import re -from collections.abc import Generator -from contextvars import ContextVar -from dataclasses import dataclass +from collections.abc import AsyncGenerator +from dataclasses import asdict +from datetime import UTC, datetime from pathlib import Path from typing import Any, cast -from loguru import logger +from pydantic.dataclasses import dataclass from rapidfuzz import fuzz, process -from republic import LLM, TapeEntry -from republic.tape import Tape +from republic import LLM, Tape, TapeEntry -from bub.tape.anchors import AnchorSummary -from bub.tape.store import FileTapeStore +from bub.builtin.store import ForkTapeStore + +WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") +MIN_FUZZY_QUERY_LENGTH = 3 +MIN_FUZZY_SCORE = 80 +MAX_FUZZY_CANDIDATES = 128 @dataclass(frozen=True) @@ -32,65 +32,23 @@ class TapeInfo: last_token_usage: int | None -_tape_context: ContextVar[Tape] = ContextVar("tape") -WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") -MIN_FUZZY_QUERY_LENGTH = 3 -MIN_FUZZY_SCORE = 80 -MAX_FUZZY_CANDIDATES = 128 - +@dataclass(frozen=True) +class AnchorSummary: + """Rendered anchor summary.""" -def current_tape() -> str: - """Get the name of the current tape in context.""" - tape = _tape_context.get(None) - if tape is None: - return "-" - return tape.name # type: ignore[no-any-return] + name: str + state: dict[str, object] class TapeService: - """Tape helper with app-specific operations.""" - - def __init__(self, llm: LLM, tape_name: str, *, store: FileTapeStore) -> None: + def __init__(self, llm: LLM, archive_path: Path, store: ForkTapeStore) -> None: self._llm = llm + self._archive_path = archive_path self._store = store - self._tape = llm.tape(tape_name) - self._bootstrapped = False - - @property - def tape(self) -> Tape: - return _tape_context.get(self._tape) - - @contextlib.contextmanager - def fork_tape(self) -> Generator[Tape, None, None]: - fork_name = self._store.fork(self._tape.name) - reset_token = _tape_context.set(self._llm.tape(fork_name)) - try: - yield _tape_context.get() - finally: - self._store.merge(fork_name, self._tape.name) - _tape_context.reset(reset_token) - logger.info("Merged forked tape '{}' back into '{}'", fork_name, self._tape.name) - - async def ensure_bootstrap_anchor(self) -> None: - if self._bootstrapped: - return - self._bootstrapped = True - anchors = list(await self._tape.query_async.kinds("anchor").all()) - if anchors: - return - await self.handoff("session/start", state={"owner": "human"}) - - async def handoff(self, name: str, *, state: dict[str, Any] | None = None) -> list[TapeEntry]: - return cast(list[TapeEntry], await self.tape.handoff_async(name, state=state)) - - async def append_event(self, name: str, data: dict[str, Any]) -> None: - await self.tape.append_async(TapeEntry.event(name, data=data)) - - async def append_system(self, content: str) -> None: - await self.tape.append_async(TapeEntry.system(content)) - - async def info(self) -> TapeInfo: - entries = list(await self._tape.query_async.all()) + + async def info(self, tape_name: str) -> TapeInfo: + tape = self._llm.tape(tape_name) + entries = list(await tape.query_async.all()) anchors = [entry for entry in entries if entry.kind == "anchor"] last_anchor = anchors[-1].payload.get("name") if anchors else None if last_anchor is not None: @@ -98,16 +56,15 @@ async def info(self) -> TapeInfo: else: entries_since_last_anchor = entries last_token_usage: int | None = None - for entry in reversed(entries): + for entry in reversed(entries_since_last_anchor): if entry.kind == "event" and entry.payload.get("name") == "run": with contextlib.suppress(AttributeError): token_usage = entry.payload.get("data", {}).get("usage", {}).get("total_tokens") if token_usage and isinstance(token_usage, int): last_token_usage = token_usage break - return TapeInfo( - name=self._tape.name, + name=tape.name, entries=len(entries), anchors=len(anchors), last_anchor=str(last_anchor) if last_anchor else None, @@ -115,19 +72,15 @@ async def info(self) -> TapeInfo: last_token_usage=last_token_usage, ) - async def reset(self, *, archive: bool = False) -> str: - archive_path: Path | None = None - if archive and self._store is not None: - archive_path = self._store.archive(self._tape.name) - await self._tape.reset_async() - state = {"owner": "human"} - if archive_path is not None: - state["archived"] = str(archive_path) - await self._tape.handoff_async("session/start", state=state) - return f"Archived: {archive_path}" if archive_path else "ok" + async def ensure_bootstrap_anchor(self, tape_name: str) -> None: + tape = self._llm.tape(tape_name) + anchors = list(await tape.query_async.kinds("anchor").all()) + if not anchors: + await tape.handoff_async("session/start", state={"owner": "human"}) - async def anchors(self, *, limit: int = 20) -> list[AnchorSummary]: - entries = list(await self._tape.query_async.kinds("anchor").all()) + async def anchors(self, tape_name: str, limit: int = 20) -> list[AnchorSummary]: + tape = self._llm.tape(tape_name) + entries = list(await tape.query_async.kinds("anchor").all()) results: list[AnchorSummary] = [] for entry in entries[-limit:]: name = str(entry.payload.get("name", "-")) @@ -136,32 +89,39 @@ async def anchors(self, *, limit: int = 20) -> list[AnchorSummary]: results.append(AnchorSummary(name=name, state=state_dict)) return results - async def between_anchors(self, start: str, end: str, *, kinds: tuple[str, ...] = ()) -> list[TapeEntry]: - query = self.tape.query_async.between_anchors(start, end) - if kinds: - query = query.kinds(*kinds) - return list(await query.all()) - - async def after_anchor(self, anchor: str, *, kinds: tuple[str, ...] = ()) -> list[TapeEntry]: - query = self.tape.query_async.after_anchor(anchor) - if kinds: - query = query.kinds(*kinds) - return list(await query.all()) - - async def from_last_anchor(self, *, kinds: tuple[str, ...] = ()) -> list[TapeEntry]: - query = self.tape.query_async.last_anchor() - if kinds: - query = query.kinds(*kinds) - return list(await query.all()) - - async def search(self, query: str, *, limit: int = 20, all_tapes: bool = False) -> list[TapeEntry]: + async def _archive(self, tape_name: str) -> Path: + tape = self._llm.tape(tape_name) + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + self._archive_path.mkdir(parents=True, exist_ok=True) + archive_path = self._archive_path / f"{tape.name}.jsonl.{stamp}.bak" + with archive_path.open("w", encoding="utf-8") as f: + for entry in await tape.query_async.all(): + f.write(json.dumps(asdict(entry)) + "\n") + return archive_path + + async def reset(self, tape_name: str, *, archive: bool = False) -> str: + tape = self._llm.tape(tape_name) + archive_path: Path | None = None + if archive: + archive_path = await self._archive(tape_name) + await tape.reset_async() + state = {"owner": "human"} + if archive_path is not None: + state["archived"] = str(archive_path) + await tape.handoff_async("session/start", state=state) + return f"Archived: {archive_path}" if archive_path else "ok" + + async def handoff(self, tape_name: str, *, name: str, state: dict[str, Any] | None = None) -> list[TapeEntry]: + tape = self._llm.tape(tape_name) + entries = await tape.handoff_async(name, state=state) + return cast(list[TapeEntry], entries) + + async def search(self, tape_name: str, query: str, *, limit: int = 20) -> list[TapeEntry]: normalized_query = query.strip().lower() if not normalized_query: return [] results: list[TapeEntry] = [] - tapes = [self.tape] - if all_tapes: - tapes = [self._llm.tape(name) for name in self._store.list_tapes()] + tapes = [self._llm.tape(tape_name)] for tape in tapes: count = 0 @@ -214,3 +174,19 @@ def _is_fuzzy_match(normalized_query: str, payload_text: str, meta_text: str) -> score_cutoff=MIN_FUZZY_SCORE, ) return best_match is not None + + async def append_event(self, tape_name: str, name: str, payload: dict[str, Any], **meta: Any) -> None: + tape = self._llm.tape(tape_name) + await tape.append_async(TapeEntry.event(name=name, payload=payload, **meta)) + + def session_tape(self, session_id: str, workspace: Path) -> Tape: + workspace_hash = hashlib.md5(str(workspace.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + tape_name = ( + workspace_hash + "__" + hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + ) + return self._llm.tape(tape_name) + + @contextlib.asynccontextmanager + async def fork_tape(self, tape_name: str) -> AsyncGenerator[None, None]: + async with self._store.fork(tape_name): + yield diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py new file mode 100644 index 00000000..c8c9605a --- /dev/null +++ b/src/bub/builtin/tools.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from republic import ToolContext + +from bub.skills import discover_skills +from bub.tools import tool + +if TYPE_CHECKING: + from bub.builtin.agent import Agent + +DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 + + +def _get_agent(context: ToolContext) -> Agent: + if "_runtime_agent" not in context.state: + raise RuntimeError("no runtime agent found in tool context") + return cast("Agent", context.state["_runtime_agent"]) + + +@tool(context=True) +async def bash( + cmd: str, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS, *, context: ToolContext +) -> str: + """Run a shell command and return its output within a time limit. Raises if the command fails or times out.""" + workspace = context.state.get("_runtime_workspace") + completed = await asyncio.create_subprocess_shell( + cmd, + cwd=cwd or workspace, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + async with asyncio.timeout(timeout_seconds): + stdout_bytes, stderr_bytes = await completed.communicate() + stdout_text = (stdout_bytes or b"").decode("utf-8", errors="replace").strip() + stderr_text = (stderr_bytes or b"").decode("utf-8", errors="replace").strip() + if completed.returncode != 0: + message = stderr_text or stdout_text or f"exit={completed.returncode}" + raise RuntimeError(f"exit={completed.returncode}: {message}") + return stdout_text or "(no output)" + + +@tool(context=True, name="fs.read") +def fs_read(path: str, offset: int = 0, limit: int | None = None, *, context: ToolContext) -> str: + """Read a text file and return its content. Supports optional pagination with offset and limit.""" + resolved_path = _resolve_path(context, path) + text = resolved_path.read_text(encoding="utf-8") + lines = text.splitlines() + start = max(0, min(offset, len(lines))) + end = len(lines) if limit is None else min(len(lines), start + max(0, limit)) + return "\n".join(lines[start:end]) + + +@tool(context=True, name="fs.write") +def fs_write(path: str, content: str, *, context: ToolContext) -> str: + """Write content to a text file.""" + resolved_path = _resolve_path(context, path) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + resolved_path.write_text(content, encoding="utf-8") + return f"wrote: {resolved_path}" + + +@tool(context=True, name="fs.edit") +def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolContext) -> str: + """Edit a text file by replacing old text with new text. You can specify the line number to start searching for the old text.""" + resolved_path = _resolve_path(context, path) + text = resolved_path.read_text(encoding="utf-8") + lines = text.splitlines() + prev, to_replace = "\n".join(lines[:start]), "\n".join(lines[start:]) + if old not in to_replace: + raise ValueError(f"'{old}' not found in {resolved_path} from line {start}") + replaced = to_replace.replace(old, new) + resolved_path.write_text(prev + "\n" + replaced, encoding="utf-8") + return f"edited: {resolved_path}" + + +@tool(context=True, name="skill.load") +def skill_load(name: str, *, context: ToolContext) -> str: + """Load the skill content by name. The skill must be located in predefined locations and have a valid frontmatter.""" + from bub.utils import workspace_from_state + + workspace = workspace_from_state(context.state) + skill_index = {skill.name: skill for skill in discover_skills(workspace)} + if name.casefold() not in skill_index: + return "(no such skill)" + skill = skill_index[name.casefold()] + return skill.body() or "(skill has no body)" + + +@tool(context=True, name="tape.info") +async def tape_info(context: ToolContext) -> str: + """Get information about the current tape, such as number of entries and anchors.""" + agent = _get_agent(context) + info = await agent.tapes.info(context.tape or "") + return ( + f"name: {info.name}\n" + f"entries: {info.entries}\n" + f"anchors: {info.anchors}\n" + f"last_anchor: {info.last_anchor}\n" + f"entries_since_last_anchor: {info.entries_since_last_anchor}\n" + f"last_token_usage: {info.last_token_usage}" + ) + + +@tool(context=True, name="tape.search") +async def tape_search(query: str, limit: int = 20, *, context: ToolContext) -> str: + """Search for entries in the current tape that match the query. Returns a list of matching entries.""" + agent = _get_agent(context) + entries = await agent.tapes.search(context.tape or "", query=query, limit=limit) + if not entries: + return "(no matches)" + return "\n".join(f"- {json.dumps(entry.payload)}" for entry in entries) + + +@tool(context=True, name="tape.reset") +async def tape_reset(archive: bool = False, *, context: ToolContext) -> str: + """Reset the current tape, optionally archiving it.""" + agent = _get_agent(context) + result = await agent.tapes.reset(context.tape or "", archive=archive) + return result + + +@tool(context=True, name="tape.handoff") +async def tape_handoff(name: str = "handoff", summary: str = "", *, context: ToolContext) -> str: + """Add a handoff anchor to the current tape.""" + agent = _get_agent(context) + await agent.tapes.handoff(context.tape or "", name=name, state={"summary": summary}) + return f"anchor added: {name}" + + +@tool(context=True, name="tape.anchors") +async def tape_anchors(*, context: ToolContext) -> str: + """List anchors in the current tape.""" + agent = _get_agent(context) + anchors = await agent.tapes.anchors(context.tape or "") + if not anchors: + return "(no anchors)" + return "\n".join(f"- {anchor.name}" for anchor in anchors) + + +@tool(name="help") +def show_help() -> str: + """Show a help message.""" + return ( + "Commands use ',' at line start.\n" + "Known internal commands:\n" + " ,help\n" + " ,skill.load name=foo\n" + " ,tape.info\n" + " ,tape.search query=error\n" + " ,tape.handoff name=phase-1 summary='done'\n" + " ,tape.anchors\n" + " ,fs.read path=README.md\n" + " ,fs.write path=tmp.txt content='hello'\n" + " ,fs.edit path=tmp.txt old=hello new=world\n" + "Any unknown command after ',' is executed as shell via bash." + ) + + +def _resolve_path(context: ToolContext, raw_path: str) -> Path: + workspace = context.state.get("_runtime_workspace") + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + if workspace is None: + raise ValueError(f"relative path '{raw_path}' is not allowed without a workspace") + if not isinstance(workspace, str | Path): + raise TypeError("runtime workspace must be a filesystem path") + workspace_path = Path(workspace) + return (workspace_path / path).resolve() diff --git a/src/bub/channels/__init__.py b/src/bub/channels/__init__.py index d45f044b..12bcf9b5 100644 --- a/src/bub/channels/__init__.py +++ b/src/bub/channels/__init__.py @@ -1,17 +1,5 @@ -"""Channel adapters and bus exports.""" +from .base import Channel +from .manager import ChannelManager +from .message import ChannelMessage -from bub.channels.base import BaseChannel -from bub.channels.cli import CliChannel -from bub.channels.discord import DiscordChannel, DiscordConfig -from bub.channels.manager import ChannelManager -from bub.channels.telegram import TelegramChannel, TelegramConfig - -__all__ = [ - "BaseChannel", - "ChannelManager", - "CliChannel", - "DiscordChannel", - "DiscordConfig", - "TelegramChannel", - "TelegramConfig", -] +__all__ = ["Channel", "ChannelManager", "ChannelMessage"] diff --git a/src/bub/channels/base.py b/src/bub/channels/base.py index 01b398fd..e00ce436 100644 --- a/src/bub/channels/base.py +++ b/src/bub/channels/base.py @@ -1,61 +1,29 @@ -"""Base channel interface.""" - -from __future__ import annotations - +import asyncio from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any - -from bub.app.runtime import AppRuntime - -if TYPE_CHECKING: - from bub.core import LoopResult - - -def exclude_none(d: dict[str, Any]) -> dict[str, Any]: - return {k: v for k, v in d.items() if v is not None} - - -class BaseChannel[T](ABC): - """Abstract base class for channel adapters.""" +from typing import ClassVar - name: str = "base" +from bub.channels.message import ChannelMessage - def __init__(self, runtime: AppRuntime) -> None: - self.runtime = runtime - @abstractmethod - async def start(self, on_receive: Callable[[T], Awaitable[None]]) -> None: - """Start the channel and set up the receive callback.""" - - @property - def output_channel(self) -> str: - """The name of the channel to send outputs to. Defaults to the same channel.""" - return self.name +class Channel(ABC): + """Base class for all channels""" - @property - def debounce_enabled(self) -> bool: - """Whether inbound messages should be debounced before model execution.""" - return True + name: ClassVar[str] = "base" @abstractmethod - def is_mentioned(self, message: T) -> bool: - """Determine if the message is relevant to this channel.""" + async def start(self, stop_event: asyncio.Event) -> None: + """Start listening for events and dispatching to handlers.""" @abstractmethod - async def get_session_prompt(self, message: T) -> tuple[str, str]: - """Get the session id and prompt text for the given message.""" - pass + async def stop(self) -> None: + """Stop the channel and clean up resources.""" - async def run_prompt(self, session_id: str, prompt: str) -> LoopResult: - """Run the given prompt through the runtime and return the result.""" - return await self.runtime.handle_input(session_id, prompt) - - def format_prompt(self, prompt: str) -> str: - """Format accumulated prompt text before sending it to the runtime.""" - return f"channel: ${self.output_channel}\n{prompt}" - - @abstractmethod - async def process_output(self, session_id: str, output: LoopResult) -> None: - """Process the output returned by the LLM.""" - pass + @property + def needs_debounce(self) -> bool: + """Whether this channel needs debounce to prevent overload. Default to False.""" + return False + + async def send(self, message: ChannelMessage) -> None: + """Send a message to the channel. Optional to implement.""" + # Do nothing by default + return diff --git a/src/bub/channels/cli.py b/src/bub/channels/cli.py deleted file mode 100644 index 962f4be1..00000000 --- a/src/bub/channels/cli.py +++ /dev/null @@ -1,148 +0,0 @@ -"""CLI channel adapter.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from datetime import datetime -from hashlib import md5 -from pathlib import Path - -from loguru import logger -from prompt_toolkit import PromptSession -from prompt_toolkit.completion import WordCompleter -from prompt_toolkit.formatted_text import FormattedText -from prompt_toolkit.history import FileHistory -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.patch_stdout import patch_stdout -from rich import get_console - -from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel -from bub.cli.render import CliRenderer -from bub.core.agent_loop import LoopResult - - -class CliChannel(BaseChannel[str]): - """Interactive terminal channel.""" - - name = "cli" - - def __init__(self, runtime: AppRuntime, *, session_id: str = "cli") -> None: - super().__init__(runtime) - self._session_id = session_id - self._session = runtime.get_session(session_id) - self._renderer = CliRenderer(get_console()) - self._mode = "agent" - self._last_tape_info: object | None = None - self._prompt = self._build_prompt() - self._stop_requested = False - - @property - def debounce_enabled(self) -> bool: - return False - - async def start(self, on_receive: Callable[[str], Awaitable[None]]) -> None: - self._renderer.welcome(model=self.runtime.settings.model, workspace=str(self.runtime.workspace)) - await self._refresh_tape_info() - - while not self._stop_requested: - try: - with patch_stdout(raw=True): - raw = (await self._prompt.prompt_async(self._prompt_message())).strip() - except KeyboardInterrupt: - self._renderer.info("Interrupted. Use ',quit' to exit.") - continue - except EOFError: - break - - if not raw: - continue - - request = self._normalize_input(raw) - with self._renderer.console.status("[cyan]Processing...[/cyan]", spinner="dots"): - await on_receive(request) - - self._renderer.info("Bye.") - - def is_mentioned(self, message: str) -> bool: - _ = message - return True - - async def get_session_prompt(self, message: str) -> tuple[str, str]: - return self._session_id, message - - def format_prompt(self, prompt: str) -> str: - return prompt - - async def process_output(self, session_id: str, output: LoopResult) -> None: - _ = session_id - await self._refresh_tape_info() - if output.immediate_output: - self._renderer.command_output(output.immediate_output) - if output.error: - self._renderer.error(output.error) - if output.assistant_output: - self._renderer.assistant_output(output.assistant_output) - if output.exit_requested: - self._stop_requested = True - - async def _refresh_tape_info(self) -> None: - try: - self._last_tape_info = await self._session.tape.info() - except Exception as exc: - self._last_tape_info = None - logger.debug("cli.tape_info.unavailable session_id={} error={}", self._session_id, exc) - - def _build_prompt(self) -> PromptSession[str]: - kb = KeyBindings() - - @kb.add("c-x", eager=True) - def _toggle_mode(event) -> None: - self._mode = "shell" if self._mode == "agent" else "agent" - event.app.invalidate() - - def _tool_sort_key(tool_name: str) -> tuple[str, str]: - section, _, name = tool_name.rpartition(".") - return (section, name) - - history_file = self._history_file(self.runtime.settings.resolve_home(), self.runtime.workspace) - history_file.parent.mkdir(parents=True, exist_ok=True) - history = FileHistory(str(history_file)) - tool_names = sorted((f",{tool}" for tool in self._session.tool_view.all_tools()), key=_tool_sort_key) - completer = WordCompleter(tool_names, ignore_case=True) - return PromptSession( - completer=completer, - complete_while_typing=True, - key_bindings=kb, - history=history, - bottom_toolbar=self._render_bottom_toolbar, - ) - - def _prompt_message(self) -> FormattedText: - cwd = Path.cwd().name - symbol = ">" if self._mode == "agent" else "," - return FormattedText([("bold", f"{cwd} {symbol} ")]) - - def _render_bottom_toolbar(self) -> FormattedText: - info = self._last_tape_info - now = datetime.now().strftime("%H:%M") - left = f"{now} mode:{self._mode}" - right = ( - f"model:{self.runtime.settings.model} " - f"entries:{getattr(info, 'entries', '-')} " - f"anchors:{getattr(info, 'anchors', '-')} " - f"last:{getattr(info, 'last_anchor', None) or '-'}" - ) - return FormattedText([("", f"{left} {right}")]) - - def _normalize_input(self, raw: str) -> str: - if self._mode != "shell": - return raw - if raw.startswith(","): - return raw - return f", {raw}" - - @staticmethod - def _history_file(home: Path, workspace: Path) -> Path: - workspace_hash = md5(str(workspace).encode("utf-8")).hexdigest() # noqa: S324 - return home / "history" / f"{workspace_hash}.history" diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py new file mode 100644 index 00000000..99acedb6 --- /dev/null +++ b/src/bub/channels/cli/__init__.py @@ -0,0 +1,174 @@ +import asyncio +import contextlib +from collections.abc import AsyncGenerator +from datetime import datetime +from hashlib import md5 +from pathlib import Path + +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.history import FileHistory +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.patch_stdout import patch_stdout +from rich import get_console + +from bub.builtin.agent import Agent +from bub.builtin.tape import TapeInfo +from bub.channels.base import Channel +from bub.channels.cli.renderer import CliRenderer +from bub.channels.message import ChannelMessage +from bub.envelope import content_of, field_of +from bub.tools import REGISTRY +from bub.types import MessageHandler + + +class CliChannel(Channel): + """A simple CLI channel for testing and debugging.""" + + name = "cli" + _stop_event: asyncio.Event + + def __init__(self, on_receive: MessageHandler, agent: Agent) -> None: + self._on_receive = on_receive + self._agent = agent + self._message_template = { + "chat_id": "cli_chat", + "channel": self.name, + "session_id": "cli_session", + } + self._mode = "agent" # or "shell" + self._main_task: asyncio.Task | None = None + self._renderer = CliRenderer(get_console()) + self._prompt = self._build_prompt(Path.cwd()) + self._last_tape_info: TapeInfo | None = None + self._workspace = Path.cwd() + + async def _refresh_tape_info(self) -> None: + tape = self._agent.tapes.session_tape(self._message_template["session_id"], self._workspace) + info = await self._agent.tapes.info(tape.name) + self._last_tape_info = info + + def set_metadata(self, session_id: str | None = None, chat_id: str | None = None) -> None: + if session_id is not None: + self._message_template["session_id"] = session_id + if chat_id is not None: + self._message_template["chat_id"] = chat_id + + async def start(self, stop_event: asyncio.Event) -> None: + self._stop_event = stop_event + self._main_task = asyncio.create_task(self._main_loop()) + + async def stop(self) -> None: + if self._main_task is not None: + self._main_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._main_task + + async def send(self, message: ChannelMessage) -> None: + match message.kind: + case "error": + self._renderer.error(content_of(message)) + case "command": + self._renderer.command_output(content_of(message)) + case _: + self._renderer.assistant_output(content_of(message)) + + async def _main_loop(self) -> None: + self._renderer.welcome(model=self._agent.settings.model, workspace=str(self._workspace)) + await self._refresh_tape_info() + request_completed = asyncio.Event() + + while not self._stop_event.is_set(): + try: + with patch_stdout(raw=True): + raw = (await self._prompt.prompt_async(self._prompt_message())).strip() + except KeyboardInterrupt: + self._renderer.info("Interrupted. Use ',quit' to exit.") + continue + except EOFError: + break + + if not raw: + continue + if raw in {",quit", ",exit"}: + break + + request = self._normalize_input(raw) + + message = ChannelMessage( + session_id=self._message_template["session_id"], + channel=self._message_template["channel"], + chat_id=self._message_template["chat_id"], + content=request, + lifespan=self.message_lifespan(request_completed), + ) + with self._renderer.console.status("[cyan]Processing...[/cyan]", spinner="dots"): + await self._on_receive(message) + await request_completed.wait() + request_completed.clear() + + self._renderer.info("Bye.") + self._stop_event.set() + + @contextlib.asynccontextmanager + async def message_lifespan(self, request_completed: asyncio.Event) -> AsyncGenerator[None, None]: + try: + yield + finally: + await self._refresh_tape_info() + request_completed.set() + + def _normalize_input(self, raw: str) -> str: + if self._mode != "shell": + return raw + if raw.startswith(","): + return raw + return f",{raw}" + + def _prompt_message(self) -> FormattedText: + cwd = Path.cwd().name + symbol = ">" if self._mode == "agent" else "," + return FormattedText([("bold", f"{cwd} {symbol} ")]) + + def _build_prompt(self, workspace: Path) -> PromptSession[str]: + kb = KeyBindings() + + @kb.add("c-x", eager=True) + def _toggle_mode(event) -> None: + self._mode = "shell" if self._mode == "agent" else "agent" + event.app.invalidate() + + def _tool_sort_key(tool_name: str) -> tuple[str, str]: + section, _, name = tool_name.rpartition(".") + return (section, name) + + history_file = self._history_file(self._agent.settings.home, workspace) + history_file.parent.mkdir(parents=True, exist_ok=True) + history = FileHistory(str(history_file)) + tool_names = sorted((f",{name}" for name in REGISTRY), key=_tool_sort_key) + completer = WordCompleter(tool_names, ignore_case=True) + return PromptSession( + completer=completer, + complete_while_typing=True, + key_bindings=kb, + history=history, + bottom_toolbar=self._render_bottom_toolbar, + ) + + def _render_bottom_toolbar(self) -> FormattedText: + info = self._last_tape_info + now = datetime.now().strftime("%H:%M") + left = f"{now} mode:{self._mode}" + right = ( + f"model:{self._agent.settings.model} " + f"entries:{field_of(info, 'entries', '-')} " + f"anchors:{field_of(info, 'anchors', '-')} " + f"last:{field_of(info, 'last_anchor', None) or '-'}" + ) + return FormattedText([("", f"{left} {right}")]) + + @staticmethod + def _history_file(home: Path, workspace: Path) -> Path: + workspace_hash = md5(str(workspace).encode("utf-8"), usedforsecurity=False).hexdigest() + return home / "history" / f"{workspace_hash}.history" diff --git a/src/bub/cli/render.py b/src/bub/channels/cli/renderer.py similarity index 100% rename from src/bub/cli/render.py rename to src/bub/channels/cli/renderer.py diff --git a/src/bub/channels/discord.py b/src/bub/channels/discord.py deleted file mode 100644 index 0ac233d7..00000000 --- a/src/bub/channels/discord.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Discord channel adapter.""" - -from __future__ import annotations - -import contextlib -import json -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any, cast - -import discord -from discord.ext import commands -from loguru import logger - -from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel, exclude_none -from bub.channels.utils import resolve_proxy -from bub.core.agent_loop import LoopResult - - -def _message_type(message: discord.Message) -> str: - if message.content: - return "text" - if message.attachments: - return "attachment" - if message.stickers: - return "sticker" - return "unknown" - - -@dataclass(frozen=True) -class DiscordConfig: - """Discord adapter config.""" - - token: str - allow_from: set[str] - allow_channels: set[str] - command_prefix: str = "!" - proxy: str | None = None - - -class DiscordChannel(BaseChannel[discord.Message]): - """Discord adapter based on discord.py.""" - - name = "discord" - - def __init__(self, runtime: AppRuntime) -> None: - super().__init__(runtime) - settings = runtime.settings - self._config = DiscordConfig( - token=settings.discord_token or "", - allow_from=set(settings.discord_allow_from), - allow_channels=set(settings.discord_allow_channels), - command_prefix=settings.discord_command_prefix, - proxy=settings.discord_proxy, - ) - self._bot: commands.Bot | None = None - self._on_receive: Callable[[discord.Message], Awaitable[None]] | None = None - self._latest_message_by_session: dict[str, discord.Message] = {} - - async def start(self, on_receive: Callable[[discord.Message], Awaitable[None]]) -> None: - if not self._config.token: - raise RuntimeError("discord token is empty") - - self._on_receive = on_receive - intents = discord.Intents.default() - intents.messages = True - intents.message_content = True - - proxy, _ = resolve_proxy(self._config.proxy) - bot = commands.Bot(command_prefix=self._config.command_prefix, intents=intents, help_command=None, proxy=proxy) - self._bot = bot - - @bot.event - async def on_ready() -> None: - logger.info("discord.ready user={} id={}", str(bot.user), bot.user.id if bot.user else "") - - @bot.event - async def on_message(message: discord.Message) -> None: - await bot.process_commands(message) - await self._on_message(message) - - logger.info( - "discord.start allow_from_count={} allow_channels_count={} proxy_enabled={}", - len(self._config.allow_from), - len(self._config.allow_channels), - bool(proxy), - ) - try: - async with bot: - await bot.start(self._config.token) - finally: - self._bot = None - logger.info("discord.stopped") - - async def get_session_prompt(self, message: discord.Message) -> tuple[str, str]: - channel_id = str(message.channel.id) - session_id = f"{self.name}:{channel_id}" - content, media = self._parse_message(message) - - prefix = f"{self._config.command_prefix}bub " - if content.startswith(prefix): - content = content[len(prefix) :] - - if content.strip().startswith(","): - self._latest_message_by_session[session_id] = message - return session_id, content - - metadata: dict[str, Any] = { - "message_id": message.id, - "type": _message_type(message), - "username": message.author.name, - "full_name": getattr(message.author, "display_name", message.author.name), - "sender_id": str(message.author.id), - "date": message.created_at.timestamp() if message.created_at else None, - "channel_id": str(message.channel.id), - "guild_id": str(message.guild.id) if message.guild else None, - } - - if media: - metadata["media"] = media - - reply_meta = self._extract_reply_metadata(message) - if reply_meta: - metadata["reply_to_message"] = reply_meta - - metadata_json = json.dumps( - {"message": content, "channel_id": channel_id, **exclude_none(metadata)}, ensure_ascii=False - ) - self._latest_message_by_session[session_id] = message - return session_id, metadata_json - - async def process_output(self, session_id: str, output: LoopResult) -> None: - parts = [part for part in (output.immediate_output, output.assistant_output) if part] - if output.error: - parts.append(f"Error: {output.error}") - content = "\n\n".join(parts).strip() - if content: - print(content, flush=True) - - send_content = output.immediate_output.strip() - if not send_content: - return - - channel = await self._resolve_channel(session_id) - if channel is None: - logger.warning("discord.outbound unresolved channel session_id={}", session_id) - return - - source = self._latest_message_by_session.get(session_id) - reference = source.to_reference(fail_if_not_exists=False) if source is not None else None - for chunk in self._chunk_message(send_content): - kwargs: dict[str, Any] = {"content": chunk} - if reference is not None: - kwargs["reference"] = reference - kwargs["mention_author"] = False - await channel.send(**kwargs) - - async def _on_message(self, message: discord.Message) -> None: - if message.author.bot: - return - if self._on_receive is None: - logger.warning("discord.inbound no handler for received messages") - return - - content, _ = self._parse_message(message) - logger.info( - "discord.inbound channel_id={} sender_id={} username={} content={}", - message.channel.id, - message.author.id, - message.author.name, - content[:100], - ) - - async with message.channel.typing(): - await self._on_receive(message) - - async def _resolve_channel(self, session_id: str) -> discord.abc.Messageable | None: - if self._bot is None: - return None - channel_id = int(session_id.split(":", 1)[1]) - channel = self._bot.get_channel(channel_id) - if channel is not None: - return channel # type: ignore[return-value] - with contextlib.suppress(Exception): - fetched = await self._bot.fetch_channel(channel_id) - if isinstance(fetched, discord.abc.Messageable): - return fetched - return None - - def is_mentioned(self, message: discord.Message) -> bool: - channel_id = str(message.channel.id) - if self._config.allow_channels and channel_id not in self._config.allow_channels: - return False - - if not message.content.strip(): - return False - - sender_tokens = {str(message.author.id), message.author.name} - if getattr(message.author, "global_name", None): - sender_tokens.add(cast(str, message.author.global_name)) - if self._config.allow_from and sender_tokens.isdisjoint(self._config.allow_from): - logger.warning( - "discord.inbound.denied channel_id={} sender_id={} reason=allow_from", - message.channel.id, - message.author.id, - ) - return False - - if ( - isinstance(message.channel, discord.DMChannel) - or "bub" in message.content.lower() - or self._is_bub_scoped_thread(message) - or message.content.startswith(f"{self._config.command_prefix}bub") - ): - return True - - bot_user = self._bot.user if self._bot is not None else None - if bot_user is None: - return False - if bot_user in message.mentions: - return True - - ref = message.reference - if ref is None: - return False - resolved = ref.resolved - return bool(isinstance(resolved, discord.Message) and resolved.author and resolved.author.id == bot_user.id) - - @staticmethod - def _is_bub_scoped_thread(message: discord.Message) -> bool: - channel = message.channel - thread_name = getattr(channel, "name", None) - if not isinstance(thread_name, str): - return False - is_thread = isinstance(channel, discord.Thread) or getattr(channel, "parent", None) is not None - return is_thread and thread_name.lower().startswith("bub") - - @staticmethod - def _parse_message(message: discord.Message) -> tuple[str, dict[str, Any] | None]: - if message.content: - return message.content, None - - if message.attachments: - attachment_lines: list[str] = [] - attachment_meta: list[dict[str, Any]] = [] - for att in message.attachments: - attachment_lines.append(f"[Attachment: {att.filename}]") - attachment_meta.append( - exclude_none({ - "id": str(att.id), - "filename": att.filename, - "content_type": att.content_type, - "size": att.size, - "url": att.url, - }) - ) - return "\n".join(attachment_lines), {"attachments": attachment_meta} - - if message.stickers: - lines = [f"[Sticker: {sticker.name}]" for sticker in message.stickers] - meta = [{"id": str(sticker.id), "name": sticker.name} for sticker in message.stickers] - return "\n".join(lines), {"stickers": meta} - - return "[Unknown message type]", None - - @staticmethod - def _extract_reply_metadata(message: discord.Message) -> dict[str, Any] | None: - ref = message.reference - if ref is None: - return None - resolved = ref.resolved - if not isinstance(resolved, discord.Message): - return None - return exclude_none({ - "message_id": str(resolved.id), - "from_user_id": str(resolved.author.id), - "from_username": resolved.author.name, - "from_is_bot": resolved.author.bot, - "text": (resolved.content or "")[:100], - }) - - @staticmethod - def _chunk_message(text: str, *, limit: int = 2000) -> list[str]: - if len(text) <= limit: - return [text] - chunks: list[str] = [] - remaining = text - while remaining: - if len(remaining) <= limit: - chunks.append(remaining) - break - split_at = remaining.rfind("\n", 0, limit) - if split_at <= 0: - split_at = limit - chunks.append(remaining[:split_at].rstrip()) - remaining = remaining[split_at:].lstrip("\n") - return [chunk for chunk in chunks if chunk] diff --git a/src/bub/channels/handler.py b/src/bub/channels/handler.py new file mode 100644 index 00000000..f955a5f2 --- /dev/null +++ b/src/bub/channels/handler.py @@ -0,0 +1,81 @@ +import asyncio +import re + +from loguru import logger + +from bub.channels.message import ChannelMessage +from bub.types import MessageHandler + + +class BufferedMessageHandler: + """A message handler that buffers incoming messages and processes them in batch with debounce and active time window.""" + + def __init__( + self, handler: MessageHandler, *, active_time_window: float, max_wait_seconds: float, debounce_seconds: float + ) -> None: + self._handler = handler + self._pending_messages: list[ChannelMessage] = [] + self._last_active_time: float | None = None + self._event = asyncio.Event() + self._timer: asyncio.TimerHandle | None = None + self._in_processing: asyncio.Task | None = None + self._loop = asyncio.get_running_loop() + + self.active_time_window = active_time_window + self.max_wait_seconds = max_wait_seconds + self.debounce_seconds = debounce_seconds + + def _reset_timer(self, timeout: float) -> None: + self._event.clear() + if self._timer: + self._timer.cancel() + self._timer = self._loop.call_later(timeout, self._event.set) + + async def _process(self) -> None: + await self._event.wait() + message = ChannelMessage.from_batch(self._pending_messages) + self._pending_messages.clear() + self._in_processing = None + await self._handler(message) + + @staticmethod + def prettify(content: str) -> str: + return re.sub(r'data:[^;]+;base64,[^"]+', "[media]", content) + + async def __call__(self, message: ChannelMessage) -> None: + now = self._loop.time() + if message.content.startswith(","): + logger.info( + "session.message received command session_id={}, content={}", + message.session_id, + self.prettify(message.content), + ) + await self._handler(message) + return + if not message.is_active and ( + self._last_active_time is None or now - self._last_active_time > self.active_time_window + ): + self._last_active_time = None + logger.info( + "session.message received ignored session_id={}, content={}", + message.session_id, + self.prettify(message.content), + ) + return + self._pending_messages.append(message) + if message.is_active: + self._last_active_time = now + logger.info( + "session.message received active session_id={}, content={}", + message.session_id, + self.prettify(message.content), + ) + self._reset_timer(self.debounce_seconds) + if self._in_processing is None: + self._in_processing = asyncio.create_task(self._process()) + elif self._last_active_time is not None and self._in_processing is None: + logger.info( + "session.receive followup session_id={} message={}", message.session_id, self.prettify(message.content) + ) + self._reset_timer(self.max_wait_seconds) + self._in_processing = asyncio.create_task(self._process()) diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index 5f53a758..8823ecf1 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -1,84 +1,133 @@ -"""Channel manager.""" - -from __future__ import annotations - import asyncio import contextlib -import functools +from collections.abc import Collection from loguru import logger - -from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel -from bub.channels.runner import SessionRunner +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from bub.channels.base import Channel +from bub.channels.handler import BufferedMessageHandler +from bub.channels.message import ChannelMessage +from bub.envelope import content_of, field_of +from bub.framework import BubFramework +from bub.types import Envelope, MessageHandler +from bub.utils import wait_until_stopped + + +class ChannelSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="BUB_", extra="ignore", env_file=".env") + + enabled_channels: str = Field( + default="all", description="Comma-separated list of enabled channels, or 'all' for all channels." + ) + debounce_seconds: float = Field( + default=1.0, + description="Minimum seconds between processing two messages from the same channel to prevent overload.", + ) + max_wait_seconds: float = Field( + default=10.0, + description="Maximum seconds to wait for processing before new messages reach the channel.", + ) + active_time_window: float = Field( + default=60.0, + description="Time window in seconds to consider a channel active for processing messages.", + ) class ChannelManager: - """Coordinate inbound routing and outbound dispatch for channels.""" - - def __init__(self, runtime: AppRuntime, *, include_defaults: bool = True) -> None: - self.runtime = runtime - self._channels: dict[str, BaseChannel] = {} - self._channel_tasks: list[asyncio.Task[None]] = [] - self._session_runners: dict[str, SessionRunner] = {} - if include_defaults: - for channel_cls in self.default_channels(): - self.register(channel_cls) - runtime.install_hooks(self) - - def register[T: type[BaseChannel]](self, channel: T) -> T: - self.register_instance(channel(self.runtime)) - return channel - - def register_instance[T: BaseChannel](self, channel: T) -> T: - if channel.name in self._channels: - raise ValueError(f"channel '{channel.name}' already registered") - self._channels[channel.name] = channel - return channel - - @property - def channels(self) -> dict[str, BaseChannel]: - return dict(self._channels) - - async def run(self) -> None: - logger.info("channel.manager.start channels={}", self.enabled_channels()) - for channel in self._channels.values(): - task = asyncio.create_task(channel.start(functools.partial(self._process_input, channel))) - self._channel_tasks.append(task) + def __init__(self, framework: BubFramework, enabled_channels: Collection[str] | None = None) -> None: + self.framework = framework + self._channels: dict[str, Channel] = self.framework.get_channels(self.on_receive) + self._settings = ChannelSettings() + if enabled_channels is not None: + self._enabled_channels = list(enabled_channels) + else: + self._enabled_channels = self._settings.enabled_channels.split(",") + self._messages = asyncio.Queue[ChannelMessage]() + self._ongoing_tasks: set[asyncio.Task] = set() + self._session_handlers: dict[str, MessageHandler] = {} + + async def on_receive(self, message: ChannelMessage) -> None: + channel = message.channel + session_id = message.session_id + if channel not in self._channels: + logger.warning(f"Received message from unknown channel '{channel}', ignoring.") + return + if session_id not in self._session_handlers: + handler: MessageHandler + if self._channels[channel].needs_debounce: + handler = BufferedMessageHandler( + self._messages.put, + active_time_window=self._settings.active_time_window, + max_wait_seconds=self._settings.max_wait_seconds, + debounce_seconds=self._settings.debounce_seconds, + ) + else: + handler = self._messages.put + self._session_handlers[session_id] = handler + await self._session_handlers[session_id](message) + + def get_channel(self, name: str) -> Channel | None: + return self._channels.get(name) + + async def dispatch(self, message: Envelope) -> bool: + channel_name = field_of(message, "output_channel", field_of(message, "channel")) + if channel_name is None: + return False + + channel_key = str(channel_name) + channel = self.get_channel(channel_key) + if channel is None: + return False + + outbound = ChannelMessage( + session_id=str(field_of(message, "session_id", f"{channel_key}:default")), + channel=channel_key, + chat_id=str(field_of(message, "chat_id", "default")), + content=content_of(message), + context=field_of(message, "context", {}), + kind=field_of(message, "kind", "normal"), + ) + await channel.send(outbound) + return True + + def enabled_channels(self) -> list[Channel]: + if "all" in self._enabled_channels: + # Exclude 'cli' channel from 'all' to prevent interference with other channels + return [channel for name, channel in self._channels.items() if name != "cli"] + return [channel for name, channel in self._channels.items() if name in self._enabled_channels] + + async def listen_and_run(self) -> None: + stop_event = asyncio.Event() + self.framework.bind_outbound_router(self) + for channel in self.enabled_channels(): + await channel.start(stop_event) + logger.info("channel.manager started listening") try: - await asyncio.gather(*self._channel_tasks) + while True: + message = await wait_until_stopped(self._messages.get(), stop_event) + task = asyncio.create_task(self.framework.process_inbound(message)) + task.add_done_callback(lambda t: self._ongoing_tasks.discard(t)) + self._ongoing_tasks.add(task) + except asyncio.CancelledError: + logger.info("channel.manager received shutdown signal") + except Exception: + logger.exception("channel.manager error") + raise finally: - for task in self._channel_tasks: - task.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - await asyncio.gather(*self._channel_tasks) - self._channel_tasks.clear() - logger.info("channel.manager.stop") - - def enabled_channels(self) -> list[str]: - return sorted(self._channels) - - def default_channels(self) -> list[type[BaseChannel]]: - """Return the built-in channels.""" - result: list[type[BaseChannel]] = [] - - if self.runtime.settings.telegram_enabled: - from bub.channels.telegram import TelegramChannel - - result.append(TelegramChannel) - if self.runtime.settings.discord_enabled: - from bub.channels.discord import DiscordChannel - - result.append(DiscordChannel) - return result - - async def _process_input[T](self, channel: BaseChannel[T], message: T) -> None: - session_id, _ = await channel.get_session_prompt(message) - if session_id not in self._session_runners: - self._session_runners[session_id] = SessionRunner( - session_id, - self.runtime.settings.message_debounce_seconds, - self.runtime.settings.message_delay_seconds, - self.runtime.settings.active_time_window_seconds, - ) - await self._session_runners[session_id].process_message(channel, message) + self.framework.bind_outbound_router(None) + await self.shutdown() + logger.info("channel.manager stopped") + + async def shutdown(self) -> None: + count = 0 + while self._ongoing_tasks: + task = self._ongoing_tasks.pop() + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + count += 1 + logger.info(f"channel.manager cancelled {count} in-flight tasks") + for channel in self.enabled_channels(): + await channel.stop() diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py new file mode 100644 index 00000000..c7bfba0b --- /dev/null +++ b/src/bub/channels/message.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import contextlib +import json +from dataclasses import dataclass, field, replace +from typing import Any, Literal + +type MessageKind = Literal["error", "normal", "command"] + + +@dataclass +class ChannelMessage: + """Structured message data from channels to framework.""" + + session_id: str + channel: str + content: str + chat_id: str = "default" + is_active: bool = False + kind: MessageKind = "normal" + context: dict[str, Any] = field(default_factory=dict) + lifespan: contextlib.AbstractAsyncContextManager | None = None + output_channel: str = "" + + def __post_init__(self) -> None: + self.context.update({"channel": "$" + self.channel, "chat_id": self.chat_id}) + if not self.output_channel: # output to the same channel by default + self.output_channel = self.channel + + @property + def context_str(self) -> str: + """String representation of the context for prompt building.""" + return json.dumps(self.context, ensure_ascii=False)[1:-1] + + @classmethod + def from_batch(cls, batch: list[ChannelMessage]) -> ChannelMessage: + """Create a single message by combining a batch of messages.""" + if not batch: + raise ValueError("Batch cannot be empty") + template = batch[-1] + content = "\n".join(message.content for message in batch) + return replace(template, content=content) diff --git a/src/bub/channels/runner.py b/src/bub/channels/runner.py deleted file mode 100644 index b307672a..00000000 --- a/src/bub/channels/runner.py +++ /dev/null @@ -1,83 +0,0 @@ -import asyncio -from typing import Any - -from loguru import logger - -from bub.channels.base import BaseChannel - - -class SessionRunner: - def __init__( - self, session_id: str, debounce_seconds: int, message_delay_seconds: int, active_time_window_seconds: int - ) -> None: - self.session_id = session_id - self.debounce_seconds = debounce_seconds - self.message_delay_seconds = message_delay_seconds - self.active_time_window_seconds = active_time_window_seconds - self._prompts: list[str] = [] - self._event = asyncio.Event() - self._timer: asyncio.TimerHandle | None = None - self._last_mentioned_at: float | None = None - self._running_task: asyncio.Task[None] | None = None - self._loop = asyncio.get_running_loop() - - async def _run(self, channel: BaseChannel) -> None: - await self._event.wait() - prompt = channel.format_prompt("\n".join(self._prompts)) - self._prompts.clear() - self._running_task = None - try: - result = await channel.run_prompt(self.session_id, prompt) - await channel.process_output(self.session_id, result) - except Exception: - if not channel.debounce_enabled: - raise - logger.exception("session.run.error session_id={}", self.session_id) - - def reset_timer(self, timeout: int) -> None: - self._event.clear() - if self._timer: - self._timer.cancel() - self._timer = self._loop.call_later(timeout, self._event.set) - - async def process_message(self, channel: BaseChannel, message: Any) -> None: - is_mentioned = channel.is_mentioned(message) - _, prompt = await channel.get_session_prompt(message) - now = self._loop.time() - if not is_mentioned and ( - self._last_mentioned_at is None or now - self._last_mentioned_at > self.active_time_window_seconds - ): - self._last_mentioned_at = None - logger.info("session.receive ignored session_id={} message={}", self.session_id, prompt) - return - if prompt.startswith(","): - logger.info("session.receive.command session_id={} message={}", self.session_id, prompt) - try: - result = await channel.run_prompt(self.session_id, prompt) - await channel.process_output(self.session_id, result) - except Exception: - if not channel.debounce_enabled: - raise - logger.exception("session.run.error session_id={}", self.session_id) - return - elif not channel.debounce_enabled: - logger.info("session.receive.immediate session_id={} message={}", self.session_id, prompt) - result = await channel.run_prompt(self.session_id, prompt) - await channel.process_output(self.session_id, result) - return - - self._prompts.append(prompt) - if is_mentioned: - # Debounce mentioned messages before responding. - self._last_mentioned_at = now - logger.info("session.receive.mentioned session_id={} message={}", self.session_id, prompt) - self.reset_timer(self.debounce_seconds) - if self._running_task is None: - self._running_task = asyncio.create_task(self._run(channel)) - return await self._running_task - elif self._last_mentioned_at is not None and self._running_task is None: - # Otherwise if bot is mentioned before, we will keep reading messages for at most 60s. - logger.info("session.receive followup session_id={} message={}", self.session_id, prompt) - self.reset_timer(self.message_delay_seconds) - self._running_task = asyncio.create_task(self._run(channel)) - return await self._running_task diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index 757e6eef..580d3de7 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -1,22 +1,40 @@ -"""Telegram channel adapter.""" - from __future__ import annotations import asyncio +import base64 import contextlib import json -from collections.abc import AsyncGenerator, Awaitable, Callable -from dataclasses import dataclass +from collections.abc import AsyncGenerator, Callable from typing import Any, ClassVar from loguru import logger -from telegram import Message, Update -from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict +from telegram import Bot, Message, Update +from telegram.ext import Application, CommandHandler, ContextTypes, filters +from telegram.ext import MessageHandler as TelegramMessageHandler + +from bub.channels.base import Channel +from bub.channels.message import ChannelMessage +from bub.types import MessageHandler +from bub.utils import exclude_none + + +class TelegramSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="BUB_TELEGRAM_", extra="ignore", env_file=".env") + + token: str = Field(default="", description="Telegram bot token.") + allow_users: str | None = Field( + default=None, description="Comma-separated list of allowed Telegram user IDs, or empty for no restriction." + ) + allow_chats: str | None = Field( + default=None, description="Comma-separated list of allowed Telegram chat IDs, or empty for no restriction." + ) + proxy: str | None = Field( + default=None, + description="Optional proxy URL for connecting to Telegram API, e.g. 'http://user:pass@host:port' or 'socks5://host:port'.", + ) -from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel, exclude_none -from bub.channels.utils import resolve_proxy -from bub.core.agent_loop import LoopResult NO_ACCESS_MESSAGE = "You are not allowed to chat with me. Please deploy your own instance of Bub." @@ -98,203 +116,195 @@ def _is_reply_to_bot(message: Message, bot_id: int) -> bool: MESSAGE_FILTER = BubMessageFilter() -@dataclass(frozen=True) -class TelegramConfig: - """Telegram adapter config.""" - - token: str - allow_from: set[str] - allow_chats: set[str] - proxy: str | None = None - - -class TelegramChannel(BaseChannel[Message]): - """Telegram adapter using long polling mode.""" - +class TelegramChannel(Channel): name = "telegram" + _app: Application - def __init__(self, runtime: AppRuntime) -> None: - super().__init__(runtime) - settings = runtime.settings - assert settings.telegram_token is not None # noqa: S101 - self._config = TelegramConfig( - token=settings.telegram_token, - allow_from=set(settings.telegram_allow_from), - allow_chats=set(settings.telegram_allow_chats), - proxy=settings.telegram_proxy, - ) - self._app: Application | None = None - self._on_receive: Callable[[Message], Awaitable[None]] | None = None - - def is_mentioned(self, message: Message) -> bool: - return bool(MESSAGE_FILTER.filter(message)) - - async def start(self, on_receive: Callable[[Message], Awaitable[None]]) -> None: + def __init__(self, on_receive: MessageHandler) -> None: self._on_receive = on_receive - proxy, _ = resolve_proxy(self._config.proxy) + self._settings = TelegramSettings() + self._allow_users = {uid.strip() for uid in (self._settings.allow_users or "").split(",") if uid.strip()} + self._allow_chats = {cid.strip() for cid in (self._settings.allow_chats or "").split(",") if cid.strip()} + self._parser = TelegramMessageParser(bot_getter=lambda: self._app.bot) + self._typing_tasks: dict[str, asyncio.Task] = {} + + @property + def needs_debounce(self) -> bool: + return True + + async def start(self, stop_event: asyncio.Event) -> None: + proxy = self._settings.proxy logger.info( - "telegram.start allow_from_count={} allow_chats_count={} proxy_enabled={}", - len(self._config.allow_from), - len(self._config.allow_chats), + "telegram.start allow_users_count={} allow_chats_count={} proxy_enabled={}", + len(self._allow_users), + len(self._allow_chats), bool(proxy), ) - builder = Application.builder().token(self._config.token) + builder = Application.builder().token(self._settings.token) if proxy: builder = builder.proxy(proxy).get_updates_proxy(proxy) self._app = builder.build() self._app.add_handler(CommandHandler("start", self._on_start)) - self._app.add_handler(CommandHandler("bub", self._on_text, has_args=True, block=False)) - self._app.add_handler(MessageHandler(~filters.COMMAND, self._on_text, block=False)) + self._app.add_handler(CommandHandler("bub", self._on_message, has_args=True, block=False)) + self._app.add_handler(TelegramMessageHandler(~filters.COMMAND, self._on_message, block=False)) await self._app.initialize() await self._app.start() updater = self._app.updater if updater is None: return await updater.start_polling(drop_pending_updates=True, allowed_updates=["message"]) - logger.info("telegram.polling") - try: - await asyncio.Event().wait() # Keep running until stopped - finally: - updater = self._app.updater - with contextlib.suppress(Exception): - if updater is not None and updater.running: - await updater.stop() - await self._app.stop() - await self._app.shutdown() - self._app = None - logger.info("telegram.stopped") - - async def get_session_prompt(self, message: Message) -> tuple[str, str]: - chat_id = str(message.chat_id) - session_id = f"{self.name}:{chat_id}" - content, media = self._parse_message(message) - if content.startswith("/bub "): - content = content[5:] - - # Pass comma commands directly to the input handler - if content.strip().startswith(","): - return session_id, content - - metadata: dict[str, Any] = { - "message_id": message.message_id, - "type": _message_type(message), - "username": message.from_user.username if message.from_user else "", - "full_name": message.from_user.full_name if message.from_user else "", - "sender_id": str(message.from_user.id) if message.from_user else "", - "sender_is_bot": message.from_user.is_bot if message.from_user else None, - "date": message.date.timestamp() if message.date else None, - } - - if media: - metadata["media"] = media - caption = getattr(message, "caption", None) - if caption: - metadata["caption"] = caption + logger.info("telegram.start polling") - reply_meta = self._extract_reply_metadata(message) - if reply_meta: - metadata["reply_to_message"] = reply_meta - - metadata_json = json.dumps({"message": content, "chat_id": chat_id, **metadata}, ensure_ascii=False) - return session_id, metadata_json + async def stop(self) -> None: + updater = self._app.updater + with contextlib.suppress(Exception): + if updater is not None and updater.running: + await updater.stop() + await self._app.stop() + await self._app.shutdown() + for task in self._typing_tasks.values(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + self._typing_tasks.clear() + logger.info("telegram.stopped") - async def process_output(self, session_id: str, output: LoopResult) -> None: - parts = [part for part in (output.immediate_output, output.assistant_output) if part] - if output.error: - parts.append(f"error: {output.error}") - content = "\n\n".join(parts).strip() - if not content: + async def send(self, message: ChannelMessage) -> None: + chat_id = message.chat_id + content = message.content + try: + data = json.loads(content) + text = data.get("message", "") + except json.JSONDecodeError: + text = content + if not text.strip(): return - logger.info("telegram.outbound session_id={} content={}", session_id, content) - send_back_text = [output.immediate_output] if output.immediate_output else [] - if not self.runtime.settings.proactive_response: - send_back_text.extend([output.assistant_output] if output.assistant_output else []) - # NOTE: assistant output is ignored intentionally to rely on the telegram skill to send messages proactively. - # Feel free to override this method to ensure response for every message received. - if output.error: - send_back_text.append(f"Error: {output.error}") - if send_back_text and self._app is not None: - await self._app.bot.send_message(chat_id=session_id.split(":", 1)[1], text="\n\n".join(send_back_text)) + await self._app.bot.send_message(chat_id=chat_id, text=text) async def _on_start(self, update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None: return - if self._config.allow_chats and str(update.message.chat_id) not in self._config.allow_chats: + if self._allow_chats and str(update.message.chat_id) not in self._allow_chats: await update.message.reply_text(NO_ACCESS_MESSAGE) return await update.message.reply_text("Bub is online. Send text to start.") - async def _on_text(self, update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: + async def _on_message(self, update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None or update.effective_user is None: return chat_id = str(update.message.chat_id) - if self._config.allow_chats and chat_id not in self._config.allow_chats: + if self._allow_chats and chat_id not in self._allow_chats: return user = update.effective_user sender_tokens = {str(user.id)} if user.username: sender_tokens.add(user.username) - if self._config.allow_from and sender_tokens.isdisjoint(self._config.allow_from): + if self._allow_users and sender_tokens.isdisjoint(self._allow_users): await update.message.reply_text("Access denied.") return + await self._on_receive(await self._build_message(update.message)) - text, _ = self._parse_message(update.message) - if text.startswith("/bot ") or text.startswith("/bub "): - text = text[5:] + async def _build_message(self, message: Message) -> ChannelMessage: + chat_id = str(message.chat_id) + session_id = f"{self.name}:{chat_id}" + content, metadata = await self._parser.parse(message) + if content.startswith("/bub "): + content = content[5:] - if self._on_receive is None: - logger.warning("telegram.inbound no handler for received messages") - return - async with self._start_typing(chat_id): - await self._on_receive(update.message) + # Pass comma commands directly to the input handler + if content.strip().startswith(","): + return ChannelMessage(session_id=session_id, content=content.strip(), channel=self.name, chat_id=chat_id) + + reply_meta = await self._parser.get_reply(message) + if reply_meta: + metadata["reply_to_message"] = reply_meta + content = json.dumps({"message": content, "chat_id": chat_id, **metadata}, ensure_ascii=False) + is_active = MESSAGE_FILTER.filter(message) is not False + return ChannelMessage( + session_id=session_id, + channel=self.name, + chat_id=chat_id, + content=content, + is_active=is_active, + lifespan=self.start_typing(chat_id), + output_channel="null", # disable outbound for telegram messages + ) @contextlib.asynccontextmanager - async def _start_typing(self, chat_id: str) -> AsyncGenerator[None, None]: - typing_task = asyncio.create_task(self._typing_loop(chat_id)) + async def start_typing(self, chat_id: str) -> AsyncGenerator[None, None]: + if chat_id in self._typing_tasks: + yield + return + task = asyncio.create_task(self._typing_loop(chat_id)) + self._typing_tasks[chat_id] = task try: yield finally: - typing_task.cancel() + task.cancel() with contextlib.suppress(asyncio.CancelledError): - await typing_task + await task + del self._typing_tasks[chat_id] async def _typing_loop(self, chat_id: str) -> None: - try: - while self._app is not None: - await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing") - await asyncio.sleep(4) - except Exception: - logger.exception("telegram.typing_loop.error chat_id={}", chat_id) - return + while True: + try: + await self._app.bot.send_chat_action(chat_id=chat_id, action="typing") + await asyncio.sleep(4) # Telegram typing status lasts for 5 seconds, so we refresh it every 4 seconds + except Exception as e: + logger.error(f"Error in typing loop for chat_id={chat_id}: {e}") + break + - @classmethod - def _parse_message(cls, message: Message) -> tuple[str, dict[str, Any] | None]: +class TelegramMessageParser: + def __init__(self, bot_getter: Callable[[], Bot] | None = None) -> None: + self._bot_getter = bot_getter + + async def parse(self, message: Message) -> tuple[str, dict[str, Any]]: msg_type = _message_type(message) + content, media = f"[Unsupported message type: {msg_type}]", None if msg_type == "text": - return getattr(message, "text", None) or "", None - parser = cls._MEDIA_MESSAGE_PARSERS.get(msg_type) - if parser is not None: - return parser(message) - return "[Unknown message type]", None + content, media = getattr(message, "text", None) or "", None + else: + parser = getattr(self, f"_parse_{msg_type}", None) + if parser is not None: + content, media = await parser(message) + metadata = exclude_none({ + "message_id": message.message_id, + "type": _message_type(message), + "username": message.from_user.username if message.from_user else "", + "full_name": message.from_user.full_name if message.from_user else "", + "sender_id": str(message.from_user.id) if message.from_user else "", + "sender_is_bot": message.from_user.is_bot if message.from_user else None, + "date": message.date.timestamp() if message.date else None, + "media": media, + }) + return content, metadata - @staticmethod - def _parse_photo(message: Message) -> tuple[str, dict[str, Any] | None]: + async def get_reply(self, message: Message) -> dict[str, Any] | None: + reply_to = message.reply_to_message + if reply_to is None or reply_to.from_user is None: + return None + content, metadata = await self.parse(reply_to) + return {"message": content, **metadata} + + async def _parse_photo(self, message: Message) -> tuple[str, dict[str, Any] | None]: caption = getattr(message, "caption", None) or "" formatted = f"[Photo message] Caption: {caption}" if caption else "[Photo message]" photos = getattr(message, "photo", None) or [] if not photos: return formatted, None largest = photos[-1] - metadata = exclude_none({ + mime_type = "image/jpeg" + media = exclude_none({ "file_id": largest.file_id, "file_size": largest.file_size, "width": largest.width, "height": largest.height, + "data_url": await self._download_media(mime_type, largest.file_id, largest.file_size), }) - return formatted, metadata + return formatted, media - @staticmethod - def _parse_audio(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_audio(self, message: Message) -> tuple[str, dict[str, Any] | None]: audio = getattr(message, "audio", None) if audio is None: return "[Audio]", None @@ -303,37 +313,59 @@ def _parse_audio(message: Message) -> tuple[str, dict[str, Any] | None]: duration = audio.duration or 0 metadata = exclude_none({ "file_id": audio.file_id, + "mime_type": audio.mime_type, "file_size": audio.file_size, "duration": audio.duration, "title": audio.title, "performer": audio.performer, + "data_url": await self._download_media( + audio.mime_type or "application/octet-stream", audio.file_id, audio.file_size + ), }) if performer: return f"[Audio: {performer} - {title} ({duration}s)]", metadata return f"[Audio: {title} ({duration}s)]", metadata - @staticmethod - def _parse_sticker(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _download_media(self, mime_type: str, file_id: str, file_size: int) -> str | None: + if not file_id: + raise ValueError("file_id must not be empty") + if self._bot_getter is None: + raise RuntimeError("Telegram bot is not configured for media downloads.") + if file_size > 2 * 1024 * 1024: # limit to 2MB + return None + bot = self._bot_getter() + if bot is None: + raise RuntimeError("Telegram bot is not available for media downloads.") + + telegram_file = await bot.get_file(file_id) + if telegram_file is None: + raise RuntimeError(f"Telegram file lookup returned no result for file_id={file_id}.") + data = await telegram_file.download_as_bytearray() + print("File size:", len(data)) + return f"data:{mime_type};base64,{base64.b64encode(data).decode('utf-8')}" + + async def _parse_sticker(self, message: Message) -> tuple[str, dict[str, Any] | None]: sticker = getattr(message, "sticker", None) if sticker is None: return "[Sticker]", None emoji = sticker.emoji or "" set_name = sticker.set_name or "" + mime_type = "image/webp" if not sticker.is_animated else "video/webm" metadata = exclude_none({ "file_id": sticker.file_id, "width": sticker.width, "height": sticker.height, + "mime_type": mime_type, "emoji": sticker.emoji, "set_name": sticker.set_name, "is_animated": sticker.is_animated, - "is_video": sticker.is_video, + "data_url": await self._download_media(mime_type, sticker.file_id, sticker.file_size), }) if emoji: return f"[Sticker: {emoji} from {set_name}]", metadata return f"[Sticker from {set_name}]", metadata - @staticmethod - def _parse_video(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_video(self, message: Message) -> tuple[str, dict[str, Any] | None]: video = getattr(message, "video", None) duration = video.duration if video else 0 caption = getattr(message, "caption", None) or "" @@ -347,20 +379,25 @@ def _parse_video(message: Message) -> tuple[str, dict[str, Any] | None]: "width": video.width, "height": video.height, "duration": video.duration, + "mime_type": video.mime_type, + "data_url": await self._download_media(video.mime_type or "video/mp4", video.file_id, video.file_size), }) return formatted, metadata - @staticmethod - def _parse_voice(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_voice(self, message: Message) -> tuple[str, dict[str, Any] | None]: voice = getattr(message, "voice", None) duration = voice.duration if voice else 0 if voice is None: return f"[Voice message: {duration}s]", None - metadata = exclude_none({"file_id": voice.file_id, "duration": voice.duration}) + metadata = exclude_none({ + "file_id": voice.file_id, + "duration": voice.duration, + "mime_type": voice.mime_type, + "data_url": await self._download_media(voice.mime_type or "audio/ogg", voice.file_id, voice.file_size), + }) return f"[Voice message: {duration}s]", metadata - @staticmethod - def _parse_document(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_document(self, message: Message) -> tuple[str, dict[str, Any] | None]: document = getattr(message, "document", None) if document is None: return "[Document]", None @@ -374,37 +411,23 @@ def _parse_document(message: Message) -> tuple[str, dict[str, Any] | None]: "file_name": document.file_name, "file_size": document.file_size, "mime_type": document.mime_type, + "data_url": await self._download_media( + document.mime_type or "application/octet-stream", document.file_id, document.file_size + ), }) return formatted, metadata - @staticmethod - def _parse_video_note(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_video_note(self, message: Message) -> tuple[str, dict[str, Any] | None]: video_note = getattr(message, "video_note", None) duration = video_note.duration if video_note else 0 if video_note is None: return f"[Video note: {duration}s]", None - metadata = exclude_none({"file_id": video_note.file_id, "duration": video_note.duration}) - return f"[Video note: {duration}s]", metadata - - @staticmethod - def _extract_reply_metadata(message: Message) -> dict[str, Any] | None: - reply_to = message.reply_to_message - if reply_to is None or reply_to.from_user is None: - return None - return exclude_none({ - "message_id": reply_to.message_id, - "from_user_id": reply_to.from_user.id, - "from_username": reply_to.from_user.username, - "from_is_bot": reply_to.from_user.is_bot, - "text": (reply_to.text or "")[:100] if reply_to.text else "", + metadata = exclude_none({ + "file_id": video_note.file_id, + "duration": video_note.duration, + "mime_type": video_note.mime_type, + "data_url": await self._download_media( + video_note.mime_type or "video/mp4", video_note.file_id, video_note.file_size + ), }) - - _MEDIA_MESSAGE_PARSERS: ClassVar[dict[str, Callable[[Message], tuple[str, dict[str, Any] | None]]]] = { - "photo": _parse_photo, - "audio": _parse_audio, - "sticker": _parse_sticker, - "video": _parse_video, - "voice": _parse_voice, - "document": _parse_document, - "video_note": _parse_video_note, - } + return f"[Video note: {duration}s]", metadata diff --git a/src/bub/channels/utils.py b/src/bub/channels/utils.py deleted file mode 100644 index f92e132f..00000000 --- a/src/bub/channels/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Channel utility helpers.""" - -from __future__ import annotations - - -def resolve_proxy(explicit_proxy: str | None) -> tuple[str | None, str]: - if explicit_proxy: - return explicit_proxy, "explicit" - - # Proxy usage must be opt-in; ignore ambient env vars and OS proxy settings. - return None, "none" diff --git a/src/bub/cli/__init__.py b/src/bub/cli/__init__.py deleted file mode 100644 index abd2e698..00000000 --- a/src/bub/cli/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""CLI package export.""" - -from bub.cli.app import app - -__all__ = ["app"] diff --git a/src/bub/cli/app.py b/src/bub/cli/app.py deleted file mode 100644 index 31281585..00000000 --- a/src/bub/cli/app.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Typer CLI entrypoints.""" - -from __future__ import annotations - -import asyncio -import contextlib -import sys -from pathlib import Path -from typing import Annotated - -import typer -from loguru import logger - -from bub.app import build_runtime -from bub.app.runtime import AppRuntime -from bub.channels import ChannelManager, CliChannel -from bub.logging_utils import configure_logging - -app = typer.Typer(name="bub", help="Tape-first coding agent CLI", add_completion=False) - - -def _parse_subset(values: list[str] | None) -> set[str] | None: - if values is None: - return None - - names: set[str] = set() - for raw in values: - for part in raw.split(","): - name = part.strip() - if name: - names.add(name) - return names or None - - -@app.callback(invoke_without_command=True) -def _default(ctx: typer.Context) -> None: - if ctx.invoked_subcommand is None: - chat() - - -@app.command() -def chat( - workspace: Annotated[Path | None, typer.Option("--workspace", "-w")] = None, - model: Annotated[str | None, typer.Option("--model")] = None, - max_tokens: Annotated[int | None, typer.Option("--max-tokens")] = None, - session_id: Annotated[str, typer.Option("--session-id", envvar="BUB_SESSION_ID")] = "cli", - disable_scheduler: Annotated[bool, typer.Option("--disable-scheduler", envvar="BUB_DISABLE_SCHEDULER")] = False, -) -> None: - """Run interactive CLI.""" - - configure_logging(profile="chat") - resolved_workspace = (workspace.expanduser() if workspace else Path.cwd()).resolve() - logger.info( - "chat.start workspace={} model={} max_tokens={}", - str(resolved_workspace), - model or "", - max_tokens if max_tokens is not None else "", - ) - with build_runtime( - resolved_workspace, model=model, max_tokens=max_tokens, enable_scheduler=not disable_scheduler - ) as runtime: - manager = ChannelManager(runtime, include_defaults=False) - manager.register_instance(CliChannel(runtime, session_id=session_id)) - asyncio.run(_serve_channels(manager)) - - -@app.command() -def idle( - workspace: Annotated[Path | None, typer.Option("--workspace", "-w")] = None, - model: Annotated[str | None, typer.Option("--model")] = None, - max_tokens: Annotated[int | None, typer.Option("--max-tokens")] = None, -) -> None: - """Start the scheduler only, this is a good option for running a completely autonomous agent.""" - from apscheduler.schedulers.blocking import BlockingScheduler - - from bub.app.jobstore import JSONJobStore - from bub.config.settings import load_settings - - configure_logging(profile="chat") - resolved_workspace = (workspace or Path.cwd()).resolve() - logger.info( - "idle.start workspace={} model={} max_tokens={}", - str(resolved_workspace), - model or "", - max_tokens if max_tokens is not None else "", - ) - settings = load_settings(resolved_workspace) - job_store = JSONJobStore(settings.resolve_home() / "jobs.json") - scheduler = BlockingScheduler(jobstores={"default": job_store}) - try: - scheduler.start() - finally: - logger.info("idle.stop workspace={}", str(resolved_workspace)) - - -@app.command() -def run( - message: Annotated[str, typer.Argument()], - workspace: Annotated[Path | None, typer.Option("--workspace", "-w")] = None, - model: Annotated[str | None, typer.Option("--model")] = None, - max_tokens: Annotated[int | None, typer.Option("--max-tokens")] = None, - session_id: Annotated[str, typer.Option("--session-id", envvar="BUB_SESSION_ID")] = "cli", - tools: Annotated[ - list[str] | None, - typer.Option( - "--tools", - help="Allowed tool names (repeatable or comma-separated, supports command and model names).", - ), - ] = None, - skills: Annotated[ - list[str] | None, - typer.Option( - "--skills", - help="Allowed skill names (repeatable or comma-separated).", - ), - ] = None, - disable_scheduler: Annotated[bool, typer.Option("--disable-scheduler", envvar="BUB_DISABLE_SCHEDULER")] = False, -) -> None: - """Run a single message and exit, useful for quick testing or one-off commands.""" - - configure_logging() - resolved_workspace = (workspace.expanduser() if workspace else Path.cwd()).resolve() - allowed_tools = _parse_subset(tools) - allowed_skills = _parse_subset(skills) - logger.info( - "run.start workspace={} model={} max_tokens={} allowed_tools={} allowed_skills={}", - str(resolved_workspace), - model or "", - max_tokens if max_tokens is not None else "", - ",".join(sorted(allowed_tools)) if allowed_tools else "", - ",".join(sorted(allowed_skills)) if allowed_skills else "", - ) - with build_runtime( - resolved_workspace, - model=model, - max_tokens=max_tokens, - allowed_tools=allowed_tools, - allowed_skills=allowed_skills, - enable_scheduler=not disable_scheduler, - ) as runtime: - asyncio.run(_run_once(runtime, session_id, message)) - - -async def _run_once(runtime: AppRuntime, session_id: str, message: str) -> None: - import rich - - async with runtime.graceful_shutdown(): - try: - result = await runtime.handle_input(session_id, message) - if result.error: - rich.print(f"[red]Error:[/red] {result.error}", file=sys.stderr) - else: - rich.print(result.assistant_output or result.immediate_output or "") - except asyncio.CancelledError: - rich.print("[yellow]Operation interrupted.[/yellow]", file=sys.stderr) - - -@app.command() -def message( - workspace: Annotated[Path | None, typer.Option("--workspace", "-w")] = None, - model: Annotated[str | None, typer.Option("--model")] = None, - max_tokens: Annotated[int | None, typer.Option("--max-tokens")] = None, - proactive_response: Annotated[bool, typer.Option("--proactive-response", envvar="BUB_PROACTIVE_RESPONSE")] = False, -) -> None: - """Run message channels with the same agent loop runtime.""" - - configure_logging() - resolved_workspace = (workspace.expanduser() if workspace else Path.cwd()).resolve() - logger.info( - "message.start workspace={} model={} max_tokens={}, proactive_response={}", - str(resolved_workspace), - model or "", - max_tokens if max_tokens is not None else "", - proactive_response, - ) - - with build_runtime(resolved_workspace, model=model, max_tokens=max_tokens) as runtime: - runtime.settings.proactive_response = proactive_response - manager = ChannelManager(runtime) - asyncio.run(_serve_channels(manager)) - - -async def _serve_channels(manager: ChannelManager) -> None: - task = asyncio.create_task(manager.run()) - try: - async with manager.runtime.graceful_shutdown() as stop_event: - task.add_done_callback(lambda t: stop_event.set()) - await stop_event.wait() - except asyncio.CancelledError: - pass - finally: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - logger.info("channels.stop") - - -if __name__ == "__main__": - app() diff --git a/src/bub/config/__init__.py b/src/bub/config/__init__.py deleted file mode 100644 index 88d4bd00..00000000 --- a/src/bub/config/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Configuration package.""" - -from bub.config.settings import Settings, load_settings - -__all__ = ["Settings", "load_settings"] diff --git a/src/bub/config/settings.py b/src/bub/config/settings.py deleted file mode 100644 index 3627aa36..00000000 --- a/src/bub/config/settings.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Application settings.""" - -from __future__ import annotations - -import os -from pathlib import Path - -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """Runtime settings loaded from environment and .env files.""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - env_prefix="BUB_", - case_sensitive=False, - extra="ignore", - env_parse_none_str="null", - ) - - model: str = "openrouter:qwen/qwen3-coder-next" - api_key: str | None = None - api_base: str | None = None - ollama_api_key: str | None = None - ollama_api_base: str | None = None - llm_api_key: str | None = Field(default=None, validation_alias="LLM_API_KEY") - openrouter_api_key: str | None = Field(default=None, validation_alias="OPENROUTER_API_KEY") - max_tokens: int = Field(default=1024, ge=1) - model_timeout_seconds: int | None = 90 - system_prompt: str = "" - - home: str | None = None - workspace_path: str | None = None - tape_name: str = "bub" - max_steps: int = Field(default=20, ge=1) - - proactive_response: bool = False - message_delay_seconds: int = 10 - message_debounce_seconds: int = 1 - active_time_window_seconds: int = 60 - - telegram_enabled: bool = False - telegram_token: str | None = None - telegram_allow_from: list[str] = Field(default_factory=list) - telegram_allow_chats: list[str] = Field(default_factory=list) - telegram_proxy: str | None = Field(default=None) - - discord_enabled: bool = False - discord_token: str | None = None - discord_allow_from: list[str] = Field(default_factory=list) - discord_allow_channels: list[str] = Field(default_factory=list) - discord_command_prefix: str = "!" - discord_proxy: str | None = None - - @property - def resolved_api_key(self) -> str | None: - if self.api_key: - return self.api_key - if self.llm_api_key: - return self.llm_api_key - if self.openrouter_api_key: - return self.openrouter_api_key - return os.getenv("LLM_API_KEY") or os.getenv("OPENROUTER_API_KEY") - - def resolve_home(self) -> Path: - if self.home: - return Path(self.home).expanduser().resolve() - return (Path.home() / ".bub").resolve() - - -def load_settings(workspace_path: Path | None = None) -> Settings: - """Load settings with optional workspace override.""" - - if workspace_path is None: - return Settings() - - return Settings(workspace_path=str(workspace_path.resolve())) diff --git a/src/bub/core/__init__.py b/src/bub/core/__init__.py deleted file mode 100644 index 587da740..00000000 --- a/src/bub/core/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Core runtime components.""" - -from bub.core.agent_loop import AgentLoop, LoopResult -from bub.core.model_runner import ModelRunner -from bub.core.router import CommandExecutionResult, InputRouter, UserRouteResult -from bub.core.types import HookContext - -__all__ = [ - "AgentLoop", - "CommandExecutionResult", - "HookContext", - "InputRouter", - "LoopResult", - "ModelRunner", - "UserRouteResult", -] diff --git a/src/bub/core/agent_loop.py b/src/bub/core/agent_loop.py deleted file mode 100644 index 5a686ee3..00000000 --- a/src/bub/core/agent_loop.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Forward-only agent loop.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from bub.core.model_runner import ModelRunner, ModelTurnResult -from bub.core.router import InputRouter -from bub.tape.service import TapeService - - -@dataclass(frozen=True) -class LoopResult: - """Loop output for one input turn.""" - - immediate_output: str - assistant_output: str - exit_requested: bool - steps: int - error: str | None = None - - -class AgentLoop: - """Deterministic single-session loop built on an endless tape.""" - - def __init__(self, *, router: InputRouter, model_runner: ModelRunner, tape: TapeService) -> None: - self._router = router - self._model_runner = model_runner - self._tape = tape - - async def handle_input(self, raw: str) -> LoopResult: - route = await self._router.route_user(raw) - if route.exit_requested: - return LoopResult( - immediate_output=route.immediate_output, - assistant_output="", - exit_requested=True, - steps=0, - error=None, - ) - - if not route.enter_model: - return LoopResult( - immediate_output=route.immediate_output, - assistant_output="", - exit_requested=False, - steps=0, - error=None, - ) - - model_result = await self._model_runner.run(route.model_prompt) - await self._record_result(model_result) - return LoopResult( - immediate_output=route.immediate_output, - assistant_output=model_result.visible_text, - exit_requested=model_result.exit_requested, - steps=model_result.steps, - error=model_result.error, - ) - - async def _record_result(self, result: ModelTurnResult) -> None: - await self._tape.append_event( - "loop.result", - { - "steps": result.steps, - "followups": result.command_followups, - "exit_requested": result.exit_requested, - "error": result.error, - }, - ) diff --git a/src/bub/core/command_detector.py b/src/bub/core/command_detector.py deleted file mode 100644 index 665a26cc..00000000 --- a/src/bub/core/command_detector.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Input command detection.""" - -from __future__ import annotations - -import re -import shutil - -from bub.core.commands import parse_command_words, parse_internal_command -from bub.core.types import DetectedCommand - -INTERNAL_PREFIX = "," -ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") -SAFE_PATH_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~/-]+$") -MAX_PATH_TOKEN_LENGTH = 240 - - -def detect_line_command(line: str) -> DetectedCommand | None: - """Detect whether one line should be treated as command.""" - - stripped = line.strip() - if not stripped: - return None - - if stripped.startswith(INTERNAL_PREFIX): - name, args_tokens = parse_internal_command(stripped) - if not name: - return None - return DetectedCommand(kind="internal", raw=stripped, name=name, args_tokens=args_tokens) - - if _is_shell_command(stripped): - words = parse_command_words(stripped) - if not words: - return None - command_name, args_tokens = _shell_command_parts(words) - return DetectedCommand(kind="shell", raw=stripped, name=command_name, args_tokens=args_tokens) - - return None - - -def _is_shell_command(line: str) -> bool: - words = parse_command_words(line) - if not words: - return False - - env_prefixed_command = _command_word_from_env_prefix(words) - if env_prefixed_command is not None: - return _is_path_like(env_prefixed_command) or shutil.which(env_prefixed_command) is not None - - first_word = words[0] - if _is_path_like(first_word): - return True - return shutil.which(first_word) is not None - - -def _is_path_like(token: str) -> bool: - if len(token) > MAX_PATH_TOKEN_LENGTH: - return False - if "://" in token: - return False - if any(ch in token for ch in ("\n", "\r", "\t", " ", '"', "'", "`", "=")): - return False - if SAFE_PATH_TOKEN_RE.fullmatch(token) is None: - return False - return token.startswith(("./", "../", "/", "~/")) or "/" in token - - -def _command_word_from_env_prefix(words: list[str]) -> str | None: - index = 0 - while index < len(words) and _is_env_assignment(words[index]): - index += 1 - if index == 0 or index >= len(words): - return None - return words[index] - - -def _is_env_assignment(token: str) -> bool: - if ENV_ASSIGN_RE.match(token) is None: - return False - _, value = token.split("=", 1) - if not value: - return False - return "\n" not in value and "\r" not in value and "\t" not in value - - -def _shell_command_parts(words: list[str]) -> tuple[str, list[str]]: - index = 0 - while index < len(words) and _is_env_assignment(words[index]): - index += 1 - if index < len(words): - return words[index], words[index + 1 :] - return words[0], words[1:] diff --git a/src/bub/core/commands.py b/src/bub/core/commands.py deleted file mode 100644 index ee8a8e09..00000000 --- a/src/bub/core/commands.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Command parsing helpers.""" - -from __future__ import annotations - -import shlex -from dataclasses import dataclass - - -@dataclass(frozen=True) -class ParsedArgs: - """Parsed command arguments.""" - - kwargs: dict[str, object] - positional: list[str] - - -def parse_command_words(text: str) -> list[str]: - """Split command text into words using shell rules.""" - - try: - return shlex.split(text) - except ValueError: - return [] - - -def parse_internal_command(line: str) -> tuple[str, list[str]]: - """Parse ',name ...' command line into name and args tokens.""" - - body = line.strip()[1:].strip() - words = parse_command_words(body) - if not words: - return "", [] - - return words[0], words[1:] - - -def parse_kv_arguments(tokens: list[str]) -> ParsedArgs: - """Parse tool arguments from tokens.""" - - kwargs: dict[str, object] = {} - positional: list[str] = [] - idx = 0 - while idx < len(tokens): - token = tokens[idx] - - if token.startswith("--"): - key = token[2:] - if "=" in key: - name, value = key.split("=", 1) - kwargs[name] = value - idx += 1 - continue - - if idx + 1 < len(tokens) and not tokens[idx + 1].startswith("--"): - kwargs[key] = tokens[idx + 1] - idx += 2 - continue - - kwargs[key] = True - idx += 1 - continue - - if "=" in token: - key, value = token.split("=", 1) - kwargs[key] = value - idx += 1 - continue - - positional.append(token) - idx += 1 - - return ParsedArgs(kwargs=kwargs, positional=positional) diff --git a/src/bub/core/model_runner.py b/src/bub/core/model_runner.py deleted file mode 100644 index cb2dc832..00000000 --- a/src/bub/core/model_runner.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Model turn runner.""" - -from __future__ import annotations - -import asyncio -import re -import textwrap -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import ClassVar - -from loguru import logger -from republic import Tool, ToolAutoResult - -from bub.core.router import AssistantRouteResult, InputRouter -from bub.skills.loader import SkillMetadata -from bub.skills.view import render_compact_skills -from bub.tape.service import TapeService -from bub.tools.progressive import ProgressiveToolView -from bub.tools.view import render_tool_prompt_block - -HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") -TOOL_CONTINUE_PROMPT = "Continue the task." - - -@dataclass(frozen=True) -class ModelTurnResult: - """Result of one model turn loop.""" - - visible_text: str - exit_requested: bool - steps: int - error: str | None = None - command_followups: int = 0 - - -@dataclass -class _PromptState: - prompt: str - step: int = 0 - followups: int = 0 - visible_parts: list[str] = field(default_factory=list) - error: str | None = None - exit_requested: bool = False - - -class ModelRunner: - """Runs assistant loop over tape with command-aware follow-up handling.""" - - DEFAULT_HEADERS: ClassVar[dict[str, str]] = {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"} - - def __init__( - self, - *, - tape: TapeService, - router: InputRouter, - tool_view: ProgressiveToolView, - tools: list[Tool], - list_skills: Callable[[], list[SkillMetadata]], - model: str, - max_steps: int, - max_tokens: int, - model_timeout_seconds: int | None, - base_system_prompt: str, - get_workspace_system_prompt: Callable[[], str], - ) -> None: - self._tape = tape - self._router = router - self._tool_view = tool_view - self._tools = tools - self._list_skills = list_skills - self._model = model - self._max_steps = max_steps - self._max_tokens = max_tokens - self._model_timeout_seconds = model_timeout_seconds - self._base_system_prompt = base_system_prompt.strip() - self._get_workspace_system_prompt = get_workspace_system_prompt - self._expanded_skills: set[str] = set() - - def reset_context(self) -> None: - """Clear volatile model-side context caches within one session.""" - self._expanded_skills.clear() - - async def run(self, prompt: str) -> ModelTurnResult: - state = _PromptState(prompt=prompt) - self._activate_hints(prompt) - - while state.step < self._max_steps and not state.exit_requested: - state.step += 1 - logger.info("model.runner.step step={} model={}", state.step, self._model) - await self._tape.append_event( - "loop.step.start", - { - "step": state.step, - "model": self._model, - }, - ) - response = await self._chat(state.prompt) - if response.error is not None: - state.error = response.error - await self._tape.append_event( - "loop.step.error", - { - "step": state.step, - "error": response.error, - }, - ) - break - - if response.followup_prompt: - await self._tape.append_event( - "loop.step.finish", - { - "step": state.step, - "visible_text": False, - "followup": True, - "exit_requested": False, - }, - ) - state.prompt = response.followup_prompt - state.followups += 1 - continue - - assistant_text = response.text - if not assistant_text.strip(): - await self._tape.append_event("loop.step.empty", {"step": state.step}) - break - - self._activate_hints(assistant_text) - route = await self._router.route_assistant(assistant_text) - await self._consume_route(state, route) - if not route.next_prompt: - break - state.prompt = route.next_prompt - state.followups += 1 - - if state.step >= self._max_steps and not state.error: - state.error = f"max_steps_reached={self._max_steps}" - await self._tape.append_event("loop.max_steps", {"max_steps": self._max_steps}) - - return ModelTurnResult( - visible_text="\n\n".join(part for part in state.visible_parts if part).strip(), - exit_requested=state.exit_requested, - steps=state.step, - error=state.error, - command_followups=state.followups, - ) - - async def _consume_route(self, state: _PromptState, route: AssistantRouteResult) -> None: - if route.visible_text: - state.visible_parts.append(route.visible_text) - if route.exit_requested: - state.exit_requested = True - await self._tape.append_event( - "loop.step.finish", - { - "step": state.step, - "visible_text": bool(route.visible_text), - "followup": bool(route.next_prompt), - "exit_requested": route.exit_requested, - }, - ) - - async def _chat(self, prompt: str) -> _ChatResult: - system_prompt = self._render_system_prompt() - try: - async with asyncio.timeout(self._model_timeout_seconds): - provider, _, _ = self._model.partition(":") - if provider.casefold() == "vertexai": - output = await self._tape.tape.run_tools_async( - prompt=prompt, - system_prompt=system_prompt, - max_tokens=self._max_tokens, - tools=self._tools, - http_options={"headers": self.DEFAULT_HEADERS}, - ) - else: - output = await self._tape.tape.run_tools_async( - prompt=prompt, - system_prompt=system_prompt, - max_tokens=self._max_tokens, - tools=self._tools, - extra_headers=self.DEFAULT_HEADERS, - ) - return _ChatResult.from_tool_auto(output) - except TimeoutError: - return _ChatResult( - text="", - error=f"model_timeout: no response within {self._model_timeout_seconds}s", - ) - except Exception as exc: - logger.exception("model.call.error") - return _ChatResult(text="", error=f"model_call_error: {exc!s}") - - def _render_system_prompt(self) -> str: - blocks: list[str] = [] - if self._base_system_prompt: - blocks.append(self._base_system_prompt) - if workspace_system_prompt := self._get_workspace_system_prompt(): - blocks.append(workspace_system_prompt) - blocks.append(render_tool_prompt_block(self._tool_view)) - compact_skills = render_compact_skills(self._list_skills(), self._expanded_skills) - if compact_skills: - blocks.append(compact_skills) - blocks.append(_runtime_contract()) - return "\n\n".join(block for block in blocks if block.strip()) - - def _activate_hints(self, text: str) -> None: - skill_index = self._build_skill_index() - for match in HINT_RE.finditer(text): - hint = match.group(1) - self._tool_view.note_hint(hint) - - skill = skill_index.get(hint.casefold()) - if skill is None: - continue - self._expanded_skills.add(skill.name) - - def _build_skill_index(self) -> dict[str, SkillMetadata]: - return {skill.name.casefold(): skill for skill in self._list_skills()} - - -@dataclass(frozen=True) -class _ChatResult: - text: str - error: str | None = None - followup_prompt: str | None = None - - @classmethod - def from_tool_auto(cls, output: ToolAutoResult) -> _ChatResult: - if output.kind == "text": - return cls(text=output.text or "") - if output.kind == "tools": - return cls(text="", followup_prompt=TOOL_CONTINUE_PROMPT) - - if output.tool_calls or output.tool_results: - return cls(text="", followup_prompt=TOOL_CONTINUE_PROMPT) - - if output.error is None: - return cls(text="", error="tool_auto_error: unknown") - return cls(text="", error=f"{output.error.kind.value}: {output.error.message}") - - -def _runtime_contract() -> str: - return textwrap.dedent("""\ - - 1. Use tool calls for all actions (file ops, shell, web, tape, skills). - 2. Do not emit comma-prefixed commands in normal flow; use tool calls instead. - 3. If a compatibility fallback is required, runtime can still parse comma commands. - 4. Never emit '' blocks yourself; those are runtime-generated. - 5. When enough evidence is collected, return plain natural language answer. - 6. Use '$name' hints to request detail expansion for tools/skills when needed. - - - Excessively long context may cause model call failures. In this case, you SHOULD first use tape.handoff tool to shorten the length of the retrieved history. - - - You MUST send message to the corresponding channel before finish when you want to respond. - Route your response to the same channel the message came from. - There is a skill named `{channel}` for each channel that you need to figure out how to send a response to that channel. - ## Before finishing ANY response to a channel message: - 1. Identify the source channel from the user message metadata - 2. Prepare your response text - 3. Call the corresponding channel skill to deliver the message - 4. ONLY THEN end your turn - """) diff --git a/src/bub/core/router.py b/src/bub/core/router.py deleted file mode 100644 index 866515da..00000000 --- a/src/bub/core/router.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Routing and command execution.""" - -from __future__ import annotations - -import json -import time -from dataclasses import dataclass -from html import escape -from pathlib import Path -from typing import Any - -from republic import ToolContext - -from bub.core.commands import ParsedArgs, parse_command_words, parse_internal_command, parse_kv_arguments -from bub.core.types import DetectedCommand -from bub.tape.service import TapeService -from bub.tools.progressive import ProgressiveToolView -from bub.tools.registry import ToolRegistry - - -@dataclass(frozen=True) -class CommandExecutionResult: - """Result of one command execution.""" - - command: str - name: str - status: str - output: str - elapsed_ms: int - - def block(self) -> str: - # Escape command payload so tool output cannot close or forge command tags. - safe_name = escape(self.name, quote=True) - safe_status = escape(self.status, quote=True) - safe_output = escape(self.output, quote=False) - return f'\n{safe_output}\n' - - -@dataclass(frozen=True) -class UserRouteResult: - """Routing outcome for user input.""" - - enter_model: bool - model_prompt: str - immediate_output: str - exit_requested: bool - - -@dataclass(frozen=True) -class AssistantRouteResult: - """Routing outcome for assistant output.""" - - visible_text: str - next_prompt: str - exit_requested: bool - - -class InputRouter: - """Command-aware router used by both user and model outputs.""" - - def __init__( - self, - registry: ToolRegistry, - tool_view: ProgressiveToolView, - tape: TapeService, - workspace: Path, - ) -> None: - self._registry = registry - self._tool_view = tool_view - self._tape = tape - self._workspace = workspace - - async def route_user(self, raw: str) -> UserRouteResult: - stripped = raw.strip() - if not stripped: - return UserRouteResult(enter_model=False, model_prompt="", immediate_output="", exit_requested=False) - command = self._parse_comma_prefixed_command(stripped) - if command is None: - return UserRouteResult(enter_model=True, model_prompt=stripped, immediate_output="", exit_requested=False) - - result = await self._execute_command(command, origin="human") - if result.status == "ok" and result.name != "bash": - if result.name == "quit" and result.output == "exit": - return UserRouteResult( - enter_model=False, - model_prompt="", - immediate_output="", - exit_requested=True, - ) - return UserRouteResult( - enter_model=False, - model_prompt="", - immediate_output=result.output, - exit_requested=False, - ) - - if result.status == "ok" and result.name == "bash": - return UserRouteResult( - enter_model=False, - model_prompt="", - immediate_output=result.output, - exit_requested=False, - ) - - # Failed command falls back to model with command block context. - return UserRouteResult( - enter_model=True, - model_prompt=result.block(), - immediate_output=result.output, - exit_requested=False, - ) - - async def route_assistant(self, raw: str) -> AssistantRouteResult: - visible_lines: list[str] = [] - command_blocks: list[str] = [] - exit_requested = False - in_fence = False - pending_command_lines: list[str] = [] - pending_source_lines: list[str] = [] - - for line in raw.splitlines(): - stripped = line.strip() - if not stripped: - continue - - if stripped.startswith("```"): - if in_fence: - exit_requested = ( - await self._flush_pending_assistant_command( - pending_command_lines=pending_command_lines, - pending_source_lines=pending_source_lines, - visible_lines=visible_lines, - command_blocks=command_blocks, - ) - or exit_requested - ) - in_fence = not in_fence - continue - - if in_fence: - shell_candidate = self._parse_comma_prefixed_command(stripped) - if shell_candidate is not None and shell_candidate.kind == "shell": - exit_requested = ( - await self._flush_pending_assistant_command( - pending_command_lines=pending_command_lines, - pending_source_lines=pending_source_lines, - visible_lines=visible_lines, - command_blocks=command_blocks, - ) - or exit_requested - ) - pending_command_lines.append(shell_candidate.raw) - pending_source_lines.append(line) - continue - if pending_command_lines: - pending_command_lines.append(line) - pending_source_lines.append(line) - continue - visible_lines.append(line) - continue - - command = self._parse_comma_prefixed_command(stripped) - if command is None: - visible_lines.append(line) - continue - - exit_requested = await self._execute_assistant_command(command, command_blocks) or exit_requested - - exit_requested = ( - await self._flush_pending_assistant_command( - pending_command_lines=pending_command_lines, - pending_source_lines=pending_source_lines, - visible_lines=visible_lines, - command_blocks=command_blocks, - ) - or exit_requested - ) - visible_text = "\n".join(visible_lines).strip() - if command_blocks: - # Hide execution-phase chatter and keep only post-execution assistant answers. - visible_text = "" - next_prompt = "\n".join(command_blocks).strip() - return AssistantRouteResult( - visible_text=visible_text, - next_prompt=next_prompt, - exit_requested=exit_requested, - ) - - async def _execute_assistant_command(self, command: DetectedCommand, command_blocks: list[str]) -> bool: - result = await self._execute_command(command, origin="assistant") - command_blocks.append(result.block()) - return result.name == "quit" and result.status == "ok" and result.output == "exit" - - async def _flush_pending_assistant_command( - self, - *, - pending_command_lines: list[str], - pending_source_lines: list[str], - visible_lines: list[str], - command_blocks: list[str], - ) -> bool: - if not pending_command_lines: - return False - - command_text = "\n".join(pending_command_lines).strip() - words = parse_command_words(command_text) - command = ( - DetectedCommand(kind="shell", raw=command_text, name=words[0], args_tokens=words[1:]) if words else None - ) - pending_command_lines.clear() - source_lines = list(pending_source_lines) - pending_source_lines.clear() - - if command is None: - visible_lines.extend(source_lines) - return False - return await self._execute_assistant_command(command, command_blocks) - - def _parse_comma_prefixed_command(self, stripped: str) -> DetectedCommand | None: - if not stripped.startswith(","): - return None - body = stripped[1:].lstrip() - if not body: - return None - name, args_tokens = parse_internal_command(stripped) - if name: - resolved = self._resolve_internal_name(name) - if self._registry.has(resolved): - return DetectedCommand(kind="internal", raw=stripped, name=name, args_tokens=args_tokens) - - words = parse_command_words(body) - if not words: - return None - return DetectedCommand(kind="shell", raw=body, name=words[0], args_tokens=words[1:]) - - async def _execute_command(self, command: DetectedCommand, *, origin: str) -> CommandExecutionResult: - start = time.time() - - if command.kind == "shell": - return await self._execute_shell(command, origin=origin, start=start) - return await self._execute_internal(command, origin=origin, start=start) - - async def _execute_shell(self, command: DetectedCommand, *, origin: str, start: float) -> CommandExecutionResult: - elapsed_ms: int - state = self._tape.tape.context.state - context = ToolContext(self._tape.tape.name, "execute_internal", state=state) - try: - output = await self._registry.execute( - "bash", - kwargs={ - "cmd": command.raw, - "cwd": str(self._workspace), - }, - context=context, - ) - status = "ok" - text = str(output) - except Exception as exc: - status = "error" - text = f"{exc!s}" - - elapsed_ms = int((time.time() - start) * 1000) - await self._record_command(command=command, status=status, output=text, elapsed_ms=elapsed_ms, origin=origin) - return CommandExecutionResult( - command=command.raw, - name="bash", - status=status, - output=text, - elapsed_ms=elapsed_ms, - ) - - async def _execute_internal(self, command: DetectedCommand, *, origin: str, start: float) -> CommandExecutionResult: - name = self._resolve_internal_name(command.name) - parsed_args = parse_kv_arguments(command.args_tokens) - - if name == "tool.describe" and parsed_args.positional and "name" not in parsed_args.kwargs: - parsed_args.kwargs["name"] = parsed_args.positional[0] - - if name == "handoff": - self._inject_default_handoff_name(parsed_args) - - if self._registry.has(name) is False: - elapsed_ms = int((time.time() - start) * 1000) - text = f"unknown internal command: {command.name}" - await self._record_command( - command=command, - status="error", - output=text, - elapsed_ms=elapsed_ms, - origin=origin, - ) - return CommandExecutionResult( - command=command.raw, - name=name, - status="error", - output=text, - elapsed_ms=elapsed_ms, - ) - - state = self._tape.tape.context.state - context = ToolContext(self._tape.tape.name, "execute_internal", state=state) - try: - output = await self._registry.execute(name, kwargs=dict(parsed_args.kwargs), context=context) - status = "ok" - text = str(output) - if name == "tool.describe": - described = parsed_args.kwargs.get("name") - if isinstance(described, str): - self._tool_view.note_selected(described) - elif name not in {"help", "tools"}: - self._tool_view.note_selected(name) - except Exception as exc: - status = "error" - text = f"{exc!s}" - - elapsed_ms = int((time.time() - start) * 1000) - await self._record_command(command=command, status=status, output=text, elapsed_ms=elapsed_ms, origin=origin) - return CommandExecutionResult( - command=command.raw, - name=name, - status=status, - output=text, - elapsed_ms=elapsed_ms, - ) - - @staticmethod - def _resolve_internal_name(name: str) -> str: - aliases = { - "tool": "tool.describe", - "tape": "tape.info", - } - return aliases.get(name, name) - - @staticmethod - def _inject_default_handoff_name(parsed_args: ParsedArgs) -> None: - if "name" in parsed_args.kwargs: - return - if parsed_args.positional: - parsed_args.kwargs["name"] = parsed_args.positional[0] - else: - parsed_args.kwargs["name"] = "handoff" - - async def _record_command( - self, - *, - command: DetectedCommand, - status: str, - output: str, - elapsed_ms: int, - origin: str, - ) -> None: - await self._tape.append_event( - "command", - { - "origin": origin, - "kind": command.kind, - "raw": command.raw, - "name": command.name, - "status": status, - "elapsed_ms": elapsed_ms, - "output": output, - }, - ) - - def render_failure_context(self, result: CommandExecutionResult) -> str: - return result.block() - - @staticmethod - def to_json(data: Any) -> str: - return json.dumps(data, ensure_ascii=False) diff --git a/src/bub/core/types.py b/src/bub/core/types.py deleted file mode 100644 index 0f39440c..00000000 --- a/src/bub/core/types.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Shared core dataclasses.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Protocol - -if TYPE_CHECKING: - from bub.app.runtime import AppRuntime - from bub.channels.base import BaseChannel - - -@dataclass(frozen=True) -class DetectedCommand: - """Detected command parsed from a line.""" - - kind: str # internal|shell - raw: str - name: str - args_tokens: list[str] = field(default_factory=list) - - -@dataclass(frozen=True) -class ParsedAssistantMessage: - """Assistant output split between text and command lines.""" - - visible_lines: list[str] - commands: list[DetectedCommand] - - -class HookContext(Protocol): - """Context object passed to hooks.""" - - runtime: AppRuntime - - def register_channel(self, channel: type[BaseChannel]) -> None: - """Register a custom channel.""" - - def default_channels(self) -> list[type[BaseChannel]]: - """Return the default channels to be registered.""" - ... diff --git a/src/bub/envelope.py b/src/bub/envelope.py new file mode 100644 index 00000000..362d4bc5 --- /dev/null +++ b/src/bub/envelope.py @@ -0,0 +1,42 @@ +"""Utilities for reading and normalizing user-defined envelopes.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from bub.types import Envelope + + +def field_of(message: Envelope, key: str, default: Any = None) -> Any: + """Read a field from mapping-like or attribute-based messages.""" + + if isinstance(message, Mapping): + return message.get(key, default) + return getattr(message, key, default) + + +def content_of(message: Envelope) -> str: + """Get textual content from any envelope shape.""" + + return str(field_of(message, "content", "")) + + +def normalize_envelope(message: Envelope) -> dict[str, Any]: + """Convert arbitrary message objects to a mutable envelope mapping.""" + + if isinstance(message, Mapping): + return dict(message) + if hasattr(message, "__dict__"): + return dict(vars(message)) + return {"content": str(message)} + + +def unpack_batch(batch: Any) -> list[Envelope]: + """Normalize one render_outbound return value to a list of envelopes.""" + + if batch is None: + return [] + if isinstance(batch, list | tuple): + return list(batch) + return [batch] diff --git a/src/bub/framework.py b/src/bub/framework.py new file mode 100644 index 00000000..4dc9dd49 --- /dev/null +++ b/src/bub/framework.py @@ -0,0 +1,206 @@ +"""Hook-first Bub framework runtime.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pluggy +import typer +from loguru import logger +from republic import AsyncTapeStore +from republic.tape import TapeStore + +from bub.envelope import content_of, field_of, unpack_batch +from bub.hook_runtime import HookRuntime +from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs +from bub.types import Envelope, MessageHandler, OutboundChannelRouter, TurnResult + +if TYPE_CHECKING: + from bub.channels.base import Channel + + +@dataclass(frozen=True) +class PluginStatus: + is_success: bool + detail: str | None = None + + +class BubFramework: + """Minimal framework core. Everything grows from hook skills.""" + + def __init__(self) -> None: + self.workspace = Path.cwd().resolve() + self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) + self._plugin_manager.add_hookspecs(BubHookSpecs) + self._hook_runtime = HookRuntime(self._plugin_manager) + self._plugin_status: dict[str, PluginStatus] = {} + self._outbound_router: OutboundChannelRouter | None = None + + def _load_builtin_hooks(self) -> None: + from bub.builtin.hook_impl import BuiltinImpl + + impl = BuiltinImpl(self) + + try: + self._plugin_manager.register(impl, name="builtin") + except Exception as exc: + self._plugin_status["builtin"] = PluginStatus(is_success=False, detail=str(exc)) + else: + self._plugin_status["builtin"] = PluginStatus(is_success=True) + + def load_hooks(self) -> None: + import importlib.metadata + + self._load_builtin_hooks() + for entry_point in importlib.metadata.entry_points(group="bub"): + try: + plugin = entry_point.load() + if callable(plugin): # Support entry points that are classes + plugin = plugin(self) + self._plugin_manager.register(plugin, name=entry_point.name) + except Exception as exc: + logger.warning(f"Failed to load plugin '{entry_point.name}': {exc}") + self._plugin_status[entry_point.name] = PluginStatus(is_success=False, detail=str(exc)) + else: + self._plugin_status[entry_point.name] = PluginStatus(is_success=True) + + def create_cli_app(self) -> typer.Typer: + """Create CLI app by collecting commands from hooks. Can be used for custom CLI entry point.""" + app = typer.Typer(name="bub", help="Batteries-included, hook-first AI framework", add_completion=False) + + @app.callback(invoke_without_command=True) + def _main( + ctx: typer.Context, + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Path to the workspace"), + ) -> None: + if workspace: + self.workspace = Path(workspace).resolve() + ctx.obj = self + + self._hook_runtime.call_many_sync("register_cli_commands", app=app) + return app + + async def process_inbound(self, inbound: Envelope) -> TurnResult: + """Run one inbound message through hooks and return turn result.""" + + try: + session_id = await self._hook_runtime.call_first( + "resolve_session", message=inbound + ) or self._default_session_id(inbound) + if isinstance(inbound, dict): + inbound.setdefault("session_id", session_id) + state = {"_runtime_workspace": str(self.workspace)} + for hook_state in reversed( + await self._hook_runtime.call_many("load_state", message=inbound, session_id=session_id) + ): + if isinstance(hook_state, dict): + state.update(hook_state) + prompt = await self._hook_runtime.call_first( + "build_prompt", message=inbound, session_id=session_id, state=state + ) + if not prompt: + prompt = content_of(inbound) + model_output = "" + try: + model_output = await self._hook_runtime.call_first( + "run_model", prompt=prompt, session_id=session_id, state=state + ) + if model_output is None: + await self._hook_runtime.notify_error( + stage="run_model:fallback", + error=RuntimeError("no model skill returned output"), + message=inbound, + ) + model_output = prompt + else: + model_output = str(model_output) + finally: + await self._hook_runtime.call_many( + "save_state", + session_id=session_id, + state=state, + message=inbound, + model_output=model_output, + ) + + outbounds = await self._collect_outbounds(inbound, session_id, state, model_output) + for outbound in outbounds: + await self._hook_runtime.call_many("dispatch_outbound", message=outbound) + return TurnResult(session_id=session_id, prompt=prompt, model_output=model_output, outbounds=outbounds) + except Exception as exc: + await self._hook_runtime.notify_error(stage="turn", error=exc, message=inbound) + raise + + def hook_report(self) -> dict[str, list[str]]: + """Return hook implementation summary for diagnostics.""" + + return self._hook_runtime.hook_report() + + def bind_outbound_router(self, router: OutboundChannelRouter | None) -> None: + self._outbound_router = router + + async def dispatch_via_router(self, message: Envelope) -> bool: + if self._outbound_router is None: + return False + return await self._outbound_router.dispatch(message) + + @staticmethod + def _default_session_id(message: Envelope) -> str: + session_id = field_of(message, "session_id") + if session_id is not None: + return str(session_id) + channel = str(field_of(message, "channel", "default")) + chat_id = str(field_of(message, "chat_id", "default")) + return f"{channel}:{chat_id}" + + async def _collect_outbounds( + self, + message: Envelope, + session_id: str, + state: dict[str, Any], + model_output: str, + ) -> list[Envelope]: + batches = await self._hook_runtime.call_many( + "render_outbound", + message=message, + session_id=session_id, + state=state, + model_output=model_output, + ) + outbounds: list[Envelope] = [] + for batch in batches: + outbounds.extend(unpack_batch(batch)) + if outbounds: + return outbounds + + fallback: dict[str, Any] = { + "content": model_output, + "session_id": session_id, + } + channel = field_of(message, "channel") + chat_id = field_of(message, "chat_id") + if channel is not None: + fallback["channel"] = channel + if chat_id is not None: + fallback["chat_id"] = chat_id + return [fallback] + + def get_channels(self, message_handler: MessageHandler) -> dict[str, Channel]: + channels: dict[str, Channel] = {} + for result in self._hook_runtime.call_many_sync("provide_channels", message_handler=message_handler): + for channel in result: + if channel.name not in channels: + channels[channel.name] = channel + return channels + + def get_tape_store(self) -> TapeStore | AsyncTapeStore | None: + return self._hook_runtime.call_first_sync("provide_tape_store") + + def get_system_prompt(self, prompt: str, state: dict[str, Any]) -> str: + return "\n\n".join( + result + for result in reversed(self._hook_runtime.call_many_sync("system_prompt", prompt=prompt, state=state)) + if result + ) diff --git a/src/bub/hook_runtime.py b/src/bub/hook_runtime.py new file mode 100644 index 00000000..22ab3abe --- /dev/null +++ b/src/bub/hook_runtime.py @@ -0,0 +1,169 @@ +"""Hook execution runtime with per-adapter fault isolation.""" + +from __future__ import annotations + +import inspect +from typing import Any + +import pluggy +from loguru import logger + +from bub.types import Envelope + + +class HookRuntime: + """Safe wrapper around pluggy hook execution.""" + + def __init__(self, plugin_manager: pluggy.PluginManager) -> None: + self._plugin_manager = plugin_manager + + async def call_first(self, hook_name: str, **kwargs: Any) -> Any: + """Run hook implementations in precedence order and return first non-None value.""" + + for impl in self._iter_hookimpls(hook_name): + call_kwargs = self._kwargs_for_impl(impl, kwargs) + value = await self._invoke_impl_async( + hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs + ) + if value is _SKIP_VALUE: + continue + if value is not None: + return value + return None + + async def call_many(self, hook_name: str, **kwargs: Any) -> list[Any]: + """Run all implementations and collect successful return values.""" + + results: list[Any] = [] + for impl in self._iter_hookimpls(hook_name): + call_kwargs = self._kwargs_for_impl(impl, kwargs) + value = await self._invoke_impl_async( + hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs + ) + if value is _SKIP_VALUE: + continue + results.append(value) + return results + + def call_first_sync(self, hook_name: str, **kwargs: Any) -> Any: + """Synchronous variant of call_first for bootstrap hooks.""" + + for impl in self._iter_hookimpls(hook_name): + call_kwargs = self._kwargs_for_impl(impl, kwargs) + value = self._invoke_impl_sync(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + if value is _SKIP_VALUE: + continue + if value is not None: + return value + return None + + def call_many_sync(self, hook_name: str, **kwargs: Any) -> list[Any]: + """Synchronous variant of call_many for bootstrap hooks.""" + + results: list[Any] = [] + for impl in self._iter_hookimpls(hook_name): + call_kwargs = self._kwargs_for_impl(impl, kwargs) + value = self._invoke_impl_sync(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + if value is _SKIP_VALUE: + continue + results.append(value) + return results + + async def notify_error(self, *, stage: str, error: Exception, message: Envelope | None) -> None: + """Call on_error hooks, swallowing observer failures.""" + + for impl in self._iter_hookimpls("on_error"): + call_kwargs = self._kwargs_for_impl(impl, {"stage": stage, "error": error, "message": message}) + try: + value = impl.function(**call_kwargs) + if inspect.isawaitable(value): + await value + except Exception: + logger.opt(exception=True).warning( + "hook.on_error_failed stage={} adapter={}", + stage, + impl.plugin_name or "", + ) + + def notify_error_sync(self, *, stage: str, error: Exception, message: Envelope | None) -> None: + """Synchronous on_error dispatch for bootstrap paths.""" + + for impl in self._iter_hookimpls("on_error"): + call_kwargs = self._kwargs_for_impl(impl, {"stage": stage, "error": error, "message": message}) + try: + value = impl.function(**call_kwargs) + except Exception: + logger.opt(exception=True).warning( + "hook.on_error_failed stage={} adapter={}", + stage, + impl.plugin_name or "", + ) + continue + if inspect.isawaitable(value): + logger.warning( + "hook.async_not_supported hook=on_error adapter={}", + impl.plugin_name or "", + ) + + def hook_report(self) -> dict[str, list[str]]: + """Build a hook->adapters mapping for diagnostics.""" + + report: dict[str, list[str]] = {} + for hook_name, hook_caller in sorted(self._plugin_manager.hook.__dict__.items()): + if hook_name.startswith("_") or not hasattr(hook_caller, "get_hookimpls"): + continue + adapter_names = [impl.plugin_name for impl in hook_caller.get_hookimpls()] + if adapter_names: + report[hook_name] = adapter_names + return report + + async def _invoke_impl_async( + self, + *, + hook_name: str, + impl: Any, + call_kwargs: dict[str, Any], + kwargs: dict[str, Any], + ) -> Any: + value = impl.function(**call_kwargs) + if inspect.isawaitable(value): + value = await value + return value + + def _invoke_impl_sync( + self, + *, + hook_name: str, + impl: Any, + call_kwargs: dict[str, Any], + kwargs: dict[str, Any], + ) -> Any: + value = impl.function(**call_kwargs) + if inspect.isawaitable(value): + logger.warning( + "hook.async_not_supported hook={} adapter={}", + hook_name, + impl.plugin_name or "", + ) + return _SKIP_VALUE + return value + + def _iter_hookimpls(self, hook_name: str) -> list[Any]: + hook = getattr(self._plugin_manager.hook, hook_name, None) + if hook is None or not hasattr(hook, "get_hookimpls"): + return [] + return list(reversed(hook.get_hookimpls())) + + @staticmethod + def _kwargs_for_impl(impl: Any, kwargs: dict[str, Any]) -> dict[str, Any]: + return {name: kwargs[name] for name in impl.argnames if name in kwargs} + + +def _message_from_kwargs(kwargs: dict[str, Any]) -> Envelope | None: + message = kwargs.get("message") + if message is None: + return None + return message + + +_SKIP_VALUE = object() diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py new file mode 100644 index 00000000..0e5ae423 --- /dev/null +++ b/src/bub/hookspecs.py @@ -0,0 +1,91 @@ +"""Pluggy hook namespace and framework hook specifications.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pluggy +from republic import AsyncTapeStore +from republic.tape import TapeStore + +from bub.types import Envelope, MessageHandler, State + +if TYPE_CHECKING: + from bub.channels.base import Channel + +BUB_HOOK_NAMESPACE = "bub" +hookspec = pluggy.HookspecMarker(BUB_HOOK_NAMESPACE) +hookimpl = pluggy.HookimplMarker(BUB_HOOK_NAMESPACE) + + +class BubHookSpecs: + """Hook contract for Bub framework extensions.""" + + @hookspec(firstresult=True) + def resolve_session(self, message: Envelope) -> str: + """Resolve session id for one inbound message.""" + raise NotImplementedError + + @hookspec(firstresult=True) + def load_state(self, message: Envelope, session_id: str) -> State: + """Load state snapshot for one session.""" + raise NotImplementedError + + @hookspec(firstresult=True) + def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: + """Build model prompt for this turn.""" + raise NotImplementedError + + @hookspec(firstresult=True) + def run_model(self, prompt: str, session_id: str, state: State) -> str: + """Run model for one turn and return plain text output.""" + raise NotImplementedError + + @hookspec + def save_state( + self, + session_id: str, + state: State, + message: Envelope, + model_output: str, + ) -> None: + """Persist state updates after one model turn.""" + + @hookspec + def render_outbound( + self, + message: Envelope, + session_id: str, + state: State, + model_output: str, + ) -> list[Envelope]: + """Render outbound messages from model output.""" + raise NotImplementedError + + @hookspec + def dispatch_outbound(self, message: Envelope) -> bool: + """Dispatch one outbound message to external channel(s).""" + raise NotImplementedError + + @hookspec + def register_cli_commands(self, app: Any) -> None: + """Register CLI commands onto the root Typer application.""" + + @hookspec + def on_error(self, stage: str, error: Exception, message: Envelope | None) -> None: + """Observe framework errors from any stage.""" + + @hookspec + def system_prompt(self, prompt: str, state: State) -> str: + """Provide a system prompt to be prepended to all model prompts.""" + raise NotImplementedError + + @hookspec(firstresult=True) + def provide_tape_store(self) -> TapeStore | AsyncTapeStore: + """Provide a tape store instance for Bub's conversation recording feature.""" + ... + + @hookspec + def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: + """Provide a list of channels for receiving messages.""" + raise NotImplementedError diff --git a/src/bub/integrations/republic_client.py b/src/bub/integrations/republic_client.py deleted file mode 100644 index 7d4efc9d..00000000 --- a/src/bub/integrations/republic_client.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Republic integration helpers.""" - -from __future__ import annotations - -from pathlib import Path - -from republic import LLM - -from bub.config.settings import Settings -from bub.tape.context import default_tape_context -from bub.tape.store import FileTapeStore - -AGENTS_FILE = "AGENTS.md" - - -def build_tape_store(settings: Settings, workspace: Path) -> FileTapeStore: - """Build persistent tape store for one workspace.""" - - return FileTapeStore(settings.resolve_home(), workspace) - - -def build_llm(settings: Settings, store: FileTapeStore) -> LLM: - """Build Republic LLM client configured for Bub runtime.""" - - client_args = None - if "azure" in settings.model: - client_args = {"api_version": "2025-01-01-preview"} - - return LLM( - settings.model, - api_key=settings.resolved_api_key, - api_base=settings.api_base, - tape_store=store, - context=default_tape_context(), - client_args=client_args, - ) - - -def read_workspace_agents_prompt(workspace: Path) -> str: - """Read workspace AGENTS.md if present.""" - - prompt_file = workspace / AGENTS_FILE - if not prompt_file.is_file(): - return "" - try: - return prompt_file.read_text(encoding="utf-8").strip() - except OSError: - return "" diff --git a/src/bub/logging_utils.py b/src/bub/logging_utils.py deleted file mode 100644 index 89d8b49a..00000000 --- a/src/bub/logging_utils.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Runtime logging helpers.""" - -from __future__ import annotations - -import os -import sys -from logging import Handler -from typing import Literal - -import loguru -from loguru import logger -from rich import get_console -from rich.logging import RichHandler - -LogProfile = Literal["default", "chat"] - -_PROFILE_FORMATS: dict[LogProfile, str] = { - "chat": "{level} | {extra[tape]} |{message}", - "default": "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<6} | {name}:{function}:{line} | {extra[tape]} | {message}", -} -_CONFIGURED_PROFILE: LogProfile | None = None - - -def _build_chat_handler() -> Handler: - return RichHandler( - console=get_console(), - show_level=True, - show_time=False, - show_path=False, - markup=False, - rich_tracebacks=False, - ) - - -def configure_logging(*, profile: LogProfile = "default") -> None: - """Configure process-level logging once.""" - from bub.tape.service import current_tape - - def inject_context(record: loguru.Record) -> None: - record["extra"]["tape"] = current_tape() - - global _CONFIGURED_PROFILE - if profile == _CONFIGURED_PROFILE: - return - - level = os.getenv("BUB_LOG_LEVEL", "INFO").upper() - logger.remove() - if profile == "chat": - logger.add( - _build_chat_handler(), - level=level, - format="{message}", - backtrace=False, - diagnose=False, - ) - else: - logger.add( - sys.stderr, - level=level, - format=_PROFILE_FORMATS[profile], - backtrace=False, - diagnose=False, - ) - logger.configure(patcher=inject_context) - _CONFIGURED_PROFILE = profile diff --git a/src/bub/skills.py b/src/bub/skills.py new file mode 100644 index 00000000..cbc8887e --- /dev/null +++ b/src/bub/skills.py @@ -0,0 +1,177 @@ +"""Skill discovery and Bub runtime adapter loading.""" + +from __future__ import annotations + +import re +import warnings +from collections.abc import Collection +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +PROJECT_SKILLS_DIR = ".agents/skills" +LEGACY_SKILLS_DIR = ".agent/skills" +SKILL_FILE_NAME = "SKILL.md" +SKILL_SOURCES = ("project", "global", "builtin") +SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +@dataclass(frozen=True) +class SkillMetadata: + """Discovered skill metadata.""" + + name: str + description: str + location: Path + source: str + metadata: dict[str, Any] = field(default_factory=dict) + + def body(self) -> str: + front_matter_pattern = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) + try: + content = self.location.read_text(encoding="utf-8").strip() + except OSError: + return "" + return front_matter_pattern.sub("", content, count=1).strip() + + +def discover_skills(workspace_path: Path) -> list[SkillMetadata]: + """Discover skills from project, global, and builtin roots with override precedence.""" + + skills_by_name: dict[str, SkillMetadata] = {} + for root, source in _iter_skill_roots(workspace_path): + if not root.is_dir(): + continue + for skill_dir in sorted(root.iterdir()): + if not skill_dir.is_dir(): + continue + metadata = _read_skill(skill_dir, source=source) + if metadata is None: + continue + key = metadata.name.casefold() + if key not in skills_by_name: + skills_by_name[key] = metadata + + return sorted(skills_by_name.values(), key=lambda item: item.name.casefold()) + + +def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: + skill_file = skill_dir / SKILL_FILE_NAME + if not skill_file.is_file(): + return None + + try: + content = skill_file.read_text(encoding="utf-8").strip() + except OSError: + return None + + metadata = _parse_frontmatter(content) + if not _is_valid_frontmatter(skill_dir=skill_dir, metadata=metadata): + return None + name = str(metadata["name"]).strip() + description = str(metadata["description"]).strip() + + return SkillMetadata( + name=name, + description=description, + location=skill_file.resolve(), + source=source, + metadata={str(key).casefold(): value for key, value in metadata.items() if key is not None}, + ) + + +def _parse_frontmatter(content: str) -> dict[str, Any]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return {} + + for idx, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + payload = "\n".join(lines[1:idx]) + try: + parsed = yaml.safe_load(payload) + except yaml.YAMLError: + parsed = {} + if isinstance(parsed, dict): + return {str(key).lower(): value for key, value in parsed.items()} + return {} + + +def _is_valid_frontmatter(*, skill_dir: Path, metadata: dict[str, object]) -> bool: + name = metadata.get("name") + description = metadata.get("description") + return ( + _is_valid_name(name=name, skill_dir=skill_dir) + and _is_valid_description(description) + and _is_valid_metadata_field(metadata.get("metadata")) + ) + + +def _is_valid_name(*, name: object, skill_dir: Path) -> bool: + if not isinstance(name, str): + return False + normalized_name = name.strip() + if not normalized_name or len(normalized_name) > 64: + return False + if normalized_name != skill_dir.name: + return False + return SKILL_NAME_PATTERN.fullmatch(normalized_name) is not None + + +def _is_valid_description(description: object) -> bool: + if not isinstance(description, str): + return False + normalized = description.strip() + return bool(normalized) and len(normalized) <= 1024 + + +def _is_valid_metadata_field(metadata_field: object) -> bool: + if metadata_field is None: + return True + if not isinstance(metadata_field, dict): + return False + return all(isinstance(key, str) and isinstance(value, str) for key, value in metadata_field.items()) + + +def _builtin_skills_root() -> list[Path]: + import importlib + + return [Path(p) for p in importlib.import_module("bub_skills").__path__] + + +def _iter_skill_roots(workspace_path: Path) -> list[tuple[Path, str]]: + roots: list[tuple[Path, str]] = [] + for source in SKILL_SOURCES: + if source == "project": + roots.append((workspace_path / PROJECT_SKILLS_DIR, source)) + legacy_path = workspace_path / LEGACY_SKILLS_DIR + if legacy_path.is_dir(): + warnings.warn( + f"Found legacy skills directory at '{legacy_path}'. Please move it to '{PROJECT_SKILLS_DIR}' to avoid this warning in the future.", + category=UserWarning, + stacklevel=2, + ) + roots.append((legacy_path, source)) + elif source == "global": + roots.append((Path.home() / PROJECT_SKILLS_DIR, source)) + elif source == "builtin": + for path in _builtin_skills_root(): + roots.append((path, source)) + return roots + + +def render_skills_prompt(skills: list[SkillMetadata], expanded_skills: Collection[str] = ()) -> str: + if not skills: + return "" + lines = [""] + for skill in skills: + line = f"- {skill.name}: {skill.description}" + if skill.name in expanded_skills: + body = skill.body() + if body: + line += f"\n{body}" + lines.append(line) + lines.append("") + return "\n".join(lines) diff --git a/src/bub/skills/__init__.py b/src/bub/skills/__init__.py deleted file mode 100644 index 00e20225..00000000 --- a/src/bub/skills/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Skill discovery package.""" - -from bub.skills.loader import SkillMetadata, discover_skills -from bub.skills.view import render_compact_skills - -__all__ = ["SkillMetadata", "discover_skills", "render_compact_skills"] diff --git a/src/bub/skills/discord/SKILL.md b/src/bub/skills/discord/SKILL.md deleted file mode 100644 index 3c2a866d..00000000 --- a/src/bub/skills/discord/SKILL.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -name: discord -description: | - Discord Bot integration for sending messages, managing channels, and responding to events. - Use when Bub needs to: (1) Send messages to Discord channels, (2) Create Discord bot with discord.py, - (3) Handle Discord events (on_message, on_member_join, etc.), (4) Work with Discord webhooks, - or (5) Any Discord-related functionality. -metadata: - channel: discord ---- - -# Discord Bot Skill - -Send messages and interact with Discord using discord.py. - -## Response Contract (Important) - -When the user asks to send or draft a Discord message: - -- Return only the final message content intended for Discord. -- Do not include action narration or meta text such as: - - "I already prepared..." - - "I can switch to another version..." - - "If you want, I can..." -- Do not prepend or append explanatory wrappers around the message body. -- If a style is requested (short, technical, casual), apply it directly in the final message. -- Keep the message concise unless the user explicitly requests detail. - -## Quick Start - -```bash -# Dependencies are declared in each script via PEP 723. -# Paths are relative to this skill directory. -# Run scripts directly with uv; it will resolve dependencies from the script header. -uv run ./scripts/discord_send.py --help -uv run ./scripts/discord_bot.py -``` - -## Sending Messages - -### Basic Message - -```python -import discord -from discord.ext import commands - -intents = discord.Intents.default() -intents.message_content = True - -bot = commands.Bot(command_prefix='!', intents=intents) - -@bot.event -async def on_ready(): - channel = bot.get_channel(CHANNEL_ID) - await channel.send("Hello from Bub!") -``` - -### Send to Channel by ID - -```python -channel = bot.get_channel(123456789) -await channel.send("Message") -``` - -### Send to Thread - -```python -thread = bot.get_channel(THREAD_ID) -await thread.send("Message in thread") -``` - -### Embed Message - -```python -embed = discord.Embed( - title="Title", - description="Description", - color=discord.Color.blue() -) -embed.add_field(name="Field", value="Value") -await channel.send(embed=embed) -``` - -## Using the Bot - -### Configuration - -Set environment variable: -```bash -export BUB_DISCORD_TOKEN="your_token_here" -``` - -### Running the Bot - -```python -import asyncio -import os -from discord_bot import run_bot - -async def main(): - token = os.environ.get("BUB_DISCORD_TOKEN") - await run_bot(token) - -asyncio.run(main()) -``` - -## Common Patterns - -### Respond to Messages - -```python -@bot.event -async def on_message(message): - if message.author.bot: - return - if "hello" in message.content.lower(): - await message.reply("Hello!") -``` - -### Command with Arguments - -```python -@bot.command(name="echo") -async def echo(ctx, *, text: str): - await ctx.send(text) -``` - -### Button Interaction - -```python -from discord.ui import Button, View - -button = Button(label="Click me", style=discord.ButtonStyle.primary) -async def callback(interaction): - await interaction.response.send_message("Clicked!") - -button.callback = callback -view = View() -view.add_item(button) -await ctx.send("Click:", view=view) -``` - -## Environment - -- `BUB_DISCORD_TOKEN`: Bot token from Discord Developer Portal diff --git a/src/bub/skills/discord/scripts/discord_bot.py b/src/bub/skills/discord/scripts/discord_bot.py deleted file mode 100644 index 05927ba3..00000000 --- a/src/bub/skills/discord/scripts/discord_bot.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env uv run -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "discord.py>=2.3.0", -# ] -# /// - -""" -Discord Bot basic scaffold. -Written to keep logic readable and testable. -""" - -import asyncio -import os -from dataclasses import dataclass - -import discord -from discord.ext import commands - - -@dataclass -class Config: - """Bot configuration.""" - - token: str - command_prefix: str = "!" - intents_messages: bool = True - intents_message_content: bool = True - - -def create_bot(config: Config) -> commands.Bot: - """ - Create a bot instance. - - Args: - config: Bot configuration. - - Returns: - A configured bot instance. - """ - intents = discord.Intents.default() - intents.messages = config.intents_messages - intents.message_content = config.intents_message_content - - bot = commands.Bot( - command_prefix=config.command_prefix, - intents=intents, - help_command=None, - ) - - return bot - - -def register_events(bot: commands.Bot) -> None: - """Register event handlers.""" - - @bot.event - async def on_ready() -> None: - """Handle bot startup completion.""" - print(f"🤖 Logged in as {bot.user}") - print(f" ID: {bot.user.id}") - - @bot.event - async def on_message(message: discord.Message) -> None: - """Handle incoming messages.""" - # Ignore messages from bots. - if message.author.bot: - return - - # Reply within an existing thread when available. - if message.thread is not None: - await message.thread.send(f"Received: {message.content}") - - # Continue command processing. - await bot.process_commands(message) - - @bot.event - async def on_command_error(ctx: commands.Context, error: commands.CommandError) -> None: - """Handle command errors.""" - if isinstance(error, commands.CommandNotFound): - await ctx.send(f"Command not found: {ctx.invoked_with}") - elif isinstance(error, commands.MissingRequiredArgument): - await ctx.send(f"Missing argument: {error.param.name}") - else: - await ctx.send(f"Error: {error}") - raise error - - -def register_commands(bot: commands.Bot) -> None: - """Register bot commands.""" - - @bot.command(name="ping") - async def ping(ctx: commands.Context) -> None: - """Ping command.""" - await ctx.send("pong 🏓") - - @bot.command(name="hello") - async def hello(ctx: commands.Context) -> None: - """Hello command.""" - await ctx.send(f"Hello, {ctx.author.mention}! 👋") - - @bot.command(name="echo") - async def echo(ctx: commands.Context, *, text: str) -> None: - """Echo command.""" - await ctx.send(text) - - @bot.command(name="info") - async def info(ctx: commands.Context) -> None: - """Bot info command.""" - embed = discord.Embed(title="🤖 Bot Info", description="Bub's Discord Bot", color=discord.Color.blue()) - embed.add_field( - name="Commands", value="!ping - ping\n!hello - hello\n!echo - echo\n!info - this", inline=False - ) - await ctx.send(embed=embed) - - -async def run_bot(token: str) -> None: - """Run the bot.""" - config = Config(token=token) - bot = create_bot(config) - - register_events(bot) - register_commands(bot) - - await bot.start(token) - - -def main() -> None: - """Entry point.""" - token = os.environ.get("BUB_DISCORD_TOKEN") - if token is None: - print("Error: BUB_DISCORD_TOKEN not set") - return - - asyncio.run(run_bot(token)) - - -if __name__ == "__main__": - main() diff --git a/src/bub/skills/discord/scripts/discord_send.py b/src/bub/skills/discord/scripts/discord_send.py deleted file mode 100644 index e2f8a4d2..00000000 --- a/src/bub/skills/discord/scripts/discord_send.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env uv run -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "discord.py>=2.3.0", -# ] -# /// - -""" -Discord message sender script. -""" - -import argparse -import asyncio -import os -import sys - -import discord - - -async def send_message( - token: str, - channel_id: int, - message: str, - embed: bool = False, -) -> None: - """Send a message to a Discord channel.""" - intents = discord.Intents.default() - intents.message_content = True - - client = discord.Client(intents=intents) - - @client.event - async def on_ready(): - print(f"Logged in as {client.user}") - channel = client.get_channel(channel_id) - if channel is None: - print(f"Channel {channel_id} not found") - await client.close() - sys.exit(1) - - if embed: - emb = discord.Embed(description=message) - await channel.send(embed=emb) - else: - await channel.send(message) - - print(f"Message sent to channel {channel_id}") - await client.close() - - await client.start(token) - - -def main(): - parser = argparse.ArgumentParser(description="Send message to Discord") - parser.add_argument("--token", "-t", default=os.environ.get("BUB_DISCORD_TOKEN")) - parser.add_argument("--channel", "-c", type=int, required=True, help="Channel ID") - parser.add_argument("--message", "-m", required=True, help="Message to send") - parser.add_argument("--embed", "-e", action="store_true", help="Send as embed") - - args = parser.parse_args() - - if not args.token: - print("Error: BUB_DISCORD_TOKEN not set") - sys.exit(1) - - asyncio.run(send_message(args.token, args.channel, args.message, args.embed)) - - -if __name__ == "__main__": - main() diff --git a/src/bub/skills/loader.py b/src/bub/skills/loader.py deleted file mode 100644 index 913b913e..00000000 --- a/src/bub/skills/loader.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Skill discovery and loading.""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Any, cast - -import yaml - -PROJECT_SKILLS_DIR = ".agent/skills" -SKILL_FILE_NAME = "SKILL.md" - - -@dataclass(frozen=True) -class SkillMetadata: - """Skill metadata used in compact prompt view.""" - - name: str - description: str - location: Path - body: str - metadata: dict[str, Any] | None = None - source: str = "unknown" - - -def discover_skills(workspace_path: Path) -> list[SkillMetadata]: - """Discover skills from project, global, and built-in roots.""" - - ordered_roots = [ - (workspace_path / PROJECT_SKILLS_DIR, "project"), - (Path.home() / PROJECT_SKILLS_DIR, "global"), - (_builtin_skills_root(), "builtin"), - ] - - by_name: dict[str, SkillMetadata] = {} - for root, source in ordered_roots: - if not root.is_dir(): - continue - for skill_dir in sorted(root.iterdir()): - if not skill_dir.is_dir(): - continue - metadata = _read_skill(skill_dir, source=source) - if metadata is None: - continue - key = metadata.name.casefold() - if key not in by_name: - by_name[key] = metadata - - return sorted(by_name.values(), key=lambda item: item.name.casefold()) - - -def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: - skill_file = skill_dir / SKILL_FILE_NAME - if not skill_file.is_file(): - return None - - try: - content = skill_file.read_text(encoding="utf-8") - except OSError: - return None - - metadata, body = _parse_frontmatter(content) - name = str(metadata.get("name") or skill_dir.name).strip() - description = str(metadata.get("description") or "No description provided.").strip() - meta = cast(dict[str, Any], metadata.get("metadata")) - - if not name: - return None - - return SkillMetadata( - name=name, description=description, location=skill_file.resolve(), source=source, metadata=meta, body=body - ) - - -def _parse_frontmatter(content: str) -> tuple[dict[str, object], str]: - lines = content.splitlines() - if not lines or lines[0].strip() != "---": - return {}, content - - for idx, line in enumerate(lines[1:], start=1): - if line.strip() == "---": - payload = "\n".join(lines[1:idx]) - try: - parsed = yaml.safe_load(payload) - except yaml.YAMLError: - parsed = {} - body = "\n".join(lines[idx + 1 :]) - if isinstance(parsed, dict): - return {str(key).lower(): value for key, value in parsed.items()}, body - return {}, body - return {}, content - - -def _builtin_skills_root() -> Path: - return Path(__file__).resolve().parent diff --git a/src/bub/skills/view.py b/src/bub/skills/view.py deleted file mode 100644 index f222760e..00000000 --- a/src/bub/skills/view.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Skill prompt rendering.""" - -from __future__ import annotations - -from bub.skills.loader import SkillMetadata - - -def render_compact_skills(skills: list[SkillMetadata], expanded_skills: set[str]) -> str: - """Render compact skill metadata for system prompt.""" - - if not skills: - return "" - channel_skills: list[SkillMetadata] = [ - skill for skill in skills if skill.metadata and skill.metadata.get("channel") - ] - lines = [""] - for skill in skills: - if skill.metadata and skill.metadata.get("channel"): - continue - lines.append(f"=== [{skill.name}]({skill.location}): {skill.description} ===") - if skill.name in expanded_skills: - lines.append(f"{skill.body.rstrip()}\n") - lines.append("") - if channel_skills: - lines.append("") - for skill in channel_skills: - lines.append(f"=== [{skill.name}]({skill.location}): {skill.description} ===") - if skill.name in expanded_skills: - lines.append(f"{skill.body.rstrip()}\n") - lines.append("") - return "\n".join(lines) diff --git a/src/bub/tape/__init__.py b/src/bub/tape/__init__.py deleted file mode 100644 index be0f2bd2..00000000 --- a/src/bub/tape/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Tape helpers.""" - -from bub.tape.anchors import AnchorSummary -from bub.tape.context import default_tape_context -from bub.tape.service import TapeService -from bub.tape.store import FileTapeStore - -__all__ = ["AnchorSummary", "FileTapeStore", "TapeService", "default_tape_context"] diff --git a/src/bub/tape/anchors.py b/src/bub/tape/anchors.py deleted file mode 100644 index 01f651cf..00000000 --- a/src/bub/tape/anchors.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Anchor models.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class AnchorSummary: - """Rendered anchor summary.""" - - name: str - state: dict[str, object] diff --git a/src/bub/tape/store.py b/src/bub/tape/store.py deleted file mode 100644 index 8c9a9ec7..00000000 --- a/src/bub/tape/store.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Persistent tape store implementation.""" - -from __future__ import annotations - -import json -import shutil -import threading -import uuid -from dataclasses import dataclass -from datetime import UTC, datetime -from hashlib import md5 -from pathlib import Path -from typing import cast -from urllib.parse import quote, unquote - -from republic.tape import InMemoryQueryMixin, TapeEntry - -TAPE_FILE_SUFFIX = ".jsonl" - - -@dataclass(frozen=True) -class TapePaths: - """Resolved tape paths for one workspace.""" - - home: Path - tape_root: Path - workspace_hash: str - - -class TapeFile: - """Helper for one tape file.""" - - def __init__(self, path: Path) -> None: - self.path = path - self.fork_start_id: int | None = None - self._lock = threading.Lock() - self._read_entries: list[TapeEntry] = [] - self._read_offset = 0 - - def copy_to(self, target: TapeFile) -> None: - if self.path.exists(): - shutil.copy2(self.path, target.path) - target._read_entries = self.read() - target.fork_start_id = self._next_id() - target._read_offset = self._read_offset - - def copy_from(self, source: TapeFile) -> None: - entries = [entry for entry in source.read() if entry.id >= (source.fork_start_id or 0)] - self._append_many(entries) - # Refresh to update intenal state - self.read() - - def _next_id(self) -> int: - if self._read_entries: - return cast(int, self._read_entries[-1].id + 1) - return 1 - - def _reset(self) -> None: - self._read_entries = [] - self._read_offset = 0 - - def reset(self) -> None: - with self._lock: - if self.path.exists(): - self.path.unlink() - self._reset() - - def read(self) -> list[TapeEntry]: - with self._lock: - return self._read_locked() - - def _read_locked(self) -> list[TapeEntry]: - if not self.path.exists(): - self._reset() - return [] - - file_size = self.path.stat().st_size - if file_size < self._read_offset: - # The file was truncated or replaced, so cached entries are stale. - self._reset() - - with self.path.open("r", encoding="utf-8") as handle: - handle.seek(self._read_offset) - for raw_line in handle: - line = raw_line.strip() - if not line: - continue - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - entry = self.entry_from_payload(payload) - if entry is not None: - self._read_entries.append(entry) - self._read_offset = handle.tell() - - return list(self._read_entries) - - @staticmethod - def entry_to_payload(entry: TapeEntry) -> dict[str, object]: - return { - "id": entry.id, - "kind": entry.kind, - "payload": dict(entry.payload), - "meta": dict(entry.meta), - "timestamp": entry.timestamp, - } - - @staticmethod - def entry_from_payload(payload: object) -> TapeEntry | None: - if not isinstance(payload, dict): - return None - entry_id = payload.get("id") - kind = payload.get("kind") - entry_payload = payload.get("payload") - meta = payload.get("meta") - if not isinstance(entry_id, int): - return None - if not isinstance(kind, str): - return None - if not isinstance(entry_payload, dict): - return None - if not isinstance(meta, dict): - meta = {} - timestamp = payload.get("timestamp", 0.0) - return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), timestamp) - - def append(self, entry: TapeEntry) -> None: - return self._append_many([entry]) - - def _append_many(self, entries: list[TapeEntry]) -> None: - if not entries: - return - - with self._lock: - # Keep cache and offset in sync before allocating new IDs. - self._read_locked() - with self.path.open("a", encoding="utf-8") as handle: - next_id = self._next_id() - for entry in entries: - stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta)) - handle.write(json.dumps(self.entry_to_payload(stored), ensure_ascii=False) + "\n") - self._read_entries.append(stored) - next_id += 1 - self._read_offset = handle.tell() - - def archive(self) -> Path | None: - if not self.path.exists(): - return None - stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") - archive_file = self.path.with_suffix(f"{TAPE_FILE_SUFFIX}.{stamp}.bak") - self.path.replace(archive_file) - return archive_file - - -class FileTapeStore(InMemoryQueryMixin): - """Append-only JSONL tape store compatible with Republic TapeStore protocol.""" - - def __init__(self, home: Path, workspace_path: Path) -> None: - self._paths = self._resolve_paths(home, workspace_path) - self._tape_files: dict[str, TapeFile] = {} - self._fork_start_ids: dict[str, int] = {} - self._lock = threading.Lock() - - def list_tapes(self) -> list[str]: - with self._lock: - tapes: list[str] = [] - prefix = f"{self._paths.workspace_hash}__" - for path in self._paths.tape_root.glob(f"{prefix}*{TAPE_FILE_SUFFIX}"): - encoded = path.name.removeprefix(prefix).removesuffix(TAPE_FILE_SUFFIX) - if not encoded or "__" in encoded: - continue - tapes.append(unquote(encoded)) - return sorted(set(tapes)) - - def fork(self, source: str) -> str: - fork_suffix = uuid.uuid4().hex[:8] - new_name = f"{source}__{fork_suffix}" - source_file = self._tape_file(source) - target_file = self._tape_file(new_name) - source_file.copy_to(target_file) - return new_name - - def merge(self, source: str, target: str) -> None: - source_file = self._tape_file(source) - target_file = self._tape_file(target) - target_file.copy_from(source_file) - source_file.path.unlink(missing_ok=True) - self._tape_files.pop(source, None) - - def reset(self, tape: str) -> None: - return self._tape_file(tape).reset() - - def read(self, tape: str) -> list[TapeEntry] | None: - tape_file = self._tape_file(tape) - if not tape_file.path.exists(): - return None - return tape_file.read() - - def append(self, tape: str, entry: TapeEntry) -> None: - return self._tape_file(tape).append(entry) - - def archive(self, tape: str) -> Path | None: - tape_file = self._tape_file(tape) - self._tape_files.pop(tape, None) - return tape_file.archive() - - def _tape_file(self, tape: str) -> TapeFile: - if tape not in self._tape_files: - encoded_name = quote(tape, safe="") - file_name = f"{self._paths.workspace_hash}__{encoded_name}{TAPE_FILE_SUFFIX}" - self._tape_files[tape] = TapeFile(self._paths.tape_root / file_name) - return self._tape_files[tape] - - @staticmethod - def _resolve_paths(home: Path, workspace_path: Path) -> TapePaths: - tape_root = (home / "tapes").resolve() - tape_root.mkdir(parents=True, exist_ok=True) - workspace_hash = md5(str(workspace_path.resolve()).encode("utf-8")).hexdigest() # noqa: S324 - return TapePaths(home=home, tape_root=tape_root, workspace_hash=workspace_hash) diff --git a/src/bub/tools.py b/src/bub/tools.py new file mode 100644 index 00000000..d723ec63 --- /dev/null +++ b/src/bub/tools.py @@ -0,0 +1,153 @@ +import inspect +import json +import time +from collections.abc import Callable, Iterable +from dataclasses import replace +from typing import Any, overload + +from loguru import logger +from pydantic import BaseModel +from republic import Tool +from republic import tool as republic_tool + +# Central registry for tools. Tools defined with the @tool decorator are automatically added here. +REGISTRY: dict[str, Tool] = {} + + +def _add_logging(tool: Tool) -> Tool: + if tool.handler is None: + return tool + + async def wrapped(*args, **kwargs): + call_kwargs = kwargs.copy() + if tool.context: + call_kwargs.pop("context", None) + _log_tool_call(tool.name, args, call_kwargs) + start = time.monotonic() + + try: + result = tool.handler(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + except Exception: + elapsed_time = (time.monotonic() - start) * 1000 + logger.exception("tool.call.error name={} elapsed_time={:.2f}ms", tool.name, elapsed_time) + raise + else: + elapsed_time = (time.monotonic() - start) * 1000 + logger.info("tool.call.success name={} elapsed_time={:.2f}ms", tool.name, elapsed_time) + return result + + return replace(tool, handler=wrapped) + + +def _shorten_text(text: str, width: int = 30, placeholder: str = "...") -> str: + if len(text) <= width: + return text + + # Reserve space for placeholder + available = width - len(placeholder) + if available <= 0: + return placeholder + + return text[:available] + placeholder + + +def _render_value(value: Any) -> str: + try: + rendered = json.dumps(value, ensure_ascii=False) + except TypeError: + rendered = repr(value) + rendered = _shorten_text(rendered, width=100, placeholder="...") + if rendered.startswith('"') and not rendered.endswith('"'): + rendered = rendered + '"' + if rendered.startswith("{") and not rendered.endswith("}"): + rendered = rendered + "}" + if rendered.startswith("[") and not rendered.endswith("]"): + rendered = rendered + "]" + return rendered + + +def _log_tool_call(name: str, args: Any, kwargs: dict[str, Any]) -> None: + params: list[str] = [] + + for value in args: + params.append(_render_value(value)) + for key, value in kwargs.items(): + rendered = _render_value(value) + params.append(f"{key}={rendered}") + params_str = f" {{ {', '.join(params)} }}" if params else "" + logger.info("tool.call.start name={}{}", name, params_str) + + +@overload +def tool( + func: Callable, + *, + name: str | None = ..., + model: type[BaseModel] | None = ..., + description: str | None = ..., + context: bool = ..., +) -> Tool: ... + + +@overload +def tool( + func: None = ..., + *, + name: str | None = ..., + model: type[BaseModel] | None = ..., + description: str | None = ..., + context: bool = ..., +) -> Callable[[Callable], Tool]: ... + + +def tool( + func: Callable | None = None, + *, + name: str | None = None, + model: type[BaseModel] | None = None, + description: str | None = None, + context: bool = False, +) -> Tool | Callable[[Callable], Tool]: + """Decorator to convert a function into a Tool instance.""" + + result = republic_tool( + func=func, + name=name, + model=model, + description=description, + context=context, + ) + if isinstance(result, Tool): + REGISTRY[result.name] = result + return _add_logging(result) + + def decorator(func: Callable) -> Tool: + tool_instance = _add_logging(result(func)) + REGISTRY[tool_instance.name] = tool_instance + return tool_instance + + return decorator + + +def _to_model_name(name: str) -> str: + return name.replace(".", "_") + + +def model_tools(tools: Iterable[Tool]) -> list[Tool]: + """Helper to convert a list of Tool instances into a format accepted by LLMs.""" + return [replace(tool, name=_to_model_name(tool.name)) for tool in tools] + + +def render_tools_prompt(tools: Iterable[Tool]) -> str: + """Render a human-readable description of tools for model prompts.""" + if not tools: + return "" + lines = [] + for tool in tools: + line = f"- {_to_model_name(tool.name)}" + if tool.description: + line += f": {tool.description}" + lines.append(line) + return f"\n{'\n'.join(lines)}\n" diff --git a/src/bub/tools/__init__.py b/src/bub/tools/__init__.py deleted file mode 100644 index 9e5a8c0c..00000000 --- a/src/bub/tools/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Tooling package.""" - -from bub.tools.progressive import ProgressiveToolView -from bub.tools.registry import ToolDescriptor, ToolRegistry - -__all__ = ["ProgressiveToolView", "ToolDescriptor", "ToolRegistry"] diff --git a/src/bub/tools/builtin.py b/src/bub/tools/builtin.py deleted file mode 100644 index 9fd6bc66..00000000 --- a/src/bub/tools/builtin.py +++ /dev/null @@ -1,478 +0,0 @@ -"""Built-in tool definitions.""" - -from __future__ import annotations - -import asyncio -import json -import os -import shutil -import uuid -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import TYPE_CHECKING -from urllib import parse as urllib_parse - -from apscheduler.jobstores.base import ConflictingIdError, JobLookupError -from apscheduler.triggers.cron import CronTrigger -from apscheduler.triggers.date import DateTrigger -from apscheduler.triggers.interval import IntervalTrigger -from pydantic import BaseModel, Field -from republic import ToolContext - -from bub.tape.service import TapeService -from bub.tools.registry import ToolRegistry - -if TYPE_CHECKING: - from bub.app.runtime import AppRuntime - -DEFAULT_OLLAMA_WEB_API_BASE = "https://ollama.com/api" -WEB_REQUEST_TIMEOUT_SECONDS = 20 -SUBPROCESS_TIMEOUT_SECONDS = 30 -MAX_FETCH_BYTES = 1_000_000 -WEB_USER_AGENT = "bub-web-tools/1.0" -SESSION_ID_ENV_VAR = "BUB_SESSION_ID" - - -class BashInput(BaseModel): - cmd: str = Field(..., description="Shell command") - cwd: str | None = Field(default=None, description="Working directory") - timeout_seconds: int = Field( - default=SUBPROCESS_TIMEOUT_SECONDS, ge=1, description="Maximum seconds to allow command to run" - ) - - -class ReadInput(BaseModel): - path: str = Field(..., description="File path") - offset: int = Field(default=0, ge=0) - limit: int | None = Field(default=None, ge=1) - - -class WriteInput(BaseModel): - path: str = Field(..., description="File path") - content: str = Field(..., description="File content") - - -class EditInput(BaseModel): - path: str = Field(..., description="File path") - old: str = Field(..., description="Search text") - new: str = Field(..., description="Replacement text") - start_line: int = Field(default=0, ge=0, description="Start line number to search from") - - -class FetchInput(BaseModel): - url: str = Field(..., description="URL") - - -class SearchInput(BaseModel): - query: str = Field(..., description="Search query") - max_results: int = Field(default=5, ge=1, le=10) - - -class HandoffInput(BaseModel): - name: str | None = Field(default=None, description="Anchor name") - summary: str | None = Field(default=None, description="Summary") - next_steps: str | None = Field(default=None, description="Next steps") - - -class ToolNameInput(BaseModel): - name: str = Field(..., description="Tool name") - - -class TapeSearchInput(BaseModel): - query: str = Field(..., description="Query") - limit: int = Field(default=20, ge=1) - - -class TapeResetInput(BaseModel): - archive: bool = Field(default=False) - - -class EmptyInput(BaseModel): - pass - - -class ScheduleAddInput(BaseModel): - after_seconds: int | None = Field(None, description="If set, schedule to run after this many seconds from now") - interval_seconds: int | None = Field(None, description="If set, repeat at this interval") - cron: str | None = Field( - None, description="If set, run with cron expression in crontab format: minute hour day month day_of_week" - ) - message: str = Field(..., description="Reminder message to send") - - -class ScheduleRemoveInput(BaseModel): - job_id: str = Field(..., description="Job id to remove") - - -def _resolve_path(workspace: Path, raw: str) -> Path: - path = Path(raw).expanduser() - if path.is_absolute(): - return path - return workspace / path - - -def _normalize_url(raw_url: str) -> str | None: - normalized = raw_url.strip() - if not normalized: - return None - - parsed = urllib_parse.urlparse(normalized) - if parsed.scheme and parsed.netloc: - if parsed.scheme not in {"http", "https"}: - return None - return normalized - - if parsed.scheme == "" and parsed.netloc == "" and parsed.path: - with_scheme = f"https://{normalized}" - parsed = urllib_parse.urlparse(with_scheme) - if parsed.netloc: - return with_scheme - - return None - - -def _normalize_api_base(raw_api_base: str) -> str | None: - normalized = raw_api_base.strip().rstrip("/") - if not normalized: - return None - - parsed = urllib_parse.urlparse(normalized) - if parsed.scheme in {"http", "https"} and parsed.netloc: - return normalized - return None - - -def _format_search_results(results: list[object]) -> str: - lines: list[str] = [] - for idx, item in enumerate(results, start=1): - if not isinstance(item, dict): - continue - title = str(item.get("title") or "(untitled)") - url = str(item.get("url") or "") - content = str(item.get("content") or "") - lines.append(f"{idx}. {title}") - if url: - lines.append(f" {url}") - if content: - lines.append(f" {content}") - return "\n".join(lines) if lines else "none" - - -def register_builtin_tools( - registry: ToolRegistry, - *, - workspace: Path, - tape: TapeService, - runtime: AppRuntime, -) -> None: - """Register built-in tools and internal commands.""" - from bub.tools.schedule import run_scheduled_reminder - - register = registry.register - - @register(name="bash", short_description="Run shell command", model=BashInput, context=True) - async def run_bash(params: BashInput, context: ToolContext) -> str: - """Execute bash in workspace. Non-zero exit raises an error. - IMPORTANT: please DO NOT use sleep to delay execution, use schedule.add tool instead. - """ - import dotenv - - cwd = params.cwd or str(workspace) - executable = shutil.which("bash") or "bash" - env = dict(os.environ) - workspace_env = workspace / ".env" - if workspace_env.is_file(): - env.update((k, v) for k, v in dotenv.dotenv_values(workspace_env).items() if v is not None) - env[SESSION_ID_ENV_VAR] = context.state.get("session_id", "") - completed = await asyncio.create_subprocess_exec( - executable, - "-lc", - params.cmd, - cwd=cwd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - ) - async with asyncio.timeout(params.timeout_seconds): - stdout_bytes, stderr_bytes = await completed.communicate() - stdout_text = (stdout_bytes or b"").decode("utf-8", errors="replace").strip() - stderr_text = (stderr_bytes or b"").decode("utf-8", errors="replace").strip() - if completed.returncode != 0: - message = stderr_text or stdout_text or f"exit={completed.returncode}" - raise RuntimeError(f"exit={completed.returncode}: {message}") - return stdout_text or "(no output)" - - @register(name="fs.read", short_description="Read file content", model=ReadInput) - def fs_read(params: ReadInput) -> str: - """Read UTF-8 text with optional offset and limit.""" - file_path = _resolve_path(workspace, params.path) - text = file_path.read_text(encoding="utf-8") - lines = text.splitlines() - start = min(params.offset, len(lines)) - end = len(lines) if params.limit is None else min(len(lines), start + params.limit) - return "\n".join(lines[start:end]) - - @register(name="fs.write", short_description="Write file content", model=WriteInput) - def fs_write(params: WriteInput) -> str: - """Write UTF-8 text to path, creating parent directory if needed.""" - file_path = _resolve_path(workspace, params.path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(params.content, encoding="utf-8") - return f"wrote: {file_path}" - - @register(name="fs.edit", short_description="Edit file content", model=EditInput) - def fs_edit(params: EditInput) -> str: - """Replace all occurrences of old text in file.""" - file_path = _resolve_path(workspace, params.path) - if not file_path.is_file(): - raise RuntimeError(f"file not found: {file_path}") - text = file_path.read_text(encoding="utf-8") - lines = text.splitlines() - start_line = min(params.start_line, len(lines)) - prev, to_replace = "\n".join(lines[:start_line]), "\n".join(lines[start_line:]) - if params.old not in to_replace: - raise RuntimeError(f"'{params.old}' not found in {file_path} from line {start_line}") - new_text = to_replace.replace(params.old, params.new) - file_path.write_text(f"{prev}\n{new_text}", encoding="utf-8") - return f"edited: {file_path}" - - @register(name="web.fetch", short_description="Fetch URL as markdown", model=FetchInput) - async def web_fetch_default(params: FetchInput) -> str: - """Fetch URL and convert HTML to markdown-like text.""" - import aiohttp - - url = _normalize_url(params.url) - if not url: - return "error: invalid url" - - try: - async with ( - aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=WEB_REQUEST_TIMEOUT_SECONDS)) as session, - session.get(url, headers={"User-Agent": WEB_USER_AGENT, "Accept": "text/markdown"}) as response, - ): - content_bytes = await response.content.read(MAX_FETCH_BYTES + 1) - truncated = len(content_bytes) > MAX_FETCH_BYTES - content = content_bytes[:MAX_FETCH_BYTES].decode("utf-8", errors="replace") - except aiohttp.ClientError as exc: - return f"HTTP error: {exc!s}" - if not content: - return "error: empty response body" - if truncated: - return f"{content}\n\n[truncated: response exceeded byte limit]" - return content - - @register(name="schedule.add", short_description="Add a cron schedule", model=ScheduleAddInput, context=True) - def schedule_add(params: ScheduleAddInput, context: ToolContext) -> str: - """Schedule a reminder message to be sent to current session in the future. You can specify either of the following scheduling options: - - after_seconds: run once after this many seconds from now - - interval_seconds: run repeatedly at this interval - - cron: run with cron expression in crontab format: minute hour day month day_of_week - """ - job_id = str(uuid.uuid4())[:8] - if params.after_seconds is not None: - trigger = DateTrigger(run_date=datetime.now(UTC) + timedelta(seconds=params.after_seconds)) - elif params.interval_seconds is not None: - trigger = IntervalTrigger(seconds=params.interval_seconds) - else: - try: - trigger = CronTrigger.from_crontab(params.cron) - except ValueError as exc: - raise RuntimeError(f"invalid cron expression: {params.cron}") from exc - - try: - job = runtime.scheduler.add_job( - run_scheduled_reminder, - trigger=trigger, - id=job_id, - kwargs={ - "message": params.message, - "session_id": context.state.get("session_id", ""), - "workspace": str(runtime.workspace), - }, - coalesce=True, - max_instances=1, - ) - except ConflictingIdError as exc: - raise RuntimeError(f"job id already exists: {job_id}") from exc - - next_run = "-" - if isinstance(job.next_run_time, datetime): - next_run = job.next_run_time.isoformat() - return f"scheduled: {job.id} next={next_run}" - - @register(name="schedule.remove", short_description="Remove a scheduled job", model=ScheduleRemoveInput) - def schedule_remove(params: ScheduleRemoveInput) -> str: - """Remove one scheduled job by id.""" - try: - runtime.scheduler.remove_job(params.job_id) - except JobLookupError as exc: - raise RuntimeError(f"job not found: {params.job_id}") from exc - return f"removed: {params.job_id}" - - @register(name="schedule.list", short_description="List scheduled jobs", model=EmptyInput, context=True) - def schedule_list(_params: EmptyInput, context: ToolContext) -> str: - """List scheduled jobs for current workspace.""" - jobs = runtime.scheduler.get_jobs() - rows: list[str] = [] - for job in jobs: - next_run = "-" - if isinstance(job.next_run_time, datetime): - next_run = job.next_run_time.isoformat() - message = str(job.kwargs.get("message", "")) - job_session = job.kwargs.get("session_id") - if job_session and job_session != context.state.get("session_id", ""): - continue - rows.append(f"{job.id} next={next_run} msg={message}") - - if not rows: - return "(no scheduled jobs)" - - return "\n".join(rows) - - if runtime.settings.ollama_api_key: - - @register(name="web.search", short_description="Search the web", model=SearchInput) - async def web_search_ollama(params: SearchInput) -> str: - import aiohttp - - api_key = runtime.settings.ollama_api_key - if not api_key: - return "error: ollama api key is not configured" - - api_base = _normalize_api_base(runtime.settings.ollama_api_base or DEFAULT_OLLAMA_WEB_API_BASE) - if not api_base: - return "error: invalid ollama api base url" - - endpoint = f"{api_base}/web_search" - payload = { - "query": params.query, - "max_results": params.max_results, - } - try: - async with ( - aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=WEB_REQUEST_TIMEOUT_SECONDS)) as session, - session.post( - endpoint, - json=payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - "User-Agent": WEB_USER_AGENT, - }, - ) as response, - ): - response_body = await response.text() - except aiohttp.ClientError as exc: - return f"HTTP error: {exc!s}" - - try: - data = json.loads(response_body) - except json.JSONDecodeError as exc: - return f"error: invalid json response: {exc!s}" - - results = data.get("results") - if not isinstance(results, list) or not results: - return "none" - return _format_search_results(results) - - else: - - @register(name="web.search", short_description="Search the web", model=SearchInput) - def web_search_default(params: SearchInput) -> str: - """Return a DuckDuckGo search URL for the query.""" - query = urllib_parse.quote_plus(params.query) - return f"https://duckduckgo.com/?q={query}" - - @register(name="help", short_description="Show command help", model=EmptyInput) - def command_help(_params: EmptyInput) -> str: - """Show Bub internal command usage and examples.""" - return ( - "Commands use ',' at line start.\n" - "Known names map to internal tools; other commands run through bash.\n" - "Examples:\n" - " ,help\n" - " ,git status\n" - " , ls -la\n" - " ,tools\n" - " ,tool.describe name=fs.read\n" - " ,tape.handoff name=phase-1 summary='Bootstrap complete'\n" - " ,tape.anchors\n" - " ,tape.info\n" - " ,tape.search query=error\n" - " ,schedule.add cron='*/5 * * * *' message='echo hello'\n" - " ,schedule.list\n" - " ,schedule.remove job_id=my-job\n" - " ,skills.list\n" - " ,quit\n" - ) - - @register(name="tools", short_description="List available tools", model=EmptyInput) - def list_tools(_params: EmptyInput) -> str: - """List all tools in compact mode.""" - return "\n".join(registry.compact_rows()) - - @register(name="tool.describe", short_description="Show tool detail", model=ToolNameInput) - def tool_describe(params: ToolNameInput) -> str: - """Expand one tool description and schema.""" - return registry.detail(params.name) - - @register(name="tape.handoff", short_description="Create anchor handoff", model=HandoffInput) - async def handoff(params: HandoffInput) -> str: - """Create tape anchor with optional summary and next_steps state.""" - anchor_name = params.name or "handoff" - state: dict[str, object] = {} - if params.summary: - state["summary"] = params.summary - if params.next_steps: - state["next_steps"] = params.next_steps - await tape.handoff(anchor_name, state=state or None) - return f"handoff created: {anchor_name}" - - @register(name="tape.anchors", short_description="List tape anchors", model=EmptyInput) - async def anchors(_params: EmptyInput) -> str: - """List recent tape anchors.""" - rows = [] - for anchor in await tape.anchors(limit=50): - rows.append(f"{anchor.name} state={json.dumps(anchor.state, ensure_ascii=False)}") - return "\n".join(rows) if rows else "(no anchors)" - - @register(name="tape.info", short_description="Show tape summary", model=EmptyInput) - async def tape_info(_params: EmptyInput) -> str: - """Show tape summary with entry and anchor counts.""" - info = await tape.info() - return "\n".join(( - f"tape={info.name}", - f"entries={info.entries}", - f"anchors={info.anchors}", - f"last_anchor={info.last_anchor or '-'}", - f"entries_since_last_anchor={info.entries_since_last_anchor}", - f"last_token_usage={info.last_token_usage or 'unknown'}", - )) - - @register(name="tape.search", short_description="Search tape entries", model=TapeSearchInput) - async def tape_search(params: TapeSearchInput) -> str: - """Search entries in tape by query. In reverse order.""" - entries = await tape.search(params.query, limit=params.limit) - if not entries: - return "(no matches)" - return "\n".join(f"#{entry.id} {entry.kind} {entry.payload}" for entry in entries) - - @register(name="tape.reset", short_description="Reset tape", model=TapeResetInput, context=True) - async def tape_reset(params: TapeResetInput, context: ToolContext) -> str: - """Reset current tape; can archive before clearing.""" - result = await tape.reset(archive=params.archive) - runtime.reset_session_context(context.state.get("session_id", "")) - return result - - @register(name="skills.list", short_description="List skills", model=EmptyInput) - def list_skills(_params: EmptyInput) -> str: - """List all discovered skills in compact form.""" - skills = runtime.discover_skills() - if not skills: - return "(no skills)" - return "\n".join(f"{skill.name}: {skill.description}" for skill in skills) - - @register(name="quit", short_description="Exit program", model=EmptyInput) - def quit_command(_params: EmptyInput) -> str: - """Request exit from interactive CLI.""" - return "exit" diff --git a/src/bub/tools/progressive.py b/src/bub/tools/progressive.py deleted file mode 100644 index b0fba931..00000000 --- a/src/bub/tools/progressive.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Progressive tool prompt rendering.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from bub.tools.registry import ToolRegistry - - -@dataclass -class ProgressiveToolView: - """Renders compact tool view and expands schema on demand.""" - - registry: ToolRegistry - expanded: set[str] = field(default_factory=set) - - def note_selected(self, name: str) -> None: - if self.registry.has(name): - self.expanded.add(name) - - def all_tools(self) -> list[str]: - return [descriptor.name for descriptor in self.registry.descriptors()] - - def reset(self) -> None: - """Clear expanded tool details for a fresh prompt context.""" - self.expanded.clear() - - def note_hint(self, hint: str) -> bool: - """Expand one tool when hint matches tool name (case-insensitive).""" - - normalized = hint.casefold() - for descriptor in self.registry.descriptors(): - model_name = self.registry.to_model_name(descriptor.name) - if descriptor.name.casefold() != normalized and model_name.casefold() != normalized: - continue - self.expanded.add(descriptor.name) - return True - return False - - def compact_block(self) -> str: - lines = [""] - for row in self.registry.compact_rows(for_model=True): - lines.append(f" - {row}") - lines.append("") - return "\n".join(lines) - - def expanded_block(self) -> str: - if not self.expanded: - return "" - - lines = [""] - for name in sorted(self.expanded): - model_name = self.registry.to_model_name(name) - try: - detail = self.registry.detail(name, for_model=True) - except KeyError: - continue - lines.append(f' ') - for line in detail.splitlines(): - lines.append(f" {line}") - lines.append(" ") - lines.append("") - return "\n".join(lines) diff --git a/src/bub/tools/registry.py b/src/bub/tools/registry.py deleted file mode 100644 index 9a9fa1fa..00000000 --- a/src/bub/tools/registry.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Unified tool registry.""" - -from __future__ import annotations - -import builtins -import inspect -import json -import time -from collections.abc import Awaitable, Callable -from copy import deepcopy -from dataclasses import dataclass -from functools import wraps -from typing import Any, cast - -from loguru import logger -from pydantic import BaseModel -from republic import Tool, ToolContext, tool_from_model - - -def _shorten_text(text: str, width: int = 30, placeholder: str = "...") -> str: - """Shorten text to width characters, cutting in the middle of words if needed. - - Unlike textwrap.shorten, this function can cut in the middle of a word, - ensuring long strings without spaces are still truncated properly. - """ - if len(text) <= width: - return text - - # Reserve space for placeholder - available = width - len(placeholder) - if available <= 0: - return placeholder - - return text[:available] + placeholder - - -@dataclass(frozen=True) -class ToolDescriptor: - """Tool metadata and runtime handle.""" - - name: str - short_description: str - detail: str - tool: Tool - source: str = "builtin" - - -class ToolRegistry: - """Registry for built-in tools, internal commands, and skill-backed tools.""" - - def __init__(self, allowed_tools: set[str] | None = None) -> None: - self._tools: dict[str, ToolDescriptor] = {} - self._allowed_tools = allowed_tools - - def register( - self, - *, - name: str, - short_description: str, - detail: str | None = None, - model: type[BaseModel] | None = None, - context: bool = False, - source: str = "builtin", - ) -> Callable[[Callable], ToolDescriptor | None]: - def decorator[**P, T](func: Callable[P, T | Awaitable[T]]) -> ToolDescriptor | None: - tool_detail = detail or func.__doc__ or "" - if ( - self._allowed_tools is not None - and name.casefold() not in self._allowed_tools - and self.to_model_name(name).casefold() not in self._allowed_tools - ): - return None - - @wraps(func) - async def handler(*args: P.args, **kwargs: P.kwargs) -> T: - context_arg = kwargs.get("context") if context else None - call_kwargs = {key: value for key, value in kwargs.items() if key != "context"} - if args and isinstance(args[0], BaseModel): - call_kwargs.update(args[0].model_dump()) - self._log_tool_call(name, call_kwargs, cast("ToolContext | None", context_arg)) - - start = time.monotonic() - try: - result = func(*args, **kwargs) - if inspect.isawaitable(result): - result = await result - except Exception: - logger.exception("tool.call.error name={}", name) - raise - else: - return result - finally: - duration = time.monotonic() - start - logger.info("tool.call.end name={} duration={:.3f}ms", name, duration * 1000) - - if model is not None: - tool = tool_from_model(model, handler, name=name, description=short_description, context=context) - else: - tool = Tool.from_callable(handler, name=name, description=short_description, context=context) - tool_desc = ToolDescriptor( - name=name, short_description=short_description, detail=tool_detail, tool=tool, source=source - ) - self._tools[name] = tool_desc - return tool_desc - - return decorator - - def has(self, name: str) -> bool: - return name in self._tools - - def get(self, name: str) -> ToolDescriptor | None: - return self._tools.get(name) - - def descriptors(self) -> builtins.list[ToolDescriptor]: - return sorted(self._tools.values(), key=lambda item: item.name) - - @staticmethod - def to_model_name(name: str) -> str: - return name.replace(".", "_") - - def compact_rows(self, *, for_model: bool = False) -> builtins.list[str]: - rows: builtins.list[str] = [] - for descriptor in self.descriptors(): - display_name = self.to_model_name(descriptor.name) if for_model else descriptor.name - if for_model and display_name != descriptor.name: - rows.append(f"{display_name} (command: {descriptor.name}): {descriptor.short_description}") - else: - rows.append(f"{display_name}: {descriptor.short_description}") - return rows - - def detail(self, name: str, *, for_model: bool = False) -> str: - descriptor = self.get(name) - if descriptor is None: - raise KeyError(name) - - schema = descriptor.tool.schema() - display_name = descriptor.name - command_name_line = "" - if for_model: - schema = deepcopy(schema) - display_name = self.to_model_name(descriptor.name) - function = schema.get("function") - if isinstance(function, dict): - function["name"] = display_name - if display_name != descriptor.name: - command_name_line = f"command_name: {descriptor.name}\n" - - return ( - f"name: {display_name}\n" - f"{command_name_line}" - f"source: {descriptor.source}\n" - f"description: {descriptor.short_description}\n" - f"detail: {descriptor.detail}\n" - f"schema: {schema}" - ) - - def model_tools(self) -> builtins.list[Tool]: - tools: builtins.list[Tool] = [] - seen_names: set[str] = set() - for descriptor in self.descriptors(): - model_name = self.to_model_name(descriptor.name) - if model_name in seen_names: - raise ValueError(f"Duplicate model tool name after conversion: {model_name}") - seen_names.add(model_name) - - base = descriptor.tool - tools.append( - Tool( - name=model_name, - description=base.description, - parameters=base.parameters, - handler=base.handler, - context=base.context, - ) - ) - return tools - - def _log_tool_call(self, name: str, kwargs: dict[str, Any], context: ToolContext | None) -> None: - params: list[str] = [] - for key, value in kwargs.items(): - try: - rendered = json.dumps(value, ensure_ascii=False) - except TypeError: - rendered = repr(value) - value = _shorten_text(rendered, width=60, placeholder="...") - if value.startswith('"') and not value.endswith('"'): - value = value + '"' - if value.startswith("{") and not value.endswith("}"): - value = value + "}" - if value.startswith("[") and not value.endswith("]"): - value = value + "]" - params.append(f"{key}={value}") - params_str = ", ".join(params) - logger.info("tool.call.start name={} {{ {} }}", name, params_str) - - async def execute( - self, - name: str, - *, - kwargs: dict[str, Any], - context: ToolContext | None = None, - ) -> Any: - descriptor = self.get(name) - if descriptor is None: - raise KeyError(name) - - if descriptor.tool.context: - kwargs["context"] = context - result = descriptor.tool.run(**kwargs) - if inspect.isawaitable(result): - result = await result - return result diff --git a/src/bub/tools/schedule.py b/src/bub/tools/schedule.py deleted file mode 100644 index 5f402e59..00000000 --- a/src/bub/tools/schedule.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys - -from loguru import logger - -SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS = 300 - - -def run_scheduled_reminder(message: str, session_id: str, workspace: str | None = None) -> None: - if session_id.startswith("telegram:"): - chat_id = session_id.split(":", 1)[1] - message = ( - f"[Reminder for Telegram chat {chat_id}, after done, send a notice to this chat if necessary]\n{message}" - ) - command = [sys.executable, "-m", "bub.cli.app", "run", "--session-id", session_id, message] - - logger.info("running scheduled reminder via bub run session_id={} message={}", session_id, message) - try: - completed = subprocess.run( - command, - check=True, - cwd=workspace, - timeout=SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - logger.error( - "scheduled reminder timed out after {}s session_id={}", - SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS, - session_id, - ) - except subprocess.CalledProcessError as exc: - logger.error("scheduled reminder failed with exit={}", exc.returncode) - else: - logger.info("scheduled reminder succeeded with exit={}", completed.returncode) diff --git a/src/bub/tools/view.py b/src/bub/tools/view.py deleted file mode 100644 index 41a0379c..00000000 --- a/src/bub/tools/view.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Tool view helpers.""" - -from __future__ import annotations - -from bub.tools.progressive import ProgressiveToolView - - -def render_tool_prompt_block(view: ProgressiveToolView) -> str: - """Render the combined tool prompt section.""" - - compact = view.compact_block() - expanded = view.expanded_block() - if not expanded: - return compact - return f"{compact}\n\n{expanded}" diff --git a/src/bub/types.py b/src/bub/types.py new file mode 100644 index 00000000..917f2132 --- /dev/null +++ b/src/bub/types.py @@ -0,0 +1,26 @@ +"""Framework-neutral data aliases.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass, field +from typing import Any, Protocol + +type Envelope = Any +type State = dict[str, Any] +type MessageHandler = Callable[[Envelope], Coroutine[Any, Any, None]] +type OutboundDispatcher = Callable[[Envelope], Coroutine[Any, Any, bool]] + + +class OutboundChannelRouter(Protocol): + async def dispatch(self, message: Envelope) -> bool: ... + + +@dataclass(frozen=True) +class TurnResult: + """Result of one complete message turn.""" + + session_id: str + prompt: str + model_output: str + outbounds: list[Envelope] = field(default_factory=list) diff --git a/src/bub/utils.py b/src/bub/utils.py new file mode 100644 index 00000000..2162363a --- /dev/null +++ b/src/bub/utils.py @@ -0,0 +1,32 @@ +import asyncio +from collections.abc import Coroutine +from pathlib import Path +from typing import Any + +from bub.types import State + + +def exclude_none(d: dict[str, Any]) -> dict[str, Any]: + """Exclude None values from a dictionary.""" + return {k: v for k, v in d.items() if v is not None} + + +async def wait_until_stopped[T](coro: Coroutine[None, None, T], stop_event: asyncio.Event) -> T: + """Run a coroutine until a stop event is set.""" + task = asyncio.create_task(coro) + waiter = asyncio.create_task(stop_event.wait()) + _ = await asyncio.wait({task, waiter}, return_when=asyncio.FIRST_COMPLETED) + if stop_event.is_set(): + task.cancel() + await task + raise asyncio.CancelledError("Operation cancelled due to stop event") + else: + waiter.cancel() + return task.result() + + +def workspace_from_state(state: State) -> Path: + raw = state.get("_runtime_workspace") + if isinstance(raw, str) and raw.strip(): + return Path(raw).expanduser().resolve() + return Path.cwd().resolve() diff --git a/src/bub_skills/README.md b/src/bub_skills/README.md new file mode 100644 index 00000000..2ae03540 --- /dev/null +++ b/src/bub_skills/README.md @@ -0,0 +1,3 @@ +# Bub Skills Directory + +This directory contains built-in skills for the Bub framework. These skills are designed to provide essential functionalities and can be easily integrated into your Bub workspace. Each skill is implemented as a plugin, allowing for modularity and ease of maintenance. diff --git a/src/bub/skills/gh/SKILL.md b/src/bub_skills/gh/SKILL.md similarity index 100% rename from src/bub/skills/gh/SKILL.md rename to src/bub_skills/gh/SKILL.md diff --git a/src/bub/skills/skill-creator/SKILL.md b/src/bub_skills/skill-creator/SKILL.md similarity index 100% rename from src/bub/skills/skill-creator/SKILL.md rename to src/bub_skills/skill-creator/SKILL.md diff --git a/src/bub/skills/skill-creator/license.txt b/src/bub_skills/skill-creator/license.txt similarity index 100% rename from src/bub/skills/skill-creator/license.txt rename to src/bub_skills/skill-creator/license.txt diff --git a/src/bub/skills/skill-creator/scripts/init_skill.py b/src/bub_skills/skill-creator/scripts/init_skill.py similarity index 100% rename from src/bub/skills/skill-creator/scripts/init_skill.py rename to src/bub_skills/skill-creator/scripts/init_skill.py diff --git a/src/bub/skills/skill-creator/scripts/quick_validate.py b/src/bub_skills/skill-creator/scripts/quick_validate.py similarity index 100% rename from src/bub/skills/skill-creator/scripts/quick_validate.py rename to src/bub_skills/skill-creator/scripts/quick_validate.py diff --git a/src/bub/skills/skill-installer/LICENSE.txt b/src/bub_skills/skill-installer/LICENSE.txt similarity index 100% rename from src/bub/skills/skill-installer/LICENSE.txt rename to src/bub_skills/skill-installer/LICENSE.txt diff --git a/src/bub/skills/skill-installer/SKILL.md b/src/bub_skills/skill-installer/SKILL.md similarity index 100% rename from src/bub/skills/skill-installer/SKILL.md rename to src/bub_skills/skill-installer/SKILL.md diff --git a/src/bub/skills/telegram/SKILL.md b/src/bub_skills/telegram/SKILL.md similarity index 92% rename from src/bub/skills/telegram/SKILL.md rename to src/bub_skills/telegram/SKILL.md index 2101cd1f..790edef3 100644 --- a/src/bub/skills/telegram/SKILL.md +++ b/src/bub_skills/telegram/SKILL.md @@ -31,7 +31,7 @@ Collect these before execution: 2. If source metadata says sender is a bot (`sender_is_bot=true`), do not use reply mode. 3. In the bot-source case, send a normal message and prefix content with `@` (or the provided source username). 4. For long-running tasks, optionally send one progress message, then edit that same message for final status. -5. Use literal newlines in message text when line breaks are needed. +5. For multi-line text, pass the content via heredoc command substitution instead of embedding raw line breaks in quoted strings. 6. Avoid emitting HTML tags in message content; use Markdown for formatting instead. ## Active Response Policy @@ -75,6 +75,17 @@ uv run ./scripts/telegram_send.py \ --chat-id \ --message "" +# Send multi-line message (heredoc) +uv run ./scripts/telegram_send.py \ + --chat-id \ + --message "$(cat <<'EOF' +Build finished successfully. +Summary: +- 12 tests passed +- 0 failures +EOF +)" + # Send reply to a specific message uv run ./scripts/telegram_send.py \ --chat-id \ diff --git a/src/bub/skills/telegram/scripts/telegram_edit.py b/src/bub_skills/telegram/scripts/telegram_edit.py similarity index 100% rename from src/bub/skills/telegram/scripts/telegram_edit.py rename to src/bub_skills/telegram/scripts/telegram_edit.py diff --git a/src/bub/skills/telegram/scripts/telegram_send.py b/src/bub_skills/telegram/scripts/telegram_send.py similarity index 100% rename from src/bub/skills/telegram/scripts/telegram_send.py rename to src/bub_skills/telegram/scripts/telegram_send.py diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 79c4f5af..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -import inspect -from typing import Any - -import pytest - -from bub.tools.registry import ToolRegistry - - -@pytest.fixture(autouse=True) -def _patch_tool_registry_execute(monkeypatch: pytest.MonkeyPatch) -> None: - async def patched_execute( - self: ToolRegistry, - name: str, - *, - kwargs: dict[str, Any], - context: Any = None, - ) -> Any: - descriptor = self.get(name) - if descriptor is None: - raise KeyError(name) - - if descriptor.tool.context: - result = descriptor.tool.run(context=context, **kwargs) - else: - result = descriptor.tool.run(**kwargs) - - if inspect.isawaitable(result): - result = await result - return result - - monkeypatch.setattr(ToolRegistry, "execute", patched_execute) diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py deleted file mode 100644 index cf727417..00000000 --- a/tests/test_agent_loop.py +++ /dev/null @@ -1,76 +0,0 @@ -from collections.abc import Generator -from contextlib import contextmanager -from dataclasses import dataclass - -import pytest - -from bub.core.agent_loop import AgentLoop -from bub.core.model_runner import ModelTurnResult -from bub.core.router import UserRouteResult - - -@dataclass -class FakeRouter: - route: UserRouteResult - - async def route_user(self, _raw: str) -> UserRouteResult: - return self.route - - -@dataclass -class FakeRunner: - result: ModelTurnResult - - async def run(self, _prompt: str) -> ModelTurnResult: - return self.result - - -class FakeTape: - def __init__(self) -> None: - self.events: list[tuple[str, dict[str, object]]] = [] - - @contextmanager - def fork_tape(self) -> Generator["FakeTape", None, None]: - yield self - - async def append_event(self, name: str, data: dict[str, object]) -> None: - self.events.append((name, data)) - - -@pytest.mark.asyncio -async def test_loop_short_circuit_without_model() -> None: - loop = AgentLoop( - router=FakeRouter( - UserRouteResult( - enter_model=False, - model_prompt="", - immediate_output="ok", - exit_requested=False, - ) - ), # type: ignore[arg-type] - model_runner=FakeRunner(ModelTurnResult("", False, 0)), # type: ignore[arg-type] - tape=FakeTape(), # type: ignore[arg-type] - ) - result = await loop.handle_input(",help") - assert result.immediate_output == "ok" - assert result.assistant_output == "" - - -@pytest.mark.asyncio -async def test_loop_runs_model_when_router_requests() -> None: - loop = AgentLoop( - router=FakeRouter( - UserRouteResult( - enter_model=True, - model_prompt="context", - immediate_output="cmd error", - exit_requested=False, - ) - ), # type: ignore[arg-type] - model_runner=FakeRunner(ModelTurnResult("answer", False, 2)), # type: ignore[arg-type] - tape=FakeTape(), # type: ignore[arg-type] - ) - result = await loop.handle_input("bad cmd") - assert result.immediate_output == "cmd error" - assert result.assistant_output == "answer" - assert result.steps == 2 diff --git a/tests/test_channels.py b/tests/test_channels.py deleted file mode 100644 index 10d46e06..00000000 --- a/tests/test_channels.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import asyncio - -import pytest - -from bub.channels.base import BaseChannel -from bub.channels.manager import ChannelManager - - -class _Settings: - telegram_enabled = False - discord_enabled = False - - -class _Runtime: - settings = _Settings() - - def install_hooks(self, manager: ChannelManager) -> None: - pass - - -class _FakeChannel(BaseChannel[object]): - name = "fake" - - def __init__(self, runtime) -> None: - super().__init__(runtime) - self.started = asyncio.Event() - self.stopped = False - - async def start(self, on_receive): # type: ignore[override] - _ = on_receive - self.started.set() - try: - await asyncio.Event().wait() - finally: - self.stopped = True - - async def get_session_prompt(self, message: object) -> tuple[str, str]: - _ = message - return "session", "prompt" - - def is_mentioned(self, message: object) -> bool: - _ = message - return True - - async def process_output(self, session_id: str, output) -> None: - _ = (session_id, output) - - -def test_channel_manager_rejects_duplicate_channel_name() -> None: - manager = ChannelManager(_Runtime()) # type: ignore[arg-type] - manager.register(_FakeChannel) - - with pytest.raises(ValueError, match="already registered"): - manager.register(_FakeChannel) - - -@pytest.mark.asyncio -async def test_channel_manager_starts_and_stops_registered_channels() -> None: - manager = ChannelManager(_Runtime()) # type: ignore[arg-type] - manager.register(_FakeChannel) - - task = asyncio.create_task(manager.run()) - channel = manager.channels["fake"] - await asyncio.wait_for(channel.started.wait(), timeout=1.0) - assert manager.enabled_channels() == ["fake"] - - task.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(task, timeout=1.0) - assert channel.stopped is True diff --git a/tests/test_channels_proxy.py b/tests/test_channels_proxy.py deleted file mode 100644 index 3a5a0cda..00000000 --- a/tests/test_channels_proxy.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from bub.channels.utils import resolve_proxy - - -def test_resolve_proxy_prefers_explicit_over_ambient(monkeypatch) -> None: - monkeypatch.setenv("HTTPS_PROXY", "http://env.proxy:8080") - proxy, source = resolve_proxy("http://explicit.proxy:9000") - assert proxy == "http://explicit.proxy:9000" - assert source == "explicit" - - -def test_resolve_proxy_is_opt_in(monkeypatch) -> None: - proxy, source = resolve_proxy(None) - assert proxy is None - assert source == "none" diff --git a/tests/test_channels_utils.py b/tests/test_channels_utils.py new file mode 100644 index 00000000..9237dbc0 --- /dev/null +++ b/tests/test_channels_utils.py @@ -0,0 +1,25 @@ +import asyncio + +import pytest + +from bub.utils import exclude_none, wait_until_stopped + + +def test_exclude_none_keeps_non_none_values() -> None: + payload = {"a": 1, "b": None, "c": "x", "d": False} + assert exclude_none(payload) == {"a": 1, "c": "x", "d": False} + + +@pytest.mark.asyncio +async def test_wait_until_stopped_returns_result_when_coroutine_finishes_first() -> None: + stop_event = asyncio.Event() + result = await wait_until_stopped(asyncio.sleep(0.01, result="done"), stop_event) + assert result == "done" + + +@pytest.mark.asyncio +async def test_wait_until_stopped_cancels_when_stop_event_set() -> None: + stop_event = asyncio.Event() + stop_event.set() + with pytest.raises(asyncio.CancelledError): + await wait_until_stopped(asyncio.sleep(0.2, result="done"), stop_event) diff --git a/tests/test_cli_app.py b/tests/test_cli_app.py deleted file mode 100644 index cf17498a..00000000 --- a/tests/test_cli_app.py +++ /dev/null @@ -1,275 +0,0 @@ -import asyncio -import contextlib -import importlib -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -from bub.core.agent_loop import LoopResult - -cli_app_module = importlib.import_module("bub.cli.app") - - -class DummyRuntime: - def __init__(self, workspace: Path) -> None: - self.workspace = workspace - - class _Settings: - model = "openrouter:test" - telegram_enabled = False - discord_enabled = False - telegram_token = None - telegram_allow_from = () - telegram_allow_chats = () - - @staticmethod - def resolve_home() -> Path: - return Path.cwd() - - self.settings = _Settings() - self.registry = type("_Registry", (), {"descriptors": staticmethod(lambda: [])})() - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb) -> None: - _ = (exc_type, exc, tb) - return None - - def set_bus(self, _bus) -> None: - return None - - def get_session(self, _session_id: str): - class _Tape: - @staticmethod - def info(): - class _Info: - entries = 0 - anchors = 0 - last_anchor = None - - return _Info() - - class _Session: - tape = _Tape() - tool_view = type("_ToolView", (), {"all_tools": staticmethod(lambda: [])})() - - return _Session() - - def install_hooks(self, _manager) -> None: - return None - - def handle_input(self, _session_id: str, _text: str): - raise AssertionError - - @contextlib.asynccontextmanager - async def graceful_shutdown(self): - stop_event = asyncio.Event() - yield stop_event - - -def test_chat_command_registers_cli_channel(monkeypatch, tmp_path: Path) -> None: - called: dict[str, object] = {} - - def _fake_build_runtime(workspace: Path, *, model=None, max_tokens=None, enable_scheduler=True): - assert workspace == tmp_path - assert enable_scheduler is True - return DummyRuntime(workspace) - - async def _fake_serve_channels(manager) -> None: - called["channels"] = manager.enabled_channels() - called["channel_type"] = type(manager.channels["cli"]).__name__ - - monkeypatch.setattr(cli_app_module, "build_runtime", _fake_build_runtime) - monkeypatch.setattr(cli_app_module, "_serve_channels", _fake_serve_channels) - - runner = CliRunner() - result = runner.invoke( - cli_app_module.app, - ["chat", "--workspace", str(tmp_path), "--session-id", "cli:test"], - ) - assert result.exit_code == 0 - assert called["channels"] == ["cli"] - assert called["channel_type"] == "CliChannel" - - -def test_run_command_expands_home_in_workspace(monkeypatch, tmp_path: Path) -> None: - captured: dict[str, Path] = {} - - class _RunRuntime(DummyRuntime): - async def handle_input(self, _session_id: str, _text: str): - class _Result: - error = None - assistant_output = "ok" - immediate_output = "" - - return _Result() - - def _fake_build_runtime(workspace: Path, **_kwargs): - captured["workspace"] = workspace - return _RunRuntime(workspace) - - fake_home = tmp_path / "home" - fake_home.mkdir() - expected_workspace = (fake_home / "workspace").resolve() - - monkeypatch.setenv("HOME", str(fake_home)) - monkeypatch.setattr(cli_app_module, "build_runtime", _fake_build_runtime) - runner = CliRunner() - result = runner.invoke(cli_app_module.app, ["run", "ping", "--workspace", "~/workspace"]) - - assert result.exit_code == 0 - assert captured["workspace"] == expected_workspace - - -def test_message_command_requires_valid_subcommand_name(monkeypatch, tmp_path: Path) -> None: - def _fake_build_runtime(workspace: Path, *, model=None, max_tokens=None): - return DummyRuntime(workspace) - - monkeypatch.setattr(cli_app_module, "build_runtime", _fake_build_runtime) - runner = CliRunner() - result = runner.invoke(cli_app_module.app, ["telegram", "--workspace", str(tmp_path)]) - assert result.exit_code != 0 - assert "No such command 'telegram'" in result.output - - -def test_run_command_forwards_allowed_tools_and_skills(monkeypatch, tmp_path: Path) -> None: - captured: dict[str, object] = {} - - class _RunRuntime(DummyRuntime): - async def handle_input(self, _session_id: str, _text: str): - return LoopResult( - immediate_output="", - assistant_output="ok", - exit_requested=False, - steps=1, - error=None, - ) - - def _fake_build_runtime( - workspace: Path, - *, - model=None, - max_tokens=None, - allowed_tools=None, - allowed_skills=None, - enable_scheduler=True, - ): - captured["workspace"] = workspace - captured["model"] = model - captured["max_tokens"] = max_tokens - captured["allowed_tools"] = allowed_tools - captured["allowed_skills"] = allowed_skills - captured["enable_scheduler"] = enable_scheduler - return _RunRuntime(workspace) - - monkeypatch.setattr(cli_app_module, "build_runtime", _fake_build_runtime) - runner = CliRunner() - result = runner.invoke( - cli_app_module.app, - [ - "run", - "ping", - "--workspace", - str(tmp_path), - "--tools", - "fs.read, web.search", - "--tools", - "bash", - "--skills", - "skill-a, skill-b", - ], - ) - - assert result.exit_code == 0 - assert "ok" in result.output - assert captured["workspace"] == tmp_path - assert captured["allowed_tools"] == {"fs.read", "web.search", "bash"} - assert captured["allowed_skills"] == {"skill-a", "skill-b"} - assert captured["enable_scheduler"] is True - - -def test_run_command_uses_env_session_id_by_default(monkeypatch, tmp_path: Path) -> None: - captured: dict[str, object] = {} - - class _RunRuntime(DummyRuntime): - async def handle_input(self, session_id: str, _text: str): - captured["session_id"] = session_id - return LoopResult( - immediate_output="", - assistant_output="ok", - exit_requested=False, - steps=1, - error=None, - ) - - monkeypatch.setenv("BUB_SESSION_ID", "parent-session") - monkeypatch.setattr(cli_app_module, "build_runtime", lambda workspace, **_: _RunRuntime(workspace)) - runner = CliRunner() - result = runner.invoke(cli_app_module.app, ["run", "ping", "--workspace", str(tmp_path)]) - - assert result.exit_code == 0 - assert captured["session_id"] == "parent-session" - - -def test_run_command_session_id_option_overrides_env(monkeypatch, tmp_path: Path) -> None: - captured: dict[str, object] = {} - - class _RunRuntime(DummyRuntime): - async def handle_input(self, session_id: str, _text: str): - captured["session_id"] = session_id - return LoopResult( - immediate_output="", - assistant_output="ok", - exit_requested=False, - steps=1, - error=None, - ) - - monkeypatch.setenv("BUB_SESSION_ID", "parent-session") - monkeypatch.setattr(cli_app_module, "build_runtime", lambda workspace, **_: _RunRuntime(workspace)) - runner = CliRunner() - result = runner.invoke( - cli_app_module.app, - ["run", "ping", "--workspace", str(tmp_path), "--session-id", "explicit-session"], - ) - - assert result.exit_code == 0 - assert captured["session_id"] == "explicit-session" - - -@pytest.mark.asyncio -async def test_serve_channels_stops_manager_on_sigterm(monkeypatch) -> None: - class _DummyRuntime: - def __init__(self) -> None: - self.stop_event: asyncio.Event | None = None - - @contextlib.asynccontextmanager - async def graceful_shutdown(self): - stop_event = asyncio.Event() - self.stop_event = stop_event - yield stop_event - - class _DummyManager: - def __init__(self) -> None: - self.calls: list[str] = [] - self.runtime = _DummyRuntime() - - async def run(self) -> None: - self.calls.append("start") - try: - await asyncio.Event().wait() - finally: - self.calls.append("stop") - - manager = _DummyManager() - - task = asyncio.create_task(cli_app_module._serve_channels(manager)) - await asyncio.sleep(0.05) - assert manager.calls == ["start"] - assert manager.runtime.stop_event is not None - manager.runtime.stop_event.set() - await asyncio.wait_for(task, timeout=1.0) - - assert manager.calls == ["start", "stop"] diff --git a/tests/test_cli_channel.py b/tests/test_cli_channel.py deleted file mode 100644 index b8b32017..00000000 --- a/tests/test_cli_channel.py +++ /dev/null @@ -1,64 +0,0 @@ -from pathlib import Path - -from bub.channels.cli import CliChannel - - -class _DummyRuntime: - def __init__(self) -> None: - self.workspace = Path.cwd() - - class _Settings: - model = "openrouter:test" - - @staticmethod - def resolve_home() -> Path: - return Path.cwd() - - self.settings = _Settings() - - def get_session(self, _session_id: str): - class _Tape: - @staticmethod - def info(): - class _Info: - entries = 0 - anchors = 0 - last_anchor = None - - return _Info() - - class _Session: - tape = _Tape() - tool_view = type("_ToolView", (), {"all_tools": staticmethod(lambda: [])})() - - return _Session() - - -def test_normalize_input_keeps_agent_mode_text() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - cli._mode = "agent" - assert cli._normalize_input("echo hi") == "echo hi" - - -def test_normalize_input_adds_shell_prefix_in_shell_mode() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - cli._mode = "shell" - assert cli._normalize_input("echo hi") == ", echo hi" - - -def test_normalize_input_keeps_explicit_prefixes_in_shell_mode() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - cli._mode = "shell" - assert cli._normalize_input(",help") == ",help" - assert cli._normalize_input(",ls -la") == ",ls -la" - assert cli._normalize_input(", ls -la") == ", ls -la" - - -def test_cli_channel_disables_debounce() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - assert cli.debounce_enabled is False - - -def test_cli_channel_does_not_wrap_prompt() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - assert cli.format_prompt("plain prompt") == "plain prompt" diff --git a/tests/test_command_detector.py b/tests/test_command_detector.py deleted file mode 100644 index 14ad3063..00000000 --- a/tests/test_command_detector.py +++ /dev/null @@ -1,36 +0,0 @@ -from bub.core.command_detector import detect_line_command - - -def test_detect_internal_command() -> None: - command = detect_line_command(",help") - assert command is not None - assert command.kind == "internal" - assert command.name == "help" - - -def test_detect_shell_command() -> None: - command = detect_line_command("echo hello") - assert command is not None - assert command.kind == "shell" - assert command.name == "echo" - - -def test_non_command_text_returns_none() -> None: - assert detect_line_command("请帮我总结今天的改动") is None - - -def test_patch_assignment_text_is_not_detected_as_shell_command() -> None: - line = 'new_text="def _is_shell_command(line: str) -> bool:\\n return False"' - assert detect_line_command(line) is None - - -def test_very_long_assignment_text_does_not_crash_or_detect_as_command() -> None: - long_payload = f'new_text="{"a/" * 400}end"' - assert detect_line_command(long_payload) is None - - -def test_env_prefixed_shell_command_is_detected() -> None: - command = detect_line_command("FOO=bar echo hello") - assert command is not None - assert command.kind == "shell" - assert command.name == "echo" diff --git a/tests/test_discord_filter.py b/tests/test_discord_filter.py deleted file mode 100644 index 7262dbd4..00000000 --- a/tests/test_discord_filter.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from types import SimpleNamespace - -from bub.channels.discord import DiscordChannel - - -@dataclass -class DummyAuthor: - id: int = 1 - name: str = "frost" - global_name: str | None = None - - -class DummyMessage: - def __init__( - self, - *, - content: str, - channel: object, - author: DummyAuthor | None = None, - ) -> None: - self.content = content - self.channel = channel - self.author = author or DummyAuthor() - self.mentions: list[object] = [] - self.reference = None - - -def _build_channel() -> DiscordChannel: - settings = SimpleNamespace( - discord_token="token", # noqa: S106 - discord_allow_from=[], - discord_allow_channels=[], - discord_command_prefix="!", - discord_proxy=None, - ) - runtime = SimpleNamespace(settings=settings) - return DiscordChannel(runtime) # type: ignore[arg-type] - - -def test_allow_message_when_content_contains_bub() -> None: - channel = _build_channel() - message = DummyMessage(content="please ask Bub to check this", channel=SimpleNamespace(id=100, name="general")) - assert channel.is_mentioned(message) is True # type: ignore[arg-type] - - -def test_allow_message_when_thread_name_starts_with_bub() -> None: - channel = _build_channel() - thread = SimpleNamespace(id=101, name="bub-help", parent=SimpleNamespace(name="forum")) - message = DummyMessage(content="hello", channel=thread) - assert channel.is_mentioned(message) is True # type: ignore[arg-type] - - -def test_reject_message_when_only_parent_name_starts_with_bub() -> None: - channel = _build_channel() - thread = SimpleNamespace(id=102, name="question-1", parent=SimpleNamespace(name="bub-forum")) - message = DummyMessage(content="hello", channel=thread) - assert channel.is_mentioned(message) is False # type: ignore[arg-type] - - -def test_reject_unrelated_message_without_bot_context() -> None: - channel = _build_channel() - message = DummyMessage(content="hello world", channel=SimpleNamespace(id=103, name="general")) - assert channel.is_mentioned(message) is False # type: ignore[arg-type] - - -def test_reject_empty_content_even_in_bub_thread() -> None: - channel = _build_channel() - thread = SimpleNamespace(id=104, name="bub-help", parent=SimpleNamespace(name="forum")) - message = DummyMessage(content=" ", channel=thread) - assert channel.is_mentioned(message) is False # type: ignore[arg-type] diff --git a/tests/test_discord_output.py b/tests/test_discord_output.py deleted file mode 100644 index b541e834..00000000 --- a/tests/test_discord_output.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import builtins -from types import SimpleNamespace - -import pytest - -from bub.channels.discord import DiscordChannel -from bub.core.agent_loop import LoopResult - - -class DummyMessageable: - def __init__(self) -> None: - self.sent: list[dict[str, object]] = [] - - async def send(self, **kwargs: object) -> None: - self.sent.append(kwargs) - - -def _build_channel() -> DiscordChannel: - settings = SimpleNamespace( - discord_token="token", # noqa: S106 - discord_allow_from=[], - discord_allow_channels=[], - discord_command_prefix="!", - discord_proxy=None, - ) - runtime = SimpleNamespace(settings=settings) - return DiscordChannel(runtime) # type: ignore[arg-type] - - -@pytest.mark.asyncio -async def test_process_output_sends_only_immediate_and_prints_full(monkeypatch: pytest.MonkeyPatch) -> None: - channel = _build_channel() - sink = DummyMessageable() - printed: list[str] = [] - - def _capture_print(*args: object, **kwargs: object) -> None: - printed.append(" ".join(str(arg) for arg in args)) - - async def _resolve_channel(_session_id: str) -> DummyMessageable: - return sink - - monkeypatch.setattr(builtins, "print", _capture_print) - channel._bot = object() # type: ignore[assignment] - channel._resolve_channel = _resolve_channel # type: ignore[method-assign] - - output = LoopResult( - immediate_output="immediate reply", - assistant_output="assistant details", - exit_requested=False, - steps=1, - error="boom", - ) - await channel.process_output("discord:1", output) - - joined = "\n".join(printed) - assert "immediate reply" in joined - assert "assistant details" in joined - assert "Error: boom" in joined - assert sink.sent == [{"content": "immediate reply"}] - - -@pytest.mark.asyncio -async def test_process_output_no_immediate_does_not_send_but_prints(monkeypatch: pytest.MonkeyPatch) -> None: - channel = _build_channel() - sink = DummyMessageable() - printed: list[str] = [] - - def _capture_print(*args: object, **kwargs: object) -> None: - printed.append(" ".join(str(arg) for arg in args)) - - async def _resolve_channel(_session_id: str) -> DummyMessageable: - return sink - - monkeypatch.setattr(builtins, "print", _capture_print) - channel._bot = object() # type: ignore[assignment] - channel._resolve_channel = _resolve_channel # type: ignore[method-assign] - - output = LoopResult( - immediate_output="", - assistant_output="assistant only", - exit_requested=False, - steps=1, - error=None, - ) - await channel.process_output("discord:1", output) - - joined = "\n".join(printed) - assert "assistant only" in joined - assert sink.sent == [] diff --git a/tests/test_envelope.py b/tests/test_envelope.py new file mode 100644 index 00000000..8b0d121e --- /dev/null +++ b/tests/test_envelope.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass + +from bub.envelope import content_of, field_of, normalize_envelope, unpack_batch + + +@dataclass +class _Message: + content: str + channel: str = "cli" + + +def test_field_of_supports_mapping_and_object() -> None: + mapping = {"content": "hello", "count": 3} + assert field_of(mapping, "content") == "hello" + assert field_of(mapping, "missing", "fallback") == "fallback" + + obj = _Message(content="world") + assert field_of(obj, "content") == "world" + assert field_of(obj, "missing", "fallback") == "fallback" + + +def test_content_of_stringifies_value() -> None: + assert content_of({"content": 123}) == "123" + assert content_of({"other": "x"}) == "" + + +def test_normalize_envelope_for_mapping_object_and_raw_value() -> None: + mapping = {"content": "hello"} + normalized_mapping = normalize_envelope(mapping) + assert normalized_mapping == mapping + assert normalized_mapping is not mapping + + obj = _Message(content="world", channel="telegram") + assert normalize_envelope(obj) == {"content": "world", "channel": "telegram"} + + assert normalize_envelope(42) == {"content": "42"} + + +def test_unpack_batch_handles_none_sequence_and_single_item() -> None: + assert unpack_batch(None) == [] + assert unpack_batch([{"content": "a"}]) == [{"content": "a"}] + assert unpack_batch(({"content": "a"}, {"content": "b"})) == [{"content": "a"}, {"content": "b"}] + assert unpack_batch({"content": "single"}) == [{"content": "single"}] diff --git a/tests/test_framework.py b/tests/test_framework.py new file mode 100644 index 00000000..51118cef --- /dev/null +++ b/tests/test_framework.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from typer.testing import CliRunner + +from bub.channels.base import Channel +from bub.framework import BubFramework +from bub.hookspecs import hookimpl + + +class NamedChannel(Channel): + def __init__(self, name: str, label: str) -> None: + self.name = name + self.label = label + + async def start(self, stop_event) -> None: + return None + + async def stop(self) -> None: + return None + + +def test_create_cli_app_sets_workspace_and_context(tmp_path: Path) -> None: + framework = BubFramework() + + class CliPlugin: + @hookimpl + def register_cli_commands(self, app: typer.Typer) -> None: + @app.command("workspace") + def workspace_command(ctx: typer.Context) -> None: + current = ctx.ensure_object(BubFramework) + typer.echo(str(current.workspace)) + + framework._plugin_manager.register(CliPlugin(), name="cli-plugin") + app = framework.create_cli_app() + + result = CliRunner().invoke(app, ["--workspace", str(tmp_path), "workspace"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == str(tmp_path.resolve()) + assert framework.workspace == tmp_path.resolve() + + +def test_get_channels_prefers_high_priority_plugin_for_duplicate_names() -> None: + framework = BubFramework() + + class LowPriorityPlugin: + @hookimpl + def provide_channels(self, message_handler): + return [NamedChannel("shared", "low"), NamedChannel("low-only", "low")] + + class HighPriorityPlugin: + @hookimpl + def provide_channels(self, message_handler): + return [NamedChannel("shared", "high"), NamedChannel("high-only", "high")] + + framework._plugin_manager.register(LowPriorityPlugin(), name="low") + framework._plugin_manager.register(HighPriorityPlugin(), name="high") + + channels = framework.get_channels(lambda message: None) + + assert set(channels) == {"shared", "low-only", "high-only"} + assert channels["shared"].label == "high" + assert channels["low-only"].label == "low" + assert channels["high-only"].label == "high" + + +def test_get_system_prompt_uses_priority_order_and_skips_empty_results() -> None: + framework = BubFramework() + + class LowPriorityPlugin: + @hookimpl + def system_prompt(self, prompt: str, state: dict[str, str]) -> str: + return "low" + + class HighPriorityPlugin: + @hookimpl + def system_prompt(self, prompt: str, state: dict[str, str]) -> str | None: + return "high" + + class EmptyPlugin: + @hookimpl + def system_prompt(self, prompt: str, state: dict[str, str]) -> str | None: + return None + + framework._plugin_manager.register(LowPriorityPlugin(), name="low") + framework._plugin_manager.register(HighPriorityPlugin(), name="high") + framework._plugin_manager.register(EmptyPlugin(), name="empty") + + prompt = framework.get_system_prompt(prompt="hello", state={}) + + assert prompt == "low\n\nhigh" + + +def test_builtin_cli_exposes_gateway_and_keeps_message_hidden_alias() -> None: + framework = BubFramework() + framework.load_hooks() + app = framework.create_cli_app() + runner = CliRunner() + + help_result = runner.invoke(app, ["--help"]) + alias_result = runner.invoke(app, ["message", "--help"]) + + assert help_result.exit_code == 0 + assert "gateway" in help_result.stdout + assert "│ message" not in help_result.stdout + assert alias_result.exit_code == 0 + assert "bub message" in alias_result.stdout + assert "Start message listeners" in alias_result.stdout diff --git a/tests/test_graceful_shutdown.py b/tests/test_graceful_shutdown.py deleted file mode 100644 index 33058345..00000000 --- a/tests/test_graceful_shutdown.py +++ /dev/null @@ -1,90 +0,0 @@ -import asyncio -import contextlib - -import pytest - -from bub.channels.base import BaseChannel -from bub.channels.manager import ChannelManager -from bub.cli.app import _serve_channels - - -class _Settings: - telegram_enabled = False - discord_enabled = False - - -class _Runtime: - settings = _Settings() - - def install_hooks(self, manager: ChannelManager) -> None: - pass - - -class _ChannelRaisesOnStop(BaseChannel[object]): - name = "bad" - - async def start(self, on_receive): # type: ignore[override] - _ = on_receive - try: - await asyncio.Event().wait() - finally: - raise RuntimeError("stop failure") - - async def get_session_prompt(self, message: object) -> tuple[str, str]: - _ = message - return "s", "p" - - def is_mentioned(self, message: object) -> bool: - _ = message - return True - - async def process_output(self, session_id: str, output): - _ = (session_id, output) - - -@pytest.mark.asyncio -async def test_channel_manager_shutdown_propagates_channel_stop_error() -> None: - manager = ChannelManager(_Runtime()) # type: ignore[arg-type] - manager.register(_ChannelRaisesOnStop) - - task = asyncio.create_task(manager.run()) - await asyncio.sleep(0.05) - task.cancel() - with pytest.raises(RuntimeError, match="stop failure"): - await asyncio.wait_for(task, timeout=1.0) - - -@pytest.mark.asyncio -async def test_serve_channels_handles_cancelled_error_from_graceful_shutdown() -> None: - class _DummyRuntime: - @contextlib.asynccontextmanager - async def graceful_shutdown(self): - stop_event = asyncio.Event() - current_task = asyncio.current_task() - waiter = asyncio.create_task(stop_event.wait()) - waiter.add_done_callback(lambda _: current_task.cancel() if current_task else None) - try: - self.stop_event = stop_event - yield stop_event - finally: - waiter.cancel() - - class _DummyManager: - def __init__(self) -> None: - self.runtime = _DummyRuntime() - self.calls: list[str] = [] - - async def run(self) -> None: - self.calls.append("start") - try: - await asyncio.Event().wait() - finally: - self.calls.append("stop") - - manager = _DummyManager() - task = asyncio.create_task(_serve_channels(manager)) - await asyncio.sleep(0.05) - assert manager.calls == ["start"] - manager.runtime.stop_event.set() - await asyncio.wait_for(task, timeout=1.0) - assert manager.calls == ["start", "stop"] diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py new file mode 100644 index 00000000..337a2bda --- /dev/null +++ b/tests/test_hook_runtime.py @@ -0,0 +1,106 @@ +import pluggy +import pytest + +from bub.hook_runtime import HookRuntime +from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl + + +def _runtime_with_plugins(*plugins: tuple[str, object]) -> HookRuntime: + manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) + manager.add_hookspecs(BubHookSpecs) + for name, plugin in plugins: + manager.register(plugin, name=name) + return HookRuntime(manager) + + +@pytest.mark.asyncio +async def test_call_first_respects_priority_and_returns_first_non_none() -> None: + called: list[str] = [] + + class LowPriority: + @hookimpl + def resolve_session(self, message): + called.append("low") + return "low" + + class MidPriority: + @hookimpl + def resolve_session(self, message): + called.append("mid") + return "mid" + + class HighPriorityReturnsNone: + @hookimpl + def resolve_session(self, message): + called.append("high") + return None + + runtime = _runtime_with_plugins( + ("low", LowPriority()), + ("mid", MidPriority()), + ("high", HighPriorityReturnsNone()), + ) + + result = await runtime.call_first("resolve_session", message={"session_id": "x"}, ignored="value") + assert result == "mid" + assert called == ["high", "mid"] + + +def test_call_many_sync_skips_async_impl() -> None: + class _AwaitableValue: + def __await__(self): + yield from () + return "async" + + class AsyncPrompt: + @hookimpl + def system_prompt(self, prompt, state): + return _AwaitableValue() + + class SyncPrompt: + @hookimpl + def system_prompt(self, prompt, state): + return "sync" + + runtime = _runtime_with_plugins( + ("sync", SyncPrompt()), + ("async", AsyncPrompt()), + ) + + assert runtime.call_many_sync("system_prompt", prompt="hello", state={}) == ["sync"] + + +@pytest.mark.asyncio +async def test_notify_error_swallows_observer_failures() -> None: + observed: list[str] = [] + + class RaisingObserver: + @hookimpl + async def on_error(self, stage, error, message): + raise RuntimeError("boom") + + class RecordingObserver: + @hookimpl + async def on_error(self, stage, error, message): + observed.append(stage) + + runtime = _runtime_with_plugins( + ("raise", RaisingObserver()), + ("record", RecordingObserver()), + ) + + await runtime.notify_error(stage="turn", error=ValueError("bad"), message={"content": "x"}) + assert observed == ["turn"] + + +def test_hook_report_lists_registered_implementations() -> None: + class SessionPlugin: + @hookimpl + def resolve_session(self, message): + return "session" + + runtime = _runtime_with_plugins(("session", SessionPlugin())) + report = runtime.hook_report() + + assert "resolve_session" in report + assert report["resolve_session"] == ["session"] diff --git a/tests/test_model_runner.py b/tests/test_model_runner.py deleted file mode 100644 index 4a9ed0a4..00000000 --- a/tests/test_model_runner.py +++ /dev/null @@ -1,454 +0,0 @@ -from dataclasses import dataclass, field - -import pytest -from republic import ToolAutoResult - -from bub.core.model_runner import TOOL_CONTINUE_PROMPT, ModelRunner -from bub.core.router import AssistantRouteResult -from bub.skills.loader import SkillMetadata - - -class FakeRouter: - def __init__(self) -> None: - self._calls = 0 - - async def route_assistant(self, raw: str) -> AssistantRouteResult: - self._calls += 1 - if self._calls == 1: - assert raw == "assistant-first" - return AssistantRouteResult(visible_text="v1", next_prompt="one", exit_requested=False) - assert raw == "assistant-second" - return AssistantRouteResult(visible_text="v2", next_prompt="", exit_requested=False) - - -class SingleStepRouter: - async def route_assistant(self, raw: str) -> AssistantRouteResult: - assert raw == "assistant-only" - return AssistantRouteResult(visible_text="done", next_prompt="", exit_requested=False) - - -class AnySingleStepRouter: - async def route_assistant(self, raw: str) -> AssistantRouteResult: - assert raw - return AssistantRouteResult(visible_text="done", next_prompt="", exit_requested=False) - - -class FollowupRouter: - def __init__(self, *, first: str, second: str) -> None: - self._calls = 0 - self._first = first - self._second = second - - async def route_assistant(self, raw: str) -> AssistantRouteResult: - self._calls += 1 - if self._calls == 1: - assert raw == self._first - return AssistantRouteResult( - visible_text="", next_prompt="followup", exit_requested=False - ) - assert raw == self._second - return AssistantRouteResult(visible_text="done", next_prompt="", exit_requested=False) - - -class ToolFollowupRouter: - async def route_assistant(self, raw: str) -> AssistantRouteResult: - assert raw == "assistant-after-tool" - return AssistantRouteResult(visible_text="tool done", next_prompt="", exit_requested=False) - - -class FakeToolView: - def __init__(self) -> None: - self.expanded: set[str] = set() - - def compact_block(self) -> str: - return "" - - def expanded_block(self) -> str: - if not self.expanded: - return "" - lines = [""] - for name in sorted(self.expanded): - lines.append(f' ') - lines.append("") - return "\n".join(lines) - - def note_hint(self, hint: str) -> bool: - normalized = hint.casefold() - if normalized == "fs.read": - self.expanded.add("fs.read") - return True - return False - - -@dataclass -class FakeTapeImpl: - class _Query: - def kinds(self, *_kinds: str) -> "FakeTapeImpl._Query": - return self - - def last_anchor(self) -> "FakeTapeImpl._Query": - return self - - def all(self) -> list[object]: - return [] - - outputs: list[ToolAutoResult] - calls: list[tuple[str, str, int]] = field(default_factory=list) - call_kwargs: list[dict[str, object]] = field(default_factory=list) - query: _Query = field(default_factory=_Query) - - async def run_tools_async( - self, - *, - prompt: str, - system_prompt: str, - max_tokens: int, - tools: list[object], - **kwargs: object, - ) -> ToolAutoResult: - self.calls.append((prompt, system_prompt, max_tokens)) - self.call_kwargs.append(kwargs) - return self.outputs.pop(0) - - -@dataclass -class FakeTapeService: - tape: FakeTapeImpl - events: list[tuple[str, dict[str, object]]] = field(default_factory=list) - - async def append_event(self, name: str, data: dict[str, object]) -> None: - self.events.append((name, data)) - - -@pytest.mark.asyncio -async def test_model_runner_follows_command_context_until_stop() -> None: - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.text_result("assistant-first"), - ToolAutoResult.text_result("assistant-second"), - ] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=FakeRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=5, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "workspace", - ) - - result = await runner.run("start") - assert result.visible_text == "v1\n\nv2" - assert result.steps == 2 - assert result.command_followups == 1 - assert result.error is None - - -@pytest.mark.asyncio -async def test_model_runner_continues_after_tool_execution() -> None: - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.tools_result( - tool_calls=[{"function": {"name": "fs.write", "arguments": '{"path":"tmp.txt","content":"hi"}'}}], - tool_results=["ok"], - ), - ToolAutoResult.text_result("assistant-after-tool"), - ] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=ToolFollowupRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=3, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "workspace", - ) - - result = await runner.run("create file") - assert result.visible_text == "tool done" - assert result.steps == 2 - assert result.command_followups == 1 - assert tape.tape.calls[1][0] == TOOL_CONTINUE_PROMPT - - -@pytest.mark.asyncio -async def test_model_runner_tool_followup_does_not_inline_tool_payload() -> None: - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.tools_result( - tool_calls=[ - { - "function": { - "name": 'fs.write"unsafe', - "arguments": '{"path":"tmp/.txt","content":"x & y"}', - } - } - ], - tool_results=['ok & "quoted"'], - ), - ToolAutoResult.text_result("assistant-after-tool"), - ] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=ToolFollowupRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=3, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "workspace", - ) - - await runner.run("create file") - followup_prompt = tape.tape.calls[1][0] - assert followup_prompt == TOOL_CONTINUE_PROMPT - - -@pytest.mark.asyncio -async def test_model_runner_expands_skill_from_hint() -> None: - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - skill = SkillMetadata( - name="friendly-python", - description="style", - location=__file__, # type: ignore[arg-type] - body="content", - source="project", - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=AnySingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [skill], - model="openrouter:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("please follow $friendly-python") - _, system_prompt, _ = tape.tape.calls[0] - assert "" in system_prompt - assert "friendly-python" in system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_expands_skill_from_assistant_hint() -> None: - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.text_result("assistant mentions $friendly-python"), - ToolAutoResult.text_result("assistant-second"), - ] - ) - ) - skill = SkillMetadata( - name="friendly-python", - description="style", - location=__file__, # type: ignore[arg-type] - body="content", - source="project", - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=FollowupRouter(first="assistant mentions $friendly-python", second="assistant-second"), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [skill], - model="openrouter:test", - max_steps=2, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("no skill hint here") - _, second_system_prompt, _ = tape.tape.calls[1] - assert "" in second_system_prompt - assert "friendly-python" in second_system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_expands_tool_from_user_hint() -> None: - tool_view = FakeToolView() - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=AnySingleStepRouter(), # type: ignore[arg-type] - tool_view=tool_view, # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("use $fs.read") - _, first_system_prompt, _ = tape.tape.calls[0] - assert "" in first_system_prompt - assert '' in first_system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_expands_tool_from_assistant_hint() -> None: - tool_view = FakeToolView() - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.text_result("assistant mentions $fs.read"), - ToolAutoResult.text_result("assistant-second"), - ] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=FollowupRouter(first="assistant mentions $fs.read", second="assistant-second"), # type: ignore[arg-type] - tool_view=tool_view, # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=2, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("no tool hint here") - _, second_system_prompt, _ = tape.tape.calls[1] - assert "" in second_system_prompt - assert '' in second_system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_refreshes_skills_from_provider_between_runs() -> None: - skill = SkillMetadata( - name="friendly-python", - description="style", - location=__file__, # type: ignore[arg-type] - body="content", - source="project", - ) - all_skills: list[SkillMetadata] = [] - - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ToolAutoResult.text_result("assistant-only"), ToolAutoResult.text_result("assistant-only")] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=AnySingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: list(all_skills), - model="openrouter:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - await runner.run("first run") - _, first_system_prompt, _ = tape.tape.calls[0] - assert "friendly-python" not in first_system_prompt - - all_skills.append(skill) - await runner.run("second run") - _, second_system_prompt, _ = tape.tape.calls[1] - assert "" in second_system_prompt - assert "friendly-python" in second_system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_passes_extra_headers_for_openrouter() -> None: - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=SingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("hi") - kwargs = tape.tape.call_kwargs[0] - assert kwargs.get("extra_headers") == ModelRunner.DEFAULT_HEADERS - - -@pytest.mark.asyncio -async def test_model_runner_maps_headers_for_vertexai() -> None: - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=SingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="vertexai:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("hi") - kwargs = tape.tape.call_kwargs[0] - assert "extra_headers" not in kwargs - assert kwargs["http_options"] == {"headers": ModelRunner.DEFAULT_HEADERS} - - -@pytest.mark.asyncio -async def test_model_runner_uses_extra_headers_for_unknown_provider() -> None: - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=SingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="custom:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("hi") - kwargs = tape.tape.call_kwargs[0] - assert kwargs.get("extra_headers") == ModelRunner.DEFAULT_HEADERS - assert "http_options" not in kwargs diff --git a/tests/test_router.py b/tests/test_router.py deleted file mode 100644 index a740d616..00000000 --- a/tests/test_router.py +++ /dev/null @@ -1,238 +0,0 @@ -from dataclasses import dataclass, field -from pathlib import Path - -import pytest -from pydantic import BaseModel, Field - -from bub.core.router import InputRouter -from bub.tools.progressive import ProgressiveToolView -from bub.tools.registry import ToolRegistry - - -class BashInput(BaseModel): - cmd: str = Field(...) - cwd: str | None = Field(default=None) - - -class EmptyInput(BaseModel): - pass - - -@dataclass -class _FakeContext: - state: dict[str, object] = field(default_factory=lambda: {"session_id": "cli:test"}) - - -@dataclass -class _FakeTapeHandle: - name: str = "test-tape" - context: _FakeContext = field(default_factory=_FakeContext) - - -@dataclass -class FakeTape: - events: list[tuple[str, dict[str, object]]] = field(default_factory=list) - tape: _FakeTapeHandle = field(default_factory=_FakeTapeHandle) - - async def append_event(self, name: str, data: dict[str, object]) -> None: - self.events.append((name, data)) - - -def _build_router( - *, - bash_error: bool = False, - bash_output: str = "ok from bash", - bash_error_message: str = "", -) -> InputRouter: - registry = ToolRegistry() - - def run_bash(params: BashInput) -> str: - if bash_error: - raise RuntimeError(bash_error_message or "bash failed") - return bash_output - - def command_help(_params: EmptyInput) -> str: - return "help text" - - def quit_command(_params: EmptyInput) -> str: - return "exit" - - registry.register( - name="bash", - short_description="Run shell command", - detail="bash detail", - model=BashInput, - )(run_bash) - registry.register( - name="help", - short_description="help", - detail="help detail", - model=EmptyInput, - )(command_help) - registry.register( - name="quit", - short_description="quit", - detail="quit detail", - model=EmptyInput, - )(quit_command) - - view = ProgressiveToolView(registry) - return InputRouter(registry, view, FakeTape(), Path.cwd()) - - -@pytest.mark.asyncio -async def test_user_internal_command_short_circuits_model() -> None: - router = _build_router() - result = await router.route_user(",help") - assert result.enter_model is False - assert result.immediate_output == "help text" - - -@pytest.mark.asyncio -async def test_user_shell_success_short_circuits_model() -> None: - router = _build_router() - for text in (",echo hi", ", echo hi", ", echo hi"): - result = await router.route_user(text) - assert result.enter_model is False - assert result.immediate_output == "ok from bash" - - -@pytest.mark.asyncio -async def test_user_shell_failure_falls_back_to_model() -> None: - router = _build_router(bash_error=True) - for text in (",echo hi", ", echo hi", ", echo hi"): - result = await router.route_user(text) - assert result.enter_model is True - assert '' in result.model_prompt - - -@pytest.mark.asyncio -async def test_user_natural_language_starting_with_command_word_goes_to_model() -> None: - router = _build_router() - result = await router.route_user("write another python file buggy , just run , then wait ten second then fix") - assert result.enter_model is True - assert result.immediate_output == "" - assert result.model_prompt.startswith("write another") - - -@pytest.mark.asyncio -async def test_user_plain_shell_like_text_without_prefix_goes_to_model() -> None: - router = _build_router() - result = await router.route_user("echo hi") - assert result.enter_model is True - assert result.immediate_output == "" - assert result.model_prompt == "echo hi" - - -@pytest.mark.asyncio -async def test_user_non_line_start_comma_text_goes_to_model() -> None: - router = _build_router() - result = await router.route_user("please run ,echo hi") - assert result.enter_model is True - assert result.immediate_output == "" - assert result.model_prompt == "please run ,echo hi" - - -@pytest.mark.asyncio -async def test_user_dollar_prefix_goes_to_model_as_plain_text() -> None: - router = _build_router() - result = await router.route_user("$echo hi") - assert result.enter_model is True - assert result.immediate_output == "" - assert result.model_prompt == "$echo hi" - - -@pytest.mark.asyncio -async def test_assistant_plain_shell_text_is_not_executed() -> None: - router = _build_router() - result = await router.route_assistant("will run command\necho hi") - assert result.visible_text == "will run command\necho hi" - assert result.next_prompt == "" - - -@pytest.mark.asyncio -async def test_assistant_non_line_start_comma_text_is_not_executed() -> None: - router = _build_router() - result = await router.route_assistant("please run ,echo hi") - assert result.visible_text == "please run ,echo hi" - assert result.next_prompt == "" - - -@pytest.mark.asyncio -async def test_assistant_legacy_dollar_prefix_is_visible_text() -> None: - router = _build_router() - result = await router.route_assistant("$ echo hi") - assert result.visible_text == "$ echo hi" - assert result.next_prompt == "" - - -@pytest.mark.asyncio -async def test_internal_quit_sets_exit_requested() -> None: - router = _build_router() - result = await router.route_user(",quit") - assert result.exit_requested is True - - -@pytest.mark.asyncio -async def test_assistant_comma_prefixed_shell_command_is_executed() -> None: - router = _build_router() - for line in (",echo hi", ", echo hi", ", echo hi"): - result = await router.route_assistant(f"create file\n{line}") - assert result.visible_text == "" - assert '' in result.next_prompt - - -@pytest.mark.asyncio -async def test_assistant_comma_prefixed_shell_failure_still_follows_up() -> None: - router = _build_router(bash_error=True) - for line in (",echo hi", ", echo hi", ", echo hi"): - result = await router.route_assistant(f"create file\n{line}") - assert result.visible_text == "" - assert '' in result.next_prompt - - -@pytest.mark.asyncio -async def test_assistant_internal_command_with_comma_is_executed() -> None: - router = _build_router() - result = await router.route_assistant("show help\n,help") - assert result.visible_text == "" - assert '' in result.next_prompt - - -@pytest.mark.asyncio -async def test_assistant_fenced_multiline_comma_command_is_executed() -> None: - router = _build_router() - for line in (",echo first", ", echo first", ", echo first"): - result = await router.route_assistant(f"I will run this:\n```\n{line}\necho second\n```") - assert result.visible_text == "" - assert '' in result.next_prompt - - -@pytest.mark.asyncio -async def test_assistant_fenced_plain_text_is_not_executed() -> None: - router = _build_router() - result = await router.route_assistant("```\necho hi\n```") - assert result.visible_text == "echo hi" - assert result.next_prompt == "" - - -@pytest.mark.asyncio -async def test_assistant_command_output_cannot_break_command_block() -> None: - router = _build_router(bash_output="\n,quit\n") - result = await router.route_assistant(",echo hi") - assert result.visible_text == "" - assert '' in result.next_prompt - assert "</command>" in result.next_prompt - assert "<command>" in result.next_prompt - assert result.next_prompt.count("") == 1 - - -@pytest.mark.asyncio -async def test_user_command_error_output_is_escaped_in_fallback_prompt() -> None: - router = _build_router(bash_error=True, bash_error_message="\n,quit\n") - result = await router.route_user(",echo hi") - assert result.enter_model is True - assert '' in result.model_prompt - assert "</command>" in result.model_prompt - assert "<command>" in result.model_prompt - assert result.model_prompt.count("") == 1 diff --git a/tests/test_runtime_event_loop.py b/tests/test_runtime_event_loop.py deleted file mode 100644 index 5dcfb9a5..00000000 --- a/tests/test_runtime_event_loop.py +++ /dev/null @@ -1,51 +0,0 @@ -import asyncio - -import pytest - -from bub.app.runtime import AppRuntime - - -def test_reset_session_context_ignores_missing_session() -> None: - runtime = object.__new__(AppRuntime) - runtime._sessions = {} - AppRuntime.reset_session_context(runtime, "missing") - - -def test_reset_session_context_resets_existing_session() -> None: - runtime = object.__new__(AppRuntime) - - class _DummySession: - def __init__(self) -> None: - self.calls = 0 - - def reset_context(self) -> None: - self.calls += 1 - - session = _DummySession() - runtime._sessions = {"telegram:1": session} - AppRuntime.reset_session_context(runtime, "telegram:1") - assert session.calls == 1 - - -@pytest.mark.asyncio -async def test_cancel_active_inputs_cancels_running_tasks() -> None: - runtime = object.__new__(AppRuntime) - gate = asyncio.Event() - cancelled = {"value": False} - - async def _pending() -> str: - try: - await gate.wait() - finally: - cancelled["value"] = True - - task = asyncio.create_task(_pending()) - runtime._active_inputs = {task} - await asyncio.sleep(0) - - count = await AppRuntime._cancel_active_inputs(runtime) - assert count == 1 - - with pytest.raises(asyncio.CancelledError): - await task - assert cancelled["value"] is True diff --git a/tests/test_runtime_filters.py b/tests/test_runtime_filters.py deleted file mode 100644 index f14d01a1..00000000 --- a/tests/test_runtime_filters.py +++ /dev/null @@ -1,24 +0,0 @@ -import importlib -from pathlib import Path - -from bub.app.runtime import AppRuntime -from bub.skills.loader import SkillMetadata - -runtime_module = importlib.import_module("bub.app.runtime") - - -def _build_runtime_stub(workspace: Path, *, allowed_skills: set[str] | None) -> AppRuntime: - runtime = object.__new__(AppRuntime) - runtime.workspace = workspace - runtime._allowed_skills = allowed_skills # type: ignore[attr-defined] - return runtime - - -def test_discover_skills_filters_by_allowlist(monkeypatch, tmp_path: Path) -> None: - alpha = SkillMetadata(name="alpha", description="a", location=tmp_path / "alpha.md", body="", source="project") - beta = SkillMetadata(name="beta", description="b", location=tmp_path / "beta.md", body="", source="project") - monkeypatch.setattr(runtime_module, "discover_skills", lambda _workspace: [alpha, beta]) - - runtime = _build_runtime_stub(tmp_path, allowed_skills={"alpha"}) - names = [skill.name for skill in runtime.discover_skills()] - assert names == ["alpha"] diff --git a/tests/test_session_runner.py b/tests/test_session_runner.py deleted file mode 100644 index a4df5aee..00000000 --- a/tests/test_session_runner.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable - -import pytest - -from bub.channels.base import BaseChannel -from bub.channels.runner import SessionRunner -from bub.core.agent_loop import LoopResult - - -class _Runtime: - pass - - -class _ImmediateChannel(BaseChannel[str]): - name = "cli" - - def __init__(self) -> None: - super().__init__(_Runtime()) # type: ignore[arg-type] - self.run_prompts: list[str] = [] - self.processed = 0 - - @property - def debounce_enabled(self) -> bool: - return False - - async def start(self, on_receive: Callable[[str], Awaitable[None]]) -> None: - _ = on_receive - - def is_mentioned(self, message: str) -> bool: - _ = message - return True - - async def get_session_prompt(self, message: str) -> tuple[str, str]: - return "cli:test", message - - async def run_prompt(self, session_id: str, prompt: str) -> LoopResult: - _ = session_id - self.run_prompts.append(prompt) - return LoopResult( - immediate_output="", - assistant_output="", - exit_requested=False, - steps=0, - error=None, - ) - - async def process_output(self, session_id: str, output: LoopResult) -> None: - _ = (session_id, output) - self.processed += 1 - - -class _DebouncedChannel(BaseChannel[str]): - name = "telegram" - - def __init__(self) -> None: - super().__init__(_Runtime()) # type: ignore[arg-type] - self.run_prompts: list[str] = [] - - async def start(self, on_receive: Callable[[str], Awaitable[None]]) -> None: - _ = on_receive - - def is_mentioned(self, message: str) -> bool: - _ = message - return True - - async def get_session_prompt(self, message: str) -> tuple[str, str]: - return "telegram:1", message - - async def run_prompt(self, session_id: str, prompt: str) -> LoopResult: - _ = session_id - self.run_prompts.append(prompt) - return LoopResult( - immediate_output="", - assistant_output="", - exit_requested=False, - steps=0, - error=None, - ) - - async def process_output(self, session_id: str, output: LoopResult) -> None: - _ = (session_id, output) - - -class _ImmediateFailingChannel(_ImmediateChannel): - async def run_prompt(self, session_id: str, prompt: str) -> LoopResult: - _ = (session_id, prompt) - raise RuntimeError("cli failure") - - -@pytest.mark.asyncio -async def test_session_runner_runs_non_debounced_channel_immediately() -> None: - runner = SessionRunner( - session_id="cli:test", - debounce_seconds=10, - message_delay_seconds=10, - active_time_window_seconds=60, - ) - channel = _ImmediateChannel() - - await runner.process_message(channel, "first") - await runner.process_message(channel, "second") - - assert channel.run_prompts == ["first", "second"] - assert channel.processed == 2 - - -@pytest.mark.asyncio -async def test_command_prompt_is_not_buffered() -> None: - runner = SessionRunner( - session_id="telegram:1", - debounce_seconds=1, - message_delay_seconds=1, - active_time_window_seconds=60, - ) - channel = _DebouncedChannel() - - await runner.process_message(channel, ",help") - - assert channel.run_prompts == [",help"] - assert runner._prompts == [] - assert runner._running_task is None - - -@pytest.mark.asyncio -async def test_session_runner_does_not_leak_command_into_batched_prompt() -> None: - runner = SessionRunner( - session_id="telegram:1", - debounce_seconds=0, - message_delay_seconds=0, - active_time_window_seconds=60, - ) - channel = _DebouncedChannel() - - await runner.process_message(channel, ",help") - await runner.process_message(channel, "hello") - - assert channel.run_prompts[0] == ",help" - assert channel.run_prompts[1] == "channel: $telegram\nhello" - - -@pytest.mark.asyncio -async def test_session_runner_raises_for_non_debounced_channel_errors() -> None: - runner = SessionRunner( - session_id="cli:test", - debounce_seconds=10, - message_delay_seconds=10, - active_time_window_seconds=60, - ) - channel = _ImmediateFailingChannel() - - with pytest.raises(RuntimeError, match="cli failure"): - await runner.process_message(channel, "hello") diff --git a/tests/test_skill_path_expansion.py b/tests/test_skill_path_expansion.py deleted file mode 100644 index 971b1477..00000000 --- a/tests/test_skill_path_expansion.py +++ /dev/null @@ -1,39 +0,0 @@ -import importlib.util -import sys -from pathlib import Path -from types import ModuleType - - -def _load_script_module(name: str, relative_path: str) -> ModuleType: - repo_root = Path(__file__).resolve().parents[1] - module_path = repo_root / relative_path - module_dir = str(module_path.parent) - spec = importlib.util.spec_from_file_location(name, module_path) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.path.insert(0, module_dir) - sys.modules[name] = module - try: - spec.loader.exec_module(module) - finally: - if sys.path and sys.path[0] == module_dir: - sys.path.pop(0) - return module - - -def test_init_skill_expands_home_in_path(monkeypatch, tmp_path: Path) -> None: - module = _load_script_module( - "skill_creator_init_script", - "src/bub/skills/skill-creator/scripts/init_skill.py", - ) - - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - - created = module.init_skill("demo-skill", "~/skills", [], False, []) - - expected = (fake_home / "skills" / "demo-skill").resolve() - assert created == expected - assert (expected / "SKILL.md").exists() diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 00000000..12bcb563 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,102 @@ +from pathlib import Path + +from bub.skills import ( + SKILL_FILE_NAME, + SkillMetadata, + _parse_frontmatter, + _read_skill, + discover_skills, + render_skills_prompt, +) + + +def _write_skill( + root: Path, + name: str, + *, + description: str = "A skill", + body: str = "Skill body", + metadata: dict[str, str] | None = None, +) -> Path: + skill_dir = root / name + skill_dir.mkdir(parents=True, exist_ok=True) + lines = [ + "---", + f"name: {name}", + f"description: {description}", + ] + if metadata is not None: + lines.append("metadata:") + for key, value in metadata.items(): + lines.append(f" {key}: {value}") + lines.extend(["---", body]) + skill_file = skill_dir / SKILL_FILE_NAME + skill_file.write_text("\n".join(lines), encoding="utf-8") + return skill_file + + +def test_skill_metadata_body_strips_frontmatter(tmp_path: Path) -> None: + skill_file = _write_skill(tmp_path, "demo-skill", body="Line 1\nLine 2") + metadata = SkillMetadata( + name="demo-skill", + description="Demo", + location=skill_file, + source="project", + ) + assert metadata.body() == "Line 1\nLine 2" + + +def test_read_skill_rejects_invalid_metadata_field_type(tmp_path: Path) -> None: + skill_dir = tmp_path / "bad-skill" + skill_dir.mkdir() + content = "---\nname: bad-skill\ndescription: bad\nmetadata:\n retries: 3\n---\nBody\n" + (skill_dir / SKILL_FILE_NAME).write_text(content, encoding="utf-8") + + assert _read_skill(skill_dir, source="project") is None + + +def test_parse_frontmatter_returns_empty_on_invalid_yaml() -> None: + content = "---\nname: [broken\n---\nbody\n" + assert _parse_frontmatter(content) == {} + + +def test_discover_skills_prefers_project_over_global_and_builtin(tmp_path: Path, monkeypatch) -> None: + project_root = tmp_path / "project" + global_root = tmp_path / "global" + builtin_root = tmp_path / "builtin" + for root in (project_root, global_root, builtin_root): + root.mkdir(parents=True) + + _write_skill(project_root, "shared", description="project version") + _write_skill(global_root, "shared", description="global version") + _write_skill(builtin_root, "shared", description="builtin version") + _write_skill(global_root, "global-only", description="global only") + + monkeypatch.setattr( + "bub.skills._iter_skill_roots", + lambda _workspace: [ + (project_root, "project"), + (global_root, "global"), + (builtin_root, "builtin"), + ], + ) + + discovered = discover_skills(tmp_path) + index = {item.name: item for item in discovered} + assert index["shared"].description == "project version" + assert index["shared"].source == "project" + assert index["global-only"].source == "global" + + +def test_render_skills_prompt_includes_expanded_body(tmp_path: Path) -> None: + skill_file = _write_skill(tmp_path, "skill-a", description="desc", body="expanded body") + skills = [ + SkillMetadata(name="skill-a", description="desc", location=skill_file, source="project"), + SkillMetadata(name="skill-b", description="desc-b", location=skill_file, source="project"), + ] + + rendered = render_skills_prompt(skills, expanded_skills={"skill-a"}) + assert "" in rendered + assert "- skill-a: desc" in rendered + assert "expanded body" in rendered + assert "- skill-b: desc-b" in rendered diff --git a/tests/test_skills_loader.py b/tests/test_skills_loader.py deleted file mode 100644 index 5f4690cd..00000000 --- a/tests/test_skills_loader.py +++ /dev/null @@ -1,17 +0,0 @@ -from pathlib import Path - -from bub.skills.loader import discover_skills - - -def test_discover_and_load_project_skill(tmp_path: Path) -> None: - skill_dir = tmp_path / ".agent" / "skills" / "demo-skill" - skill_dir.mkdir(parents=True) - skill_file = skill_dir / "SKILL.md" - skill_file.write_text( - "---\nname: demo-skill\ndescription: demo skill\n---\n\n# Demo\n", - encoding="utf-8", - ) - - skills = discover_skills(tmp_path) - names = [skill.name for skill in skills] - assert "demo-skill" in names diff --git a/tests/test_tape_context.py b/tests/test_tape_context.py deleted file mode 100644 index ec4bbd85..00000000 --- a/tests/test_tape_context.py +++ /dev/null @@ -1,49 +0,0 @@ -from republic import TapeEntry - -from bub.tape.context import default_tape_context - - -def test_default_tape_context_includes_tool_messages() -> None: - context = default_tape_context() - assert context.select is not None - - entries = [ - TapeEntry.message({"role": "user", "content": "create a file"}), - TapeEntry.tool_call([ - { - "id": "call-1", - "type": "function", - "function": {"name": "fs.write", "arguments": '{"path":"a.txt","content":"hi"}'}, - }, - { - "id": "call-2", - "type": "function", - "function": {"name": "fs.read", "arguments": '{"path":"a.txt"}'}, - }, - ]), - TapeEntry.tool_result(["ok", {"content": "hi"}]), - TapeEntry.message({"role": "assistant", "content": "done"}), - ] - - messages = context.select(entries, context) - assert messages[0] == {"role": "user", "content": "create a file"} - assert messages[1]["role"] == "assistant" - assert messages[1]["tool_calls"][0]["id"] == "call-1" - assert messages[2] == {"role": "tool", "content": "ok", "tool_call_id": "call-1", "name": "fs.write"} - assert messages[3] == { - "role": "tool", - "content": '{"content": "hi"}', - "tool_call_id": "call-2", - "name": "fs.read", - } - assert messages[4] == {"role": "assistant", "content": "done"} - - -def test_default_tape_context_handles_result_without_calls() -> None: - context = default_tape_context() - assert context.select is not None - - entries = [TapeEntry.tool_result([{"status": "ok"}])] - messages = context.select(entries, context) - - assert messages == [{"role": "tool", "content": '{"status": "ok"}'}] diff --git a/tests/test_tape_service.py b/tests/test_tape_service.py deleted file mode 100644 index ca2a5b6c..00000000 --- a/tests/test_tape_service.py +++ /dev/null @@ -1,120 +0,0 @@ -from dataclasses import dataclass - -import pytest - -from bub.tape.service import TapeService - - -@dataclass -class FakeEntry: - id: int - kind: str - payload: dict[str, object] - meta: dict[str, object] - - -class FakeTape: - class _Query: - def __init__(self, tape: "FakeTape") -> None: - self._tape = tape - - async def all(self) -> list[FakeEntry]: - return list(self._tape.entries) - - def kinds(self, *kinds: str) -> "FakeTape._Query": - return self - - def __init__(self) -> None: - self.name = "fake" - self.entries: list[FakeEntry] = [ - FakeEntry( - id=1, - kind="anchor", - payload={"name": "session/start", "state": {"owner": "human"}}, - meta={}, - ) - ] - self.reset_calls = 0 - self.query_async = self._Query(self) - - async def handoff_async(self, name: str, state: dict[str, object] | None = None) -> list[FakeEntry]: - entry = FakeEntry( - id=len(self.entries) + 1, - kind="anchor", - payload={"name": name, "state": state or {}}, - meta={}, - ) - self.entries.append(entry) - return [entry] - - async def reset_async(self) -> None: - self.reset_calls += 1 - self.entries = [] - - -@pytest.mark.asyncio -async def test_reset_rebuilds_bootstrap_anchor() -> None: - service = TapeService.__new__(TapeService) - fake_tape = FakeTape() - service._tape = fake_tape # type: ignore[attr-defined] - service._store = None # type: ignore[attr-defined] - - result = await service.reset() - - assert result == "ok" - assert fake_tape.reset_calls == 1 - anchors = [entry for entry in fake_tape.entries if entry.kind == "anchor"] - assert len(anchors) == 1 - assert anchors[0].payload["name"] == "session/start" - - -@pytest.mark.asyncio -async def test_search_supports_fuzzy_typo_matching() -> None: - service = TapeService.__new__(TapeService) - fake_tape = FakeTape() - fake_tape.entries.extend(( - FakeEntry( - id=2, - kind="message", - payload={"role": "assistant", "content": "Please review the database migration plan."}, - meta={"source": "assistant"}, - ), - FakeEntry( - id=3, - kind="message", - payload={"role": "assistant", "content": "Unrelated note"}, - meta={}, - ), - )) - service._tape = fake_tape # type: ignore[attr-defined] - - matches = await service.search("databse migrtion", limit=5) - - assert len(matches) == 1 - assert matches[0].id == 2 - - -@pytest.mark.asyncio -async def test_search_respects_limit_for_exact_match() -> None: - service = TapeService.__new__(TapeService) - fake_tape = FakeTape() - fake_tape.entries.extend(( - FakeEntry( - id=2, - kind="message", - payload={"role": "assistant", "content": "Alpha report generated"}, - meta={}, - ), - FakeEntry( - id=3, - kind="message", - payload={"role": "assistant", "content": "Alpha follow-up details"}, - meta={}, - ), - )) - service._tape = fake_tape # type: ignore[attr-defined] - - matches = await service.search("alpha", limit=1) - - assert len(matches) == 1 - assert matches[0].id == 3 diff --git a/tests/test_tape_store.py b/tests/test_tape_store.py deleted file mode 100644 index 85f052c2..00000000 --- a/tests/test_tape_store.py +++ /dev/null @@ -1,133 +0,0 @@ -from pathlib import Path - -from republic import TapeEntry - -from bub.tape.store import FileTapeStore, TapeFile - - -def test_store_isolated_by_tape_name(tmp_path: Path) -> None: - home = tmp_path / "home" - workspace = tmp_path / "workspace" - workspace.mkdir() - store = FileTapeStore(home, workspace) - - store.append("a", TapeEntry.message({"role": "user", "content": "one"})) - store.append("b", TapeEntry.message({"role": "user", "content": "two"})) - - a_entries = store.read("a") - b_entries = store.read("b") - assert a_entries is not None - assert b_entries is not None - assert a_entries[0].payload["content"] == "one" - assert b_entries[0].payload["content"] == "two" - assert sorted(store.list_tapes()) == ["a", "b"] - - -def test_archive_then_reset(tmp_path: Path) -> None: - home = tmp_path / "home" - workspace = tmp_path / "workspace" - workspace.mkdir() - store = FileTapeStore(home, workspace) - - store.append("session", TapeEntry.event("command", {"raw": "echo hi"})) - archive = store.archive("session") - assert archive is not None - assert archive.exists() - assert store.read("session") is None - - -def test_tape_file_read_is_incremental(tmp_path: Path) -> None: - tape_path = tmp_path / "tape.jsonl" - tape_file = TapeFile(tape_path) - - tape_path.write_text( - '{"id":1,"kind":"message","payload":{"content":"one"},"meta":{}}\n', - encoding="utf-8", - ) - first = tape_file.read() - assert [entry.id for entry in first] == [1] - - with tape_path.open("a", encoding="utf-8") as handle: - handle.write('{"id":2,"kind":"message","payload":{"content":"two"},"meta":{}}\n') - second = tape_file.read() - assert [entry.id for entry in second] == [1, 2] - - -def test_tape_file_read_handles_truncated_file(tmp_path: Path) -> None: - tape_path = tmp_path / "tape.jsonl" - tape_file = TapeFile(tape_path) - - tape_path.write_text( - '{"id":1,"kind":"message","payload":{"content":"one"},"meta":{}}\n', - encoding="utf-8", - ) - assert [entry.id for entry in tape_file.read()] == [1] - - tape_path.write_text("", encoding="utf-8") - assert tape_file.read() == [] - - with tape_path.open("a", encoding="utf-8") as handle: - handle.write('{"id":1,"kind":"message","payload":{"content":"reset"},"meta":{}}\n') - after_truncate = tape_file.read() - assert [entry.payload["content"] for entry in after_truncate] == ["reset"] - - -def test_tape_file_append_increments_ids_without_intermediate_read(tmp_path: Path) -> None: - tape_path = tmp_path / "tape.jsonl" - tape_file = TapeFile(tape_path) - - tape_file.append(TapeEntry.message({"role": "user", "content": "one"})) - tape_file.append(TapeEntry.message({"role": "assistant", "content": "two"})) - tape_file.append(TapeEntry.message({"role": "assistant", "content": "three"})) - - entries = tape_file.read() - assert [entry.id for entry in entries] == [1, 2, 3] - - -def test_tape_file_append_uses_existing_tail_id(tmp_path: Path) -> None: - tape_path = tmp_path / "tape.jsonl" - tape_path.write_text( - '{"id":3,"kind":"message","payload":{"role":"user","content":"existing"},"meta":{}}\n', - encoding="utf-8", - ) - tape_file = TapeFile(tape_path) - - tape_file.append(TapeEntry.message({"role": "assistant", "content": "new"})) - - entries = tape_file.read() - assert [entry.id for entry in entries] == [3, 4] - - -def test_multi_forks_merge_keeps_entries_ordered(tmp_path: Path) -> None: - home = tmp_path / "home" - workspace = tmp_path / "workspace" - workspace.mkdir() - store = FileTapeStore(home, workspace) - - root_tape = "session" - store.append(root_tape, TapeEntry.message({"role": "user", "content": "root-1"})) - - fork_a = store.fork(root_tape) - fork_b = store.fork(root_tape) - - store.append(fork_a, TapeEntry.message({"role": "assistant", "content": "fork-a-1"})) - store.append(fork_a, TapeEntry.message({"role": "assistant", "content": "fork-a-2"})) - store.append(fork_b, TapeEntry.message({"role": "assistant", "content": "fork-b-1"})) - store.append(fork_b, TapeEntry.message({"role": "assistant", "content": "fork-b-2"})) - - store.merge(fork_b, root_tape) - store.merge(fork_a, root_tape) - - merged = store.read(root_tape) - assert merged is not None - - assert [entry.payload["content"] for entry in merged] == [ - "root-1", - "fork-b-1", - "fork-b-2", - "fork-a-1", - "fork-a-2", - ] - assert [entry.id for entry in merged] == [1, 2, 3, 4, 5] - assert store.read(fork_a) is None - assert store.read(fork_b) is None diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py deleted file mode 100644 index 81376da9..00000000 --- a/tests/test_telegram_channel.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import contextlib -from types import SimpleNamespace - -import pytest - -from bub.channels.telegram import TelegramChannel - - -class _Settings: - def __init__(self) -> None: - self.telegram_token = "test-token" # noqa: S105 - self.telegram_allow_from: list[str] = [] - self.telegram_allow_chats: list[str] = [] - self.telegram_proxy: str | None = None - - -class _Runtime: - def __init__(self) -> None: - self.settings = _Settings() - - -class DummyMessage: - def __init__(self, *, chat_id: int, text: str, message_id: int = 1) -> None: - self.chat_id = chat_id - self.text = text - self.message_id = message_id - self.replies: list[str] = [] - - async def reply_text(self, text: str) -> None: - self.replies.append(text) - - -@pytest.mark.asyncio -async def test_on_text_denies_chat_not_in_allowlist() -> None: - runtime = _Runtime() - runtime.settings.telegram_allow_chats = ["123"] - channel = TelegramChannel(runtime) # type: ignore[arg-type] - - message = DummyMessage(chat_id=999, text="hello") - update = SimpleNamespace( - message=message, - effective_user=SimpleNamespace(id=1, username="tester", full_name="Test User"), - ) - - await channel._on_text(update, None) # type: ignore[arg-type] - assert message.replies == [] - - -@pytest.mark.asyncio -async def test_on_text_invokes_receive_handler_for_allowed_message() -> None: - runtime = _Runtime() - runtime.settings.telegram_allow_chats = ["999"] - channel = TelegramChannel(runtime) # type: ignore[arg-type] - - message = DummyMessage(chat_id=999, text="hello") - update = SimpleNamespace( - message=message, - effective_user=SimpleNamespace(id=1, username="tester", full_name="Test User"), - ) - - received: list[object] = [] - - async def _on_receive(msg: object) -> None: - received.append(msg) - - channel._on_receive = _on_receive - - @contextlib.asynccontextmanager - async def _start_typing(_chat_id: str): - yield - - channel._start_typing = _start_typing # type: ignore[method-assign] - - await channel._on_text(update, None) # type: ignore[arg-type] - - assert message.replies == [] - assert received == [message] - - -@pytest.mark.asyncio -async def test_on_text_always_stops_typing() -> None: - runtime = _Runtime() - runtime.settings.telegram_allow_chats = ["999"] - channel = TelegramChannel(runtime) # type: ignore[arg-type] - - message = DummyMessage(chat_id=999, text="hello") - update = SimpleNamespace( - message=message, - effective_user=SimpleNamespace(id=1, username="tester", full_name="Test User"), - ) - - calls = {"start": 0, "stop": 0} - - @contextlib.asynccontextmanager - async def _start_typing(_chat_id: str): - calls["start"] += 1 - try: - yield - finally: - calls["stop"] += 1 - - async def _on_receive(_msg: object) -> None: - raise RuntimeError("receive failed") - - channel._on_receive = _on_receive - channel._start_typing = _start_typing # type: ignore[method-assign] - - with pytest.raises(RuntimeError, match="receive failed"): - await channel._on_text(update, None) # type: ignore[arg-type] - - assert calls == {"start": 1, "stop": 1} diff --git a/tests/test_telegram_filter.py b/tests/test_telegram_filter.py deleted file mode 100644 index 2781b1f5..00000000 --- a/tests/test_telegram_filter.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from types import SimpleNamespace - -from bub.channels.telegram import BubMessageFilter - - -@dataclass -class DummyUser: - id: int - - -@dataclass -class DummyEntity: - type: str - offset: int = 0 - length: int = 0 - user: DummyUser | None = None - - -class DummyMessage: - def __init__( - self, - *, - text: str, - chat_type: str, - bot_id: int = 1000, - bot_username: str = "BubBot", - entities: list[DummyEntity] | None = None, - reply_to_message: object | None = None, - caption: str | None = None, - photo: list[object] | None = None, - ) -> None: - self.text = text - self.caption = caption - self.photo = photo - self.chat = SimpleNamespace(type=chat_type) - self.entities = entities or [] - self.caption_entities = [] - self.reply_to_message = reply_to_message - self._bot_id = bot_id - self._bot_username = bot_username - - def get_bot(self) -> object: - return SimpleNamespace(id=self._bot_id, username=self._bot_username) - - -def test_group_allows_bot_prefix() -> None: - message = DummyMessage(text="/bot hello", chat_type="group") - assert BubMessageFilter().filter(message) is False - - -def test_group_allows_at_mention_by_username_entity() -> None: - message = DummyMessage( - text="@BubBot ping", - chat_type="supergroup", - entities=[DummyEntity(type="mention", offset=0, length=7)], - ) - assert BubMessageFilter().filter(message) is True - - -def test_group_allows_at_mention_by_text_mention_entity() -> None: - message = DummyMessage( - text="ping bot", - chat_type="group", - entities=[DummyEntity(type="text_mention", user=DummyUser(id=1000))], - ) - assert BubMessageFilter().filter(message) is True - - -def test_group_allows_reply_to_bot_message() -> None: - reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=1000)) - message = DummyMessage(text="reply", chat_type="group", reply_to_message=reply_to_message) - assert BubMessageFilter().filter(message) is True - - -def test_group_rejects_unrelated_text() -> None: - message = DummyMessage(text="hello world", chat_type="group") - assert BubMessageFilter().filter(message) is False - - -def test_private_allows_media_without_text() -> None: - message = DummyMessage(text="", chat_type="private", photo=[object()]) - assert BubMessageFilter().filter(message) is True - - -def test_private_rejects_non_bot_command() -> None: - message = DummyMessage(text="/start", chat_type="private") - assert BubMessageFilter().filter(message) is True - - -def test_private_allows_bub_command() -> None: - message = DummyMessage(text="/bub summarize", chat_type="private") - assert BubMessageFilter().filter(message) is True - - -def test_group_rejects_media_without_reply_or_mention() -> None: - message = DummyMessage(text="", chat_type="group", photo=[object()]) - assert BubMessageFilter().filter(message) is False diff --git a/tests/test_telegram_session_prompt.py b/tests/test_telegram_session_prompt.py deleted file mode 100644 index d7803e90..00000000 --- a/tests/test_telegram_session_prompt.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import json -from datetime import UTC, datetime -from types import SimpleNamespace - -import pytest - -from bub.channels.telegram import TelegramChannel - - -def _build_channel() -> TelegramChannel: - runtime = SimpleNamespace( - settings=SimpleNamespace( - telegram_token="token", # noqa: S106 - telegram_allow_from=[], - telegram_allow_chats=[], - telegram_proxy=None, - ) - ) - return TelegramChannel(runtime) - - -def _build_message(*, text: str = "hello", chat_id: int = 123, message_id: int = 10) -> SimpleNamespace: - user = SimpleNamespace(id=42, username="tester", full_name="Test User", is_bot=False) - return SimpleNamespace( - chat_id=chat_id, - chat=SimpleNamespace(type="private"), - message_id=message_id, - text=text, - caption=None, - date=datetime(2026, 1, 1, tzinfo=UTC), - from_user=user, - reply_to_message=None, - photo=None, - audio=None, - sticker=None, - video=None, - voice=None, - document=None, - video_note=None, - ) - - -@pytest.mark.asyncio -async def test_get_session_prompt_wraps_text_with_notice_and_metadata() -> None: - channel = _build_channel() - message = _build_message(text="hello world") - - session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] - - assert session_id == "telegram:123" - data = json.loads(prompt) - assert data["message"] == "hello world" - assert data["chat_id"] == "123" - assert data["message_id"] == 10 - assert data["type"] == "text" - assert data["sender_id"] == "42" - assert data["sender_is_bot"] is False - - -@pytest.mark.asyncio -async def test_get_session_prompt_returns_raw_for_comma_command() -> None: - channel = _build_channel() - message = _build_message(text=",status") - - session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] - - assert session_id == "telegram:123" - assert prompt == ",status" - - -@pytest.mark.asyncio -async def test_get_session_prompt_includes_reply_metadata() -> None: - channel = _build_channel() - message = _build_message(text="replying") - message.reply_to_message = SimpleNamespace( - message_id=99, - text="original", - from_user=SimpleNamespace(id=1000, username="bot", is_bot=True), - ) - - _session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] - data = json.loads(prompt) - reply = data["reply_to_message"] - assert reply["message_id"] == 99 - assert reply["from_user_id"] == 1000 - assert reply["from_username"] == "bot" - assert reply["from_is_bot"] is True diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py deleted file mode 100644 index 104a3ebf..00000000 --- a/tests/test_tool_registry.py +++ /dev/null @@ -1,100 +0,0 @@ -import pytest -from republic import ToolContext - -from bub.tools.registry import ToolRegistry - - -@pytest.mark.asyncio -async def test_registry_logs_once_for_execute(monkeypatch) -> None: - logs: list[str] = [] - - def _capture(message: str, *args: object) -> None: - logs.append(message) - - monkeypatch.setattr("bub.tools.registry.logger.info", _capture) - monkeypatch.setattr("bub.tools.registry.logger.exception", _capture) - - registry = ToolRegistry() - - @registry.register(name="math.add", short_description="add", detail="add") - def add(*, a: int, b: int) -> int: - return a + b - - result = await registry.execute("math.add", kwargs={"a": 1, "b": 2}) - assert result == 3 - assert logs.count("tool.call.start name={} {{ {} }}") == 1 - assert logs.count("tool.call.end name={} duration={:.3f}ms") == 1 - - -@pytest.mark.asyncio -async def test_registry_logs_for_direct_tool_run_with_context(monkeypatch) -> None: - logs: list[str] = [] - - def _capture(message: str, *args: object) -> None: - logs.append(message) - - monkeypatch.setattr("bub.tools.registry.logger.info", _capture) - monkeypatch.setattr("bub.tools.registry.logger.exception", _capture) - - registry = ToolRegistry() - - @registry.register(name="fs.ctx", short_description="ctx", detail="ctx", context=True) - def handle(*, context: ToolContext, path: str) -> str: - return f"{context.run_id}:{path}" - - descriptor = registry.get("fs.ctx") - assert descriptor is not None - - output = await descriptor.tool.run(context=ToolContext(tape="t1", run_id="r1"), path="README.md") - assert output == "r1:README.md" - assert logs.count("tool.call.start name={} {{ {} }}") == 1 - assert logs.count("tool.call.end name={} duration={:.3f}ms") == 1 - - -@pytest.mark.asyncio -async def test_registry_execute_context_tool_should_work() -> None: - registry = ToolRegistry() - - @registry.register(name="ctx.echo", short_description="echo", detail="echo", context=True) - def echo(*, context: ToolContext, value: str) -> str: - return f"{context.run_id}:{value}" - - ctx = ToolContext(tape="t1", run_id="r1") - out = await registry.execute("ctx.echo", kwargs={"value": "hi"}, context=ctx) - assert out == "r1:hi" - - -@pytest.mark.asyncio -async def test_registry_model_tools_use_underscore_names_and_keep_handlers() -> None: - registry = ToolRegistry() - - @registry.register(name="fs.read", short_description="read", detail="read") - def read(*, path: str) -> str: - return f"read:{path}" - - rows = registry.compact_rows(for_model=True) - assert rows == ["fs_read (command: fs.read): read"] - - model_tools = registry.model_tools() - assert [tool.name for tool in model_tools] == ["fs_read"] - assert await model_tools[0].run(path="README.md") == "read:README.md" - - -def test_registry_model_tool_name_conflict_raises_error() -> None: - registry = ToolRegistry() - - registry.register(name="fs.read", short_description="dot", detail="dot")(lambda: "dot") - registry.register(name="fs_read", short_description="underscore", detail="underscore")(lambda: "underscore") - - with pytest.raises(ValueError, match="Duplicate model tool name"): - registry.model_tools() - - -def test_registry_restrict_to_matches_command_and_model_names() -> None: - registry = ToolRegistry({"fs_read"}) - - registry.register(name="fs.read", short_description="read", detail="read")(lambda: "read") - registry.register(name="web.search", short_description="search", detail="search")(lambda: "search") - - assert registry.get("fs.read") is not None - assert registry.get("web.search") is None diff --git a/tests/test_tools_builtin.py b/tests/test_tools_builtin.py deleted file mode 100644 index 048c151b..00000000 --- a/tests/test_tools_builtin.py +++ /dev/null @@ -1,442 +0,0 @@ -import asyncio -import inspect -import json -import re -from collections.abc import Iterator -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import pytest -from apscheduler.schedulers.background import BackgroundScheduler -from republic import ToolContext - -from bub.config.settings import Settings -from bub.tools.builtin import register_builtin_tools -from bub.tools.registry import ToolRegistry - - -@dataclass -class _TapeInfo: - name: str = "bub" - entries: int = 0 - anchors: int = 0 - last_anchor: str | None = None - entries_since_last_anchor: int = 0 - - -class _DummyTape: - async def handoff(self, _name: str, *, state: dict[str, object] | None = None) -> list[object]: - _ = state - return [] - - async def anchors(self, *, limit: int = 20) -> list[object]: - _ = limit - return [] - - async def info(self) -> _TapeInfo: - return _TapeInfo() - - async def search(self, _query: str, *, limit: int = 20) -> list[object]: - _ = limit - return [] - - async def reset(self, *, archive: bool = False) -> str: - _ = archive - return "reset" - - -class _DummyRuntime: - def __init__(self, settings: Settings, scheduler: BackgroundScheduler) -> None: - self.settings = settings - self.scheduler = scheduler - self._discovered_skills: list[object] = [] - self.reset_calls: list[str] = [] - self.workspace = Path.cwd() - - def discover_skills(self) -> list[object]: - return list(self._discovered_skills) - - def reset_session_context(self, session_id: str) -> None: - self.reset_calls.append(session_id) - - -def _build_registry(workspace: Path, settings: Settings, scheduler: BackgroundScheduler) -> ToolRegistry: - registry = ToolRegistry() - runtime = _DummyRuntime(settings, scheduler) - register_builtin_tools( - registry, - workspace=workspace, - tape=_DummyTape(), # type: ignore[arg-type] - runtime=runtime, # type: ignore[arg-type] - ) - return registry - - -def _execute_tool( - registry: ToolRegistry, - name: str, - *, - kwargs: dict[str, Any], - session_id: str = "cli:test", -) -> Any: - descriptor = registry.get(name) - context = ToolContext(tape="test", run_id="test-run", state={"session_id": session_id}) - if descriptor is not None and descriptor.tool.context: - result = descriptor.tool.run(context=context, **kwargs) - else: - result = registry.execute(name, kwargs=kwargs) - if inspect.isawaitable(result): - return asyncio.run(result) - return result - - -@pytest.fixture -def scheduler() -> Iterator[BackgroundScheduler]: - scheduler = BackgroundScheduler(daemon=True) - scheduler.start() - yield scheduler - scheduler.shutdown(wait=False) - - -def test_web_search_default_returns_duckduckgo_url(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "web.search", kwargs={"query": "psiace bub"}) - assert result == "https://duckduckgo.com/?q=psiace+bub" - - -def test_web_fetch_default_normalizes_url_and_extracts_text( - tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler -) -> None: - observed_urls: list[str] = [] - - class _Response: - class _Content: - @staticmethod - async def read(_size: int | None = None) -> bytes: - return b"

Title

Hello world.

" - - content = _Content() - - class _RequestCtx: - def __init__(self, response: _Response) -> None: - self._response = response - - async def __aenter__(self) -> _Response: - return self._response - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - class _Session: - async def __aenter__(self) -> "_Session": - return self - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - def get(self, url: str, *, headers: dict[str, str]) -> _RequestCtx: - _ = headers - observed_urls.append(url) - return _RequestCtx(_Response()) - - monkeypatch.setattr("aiohttp.ClientSession", lambda *args, **kwargs: _Session()) - - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "web.fetch", kwargs={"url": "example.com"}) - - assert observed_urls == ["https://example.com"] - assert "Title" in result - assert "Hello world." in result - - -def test_web_search_ollama_mode_calls_api(tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler) -> None: - observed_request: dict[str, str] = {} - - class _Response: - @staticmethod - async def text() -> str: - payload = { - "results": [ - { - "title": "Example", - "url": "https://example.com", - "content": "Example snippet", - } - ] - } - return json.dumps(payload) - - class _RequestCtx: - def __init__(self, response: _Response) -> None: - self._response = response - - async def __aenter__(self) -> _Response: - return self._response - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - class _Session: - async def __aenter__(self) -> "_Session": - return self - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - def post(self, url: str, *, json: dict[str, object], headers: dict[str, str]) -> _RequestCtx: - import json as json_lib - - observed_request["url"] = url - observed_request["auth"] = headers.get("Authorization", "") - observed_request["payload"] = json_lib.dumps(json) - return _RequestCtx(_Response()) - - monkeypatch.setattr("aiohttp.ClientSession", lambda *args, **kwargs: _Session()) - - settings = Settings( - _env_file=None, - model="openrouter:test", - ollama_api_key="ollama-test-key", - ollama_api_base="https://search.ollama.test/api", - ) - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "web.search", kwargs={"query": "test query", "max_results": 3}) - - assert observed_request["url"] == "https://search.ollama.test/api/web_search" - assert observed_request["auth"] == "Bearer ollama-test-key" - assert json.loads(observed_request["payload"]) == {"query": "test query", "max_results": 3} - assert "Example" in result - assert "https://example.com" in result - assert "Example snippet" in result - - -def test_web_fetch_ollama_mode_normalizes_url_and_extracts_text( - tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler -) -> None: - observed_urls: list[str] = [] - - class _Response: - class _Content: - @staticmethod - async def read(_size: int | None = None) -> bytes: - return b"

Title

Hello world.

" - - content = _Content() - - class _RequestCtx: - def __init__(self, response: _Response) -> None: - self._response = response - - async def __aenter__(self) -> _Response: - return self._response - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - class _Session: - async def __aenter__(self) -> "_Session": - return self - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - def get(self, url: str, *, headers: dict[str, str]) -> _RequestCtx: - _ = headers - observed_urls.append(url) - return _RequestCtx(_Response()) - - monkeypatch.setattr("aiohttp.ClientSession", lambda *args, **kwargs: _Session()) - - settings = Settings( - _env_file=None, - model="openrouter:test", - ollama_api_key="ollama-test-key", - ) - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "web.fetch", kwargs={"url": "example.com"}) - - assert observed_urls == ["https://example.com"] - assert "Title" in result - assert "Hello world." in result - - -def test_schedule_add_list_remove_roundtrip(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - - add_result = _execute_tool( - registry, - "schedule.add", - kwargs={ - "cron": "*/5 * * * *", - "message": "hello", - }, - ) - assert add_result.startswith("scheduled: ") - matched = re.match(r"^scheduled: (?P[a-z0-9-]+) next=.*$", add_result) - assert matched is not None - job_id = matched.group("job_id") - - list_result = _execute_tool(registry, "schedule.list", kwargs={}) - assert job_id in list_result - assert "msg=hello" in list_result - - remove_result = _execute_tool(registry, "schedule.remove", kwargs={"job_id": job_id}) - assert remove_result == f"removed: {job_id}" - - assert _execute_tool(registry, "schedule.list", kwargs={}) == "(no scheduled jobs)" - - -def test_schedule_add_rejects_invalid_cron(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - - try: - _execute_tool( - registry, - "schedule.add", - kwargs={"cron": "* * *", "message": "bad"}, - ) - raise AssertionError("expected RuntimeError") - except RuntimeError as exc: - assert "invalid cron expression" in str(exc) - - -def test_schedule_remove_missing_job_returns_error(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - - try: - _execute_tool(registry, "schedule.remove", kwargs={"job_id": "missing"}) - raise AssertionError("expected RuntimeError") - except RuntimeError as exc: - assert "job not found: missing" in str(exc) - - -def test_schedule_shared_scheduler_across_registries(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - - settings = Settings(_env_file=None, model="openrouter:test") - registry_a = _build_registry(workspace, settings, scheduler) - registry_b = _build_registry(workspace, settings, scheduler) - - add_result = _execute_tool( - registry_a, - "schedule.add", - kwargs={"cron": "*/5 * * * *", "message": "from-a"}, - ) - matched = re.match(r"^scheduled: (?P[a-z0-9-]+) next=.*$", add_result) - assert matched is not None - - assert matched.group("job_id") in _execute_tool(registry_b, "schedule.list", kwargs={}) - - -def test_skills_list_uses_latest_runtime_skills(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - @dataclass(frozen=True) - class _Skill: - name: str - description: str - - class _Runtime: - def __init__(self, settings: Settings, scheduler: BackgroundScheduler) -> None: - self.settings = settings - self.scheduler = scheduler - self._discovered_skills: list[_Skill] = [_Skill(name="alpha", description="first")] - - def discover_skills(self) -> list[_Skill]: - return list(self._discovered_skills) - - settings = Settings(_env_file=None, model="openrouter:test") - runtime = _Runtime(settings, scheduler) - registry = ToolRegistry() - register_builtin_tools( - registry, - workspace=tmp_path, - tape=_DummyTape(), # type: ignore[arg-type] - runtime=runtime, # type: ignore[arg-type] - ) - - assert _execute_tool(registry, "skills.list", kwargs={}) == "alpha: first" - - runtime._discovered_skills.append(_Skill(name="beta", description="second")) - second = _execute_tool(registry, "skills.list", kwargs={}) - assert "alpha: first" in second - assert "beta: second" in second - - -def test_bash_tool_inherits_runtime_session_id( - tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler -) -> None: - observed: dict[str, object] = {} - - class _Completed: - returncode = 0 - - @staticmethod - async def communicate() -> tuple[bytes, bytes]: - return b"ok", b"" - - async def _fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> _Completed: - observed["args"] = args - observed["kwargs"] = kwargs - return _Completed() - - monkeypatch.setattr("bub.tools.builtin.asyncio.create_subprocess_exec", _fake_create_subprocess_exec) - - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "bash", kwargs={"cmd": "echo hi"}) - - assert result == "ok" - kwargs = observed["kwargs"] - assert isinstance(kwargs, dict) - assert kwargs["env"]["BUB_SESSION_ID"] == "cli:test" - - -def test_bash_handles_non_utf8_output(tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler) -> None: - class _Completed: - returncode = 0 - - @staticmethod - async def communicate() -> tuple[bytes, bytes]: - # GBK-encoded bytes that cannot be decoded as UTF-8 - return "微软".encode("gbk"), b"" - - async def _fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> _Completed: - _ = (args, kwargs) - return _Completed() - - monkeypatch.setattr("bub.tools.builtin.asyncio.create_subprocess_exec", _fake_create_subprocess_exec) - - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "bash", kwargs={"cmd": "echo hello"}) - - # Should contain replacement character instead of raising UnicodeDecodeError - assert "�" in result - - -def test_tape_reset_also_clears_session_runtime_context(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - runtime = _DummyRuntime(settings, scheduler) - registry = ToolRegistry() - register_builtin_tools( - registry, - workspace=tmp_path, - tape=_DummyTape(), # type: ignore[arg-type] - runtime=runtime, # type: ignore[arg-type] - ) - - result = _execute_tool(registry, "tape.reset", kwargs={"archive": True}, session_id="telegram:123") - assert result == "reset" - assert runtime.reset_calls == ["telegram:123"] diff --git a/tests/test_tools_schedule.py b/tests/test_tools_schedule.py deleted file mode 100644 index c0f3cf59..00000000 --- a/tests/test_tools_schedule.py +++ /dev/null @@ -1,41 +0,0 @@ -import subprocess -import sys -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -from bub.tools.schedule import SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS, run_scheduled_reminder - - -def test_run_scheduled_reminder_invokes_bub_run(monkeypatch: Any, tmp_path: Path) -> None: - observed: dict[str, object] = {} - - def _fake_run(command: list[str], **kwargs: Any) -> Any: - observed["command"] = command - observed["kwargs"] = kwargs - return SimpleNamespace(returncode=0, stderr="", stdout="") - - monkeypatch.setattr("bub.tools.schedule.subprocess.run", _fake_run) - - run_scheduled_reminder("remind me", "telegram:42") - - assert observed["command"] == [ - sys.executable, - "-m", - "bub.cli.app", - "run", - "--session-id", - "telegram:42", - "[Reminder for Telegram chat 42, after done, send a notice to this chat if necessary]\nremind me", - ] - assert observed["kwargs"] == {"check": True, "cwd": None, "timeout": SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS} - - -def test_run_scheduled_reminder_handles_timeout(monkeypatch: Any) -> None: - def _fake_run(command: list[str], **kwargs: Any) -> Any: - _ = kwargs - raise subprocess.TimeoutExpired(cmd=command, timeout=1) - - monkeypatch.setattr("bub.tools.schedule.subprocess.run", _fake_run) - - run_scheduled_reminder("remind me", "telegram:42") diff --git a/uv.lock b/uv.lock index 211f9f5e..0b288713 100644 --- a/uv.lock +++ b/uv.lock @@ -3,115 +3,16 @@ revision = 3 requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version < '3.14'", ] [[package]] -name = "aiohappyeyeballs" -version = "2.6.1" +name = "annotated-doc" +version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -125,7 +26,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.79.0" +version = "0.84.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -137,14 +38,14 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/b1/91aea3f8fd180d01d133d931a167a78a3737b3fd39ccef2ae8d6619c24fd/anthropic-0.79.0.tar.gz", hash = "sha256:8707aafb3b1176ed6c13e2b1c9fb3efddce90d17aee5d8b83a86c70dcdcca871", size = 509825, upload-time = "2026-02-07T18:06:18.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/b2/cc0b8e874a18d7da50b0fda8c99e4ac123f23bf47b471827c5f6f3e4a767/anthropic-0.79.0-py3-none-any.whl", hash = "sha256:04cbd473b6bbda4ca2e41dd670fe2f829a911530f01697d0a1e37321eb75f3cf", size = 405918, upload-time = "2026-02-07T18:06:20.246Z" }, + { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, ] [[package]] name = "any-llm-sdk" -version = "1.8.0" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -154,18 +55,15 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/4a/9aaf246e535d4d9d59992ec9fb4c4008aeb450e5f47c87c891c7c31b2514/any_llm_sdk-1.8.0.tar.gz", hash = "sha256:4c4fc65459fa749d10cb051e116eaf4b36918e85d0ae8cd0c619862c3a442fda", size = 128310, upload-time = "2026-02-04T11:13:31.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/eb/34717209925bf9beac79eb729ac322a751bb3fce63a13741558a333bd97a/any_llm_sdk-1.9.0.tar.gz", hash = "sha256:ebce4aedb22fa944110fa58dcb98f3b788779a7862797622a17bc113d8743ead", size = 136181, upload-time = "2026-03-03T16:32:36.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/af/84ec19d7e5af2fa2ddf9762652ff68ed5d80bd0c74db3c291376dd10ecb0/any_llm_sdk-1.8.0-py3-none-any.whl", hash = "sha256:059b8216ef695f05dfc3cc0eee2f29c35160474c4ded4c8251d6b32dcd1928b1", size = 181447, upload-time = "2026-02-04T11:13:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ba/df83b28ab9f1195cb3da7a332f02d44b9c4c6249ac96e6a5a48ab2d97aa4/any_llm_sdk-1.9.0-py3-none-any.whl", hash = "sha256:4683261c407b15cd6eb7ee8f6f6d9fe9e844f37b58747334db21e9f0afbe4542", size = 191821, upload-time = "2026-03-03T16:32:35.004Z" }, ] [package.optional-dependencies] anthropic = [ { name = "anthropic" }, ] -vertexai = [ - { name = "google-genai" }, -] [[package]] name = "anyio" @@ -180,105 +78,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] -[[package]] -name = "apscheduler" -version = "3.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzlocal" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - -[[package]] -name = "audioop-lts" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, - { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, - { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, - { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, - { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, - { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, - { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, - { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, - { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, - { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, - { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, - { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, - { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, - { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, - { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, - { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, - { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, - { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, - { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, - { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, - { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, - { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, - { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, - { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, - { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, - { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, - { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, -] - [[package]] name = "bub" -version = "0.2.3" +version = "0.3.0" source = { editable = "." } dependencies = [ - { name = "aiohttp" }, - { name = "any-llm-sdk", extra = ["anthropic", "vertexai"] }, - { name = "apscheduler" }, - { name = "discord-py" }, - { name = "httpx", extra = ["socks"] }, + { name = "any-llm-sdk", extra = ["anthropic"] }, { name = "loguru" }, + { name = "pluggy" }, { name = "prompt-toolkit" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "python-dotenv" }, { name = "python-telegram-bot" }, { name = "pyyaml" }, { name = "rapidfuzz" }, { name = "republic" }, - { name = "requests" }, { name = "rich" }, - { name = "telegramify-markdown" }, { name = "typer" }, ] @@ -299,23 +114,17 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.13.3" }, - { name = "any-llm-sdk", extras = ["anthropic", "vertexai"], specifier = ">=1.8.0" }, - { name = "apscheduler", specifier = ">=3.11.2" }, - { name = "discord-py", specifier = ">=2.6.4" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, + { name = "any-llm-sdk", extras = ["anthropic"] }, { name = "loguru", specifier = ">=0.7.2" }, + { name = "pluggy", specifier = ">=1.6.0" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "rapidfuzz", specifier = ">=3.14.1" }, - { name = "republic", specifier = ">=0.5.2" }, - { name = "requests", specifier = ">=2.32.5" }, + { name = "rapidfuzz", specifier = ">=3.14.3" }, + { name = "republic", specifier = ">=0.5.3" }, { name = "rich", specifier = ">=13.0.0" }, - { name = "telegramify-markdown", specifier = ">=0.5.4" }, { name = "typer", specifier = ">=0.9.0" }, ] @@ -336,143 +145,20 @@ dev = [ [[package]] name = "cachetools" -version = "7.0.0" +version = "7.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/af/df70e9b65bc77a1cbe0768c0aa4617147f30f8306ded98c1744bcdc0ae1e/cachetools-7.0.0.tar.gz", hash = "sha256:a9abf18ff3b86c7d05b27ead412e235e16ae045925e531fae38d5fada5ed5b08", size = 35796, upload-time = "2026-02-01T18:59:47.411Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/c7/342b33cc6877eebc6c9bb45cb9f78e170e575839699f6f3cc96050176431/cachetools-7.0.2.tar.gz", hash = "sha256:7e7f09a4ca8b791d8bb4864afc71e9c17e607a28e6839ca1a644253c97dbeae0", size = 36983, upload-time = "2026-03-02T19:45:16.926Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/df/2dd32cce20cbcf6f2ec456b58d44368161ad28320729f64e5e1d5d7bd0ae/cachetools-7.0.0-py3-none-any.whl", hash = "sha256:d52fef60e6e964a1969cfb61ccf6242a801b432790fe520d78720d757c81cbd2", size = 13487, upload-time = "2026-02-01T18:59:45.981Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/4b6968e77c110f12da96fdbfcb39c6557c2e5e81bd7afcf8ed893d5bc588/cachetools-7.0.2-py3-none-any.whl", hash = "sha256:938dcad184827c5e94928c4fd5526e2b46692b7fb1ae94472da9131d0299343c", size = 13793, upload-time = "2026-03-02T19:45:15.495Z" }, ] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "chardet" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -496,72 +182,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "cryptography" -version = "46.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, -] - -[[package]] -name = "discord-py" -version = "2.6.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623, upload-time = "2025-10-08T21:45:43.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -591,100 +211,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, -] - -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +version = "3.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] [[package]] @@ -700,55 +231,11 @@ wheels = [ ] [[package]] -name = "google-auth" -version = "2.48.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, -] - -[package.optional-dependencies] -requests = [ - { name = "requests" }, -] - -[[package]] -name = "google-genai" -version = "1.65.0" +name = "griffelib" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "google-auth", extra = ["requests"] }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sniffio" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/f9/cc1191c2540d6a4e24609a586c4ed45d2db57cfef47931c139ee70e5874a/google_genai-1.65.0.tar.gz", hash = "sha256:d470eb600af802d58a79c7f13342d9ea0d05d965007cae8f76c7adff3d7a4750", size = 497206, upload-time = "2026-02-26T00:20:33.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/3c/3fea4e7c91357c71782d7dcaad7a2577d636c90317e003386893c25bc62c/google_genai-1.65.0-py3-none-any.whl", hash = "sha256:68c025205856919bc03edb0155c11b4b833810b7ce17ad4b7a9eeba5158f6c44", size = 724429, upload-time = "2026-02-26T00:20:32.186Z" }, -] - -[[package]] -name = "griffe" -version = "1.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] [[package]] @@ -788,11 +275,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[package.optional-dependencies] -socks = [ - { name = "socksio" }, -] - [[package]] name = "idna" version = "3.11" @@ -893,54 +375,62 @@ wheels = [ [[package]] name = "librt" -version = "0.7.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, - { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, - { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, - { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, - { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, - { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, - { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, - { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, - { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, - { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, - { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, - { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, - { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, - { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, - { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, - { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, - { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, - { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, - { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, - { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, - { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, - { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] [[package]] @@ -958,11 +448,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10.1" +version = "3.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] @@ -1058,15 +548,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] -[[package]] -name = "mistletoe" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/96/ea46a376a7c4cd56955ecdfff0ea68de43996a4e6d1aee4599729453bd11/mistletoe-1.4.0.tar.gz", hash = "sha256:1630f906e5e4bbe66fdeb4d29d277e2ea515d642bb18a9b49b136361a9818c9d", size = 107203, upload-time = "2024-07-14T10:17:35.212Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/0f/b5e545f0c7962be90366af3418989b12cf441d9da1e5d89d88f2f3e5cf8f/mistletoe-1.4.0-py3-none-any.whl", hash = "sha256:44a477803861de1237ba22e375c6b617690a31d2902b47279d1f8f7ed498a794", size = 51304, upload-time = "2024-07-14T10:17:33.243Z" }, -] - [[package]] name = "mkdocs" version = "1.6.1" @@ -1093,16 +574,16 @@ wheels = [ [[package]] name = "mkdocs-autorefs" -version = "1.4.3" +version = "1.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, ] [[package]] @@ -1159,115 +640,16 @@ python = [ [[package]] name = "mkdocstrings-python" -version = "2.0.1" +version = "2.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffe" }, + { name = "griffelib" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, -] - -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, ] [[package]] @@ -1314,7 +696,7 @@ wheels = [ [[package]] name = "openai" -version = "2.17.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1326,9 +708,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/a2/677f22c4b487effb8a09439fb6134034b5f0a39ca27df8b95fac23a93720/openai-2.17.0.tar.gz", hash = "sha256:47224b74bd20f30c6b0a6a329505243cb2f26d5cf84d9f8d0825ff8b35e9c999", size = 631445, upload-time = "2026-02-05T16:27:40.953Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/97/284535aa75e6e84ab388248b5a323fc296b1f70530130dee37f7f4fbe856/openai-2.17.0-py3-none-any.whl", hash = "sha256:4f393fd886ca35e113aac7ff239bcd578b81d8f104f5aedc7d3693eb2af1d338", size = 1069524, upload-time = "2026-02-05T16:27:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] @@ -1363,11 +745,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] @@ -1381,26 +763,26 @@ wheels = [ [[package]] name = "prek" -version = "0.3.2" +version = "0.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/f5/ee52def928dd1355c20bcfcf765e1e61434635c33f3075e848e7b83a157b/prek-0.3.2.tar.gz", hash = "sha256:dce0074ff1a21290748ca567b4bda7553ee305a8c7b14d737e6c58364a499364", size = 334229, upload-time = "2026-02-06T13:49:47.539Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/51/2324eaad93a4b144853ca1c56da76f357d3a70c7b4fd6659e972d7bb8660/prek-0.3.4.tar.gz", hash = "sha256:56a74d02d8b7dfe3c774ecfcd8c1b4e5f1e1b84369043a8003e8e3a779fce72d", size = 356633, upload-time = "2026-02-28T03:47:13.452Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/69/70a5fc881290a63910494df2677c0fb241d27cfaa435bbcd0de5cd2e2443/prek-0.3.2-py3-none-linux_armv6l.whl", hash = "sha256:4f352f9c3fc98aeed4c8b2ec4dbf16fc386e45eea163c44d67e5571489bd8e6f", size = 4614960, upload-time = "2026-02-06T13:50:05.818Z" }, - { url = "https://files.pythonhosted.org/packages/c0/15/a82d5d32a2207ccae5d86ea9e44f2b93531ed000faf83a253e8d1108e026/prek-0.3.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4a000cfbc3a6ec7d424f8be3c3e69ccd595448197f92daac8652382d0acc2593", size = 4622889, upload-time = "2026-02-06T13:49:53.662Z" }, - { url = "https://files.pythonhosted.org/packages/89/75/ea833b58a12741397017baef9b66a6e443bfa8286ecbd645d14111446280/prek-0.3.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5436bdc2702cbd7bcf9e355564ae66f8131211e65fefae54665a94a07c3d450a", size = 4239653, upload-time = "2026-02-06T13:50:02.88Z" }, - { url = "https://files.pythonhosted.org/packages/10/b4/d9c3885987afac6e20df4cb7db14e3b0d5a08a77ae4916488254ebac4d0b/prek-0.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0161b5f584f9e7f416d6cf40a17b98f17953050ff8d8350ec60f20fe966b86b6", size = 4595101, upload-time = "2026-02-06T13:49:49.813Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/1a06473ed83dbc898de22838abdb13954e2583ce229f857f61828384634c/prek-0.3.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e641e8533bca38797eebb49aa89ed0e8db0e61225943b27008c257e3af4d631", size = 4521978, upload-time = "2026-02-06T13:49:41.266Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5e/c38390d5612e6d86b32151c1d2fdab74a57913473193591f0eb00c894c21/prek-0.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfca1810d49d3f9ef37599c958c4e716bc19a1d78a7e88cbdcb332e0b008994f", size = 4829108, upload-time = "2026-02-06T13:49:44.598Z" }, - { url = "https://files.pythonhosted.org/packages/80/a6/cecce2ab623747ff65ed990bb0d95fa38449ee19b348234862acf9392fff/prek-0.3.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d69d754299a95a85dc20196f633232f306bee7e7c8cba61791f49ce70404ec", size = 5357520, upload-time = "2026-02-06T13:49:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/a5/18/d6bcb29501514023c76d55d5cd03bdbc037737c8de8b6bc41cdebfb1682c/prek-0.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:539dcb90ad9b20837968539855df6a29493b328a1ae87641560768eed4f313b0", size = 4852635, upload-time = "2026-02-06T13:49:58.347Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0a/ae46f34ba27ba87aea5c9ad4ac9cd3e07e014fd5079ae079c84198f62118/prek-0.3.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1998db3d0cbe243984736c82232be51318f9192e2433919a6b1c5790f600b5fd", size = 4599484, upload-time = "2026-02-06T13:49:43.296Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a9/73bfb5b3f7c3583f9b0d431924873928705cdef6abb3d0461c37254a681b/prek-0.3.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:07ab237a5415a3e8c0db54de9d63899bcd947624bdd8820d26f12e65f8d19eb7", size = 4657694, upload-time = "2026-02-06T13:50:01.074Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/0994bc176e1a80110fad3babce2c98b0ac4007630774c9e18fc200a34781/prek-0.3.2-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0ced19701d69c14a08125f14a5dd03945982edf59e793c73a95caf4697a7ac30", size = 4509337, upload-time = "2026-02-06T13:49:54.891Z" }, - { url = "https://files.pythonhosted.org/packages/f9/13/e73f85f65ba8f626468e5d1694ab3763111513da08e0074517f40238c061/prek-0.3.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ffb28189f976fa111e770ee94e4f298add307714568fb7d610c8a7095cb1ce59", size = 4697350, upload-time = "2026-02-06T13:50:04.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/47/98c46dcd580305b9960252a4eb966f1a7b1035c55c363f378d85662ba400/prek-0.3.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f63134b3eea14421789a7335d86f99aee277cb520427196f2923b9260c60e5c5", size = 4955860, upload-time = "2026-02-06T13:49:56.581Z" }, - { url = "https://files.pythonhosted.org/packages/73/42/1bb4bba3ff47897df11e9dfd774027cdfa135482c961a54e079af0faf45a/prek-0.3.2-py3-none-win32.whl", hash = "sha256:58c806bd1344becd480ef5a5ba348846cc000af0e1fbe854fef91181a2e06461", size = 4267619, upload-time = "2026-02-06T13:49:39.503Z" }, - { url = "https://files.pythonhosted.org/packages/97/11/6665f47a7c350d83de17403c90bbf7a762ef50876ece456a86f64f46fbfb/prek-0.3.2-py3-none-win_amd64.whl", hash = "sha256:70114b48e9eb8048b2c11b4c7715ce618529c6af71acc84dd8877871a2ef71a6", size = 4624324, upload-time = "2026-02-06T13:49:45.922Z" }, - { url = "https://files.pythonhosted.org/packages/22/e7/740997ca82574d03426f897fd88afe3fc8a7306b8c7ea342a8bc1c538488/prek-0.3.2-py3-none-win_arm64.whl", hash = "sha256:9144d176d0daa2469a25c303ef6f6fa95a8df015eb275232f5cb53551ecefef0", size = 4336008, upload-time = "2026-02-06T13:49:52.27Z" }, + { url = "https://files.pythonhosted.org/packages/09/20/1a964cb72582307c2f1dc7f583caab90f42810ad41551e5220592406a4c3/prek-0.3.4-py3-none-linux_armv6l.whl", hash = "sha256:c35192d6e23fe7406bd2f333d1c7dab1a4b34ab9289789f453170f33550aa74d", size = 4641915, upload-time = "2026-02-28T03:47:03.772Z" }, + { url = "https://files.pythonhosted.org/packages/c5/cb/4a21f37102bac37e415b61818344aa85de8d29a581253afa7db8c08d5a33/prek-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f784d78de72a8bbe58a5fe7bde787c364ae88f0aff5222c5c5c7287876c510a", size = 4649166, upload-time = "2026-02-28T03:47:06.164Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/a7c0d117a098d57931428bdb60fcb796e0ebc0478c59288017a2e22eca96/prek-0.3.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50a43f522625e8c968e8c9992accf9e29017abad6c782d6d176b73145ad680b7", size = 4274422, upload-time = "2026-02-28T03:46:59.356Z" }, + { url = "https://files.pythonhosted.org/packages/59/84/81d06df1724d09266df97599a02543d82fde7dfaefd192f09d9b2ccb092f/prek-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4bbb1d3912a88935f35c6ba4466b4242732e3e3a8c608623c708e83cea85de00", size = 4629873, upload-time = "2026-02-28T03:46:56.419Z" }, + { url = "https://files.pythonhosted.org/packages/09/cd/bb0aefa25cfacd8dbced75b9a9d9945707707867fa5635fb69ae1bbc2d88/prek-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca4d4134db8f6e8de3c418317becdf428957e3cab271807f475318105fd46d04", size = 4552507, upload-time = "2026-02-28T03:47:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/578a7af4861afb64ec81c03bfdcc1bb3341bb61f2fff8a094ecf13987a56/prek-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fb6395f6eb76133bb1e11fc718db8144522466cdc2e541d05e7813d1bbcae7d", size = 4865929, upload-time = "2026-02-28T03:47:09.231Z" }, + { url = "https://files.pythonhosted.org/packages/fc/48/f169406590028f7698ef2e1ff5bffd92ca05e017636c1163a2f5ef0f8275/prek-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae17813239ddcb4ae7b38418de4d49afff740f48f8e0556029c96f58e350412", size = 5390286, upload-time = "2026-02-28T03:47:10.796Z" }, + { url = "https://files.pythonhosted.org/packages/05/c5/98a73fec052059c3ae06ce105bef67caca42334c56d84e9ef75df72ba152/prek-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a621a690d9c127afc3d21c275030d364d1fbef3296c095068d3ae80a59546e", size = 4891028, upload-time = "2026-02-28T03:47:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b4/029966e35e59b59c142be7e1d2208ad261709ac1a66aa4a3ce33c5b9f91f/prek-0.3.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d978c31bc3b1f0b3d58895b7c6ac26f077e0ea846da54f46aeee4c7088b1b105", size = 4633986, upload-time = "2026-02-28T03:47:14.351Z" }, + { url = "https://files.pythonhosted.org/packages/1d/27/d122802555745b6940c99fcb41496001c192ddcdf56ec947ec10a0298e05/prek-0.3.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8e089a030f0a023c22a4bb2ec4ff3fcc153585d701cff67acbfca2f37e173ae", size = 4680722, upload-time = "2026-02-28T03:47:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/92318c96b3a67b4e62ed82741016ede34d97ea9579d3cc1332b167632222/prek-0.3.4-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8060c72b764f0b88112616763da9dd3a7c293e010f8520b74079893096160a2f", size = 4535623, upload-time = "2026-02-28T03:46:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/df/f5/6b383d94e722637da4926b4f609d36fe432827bb6f035ad46ee02bde66b6/prek-0.3.4-py3-none-musllinux_1_1_i686.whl", hash = "sha256:65b23268456b5a763278d4e1ec532f2df33918f13ded85869a1ddff761eb9697", size = 4729879, upload-time = "2026-02-28T03:46:57.886Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/fdc705b807d813fd713ffa4f67f96741542ed1dafbb221206078c06f3df4/prek-0.3.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3975c61139c7b3200e38dc3955e050b0f2615701d3deb9715696a902e850509e", size = 5001569, upload-time = "2026-02-28T03:47:00.892Z" }, + { url = "https://files.pythonhosted.org/packages/84/92/b007a41f58e8192a1e611a21b396ad870d51d7873b7af12068ebae7fc15f/prek-0.3.4-py3-none-win32.whl", hash = "sha256:37449ae82f4dc08b72e542401e3d7318f05d1163e87c31ab260a40f425d6516e", size = 4297057, upload-time = "2026-02-28T03:47:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dc/bcb02de9b11461e8e0c7d3c8fdf8cfa15ac6efe73472a4375549ba5defd2/prek-0.3.4-py3-none-win_amd64.whl", hash = "sha256:60e9aa86ca65de963510ae28c5d94b9d7a97bcbaa6e4cdb5bf5083ed4c45dc71", size = 4655174, upload-time = "2026-02-28T03:46:53.749Z" }, + { url = "https://files.pythonhosted.org/packages/0b/86/98f5598569f4cd3de7161e266fab6a8981e65555f79d4704810c1502ad0a/prek-0.3.4-py3-none-win_arm64.whl", hash = "sha256:486bdae8f4512d3b4f6eb61b83e5b7595da2adca385af4b2b7823c0ab38d1827", size = 4367817, upload-time = "2026-02-28T03:46:55.264Z" }, ] [[package]] @@ -1415,120 +797,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, -] - -[[package]] -name = "pyasn1" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.12.5" @@ -1617,16 +885,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -1640,15 +908,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.20.1" +version = "10.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, ] [[package]] @@ -1704,13 +972,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/93a3e83bdf9322c7e21cafd092e56a4a17c4d8ef4277b6eb01af1a540a6f/python_discovery-1.1.0.tar.gz", hash = "sha256:447941ba1aed8cc2ab7ee3cb91be5fc137c5bdbb05b7e6ea62fbdcb66e50b268", size = 55674, upload-time = "2026-02-26T09:42:49.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687, upload-time = "2026-02-26T09:42:48.548Z" }, +] + [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -1849,80 +1130,53 @@ wheels = [ [[package]] name = "republic" -version = "0.5.2" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "any-llm-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/d0/a509e91a47c77ef02d66693f8b7564cee913445ba0946e0ce7081e641381/republic-0.5.2.tar.gz", hash = "sha256:c3e3f3a4735a2f460ffb9872c27268975a0686882c4189917b384b35bfdfad5a", size = 100151, upload-time = "2026-03-01T06:33:18.957Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/45/21c3cd167dad5e3d2434c9454a298d85b49fe0f4740f21f35ee50f4da89d/republic-0.5.3.tar.gz", hash = "sha256:31b43d41026a3d877c894c8bf06e3295929401a991f606d3d17ce1a63562c688", size = 100213, upload-time = "2026-03-05T10:52:22.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/49/897203df2bf680091de74b88692283f421584fa1de7e7b5069efce578020/republic-0.5.2-py3-none-any.whl", hash = "sha256:bfa540e35a2f1cec22ff68fa23703eeb1ee9ccdad8dc946cc8533bfae0989530", size = 39807, upload-time = "2026-03-01T06:33:17.43Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -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/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/06/eb/77769fbc7877b38ed98d17ec5ae174e3f0b0bdafa2f5be4ef054d49efe11/republic-0.5.3-py3-none-any.whl", hash = "sha256:82bb6654fcfbb22ebd9267b23bc22a7d5fe7dc46fe9fe1a56e46e26f5ce8bcf9", size = 39881, upload-time = "2026-03-05T10:52:23.606Z" }, ] [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, -] - -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "ruff" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, - { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] @@ -1974,43 +1228,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "socksio" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, -] - -[[package]] -name = "telegramify-markdown" -version = "0.5.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mistletoe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/eb/8a3a557eec87c0fcd4c0939232fa5ea407801050370596daa4ca3e51a1db/telegramify_markdown-0.5.4.tar.gz", hash = "sha256:c32bd04e5a1c22519c011ccf7350a01b6d162e6cc9a9d89c83eff964d491007e", size = 40370, upload-time = "2025-12-20T06:43:11.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/f0/4d07bcada3cddb66bccf061661b733e8512c5580e1bd11fba2aea1488d70/telegramify_markdown-0.5.4-py3-none-any.whl", hash = "sha256:7c806e12b6c7045d7723e064a0ff25afcb16c92c0d95385b61a57b8c53a430d3", size = 33536, upload-time = "2025-12-20T06:43:10.153Z" }, -] - -[[package]] -name = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - [[package]] name = "tox" -version = "4.34.1" +version = "4.47.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, - { name = "chardet" }, { name = "colorama" }, { name = "filelock" }, { name = "packaging" }, @@ -2019,23 +1242,34 @@ dependencies = [ { name = "pyproject-api" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/9b/5909f40b281ebd37c2f83de5087b9cb8a9a64c33745f334be0aeaedadbbc/tox-4.34.1.tar.gz", hash = "sha256:ef1e82974c2f5ea02954d590ee0b967fad500c3879b264ea19efb9a554f3cc60", size = 205306, upload-time = "2026-01-09T17:42:59.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/62/182ae37b7472072086184e2246e01c6fb8399b87d76e0c79636774ee7117/tox-4.47.3.tar.gz", hash = "sha256:57643508d4c218ad312457a3b0ce3135c50fa1f9f1e4d40867683d880cad1c37", size = 254468, upload-time = "2026-03-04T02:39:01.842Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/0f/fe6629e277ce615e53d0a0b65dc23c88b15a402bb7dbf771f17bbd18f1c4/tox-4.34.1-py3-none-any.whl", hash = "sha256:5610d69708bab578d618959b023f8d7d5d3386ed14a2392aeebf9c583615af60", size = 176812, upload-time = "2026-01-09T17:42:58.629Z" }, + { url = "https://files.pythonhosted.org/packages/01/3b/66b7b2cc1478b066e55eba27e2903db2f20bf363c14ac0e33c08df3364f8/tox-4.47.3-py3-none-any.whl", hash = "sha256:e447862a6821b421bbbfb8cbac071818c0a6884907a4c964d8322516d0b19b34", size = 202177, upload-time = "2026-03-04T02:39:00.482Z" }, ] [[package]] name = "tox-uv" -version = "1.29.0" +version = "1.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tox-uv-bare" }, + { name = "uv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/51/9a6dd32e34a3ee200c7890497093875e2c0a0b08737bb897e5916c6575bc/tox_uv-1.33.1-py3-none-any.whl", hash = "sha256:0617caa6444097434cdef24477307ff3242021a44088df673ae08771d3657f79", size = 5364, upload-time = "2026-03-02T17:06:18.32Z" }, +] + +[[package]] +name = "tox-uv-bare" +version = "1.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "tox" }, - { name = "uv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/90/06752775b8cfadba8856190f5beae9f552547e0f287e0246677972107375/tox_uv-1.29.0.tar.gz", hash = "sha256:30fa9e6ad507df49d3c6a2f88894256bcf90f18e240a00764da6ecab1db24895", size = 23427, upload-time = "2025-10-09T20:40:27.384Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/7b/5ce3aa477400c7791968037b3bf27a50a4e19160a111d9956d20e5ce6b06/tox_uv_bare-1.33.1.tar.gz", hash = "sha256:169185feb3cc8f321eb2a33c575c61dc6efd9bf6044b97636a7381261d29e85c", size = 27203, upload-time = "2026-03-02T17:06:21.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/17/221d62937c4130b044bb437caac4181e7e13d5536bbede65264db1f0ac9f/tox_uv-1.29.0-py3-none-any.whl", hash = "sha256:b1d251286edeeb4bc4af1e24c8acfdd9404700143c2199ccdbb4ea195f7de6cc", size = 17254, upload-time = "2025-10-09T20:40:25.885Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8e/ae95104165f4e2da5d9d25d8c71c7c935227c3eeb88e0376dab48b787a1c/tox_uv_bare-1.33.1-py3-none-any.whl", hash = "sha256:e64fdcd607a0f66212ef9edb36a5a672f10b461fce2a8216dda3e93c45d4a3f9", size = 19718, upload-time = "2026-03-02T17:06:19.657Z" }, ] [[package]] @@ -2052,17 +1286,17 @@ wheels = [ [[package]] name = "typer" -version = "0.21.1" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] [[package]] @@ -2095,73 +1329,44 @@ 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 = "tzdata" -version = "2025.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, -] - -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, -] - -[[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 = "uv" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" }, - { url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" }, - { url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" }, - { url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" }, - { url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" }, - { url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" }, - { url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" }, +version = "0.10.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/e7/600a90d4662dbd8414c1f6b709c8c79075d37d2044f72b94acbfaf29baad/uv-0.10.8.tar.gz", hash = "sha256:4b23242b5224c7eaea481ce6c6dbc210f0eafb447cf60211633980947cd23de4", size = 3936600, upload-time = "2026-03-03T21:35:22.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/6c/8ef256575242d5f3869c5a445ffd4363b91a89acb34a3e043bec2ad5a1be/uv-0.10.8-py3-none-linux_armv6l.whl", hash = "sha256:d214c82c7c14dd23f9aeb609d03070b8ea2b2f0cf249c9321cbbb5375a17e5df", size = 22461003, upload-time = "2026-03-03T21:35:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fb/fd0656a92e6b9c4f92ddba7dcd76bd87469be500755125e06fea853dc212/uv-0.10.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1315c3901c5859aec2c5b4a17da4c5410d17f6890890f9f1a31f25aa0fa9ace", size = 21549446, upload-time = "2026-03-03T21:35:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/64/b9/1a4105df3afe7af99791f5b00fb037d85b2e3aaa1227e95878538d51ecf3/uv-0.10.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a253e5d2cae9e02654de31918b610dfc8f1f16a33f34046603757820bc45ee1b", size = 20222180, upload-time = "2026-03-03T21:35:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/c5/72/6e98e0f8b3fe80cb881c36492dca6d932fbb05f956dfdccbdb8ebe4ceff4/uv-0.10.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:57a24e15fd9dd4a36bcec2ccbe4b26d2a172c109e954a8940f5e8a8b965dae74", size = 22064813, upload-time = "2026-03-03T21:35:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/737da8577f4b1799f7024f6cd98fffcac77076a1b078b277cffc84946e96/uv-0.10.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:675dc659195f9b9811ef5534eb3f16459fc88e109aefacbc91c07751b5b9715a", size = 22064861, upload-time = "2026-03-03T21:35:25.067Z" }, + { url = "https://files.pythonhosted.org/packages/7e/21/464ee3cd81f44345953cb26dd49870811f7647f3074f7651775cadb2158b/uv-0.10.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:18d2968b0a50111c2fc6b782f7c63ded4f461c44efab537f552cf565f9aaae25", size = 22054515, upload-time = "2026-03-03T21:35:44.572Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/1c592d7b843ffa999502116b0dc573732b40cb37061a4acc741dcdb181da/uv-0.10.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed3c7ebb6f757cddedb56dec3d7c745e5ea7310b11e12ae1c28f1e8172e7bbf", size = 23433992, upload-time = "2026-03-03T21:35:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e2/2b716f0613746138294598668bbe65295a8da3d8fa104a756dec6284bf3c/uv-0.10.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffaf115501e33be0d4f13cb5b7c2b46b031d4c679a6109e24a7edfb719c44c6c", size = 24257250, upload-time = "2026-03-03T21:35:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/0165e82cd1117cd6f8a7d9a2122c23cc091f7cf738aa4a2a54579420a08f/uv-0.10.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0209ee8cb573e113ff4a760360f28448f9ebcdcf9c91ca49e872821de5d2d054", size = 23338918, upload-time = "2026-03-03T21:35:33.795Z" }, + { url = "https://files.pythonhosted.org/packages/20/74/652129a25145732482bb0020602507f52d9a5ca0e1a40ddd6deb27402333/uv-0.10.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11dc790f732dc5fee61f0f6bd998fc2e9c200df1082245604ac091c32c23a523", size = 23259370, upload-time = "2026-03-03T21:35:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/19/c5/6e5923d6c9e3b50dc8542647bea692b7c227a9489f59ddff4fdfb20d8459/uv-0.10.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e26f8c35684face38db814d452dd1a2181152dbf7f7b2de1f547e6ba0c378d67", size = 22174747, upload-time = "2026-03-03T21:35:42.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/eee9e1883888327d07f51e7595ed5952e0bca2dc79d1c03b8a6e4309553e/uv-0.10.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:385add107d40c43dc00ca8c1a21ecf43101f846f8339eb7026bf6c9f6df7760d", size = 22893359, upload-time = "2026-03-03T21:35:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/bf/36/407a22917e55ce5cc2e7af956e3b9d91648a96558858acef84e3c50d5ca8/uv-0.10.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:24e8eb28c4f05acb38e60fefe2a2b15f4283a3849ce580bf2a62aca0a13123b3", size = 22637451, upload-time = "2026-03-03T21:35:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/21/d5/dabef9914e1ff27ad95e4b1daf59cd97c80e26a44c04c2870bcca7c83fc0/uv-0.10.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:73a8c1a1fceac73cd983dcc0a64f4f94f5fd1e5428681a5a76132574264504fb", size = 23480991, upload-time = "2026-03-03T21:35:52.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c0/1a4a45a9246f087e9446d0d804a436f6ee0befeaef731b04d1b2802d9d8f/uv-0.10.8-py3-none-win32.whl", hash = "sha256:9f344fdb34938ce35e9211a1b866adfa0c7f043967652ed1431917514aeec062", size = 21579030, upload-time = "2026-03-03T21:35:28.176Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2b/b29510efa1e6f409db105dbdafbd942ca3a2b638bef682ff2e5b9f6e4021/uv-0.10.8-py3-none-win_amd64.whl", hash = "sha256:1e63015284ed28c2112717256c328513215fb966a57c5870788eac2e8f949f28", size = 23944828, upload-time = "2026-03-03T21:36:00.763Z" }, + { url = "https://files.pythonhosted.org/packages/3f/9e/b5a11b0523171c0103c4fed54da76685a765ad4d3215e8220facfd24aed9/uv-0.10.8-py3-none-win_arm64.whl", hash = "sha256:a80284f46b6f2e0b3d03eb7c2d43e17139a4ec313e8b9f56a71efafc996804cb", size = 22322224, upload-time = "2026-03-03T21:35:14.148Z" }, ] [[package]] name = "virtualenv" -version = "20.36.1" +version = "21.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, ] [[package]] @@ -2197,51 +1402,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] - [[package]] name = "win32-setctime" version = "1.2.0" @@ -2250,97 +1410,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66 wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] - -[[package]] -name = "yarl" -version = "1.22.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, -]