14 Commits

27 changed files with 1673 additions and 187 deletions
+3
View File
@@ -214,3 +214,6 @@ __marimo__/
# Streamlit
.streamlit/secrets.toml
# codegraph (local code index)
.codegraph/
+9 -6
View File
@@ -1,4 +1,4 @@
# CLAUDE.md
# AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@@ -56,12 +56,14 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
### Config (`config/`)
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit).
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in.
### Runtime (`runtime/`)
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; `instructions()` → `(server, text)` for every connected server that returned optional `InitializeResult.instructions` (usage guidance captured per connection); per-request timeout from config (default 30s).
### Memory (`memory/`)
Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `ALTER` for new columns), default local user, sessions (bound to `workspace` path; optional `provider_name`/`model`), messages stored as msgspec chat message JSON.
@@ -78,7 +80,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
### Tools (`tools/`)
@@ -94,6 +96,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs).
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
- **`DEFAULT_TOOLS` / catalog**: file + process + vcs + chat + todo + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `grep_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
### Prompting (`prompting.py`)
@@ -104,8 +107,8 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`.
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack.
- **`plyngent chat`**: provider/model (flags or interactive; Tab via readline in `prompting`); sessions store `provider_name`/`model` and restore on resume; SQLite via `[database]` (file DB under user data when url unset; explicit `url = ":memory:"` kept + warn); workspace-bound; resume latest for cwd/`--workspace` by default (`--new` / `--session`). One-shot: `-p/--prompt` and non-TTY stdin; exit codes 0/1/2/3; `--yes` (YOLO on), `--stream/--no-stream`, `--quiet`. Root `--log-level`. Enabled `[mcp]` servers start before the REPL (`_start_mcp_manager`, per-server failures warn on stderr) and are closed on exit (`McpManager.aclose`).
- Slash: Click group in `cli/slash.py` + `awaitlet` for async work; Tab completer from registry + ParamType `shell_complete`. Multiline `"""` … `"""`; `/edit` via `$VISUAL`/`$EDITOR` (blocking only). `/yolo on|off|once` for soft destructive confirms. `/model --persist` / `/models --persist` write model catalog entries into TOML. `/todos` for human show/push/pop/clear of the todo stack. `/mcp` (`list` default) prints per-server status/tool counts + an `instructions` preview when the server sent usage guidance, + last stderr lines; `/mcp reconnect` re-reads config, restarts every enabled server, and rebuilds the tool registry.
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors.
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**.
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels; TTY confirms off-loop; auto-retry 10s/20s/30s then `/retry`.
@@ -136,7 +139,7 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
- G5: PTY master FD non-inheritable; `read_pty`/`close_pty` via `to_thread`; `PtyManager.close_all()` on chat exit; `--log-level`; clearer invalid TOML errors; export/status stay secret-free
- G6: README, `doc/plyngent.example.toml`, CLAUDE overview/CLI notes
- G6: README, `doc/plyngent.example.toml`, AGENTS overview/CLI notes
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
+6 -1
View File
@@ -147,6 +147,9 @@ access_key_or_token = "sk-..."
# tool_directives = '''### Workspace ...'''
confirm_destructive = true
max_context_tokens = 200000
# Fold MCP server initialize ``instructions`` (usage guidance) into the
# system prompt when tools are on (default true; false ignores server text).
# mcp_instructions = true
# Optional plugins (entry-point names); default load none. See doc/plugins.md.
# [plugins]
@@ -157,6 +160,8 @@ Per-provider **`timeout`** is passed to the HTTP session for chat/completions, R
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
**MCP servers** (Model Context Protocol over stdio): define servers under `[mcp.servers.<name>]` (`command` + `args`, optional `env`/`cwd`/`timeout`/`read_only`) and their tools become agent tools namespaced `mcp__<server>__<tool>` (LOCAL tags; `read_only = true` also marks them READ_ONLY, eligible for `/btw --tools=read`). Names in `[mcp].disable` stay disconnected. A server may also return optional `instructions` (usage guidance) in its MCP `initialize` response; when tools are on those are folded into the agent system prompt as `MCP server <name> instructions:` blocks (`[agent] mcp_instructions = false` disables), and `/mcp` previews them. In the REPL, `/mcp` lists per-server status and tool counts; `/mcp reconnect` re-reads the config file and restarts every enabled server (adopts newly added ones).
Supported provider presets today:
| Preset | API used by the agent | Notes |
@@ -300,7 +305,7 @@ plyngent --log-level INFO chat ...
## Architecture (short)
See [doc/architecture.md](doc/architecture.md) and [CLAUDE.md](CLAUDE.md) for developers.
See [doc/architecture.md](doc/architecture.md) and [AGENTS.md](AGENTS.md) for developers.
- **`lmproto/`** — OpenAI-compatible, OpenAI Responses, Anthropic Messages, DeepSeek msgspec models and async SSE clients
- **`agent/`** — kind-based tool loop (`chat_completions` / `responses` / `messages`), streaming, usage, compact
+19
View File
@@ -26,6 +26,22 @@
# [networking]
# ssrf_assume_public_cidrs = ["198.18.0.0/15"]
# MCP (Model Context Protocol) servers exposed as agent tools.
# Defining a server opts it in; tools appear as mcp__<server>__<tool> with
# LOCAL tags (confirm flow applies). Names in disable stay disconnected.
# A server's initialize ``instructions`` (usage guidance) is folded into the
# agent system prompt when tools are on ([agent] mcp_instructions = false
# disables; /mcp previews what each server sent).
# [mcp.servers.docs]
# command = "npx"
# args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
# env = { RUST_LOG = "info" }
# cwd = "/path" # spawn working directory (empty = inherit)
# timeout = 30.0 # per-request timeout (seconds)
# read_only = true # mark every tool READ_ONLY (eligible for /btw --tools=read)
# [mcp]
# disable = ["docs"] # defined but never connected
[agent]
# Persona (omit → built-in coding-agent line). tool_directives is the tool playbook.
# Override with multi-line literals (prefer ''' so nested " is fine):
@@ -44,6 +60,9 @@ confirm_destructive = true
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
max_context_tokens = 200000
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
# into the agent system prompt when tools are on. false = ignore server text.
# mcp_instructions = true
# How open-todo nags are injected into model context:
# developer (default) | user | synthetic_tool | none
# (legacy system is treated as developer)
Generated
+201 -151
View File
@@ -4,7 +4,7 @@
[metadata]
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
lock_version = "4.5.1"
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
[[metadata.targets]]
@@ -52,53 +52,109 @@ files = [
[[package]]
name = "charset-normalizer"
version = "3.4.9"
version = "3.5.1"
requires_python = ">=3.7"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
groups = ["default"]
files = [
{file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"},
{file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"},
{file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"},
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"},
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"},
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"},
{file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"},
{file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"},
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"},
{file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"},
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"},
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"},
{file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"},
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"},
{file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"},
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"},
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"},
{file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"},
{file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"},
{file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"},
]
[[package]]
name = "click"
version = "8.4.2"
version = "8.5.0"
requires_python = ">=3.10"
summary = "Composable command line interface toolkit"
groups = ["default"]
dependencies = [
"colorama; platform_system == \"Windows\"",
]
files = [
{file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"},
{file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"},
{file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"},
{file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"},
]
[[package]]
@@ -106,8 +162,8 @@ name = "colorama"
version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text."
groups = ["default", "dev"]
marker = "platform_system == \"Windows\" or sys_platform == \"win32\""
groups = ["dev"]
marker = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -186,43 +242,43 @@ files = [
[[package]]
name = "jh2"
version = "5.0.13"
version = "5.0.14"
requires_python = ">=3.7"
summary = "HTTP/2 State-Machine based protocol implementation"
groups = ["default"]
files = [
{file = "jh2-5.0.13-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1dd137086b13d55a53c4b7b8b75869c824060a4d35615e4f49180ae58c7783f5"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75d0b2bffb2e260a0f3578a11a4999a713c94d1bd8de7cfe692c840e20ae83cf"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f681e197bf6552df3bb7a3abfd676e81316b69e36e7263e72383477174032ca2"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bde7e7ff0f17e87aca9ef23f66b80540a2de0ff8e2c26af55eb2e288a2a5f74f"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f8d364d53cc0854d910f2305726a8254acb527f13fa933dc213a591e21e7ffa"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b572daaafbd867bb5c5f324c918704ddd6d76af1cc7c2be483c3649b76ce7940"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0772ad66f346038fe05cb2928866ff1b21bcaa9ec3f14709fa1d419ed1dc287"},
{file = "jh2-5.0.13-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1d8313b13acfdef19c5ff793fdf397bae3d8d75181f6eee62604f13e4ec20a8"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:99aedd20daacb32f377152c9103d8d0271f32d1932bd39a5ed2cd2eef97f7e07"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:53b1df29ffe966cde1d2f0085b6acfcd237504627d406a51717ef3849ec88831"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:0ee6a5dd24a1dc0772a89b3498ed20f60167fbc633404b1e02895f04ae66c47c"},
{file = "jh2-5.0.13-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:521f871864880cabe361060f070516a56a54abc3e96f07bee9ba8b061eada891"},
{file = "jh2-5.0.13-cp314-cp314t-win32.whl", hash = "sha256:5909e2273f8b6fcea93c95564aa0f366ff9261a544a7a5d640ea95341321cc37"},
{file = "jh2-5.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:2cc4923ac6f57753e0cb4aef054a04f5d5c744b274dabf863f695fa3c2fd75f9"},
{file = "jh2-5.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:d3cf203de31371289d4c1e6b4d30c9f6de95b2bd2b300fe5468ff946299d0e48"},
{file = "jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3f54095cde010ff4e52f23dda92b31d3d3160c64f5f23fc6e181f8c7938afd33"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f810dd02b4a9647eacbe4eab617111c0e3ffc68e923e3ff172948db601d2ba1e"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29c23f3937f257beb5cd5b17f8727404f1267ff25d26e54c0a35d5defd0c895e"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c697b250a935030a128b45b41f507a4d33a082d8b8e12311c7e73fbb3970f057"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47d3203ed6fec39df4922764817979fad3a13d22cf750adc9cf9dc2b3399eacf"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5146468a76bbe8f4035f23a78813c5c433c70c0ca44eb0ba5558627a9e644b8"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d3d338284fbfbe173243c3cc62e510c6fa0d6d023e67da2292bd7578c734e3"},
{file = "jh2-5.0.13-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:641f65f5414b8990251bd7609513a696831558f69d2ca77d65dc3f7087f31267"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cf3dc6f7c14340241260bf63e5d6006b2ee7db45dafcb39c16fdf164ea0240a0"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:fb8e37ecf37382c02af83da521df5a77561441bc4cbb93b00627e015b7bbf349"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:296ee44dfad08ccc3857daf433dfa4a555d5b45f2df39503ccdd90ff10b32943"},
{file = "jh2-5.0.13-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1a75eed545420a31ea356e8046b8d86fcf694234dd1035191734a03720fb8a44"},
{file = "jh2-5.0.13-cp37-abi3-win32.whl", hash = "sha256:4812710810010c428db04e9290a13117f7b7c5a3a663b46a0586fe101cf25c9d"},
{file = "jh2-5.0.13-cp37-abi3-win_amd64.whl", hash = "sha256:5d61bc0a62b4eee11039bf29d4bdbb26efcff760dfb9d8e0739740028aa52b0f"},
{file = "jh2-5.0.13-cp37-abi3-win_arm64.whl", hash = "sha256:3f74dad9036e599eed51576492b00955237b86cf886f96844708a81cd0f5c710"},
{file = "jh2-5.0.13-py3-none-any.whl", hash = "sha256:2b311414989da48a155721fe47d0dc3e3b75360efd62d779aaff72d786be91cf"},
{file = "jh2-5.0.13.tar.gz", hash = "sha256:f8c78cffb3a35c4410513c3eb7989de36028c84277c04f07c97909dd94c23a75"},
{file = "jh2-5.0.14-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a1ee369128cee3a78be7ade531e7229480c22b20b2371671cfe566f34b47e079"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cfbad7863fcb8dc66dbc9da3e9ccfe11f8944d043618d486fdfd12b1ab54f6a"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9087585bd1f8c11fbade60164b9e79f42323784b22be245ceaeca758422e6bc5"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ccbfa14b306d5c5a4a68f3fe9823d686e46406c7dba5babcccb198fd5ae53a11"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5480d38a0eeb43f2bce716aaddee2b89fe30e24b9ac5b7ecfb8cbc7fb822c257"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:348824676428d44d6ddceadb2c1d0885cc351ebe0378eadfa74e382c5359abbe"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e09fb3bb222ce3cce63bcfab99cb48cbfdc44f56e81369bb1f920ef02c1105f"},
{file = "jh2-5.0.14-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f9b15a367961dbbedeb0f40380668141d3543f427246456f337a6e9fbb5ab2"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3f60ba14d122c0c72b0da283c69e1c0e717fe18c32b8fbd13202954d5d5c7f4b"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9e168d952ac659837c758e206efc7aef2a8b4eb57389691a2675f9059bdbb7d7"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:dedaf4cc07aee718c4380fcb0bea231f205be6db45a0855454b1d467a0c86837"},
{file = "jh2-5.0.14-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:9fc37f6fbeaba1fd5dd686594655448e2c98fbbb87b2a387faf5a331b2bc85b6"},
{file = "jh2-5.0.14-cp314-cp314t-win32.whl", hash = "sha256:8b4d38bd4bfb3cd2fd0eec62e95e14efd3c97b50535216acbc30afea6b5963e1"},
{file = "jh2-5.0.14-cp314-cp314t-win_amd64.whl", hash = "sha256:56c3e595254d253657a640bb1f92711836676a654a64484f6b1f1112ec52e68a"},
{file = "jh2-5.0.14-cp314-cp314t-win_arm64.whl", hash = "sha256:391a5357ac82a27d72579ccb9bcb281fd58da0c76e702158416ecabaa65fc3e2"},
{file = "jh2-5.0.14-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:68a23b2b8c1750b8bd208d7e5be0fce20f96aca795f57c56e525a36470773625"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0c2a4501c2a8ae3db2228c9138dc4a4b047750ffab018c29fbf58de59334bd8"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c32cc7136c54a63fc04abbdb656cd37dcd6b8462b946cd072ebdfbcf9350be45"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9f39c49b4b02b82202fe9b983f22f1f2aa685ae96ee5800a9b4cad511c07b68"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20517ff67db8ac5c3af0bafffe6a639721d8be32f7a9a7c2b25f0ad7f74d2c85"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4df556fe49b596774af0f49f78eff1e143448f8367609d3e6d2a50533ada3855"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3e92cd9d01351d14b15c826120bfb41f5834bb0a8860cbaa1779f557d3e1288"},
{file = "jh2-5.0.14-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6eeb6cff0f5fcd529d7e0afc75eb81ad5befd5b6b893c20f188fb7f467a0320"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:50ef70513db51c8a078e09b0bd824b0a9e3ffd92ee20122c4fa128d3a33b3625"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:52d105593e3af3384119f398682ed29fcbf79c9e87d637f8f49baaeb10adc0d4"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:bda792840b62a41bf24049849a38b9355096f1a03a82cc7bc5309e6e85789b1c"},
{file = "jh2-5.0.14-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:fddbfe1dd7c55160431975863b415b124ba408c20a8dab6ff3f2bf1eb18c3f8e"},
{file = "jh2-5.0.14-cp37-abi3-win32.whl", hash = "sha256:c41cfd834a360e3eb7b1e3d18b9d1debb16e001e08177d422f65293bd1e586aa"},
{file = "jh2-5.0.14-cp37-abi3-win_amd64.whl", hash = "sha256:1008bd1c05b879e33a9450823a67066716dd430f96a872dc652266c2271d7615"},
{file = "jh2-5.0.14-cp37-abi3-win_arm64.whl", hash = "sha256:b5c82c7045e83e89e4b59d2940c456365bd7ad7e49650ae5a977cacdd4a2354d"},
{file = "jh2-5.0.14-py3-none-any.whl", hash = "sha256:070895dfaeda94eccbdc45c7c97985d1c6ac339de7c4e329106b6f9450189bda"},
{file = "jh2-5.0.14.tar.gz", hash = "sha256:101708db41998159295c403f891dce9d8055a8dd98450c605e6eb7c34751543e"},
]
[[package]]
@@ -278,7 +334,7 @@ files = [
[[package]]
name = "niquests"
version = "3.21.0"
version = "3.21.1"
requires_python = ">=3.7"
summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze."
groups = ["default"]
@@ -288,13 +344,13 @@ dependencies = [
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
]
files = [
{file = "niquests-3.21.0-py3-none-any.whl", hash = "sha256:dbea441b9e9d5d755e02caa2b57b94cdb97b23d754ead21b80a4ac18ef66805f"},
{file = "niquests-3.21.0.tar.gz", hash = "sha256:5b7d10a05f4c7ed08cede0af74f492ae7a8a5a71291833d029e23365fc3ea80a"},
{file = "niquests-3.21.1-py3-none-any.whl", hash = "sha256:ac1ff8061cb480cdaad07f08b95cf21ee0a68ef21fbc2b6ddf5afff8e0d0072e"},
{file = "niquests-3.21.1.tar.gz", hash = "sha256:8ba6ee8712570ae41dfc59ac2ac7e509dcde660c6cc47078b43be423a2f99e64"},
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.16.0"
version = "24.19.0"
requires_python = ">=3.7"
summary = "unoffical Node.js package"
groups = ["dev"]
@@ -302,15 +358,15 @@ dependencies = [
"typing-extensions; python_version < \"3.8\"",
]
files = [
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa"},
{file = "nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36"},
{file = "nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd"},
{file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36"},
{file = "nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b"},
]
[[package]]
@@ -326,13 +382,13 @@ files = [
[[package]]
name = "platformdirs"
version = "4.11.2"
version = "4.11.7"
requires_python = ">=3.10"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
groups = ["default"]
files = [
{file = "platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4"},
{file = "platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d"},
{file = "platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6"},
{file = "platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d"},
]
[[package]]
@@ -348,13 +404,13 @@ files = [
[[package]]
name = "pygments"
version = "2.20.0"
version = "2.21.0"
requires_python = ">=3.9"
summary = "Pygments is a syntax highlighting package written in Python."
groups = ["default", "dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
{file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"},
{file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"},
]
[[package]]
@@ -410,47 +466,47 @@ files = [
[[package]]
name = "qh3"
version = "1.9.4"
version = "2.0.3"
requires_python = ">=3.7"
summary = "A lightway and fast implementation of QUIC and HTTP/3"
summary = "A lightweight and fast implementation of QUIC and HTTP/3"
groups = ["default"]
marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\""
files = [
{file = "qh3-1.9.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:100368855b0e103774438aaf8b38c711fb51b79f18e3b9dc873ea573648bcc3d"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9d45e544e0a556380bd2a92af180ee98d32bca6cf622f28350ebc000611bdf"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43f80d662078c26563a1aac32a4df22649473d996bf2422e073c8aa55b1c837c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:551f60d487d849d6f2313dda94e54ebb16b1a29acb93676540ae790a56308c24"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3755d777ff35561bc719f571ebc4b91aaa8a945c210e50b2576bf2cc69cb511f"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04b64bb18e10d8f482bea1ef43e5da3448e2d5ba78bb0c735da5b9e02d7dfb0e"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b47597daa4ae9195f81a35822217a395d3277f01a393d9c2bb3796a6b458b22c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bb9012bc02c44da6af1b931b6082588519685fad8a1f866c341f749394f53f"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9c7f3781ec022dd958b5eb0e5cb2e2a54e207a3d8837fb7290bc239912648fd7"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:633a24ab2db76d64881876c9d0f1b226a47d892bd7d78e61ea2c788ab59570af"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:f2ff36b84a4ab9b68806af1f6985fa3b47e29695effe766a3e70c21efac8157c"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2100697d736dbdfd1d3f0266c9fcadea6230b66ed59e3e7f8c9a9d1a6eec0c65"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:9ce179efaf97f927f01fe158619f5624d8d874c7161b7c497f06897cdde210d5"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:476ee4fcdbd3454eb0eb4dee5678294f06c08ca8e8b83160e2d0ae3c4ae34603"},
{file = "qh3-1.9.4-cp314-cp314t-win32.whl", hash = "sha256:fe9df8136bf358d45a2757287523abaeb208fcbe2c1e34664562eb2d8f64dbdb"},
{file = "qh3-1.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1fbdf53feb1732faf6c1cb34f2e1cbe4b543054db7b4d19d8752f1162e9370be"},
{file = "qh3-1.9.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e03ee1e2157a31c69415c3dbfbfe64e02469499ca46e968d13d8a7b289bf520a"},
{file = "qh3-1.9.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:26f7c8ed1a7ac0e25b654f030a4f472830c80890cc284dc40a31c17ecc21fbf0"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57b3dfe5dd2da2084adea63dba0bf587a4096ebf9207339187c90139d1f460d"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fa1a3debb5199da3c7f45b0ca6efa55174dfd6229370e774099073143ebe9e9"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc9ff40d3a06f27b47d93417666f03ba6fd6b9585d9711ebbddf84fa4b7c50f"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:010b132c33719af3df4c1d6f569e52a2a20046c55a31c5532e741019c8c53868"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:124045b0c04c05afc038a239d7717019b4c4b9d0cb967040b4331f4d588eff7d"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a94a1726bf46a451a0295d68fafde28794e83ab6fda7457cbd9309c38151de9"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b361e627b6a529ecb231c1d5f3f2abd6d1952b0d7cc02c7718d9657d55dae37"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:43b6cfb2240836e3e54bbe61ad1b8fa5716516ff5359131a2421c55676a24f26"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e6db3def610e9c2d2f3602d3e9c8b2156273225f6eedf74a4749d3bc21af8167"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:22d5879dc93d2c7ee14cc5933b12c7780d9c7705dff4553becea152fcb767d99"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:2c2b2005fe6c605d61cac05ec2b3c019cc546184fa43444f3d1e3d22ed020ab2"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:e991b0f20b172fea466d0ffd499a85bf2f26decdeeda537275c72b5f7b26ccfe"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c13775ed27a88b297f6a1aa05ffb286879453f99e1e53e0af2b22e7ed8f92ebf"},
{file = "qh3-1.9.4-cp37-abi3-win32.whl", hash = "sha256:952abe1be90c0114993886792a8a5ec2211f8391df41227f5b7a5a02ff71bc26"},
{file = "qh3-1.9.4-cp37-abi3-win_amd64.whl", hash = "sha256:5543cb40a48d401f3fa4ec89398e3a213634f195716a9d06d70417c05edbbb32"},
{file = "qh3-1.9.4-cp37-abi3-win_arm64.whl", hash = "sha256:d0bd73b3e73f4f80d2430d410b532222ecf57e9d4fe8600b7683c775cbff9479"},
{file = "qh3-1.9.4.tar.gz", hash = "sha256:bd2ea9baf19656c544a48a56a195f2ac257cd973b566f5f2998fa3b7446281a1"},
{file = "qh3-2.0.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:db98ea31b7486cac5dffc76c15b34c08208b219379aa9d274df21b1d47c7dbcb"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb0a8f54275e0ecb722c0a6cebeff73771b31da0dbfaed8e4b72efcbe98e2ab"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e466a285b8f598ea06a296910a817571f132bb3ff3f5a26cdc905ffe6d9d743c"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a266a68f27a86d6d49978e1c6b8e9e5dd514e018a042dafca48cbeb50a29d0d3"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2646d05d20e7171d928d9b478097743d0d2a5b3893f8caddd7984b2825d90daa"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccac4672539eb2897c3beaa73b626460040adf94c30edbd420db3988bd08e0f1"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0e57b96af05653ddcc4797fe761918d5d0a25ed90a9176260333f48e7b88f72"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73845b61987f44c5d55f9606a38b4078cac7e816dadeeaceb35a49ff741d4c8e"},
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:4f05f1b3ac250213db0da6895aeb944ca3cb63cd1a41296f1dddafd8d78a1722"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3be9e2cfc447807c4195c12d9590052ee45cc17ed92d99f44d05a49d657bf8d4"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:bb83310f908a9d00b781fe95f7acda51025d0cea3a985929e84b0d46333709a2"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:991ff127a8b0aef9ae07a4155f595158fd005d9cc522d204ec31a5a5f197517c"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:efbcd87e7c45f51690f6e526ed4956c57e428eb4c5684878987d43ffa14a93b0"},
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:86f2256aa78604d6733ff12a225d6780df18a9b171e72d45dd30ee62d5ab0568"},
{file = "qh3-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:91569e8ee979c84a1972916cc9b3283760dda111fc8f67cce45ccdb4f14b67c5"},
{file = "qh3-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:eb0333f67bee13a3b5f08e91ece87fd0f5e5448e955efb11d7bd2bfacf21bfb5"},
{file = "qh3-2.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:8c9f6b22d073948b3042ec0b50dab5581865a30d41517ba6aad8dbc1a4b39a6b"},
{file = "qh3-2.0.3-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e96296b00eb23ff828d7789070b3d22c7139efbc6bc62fed366c31bad622d174"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a09e33277ded5f94f9b73b0c8c2f5b3e044f3c41ed5c830ab61e72962e22d09"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8bbbc57b1cff5acebf736965c072f35023f4d0e5975b3d8cda7b7e07250af1b"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b8ee7434cd0ecab6b916abc63a46f4ad8b3ebbc284da405aeb650882826e91"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec715af40af199c92eedbd620bbff8ddc514e19f2bbd660e65953752c364105f"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bcbdc6a574f83f957e6bf430cda8b98a512870164563b7ef1fdd553420f032a"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5c9ba43b3f68f9ade9553ff23d89455596b9c5ba8179a103f75c8771d355535"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51480d90b4ac7e81248fd020a726d4089cf8b881a4b6cbdc3c04c329831a3fc6"},
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:235161f4cbd791e18ea865d10fdd52d0ddbcbb807a1849c98360c4e570c06844"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7abb8ae6c8ae6b49827b26eaffeac0892893450d2022818e1db3ff34d26a835e"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:57f43869ef306877167277c48c1fa7c446fc73fc9355fa1ccae5094507b63d9e"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:192021606e283a59b47bf1e1cb719087c77869976aea76f26d589a1df8aa0dd5"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:f33ca1ac278a8d8ff115bcde76fc4555fa1dbfa147e83dad2ebcba6bc663e6d5"},
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:74814e3dd91011235905856701037ead68f7a8be641f469fa5e5cdabf7d03a28"},
{file = "qh3-2.0.3-cp37-abi3-win32.whl", hash = "sha256:20ceb5482ef6f352d9c7351fbde69f94f49d07f068ce9aa4e88ea76fca7eddda"},
{file = "qh3-2.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:94ba6ddb786742ff93904db09cb4b6fbcaf7de115161c05918024cd52b2c8a61"},
{file = "qh3-2.0.3-cp37-abi3-win_arm64.whl", hash = "sha256:0b13789c68983a994e8c12e3ff0fbc02f07c0aaaeec5670b57802f5d3d555e81"},
{file = "qh3-2.0.3.tar.gz", hash = "sha256:546ab2d3193e37e98dfefe5834484e07d0bf531708f21e1a4e227a51510b648f"},
]
[[package]]
@@ -497,7 +553,7 @@ files = [
[[package]]
name = "sqlalchemy"
version = "2.0.51"
version = "2.0.52"
requires_python = ">=3.7"
summary = "Database Abstraction Library"
groups = ["default"]
@@ -507,22 +563,16 @@ dependencies = [
"typing-extensions>=4.6.0",
]
files = [
{file = "sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491"},
{file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d"},
{file = "sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54"},
{file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e"},
{file = "sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d"},
{file = "sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8"},
{file = "sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7"},
{file = "sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2"},
{file = "sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5"},
{file = "sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9"},
{file = "sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9"},
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e"},
{file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437"},
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8"},
{file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd"},
{file = "sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2"},
{file = "sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d"},
{file = "sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582"},
{file = "sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89"},
{file = "sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97"},
]
[[package]]
@@ -549,28 +599,28 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.24.900"
version = "2.24.907"
requires_python = ">=3.7"
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
groups = ["default"]
dependencies = [
"h11<1.0.0,>=0.11.0",
"jh2<6.0.0,>=5.0.3",
"qh3<2.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
]
files = [
{file = "urllib3_future-2.24.900-py3-none-any.whl", hash = "sha256:a4fe06b59b0c3ce2fffa821c3de72eaf59b1e61619becc255dee18919ee25260"},
{file = "urllib3_future-2.24.900.tar.gz", hash = "sha256:05c2e9d09293ab29a154fed8f5b60644c7cdaffe4ae2587a07b1c7f00f23a7fb"},
{file = "urllib3_future-2.24.907-py3-none-any.whl", hash = "sha256:6f9007bd9c6d3ea13d8fe70e5d4b87a6d15638a849bd2227ab5cbdfbe5d1e842"},
{file = "urllib3_future-2.24.907.tar.gz", hash = "sha256:6565b3cc160a950821953079a6ccd4daa8fd60071a13c9a251576648929f3aa7"},
]
[[package]]
name = "wassima"
version = "2.1.3"
version = "2.1.4"
requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease"
groups = ["default"]
marker = "sys_platform != \"emscripten\""
files = [
{file = "wassima-2.1.3-py3-none-any.whl", hash = "sha256:f8251c23720dd092117a554b3012756caee3feed6d0122c2458a2aca5d7d92c5"},
{file = "wassima-2.1.3.tar.gz", hash = "sha256:fcd6c38be0f909c393da35cb2a993101fcdcff673b8fa8d5da228f73b630d0d0"},
{file = "wassima-2.1.4-py3-none-any.whl", hash = "sha256:26022272d4618aada0e26df2e79dbc0e034be8be9d6d5396512690ffc6a94e7b"},
{file = "wassima-2.1.4.tar.gz", hash = "sha256:21bb77253bb8032c05393172c4d2df395f423b7e704538f3b2407f50c032034d"},
]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.4.1"
version = "0.5.0"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
+1 -1
View File
@@ -22,7 +22,7 @@ DEFAULT_WIN_PYTHON = "3.14"
DEFAULT_WIN_UV_VERSION = "0.11.28"
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``.
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "CLAUDE.md", "doc")
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "AGENTS.md", "doc")
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
+23 -1
View File
@@ -26,7 +26,7 @@ from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig
from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, configure_prompting
from plyngent.runtime import ProviderNotSupportedError, create_client
from plyngent.runtime import McpManager, ProviderNotSupportedError, create_client
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -134,6 +134,24 @@ def _setup_hooks(*, interactive: bool) -> None:
PtyManager.set_limit_continue_hook(None)
async def _start_mcp_manager(store: ConfigStore) -> McpManager | None:
"""Start configured MCP servers before the REPL state is built.
Returns ``None`` when nothing is enabled. Failures degrade to a warning
per server (the rest of the session, including LLM traffic, continues).
"""
manager = McpManager(store.mcp_config)
if not manager.enabled_servers():
return None
await manager.ensure_started()
for name, status, count in manager.statuses():
if status.startswith("error"):
click.secho(f"warning: mcp server {name!r} failed: {status}", fg="yellow", err=True)
else:
click.secho(f"mcp: {name} ({status}, tools={count})", fg="bright_black", err=True)
return manager
async def _bind_session(
state: ReplState,
*,
@@ -285,6 +303,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
except ProviderNotSupportedError as exc:
raise click.ClickException(str(exc)) from exc
mcp_manager = await _start_mcp_manager(store)
state = ReplState(
config=store,
memory=memory,
@@ -297,6 +316,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
stream_enabled=stream,
interactive_limits=interactive,
yolo=yolo,
mcp_manager=mcp_manager,
)
# Path denylist and policy confirm live on instance.workspace (no process bag).
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
@@ -353,6 +373,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
clear_private_grants(instance=state_obj.instance_state)
clear_ssrf_assume_public_cidrs()
if state_obj.mcp_manager is not None:
await state_obj.mcp_manager.aclose()
await state_obj.instance_state.shutdown()
else:
# No ReplState: only PTY class cleanup (temps require instance allowlist).
+4 -1
View File
@@ -154,7 +154,7 @@ def _pretty_line(prefix: str, *segments: tuple[str, str | None]) -> str:
def _read_file_pretty(args_json: str, result: str) -> str:
"""One-line summary for a ``read_file`` call: ``* Read 'path' (done)``."""
"""One-line summary for a ``read_file`` call: ``* Read 'path' L1-4 (done)``."""
path = _json_str_arg(args_json, "path") or "?"
if result.startswith("error: file not found"):
return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red"))
@@ -162,6 +162,9 @@ def _read_file_pretty(args_json: str, result: str) -> str:
return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red"))
if result.startswith("error:"):
return _pretty_line(f"* Read '{path}' ", ("(error)", "red"))
first = result.splitlines()[0] if result.splitlines() else ""
if first.startswith("L") and "-" in first:
return _pretty_line(f"* Read '{path}' ", (f"{first} ", "dim"), ("(done)", "green"))
return _pretty_line(f"* Read '{path}' ", ("(done)", "green"))
+58
View File
@@ -1147,6 +1147,64 @@ def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) ->
raise click.UsageError(str(exc)) from exc
_MCP_PREVIEW_MAX_CHARS = 120
def _print_mcp_status(state: ReplState) -> None:
manager = state.mcp_manager
if manager is None:
click.echo("mcp: no servers configured (or none enabled)")
return
rows = manager.statuses()
if not rows:
click.echo("mcp: no servers configured (or none enabled)")
return
for name, status, count in rows:
click.echo(f" {name}\t{status}\ttools={count}")
connection = next(
(conn for conn in manager.connections() if conn.name == name),
None,
)
if connection is not None and connection.instructions:
preview = " ".join(connection.instructions.split())
if len(preview) > _MCP_PREVIEW_MAX_CHARS:
preview = f"{preview[: _MCP_PREVIEW_MAX_CHARS - 3]}..."
click.secho(
f" instructions ({len(connection.instructions)} chars): {preview}",
fg="bright_black",
)
if connection is not None and connection.error is not None:
for line in connection.stderr_tail()[-3:]:
click.secho(f" {line}", fg="bright_black")
@slash.command("mcp")
@click.argument("action", required=False, type=click.Choice(["list", "reconnect"]))
@click.pass_obj
def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
"""List MCP server statuses, or reconnect after config edits.
``/mcp`` or ``/mcp list`` — per-server connection status and tool count.
``/mcp reconnect`` — re-read the config file, restart every enabled
server, and rebuild the tool registry (adopts newly added servers too).
"""
act = (action or "list").lower()
if act == "list":
_print_mcp_status(state)
return
from plyngent.runtime import McpManager
state.config.reload()
if state.mcp_manager is None:
state.mcp_manager = McpManager(state.config.mcp_config)
else:
_await(state.mcp_manager.restart(state.config.mcp_config))
_await(state.mcp_manager.ensure_started())
_slash_rebuild_tools_if_on(state)
click.echo(f"mcp reconnected; tools={'on' if state.tools_enabled else 'off'}")
_print_mcp_status(state)
@slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj
+22
View File
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow
from plyngent.runtime.mcp_client import McpManager
type YoloMode = Literal["off", "on", "once"]
@@ -65,6 +66,8 @@ class ReplState:
todo_stack: TodoStack = field(default_factory=TodoStack)
instance_state: InstanceState = field(default_factory=InstanceState)
session_state: SessionState = field(default_factory=SessionState)
# MCP connections started by the host app before this state was built.
mcp_manager: McpManager | None = None
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
# Session ids for Tab complete (updated when listing/creating/resuming).
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
@@ -215,6 +218,11 @@ class ReplState:
plugins_cfg.enable,
disable=plugins_cfg.disable,
)
if self.mcp_manager is not None:
from plyngent.tools.mcp import register_mcp_tools
# Connections were started by the host; registration is sync-only.
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
# Local surface: builtins + allowlisted plugins (import registers into catalog).
tools = catalog.select(surface="local")
yolo = self.effective_yolo() != "off"
@@ -229,14 +237,28 @@ class ReplState:
session_state=self.session_state,
)
def _mcp_instructions_text(self) -> str:
"""Compose connected MCP servers' initialize ``instructions`` into one block.
Only meaningful when tools are on (MCP tools are registered under the
same condition); the text is attributed per server so the model can
weigh its provenance. Gated by ``[agent] mcp_instructions`` in callers.
"""
if self.mcp_manager is None or not self.tools_enabled:
return ""
parts = [f"MCP server {name!r} instructions:\n{text}" for name, text in self.mcp_manager.instructions()]
return "\n\n".join(parts)
def _make_agent(self) -> ChatAgent:
from plyngent.cli.limits import prompt_continue_limit_async
from plyngent.config import compose_agent_system_content
agent_cfg = self.config.agent_config
mcp_text = self._mcp_instructions_text() if agent_cfg.mcp_instructions else ""
system_prompt = compose_agent_system_content(
agent_cfg.system_prompt,
agent_cfg.tool_directives,
mcp_text,
)
on_limit = prompt_continue_limit_async if self.interactive_limits else None
peak = 0
+2
View File
@@ -13,6 +13,8 @@ from .models import DatabaseConfig as DatabaseConfig
from .models import DeepSeekConvention as DeepSeekConvention
from .models import DeepseekProvider as DeepseekProvider
from .models import HttpTimeoutConfig as HttpTimeoutConfig
from .models import McpConfig as McpConfig
from .models import McpServerConfig as McpServerConfig
from .models import ModelConfig as ModelConfig
from .models import NetworkingConfig as NetworkingConfig
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
+48 -11
View File
@@ -70,19 +70,15 @@ call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behin
"""
def compose_agent_system_content(
system_prompt: str,
tool_directives: str,
) -> str | None:
"""Join persona + tool playbook into one system body, or ``None`` if both empty.
def compose_agent_system_content(*parts: str) -> str | None:
"""Join persona + tool playbook + extra blocks into one system body.
Non-empty parts are stripped and joined with a blank line. Either field may
be ``""`` to disable that part while keeping the other.
Returns ``None`` when every part is empty. Non-empty parts are stripped and
joined with a blank line. Any part may be ``""`` to disable that block
(e.g. ``system_prompt = ""`` keeps the tool playbook).
"""
parts = [part.strip() for part in (system_prompt, tool_directives) if part and part.strip()]
if not parts:
return None
return "\n\n".join(parts)
joined = "\n\n".join(part.strip() for part in parts if part and part.strip())
return joined or None
class DatabaseConfig(Struct, omit_defaults=True):
@@ -106,6 +102,9 @@ class AgentConfig(Struct, omit_defaults=True):
system_prompt: str = DEFAULT_SYSTEM_PROMPT
# Tool how-to (omit → DEFAULT_TOOL_DIRECTIVES; "" disables playbook only).
tool_directives: str = DEFAULT_TOOL_DIRECTIVES
# Fold connected MCP servers' initialize ``instructions`` (usage guidance)
# into the agent system prompt when tools are on. false = ignore server text.
mcp_instructions: bool = True
max_tool_result_chars: int = 32_000
parallel_tools: bool = True
confirm_destructive: bool = True
@@ -143,6 +142,44 @@ class PluginsConfig(Struct, omit_defaults=True):
disable: list[str] = field(default_factory=list)
class McpServerConfig(Struct, omit_defaults=True):
"""One MCP server definition (stdio transport today).
TOML example::
[mcp.servers.docs]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
env = { RUST_LOG = "info" }
# cwd = "/path" # spawn working directory (empty = inherit)
# timeout = 30.0 # per-request timeout in seconds
# read_only = true # mark every tool READ_ONLY (safe side turns)
``url`` is reserved for the streamable-HTTP transport; empty = stdio.
A defined server is connected unless its name appears in ``[mcp].disable``.
"""
command: str = ""
args: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict)
cwd: str = ""
timeout: float = 30.0
read_only: bool = False
url: str = ""
class McpConfig(Struct, omit_defaults=True):
"""Model Context Protocol servers exposed as agent tools.
Defining a server under ``[mcp.servers.<name>]`` is the opt-in; tools are
namespaced ``mcp__<server>__<tool>`` and registered with LOCAL tags. Names
in ``disable`` stay disconnected even when defined.
"""
servers: dict[str, McpServerConfig] = field(default_factory=dict)
disable: list[str] = field(default_factory=list)
class NetworkingConfig(Struct, omit_defaults=True):
"""Host-side network policy for tools such as ``fetch``.
+34
View File
@@ -9,6 +9,7 @@ import tomlkit
from .models import (
AgentConfig,
DatabaseConfig,
McpConfig,
ModelConfig,
NetworkingConfig,
PluginsConfig,
@@ -56,6 +57,29 @@ def _parse_networking(raw: dict[str, object]) -> NetworkingConfig:
return NetworkingConfig()
def _parse_mcp(raw: dict[str, object]) -> McpConfig:
"""Parse the ``[mcp]`` section, falling back to defaults.
Unknown keys (typos like ``cmds``) are rejected the same way as providers:
a malformed section silently degrades to "no MCP servers" instead of a
half-parsed one.
"""
try:
config = msgspec.convert(raw, McpConfig)
except msgspec.ValidationError:
return McpConfig()
known = {"servers", "disable"}
if set(raw) - known:
return McpConfig()
known_server = {"command", "args", "env", "cwd", "timeout", "read_only", "url"}
servers_raw = cast("dict[str, object]", raw.get("servers", {}))
for name, raw_entry in servers_raw.items():
entry = cast("dict[str, object]", raw_entry)
if not isinstance(raw_entry, dict) or not name.strip() or set(entry) - known_server:
return McpConfig()
return config
def _parse_providers(
document: tomlkit.TOMLDocument,
) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]:
@@ -125,6 +149,7 @@ class ConfigStore:
_agent: AgentConfig
_plugins: PluginsConfig
_networking: NetworkingConfig
_mcp: McpConfig
_providers: dict[str, Provider]
_bad_providers: dict[str, object]
_recoverable_providers: dict[str, Provider]
@@ -137,6 +162,7 @@ class ConfigStore:
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
@property
@@ -187,6 +213,13 @@ class ConfigStore:
"""Typed networking section (e.g. Fake-IP SSRF exemptions)."""
return self._networking
# -- mcp (read-only) --
@property
def mcp_config(self) -> McpConfig:
"""Typed MCP section (server definitions + disable list)."""
return self._mcp
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
cleaned = [name.strip() for name in names if name.strip()]
@@ -390,6 +423,7 @@ class ConfigStore:
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
# -- internal sync helpers --
+6
View File
@@ -1,5 +1,11 @@
from plyngent.config.models import McpConfig as McpConfig
from .client_factory import InvalidHttpTimeoutError as InvalidHttpTimeoutError
from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError
from .client_factory import create_client as create_client
from .client_factory import normalize_http_timeout as normalize_http_timeout
from .client_factory import provider_to_openai_config as provider_to_openai_config
from .mcp_client import McpConnectionError as McpConnectionError
from .mcp_client import McpManager as McpManager
from .mcp_client import McpServerConnection as McpServerConnection
from .mcp_client import McpTool as McpTool
+383
View File
@@ -0,0 +1,383 @@
"""MCP client: Model Context Protocol over stdio (newline-delimited JSON-RPC).
One :class:`McpServerConnection` owns one spawned server subprocess and its
initialize handshake. :class:`McpManager` owns all configured connections for
the process: start, tool listing, tool calls, reconnect, shutdown.
"""
from __future__ import annotations
import asyncio
import importlib.metadata
import json
import os
from collections import deque
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from plyngent.config.models import McpConfig, McpServerConfig
MCP_PROTOCOL_VERSION = "2025-06-18"
MCP_CLIENT_NAME = "plyngent"
DEFAULT_MCP_TIMEOUT = 30.0
_STDERR_TAIL_LINES = 32
_CLOSE_GRACE_SECONDS = 2.0
_MAX_TOOL_PAGES = 1024
type Json = dict[str, object]
def _json_dict(raw: object) -> Json:
"""Narrow an untyped JSON payload to a string-keyed object mapping."""
return cast("Json", raw) if isinstance(raw, dict) else {}
def _json_list(raw: object) -> list[object]:
"""Narrow an untyped JSON payload to a list of objects."""
return cast("list[object]", raw) if isinstance(raw, list) else []
def _client_version() -> str:
try:
return importlib.metadata.version("plyngent")
except importlib.metadata.PackageNotFoundError:
return "0.0.0"
class McpConnectionError(RuntimeError):
"""Server process failed to spawn, handshake, or answer in time."""
@dataclass(frozen=True, slots=True)
class McpTool:
"""One tool advertised by a server (``tools/list`` entry)."""
name: str
description: str
input_schema: Json
@classmethod
def from_raw(cls, raw: object) -> McpTool:
entry = _json_dict(raw)
return cls(
name=str(entry.get("name", "")),
description=str(entry.get("description", "") or ""),
input_schema=_json_dict(entry.get("inputSchema")) or {"type": "object"},
)
def _format_content(result: Json) -> str:
"""Flatten a ``tools/call`` result payload into model-facing text."""
parts: list[str] = []
for item in _json_list(result.get("content")):
entry = _json_dict(item)
kind = str(entry.get("type", ""))
if kind == "text":
text = entry.get("text")
if isinstance(text, str):
parts.append(text)
elif kind == "resource":
parts.append(f"[resource] {json.dumps(entry, ensure_ascii=False)}")
else:
mime = entry.get("mimeType", "?")
parts.append(f"[{kind or 'unknown'}: {mime}]")
return "\n".join(parts)
class McpServerConnection:
"""One server subprocess with an active MCP session (stdio transport)."""
name: str
error: str | None
tools: list[McpTool]
tools_stale: bool
# Server usage guidance from the initialize result (InitializeResult.instructions).
instructions: str | None
_config: McpServerConfig
_proc: asyncio.subprocess.Process | None
_reader_task: asyncio.Task[None] | None
_pending: dict[int, asyncio.Future[Json]]
_next_id: int
_stderr_tail: deque[str]
def __init__(self, name: str, config: McpServerConfig) -> None:
self.name = name
self._config = config
self._proc = None
self._reader_task = None
self._pending = {}
self._next_id = 1
self._stderr_tail = deque(maxlen=_STDERR_TAIL_LINES)
self.error = None
self.tools = []
self.tools_stale = False
self.instructions = None
@property
def status(self) -> str:
if self.error is not None:
return f"error: {self.error}"
if self._proc is not None:
return "connected (tools changed; /mcp reconnect)" if self.tools_stale else "connected"
return "not started"
@property
def connected(self) -> bool:
return self._proc is not None and self.error is None
def stderr_tail(self) -> list[str]:
return list(self._stderr_tail)
async def start(self) -> None:
"""Spawn the server process and run the initialize handshake."""
if self._proc is not None:
return
try:
self._proc = await asyncio.create_subprocess_exec(
self._config.command,
*self._config.args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=os.environ | self._config.env,
cwd=self._config.cwd or None,
)
except (OSError, ValueError) as exc:
self.error = f"spawn {self._config.command!r}: {exc}"
self._proc = None
return
self._reader_task = asyncio.create_task(self._read_loop())
try:
await self._initialize()
self.tools = await self.list_tools()
except (McpConnectionError, TimeoutError) as exc:
self.error = f"initialize: {exc}"
await self.aclose()
async def aclose(self) -> None:
"""Terminate the server subprocess and fail pending requests."""
proc = self._proc
self._proc = None
if self._reader_task is not None:
_ = self._reader_task.cancel()
self._reader_task = None
if proc is not None and proc.returncode is None:
_ = proc.terminate()
try:
_ = await asyncio.wait_for(proc.wait(), _CLOSE_GRACE_SECONDS)
except TimeoutError:
proc.kill()
_ = await proc.wait()
for future in self._pending.values():
if not future.done():
future.set_exception(McpConnectionError("connection closed"))
self._pending.clear()
async def _read_loop(self) -> None:
proc = self._proc
if proc is None or proc.stdout is None or proc.stderr is None:
return
stderr_task = asyncio.create_task(self._drain_stderr(proc.stderr))
try:
while True:
line = await proc.stdout.readline()
if not line:
break
self._dispatch(line)
finally:
_ = stderr_task.cancel()
for future in self._pending.values():
if not future.done():
future.set_exception(McpConnectionError("server closed the stream"))
self._pending.clear()
async def _drain_stderr(self, stderr: asyncio.StreamReader) -> None:
while True:
line = await stderr.readline()
if not line:
return
self._stderr_tail.append(line.decode(errors="replace").rstrip())
def _dispatch(self, line: bytes) -> None:
try:
payload = _json_dict(cast("object", json.loads(line)))
except json.JSONDecodeError:
return
message_id = payload.get("id")
if message_id is not None:
if isinstance(message_id, bool) or not isinstance(message_id, (int, str)):
return
request_id = int(message_id)
future = self._pending.pop(request_id, None)
if future is None or future.done():
return
error = payload.get("error")
if error is not None:
err = _json_dict(error)
detail = str(err.get("message", "unknown error")) if err else str(error)
future.set_exception(McpConnectionError(detail))
else:
future.set_result(_json_dict(payload.get("result")))
return
# Notifications carry no id; only tool-list changes matter to the host.
if payload.get("method") == "notifications/tools/list_changed":
self.tools_stale = True
def _send(self, payload: Json) -> None:
proc = self._proc
stdin = proc.stdin if proc is not None else None
if stdin is None:
msg = "server process is not running"
raise McpConnectionError(msg)
_ = stdin.write(json.dumps(payload, ensure_ascii=False).encode() + b"\n")
async def _request(self, method: str, params: Json | None = None) -> Json:
timeout = self._config.timeout or DEFAULT_MCP_TIMEOUT
future: asyncio.Future[Json] = asyncio.get_running_loop().create_future()
request_id = self._next_id
self._next_id += 1
self._pending[request_id] = future
payload: Json = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
payload["params"] = params
try:
self._send(payload)
except McpConnectionError:
_ = self._pending.pop(request_id, None)
raise
try:
return await asyncio.wait_for(future, timeout)
except TimeoutError:
_ = self._pending.pop(request_id, None)
msg = f"{method} timed out after {timeout}s"
raise McpConnectionError(msg) from None
async def _notify(self, method: str, params: Json | None = None) -> None:
payload: Json = {"jsonrpc": "2.0", "method": method}
if params is not None:
payload["params"] = params
self._send(payload)
async def _initialize(self) -> None:
result = await self._request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": MCP_CLIENT_NAME, "version": _client_version()},
},
)
# Optional server usage guidance (MCP spec: InitializeResult.instructions).
# Hosts surface this to the model when the server's tools are in play.
instructions = result.get("instructions")
self.instructions = instructions.strip() if isinstance(instructions, str) and instructions.strip() else None
await self._notify("notifications/initialized")
async def list_tools(self) -> list[McpTool]:
"""Fetch the full tool list, following ``nextCursor`` pagination."""
tools: list[McpTool] = []
cursor: str | None = None
for _ in range(_MAX_TOOL_PAGES):
params: Json = {} if cursor is None else {"cursor": cursor}
result = await self._request("tools/list", params)
tools.extend(McpTool.from_raw(item) for item in _json_list(result.get("tools")))
next_cursor = result.get("nextCursor")
if not isinstance(next_cursor, str) or not next_cursor:
return tools
cursor = next_cursor
return tools
async def call_tool(self, name: str, arguments: Json) -> str:
"""Run ``tools/call`` and return the flattened text result.
Tool-level failures (``isError``) come back as ``error: …`` text, not
exceptions: the protocol round-trip itself succeeded.
"""
result = await self._request("tools/call", {"name": name, "arguments": arguments})
text = _format_content(result)
if result.get("isError") is True:
return f"error: {text or 'tool reported failure without content'}"
return text
class McpManager:
"""All configured MCP server connections for this process."""
_config: McpConfig
_connections: dict[str, McpServerConnection]
def __init__(self, config: McpConfig) -> None:
self._config = config
self._connections = {}
def enabled_servers(self) -> dict[str, McpServerConfig]:
"""Configured servers minus the ``disable`` list, in config order."""
disabled = {name.strip() for name in self._config.disable}
return {name: cfg for name, cfg in self._config.servers.items() if name not in disabled}
async def ensure_started(self) -> None:
"""Connect every enabled server that has no live connection yet."""
for name, cfg in self.enabled_servers().items():
connection = self._connections.get(name)
if connection is not None and connection.connected:
continue
if connection is not None:
await connection.aclose()
connection = McpServerConnection(name, cfg)
self._connections[name] = connection
await connection.start()
async def restart(self, config: McpConfig) -> None:
"""Drop every connection and adopt a new config (e.g. /mcp reconnect)."""
await self.aclose()
self._config = config
async def aclose(self) -> None:
for connection in self._connections.values():
await connection.aclose()
self._connections.clear()
def statuses(self) -> list[tuple[str, str, int]]:
"""(server, status, tool_count) rows for diagnostics surfaces."""
rows: list[tuple[str, str, int]] = []
for name in self.enabled_servers():
connection = self._connections.get(name)
if connection is None:
rows.append((name, "not started", 0))
else:
rows.append((name, connection.status, len(connection.tools)))
return rows
def instructions(self) -> list[tuple[str, str]]:
"""(server, text) initialize ``instructions`` for every connected server.
Only non-empty guidance is returned, in config order. Hosts may fold
these into the agent system context (see cli.state.ReplState).
"""
rows: list[tuple[str, str]] = []
for name in self.enabled_servers():
connection = self._connections.get(name)
if connection is not None and connection.connected:
text = (connection.instructions or "").strip()
if text:
rows.append((name, text))
return rows
def connections(self) -> list[McpServerConnection]:
return list(self._connections.values())
async def refresh_tools(self) -> None:
"""Re-run tools/list on every connected server."""
for connection in self._connections.values():
if connection.connected:
connection.tools = await connection.list_tools()
connection.tools_stale = False
async def call(self, server: str, tool: str, arguments: Json) -> str:
connection = self._connections.get(server)
if connection is None or not connection.connected:
msg = f"server {server!r} is not connected"
raise McpConnectionError(msg)
return await connection.call_tool(tool, arguments)
+10 -3
View File
@@ -12,7 +12,7 @@ from plyngent.agent.tools import ToolDefinition, ToolTag
if TYPE_CHECKING:
from collections.abc import Container, Generator
type ToolSourceKind = Literal["builtin", "plugin"]
type ToolSourceKind = Literal["builtin", "plugin", "mcp"]
type ToolSurface = Literal["local", "public"]
@@ -29,8 +29,8 @@ class ToolSource:
def __str__(self) -> str:
if self.kind == "builtin":
return "builtin"
plugin = self.plugin_id or "?"
return f"plugin:{plugin}"
id_part = self.plugin_id or "?"
return f"{self.kind}:{id_part}"
@dataclass(frozen=True, slots=True)
@@ -152,6 +152,13 @@ class ToolCatalog:
def clear(self) -> None:
self._by_name.clear()
def purge_kind(self, kind: ToolSourceKind) -> int:
"""Remove every entry registered from *kind*; return the count removed."""
doomed = [name for name, entry in self._by_name.items() if entry.source.kind == kind]
for name in doomed:
del self._by_name[name]
return len(doomed)
def snapshot(self) -> dict[str, RegisteredTool]:
return dict(self._by_name)
+29 -10
View File
@@ -1,20 +1,44 @@
from __future__ import annotations
import asyncio
import re
import sys
from click import style
from plyngent.agent import ToolTag, tool
from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_prompt_async
WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. "
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
def _wait_prompt(duration: int, *, reason: str | None) -> str:
"""Two-line wait prompt: status line, then the optional-reason input prompt.
``read_line_with_timeout`` writes the prompt verbatim to stdout (no click
echo ANSI stripping), so styling is applied here and stripped when stdout
is not a TTY. On a terminal: cyan status, dimmed detail, yellow input
prompt; plain text otherwise (tests, redirected output).
"""
status = style(f"Waiting for {duration}s", fg="cyan")
if reason:
status += style(f" ({reason})", fg="bright_black")
status += style(". Press Enter to disturb.", fg="bright_black")
input_prompt = style("Reason (optional): ", fg="yellow")
rendered = f"{status}\n{input_prompt}"
if not sys.stdout.isatty():
return _ANSI_ESCAPE_RE.sub("", rendered)
return rendered
@tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
async def wait(duration: int, *, reason: str | None = None) -> str:
"""Wait ``duration`` seconds before continuing.
Interactive sessions show a line prompt: pressing Enter (optionally after
typing a reason) "disturbs" the wait so the turn continues immediately.
Non-interactive runs simply sleep the full duration.
Interactive sessions show a two-line prompt: a status line, then an
optional-reason input — pressing Enter (optionally after typing a reason)
"disturbs" the wait so the turn continues immediately. Non-interactive runs
simply sleep the full duration.
"""
if duration < 0:
return "error: duration must not be negative"
@@ -24,12 +48,7 @@ async def wait(duration: int, *, reason: str | None = None) -> str:
if not backend.is_interactive():
await asyncio.sleep(duration)
return f"waited {duration}s"
prompt = WAIT_PROMPT.format(duration=duration)
if reason:
prompt = (
f"Waiting for {duration}s (reason: {reason}). Press Enter to disturb. "
"Feel free to ship any reason before pressing Enter. "
)
prompt = _wait_prompt(duration, reason=reason)
line = await run_prompt_async(read_line_with_timeout, prompt, float(duration))
if line is None:
return f"waited {duration}s"
+108
View File
@@ -0,0 +1,108 @@
"""Register MCP server tools into the tool catalog (namespaced, LOCAL tags).
Mirrors :mod:`plyngent.tools.plugins`: the catalog only *registers* what the
model may see; hosts still select into a :class:`ToolRegistry`. Connections
must already be started (see :class:`~plyngent.runtime.mcp_client.McpManager`);
this module is sync-only so registry rebuilds never need an event loop.
Tool names are namespaced ``mcp__<server>__<tool>`` so two servers (or a
server and a builtin) can never collide on sanitized names; the catalog still
refuses true clashes.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from plyngent.agent.tools import ToolDefinition, ToolTag
from plyngent.tools.catalog import ToolSource, get_catalog
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from plyngent.config.models import McpConfig
from plyngent.runtime.mcp_client import McpManager, McpTool
_MCP_PREFIX = "mcp__"
_NAME_SEGMENT_MAX = 64
_SANITIZE_PATTERN = re.compile(r"[^A-Za-z0-9_-]")
def _sanitize_segment(part: str) -> str:
"""Reduce a server/tool name to a model-safe, bounded name segment."""
return _SANITIZE_PATTERN.sub("_", part)[:_NAME_SEGMENT_MAX] or "srv"
def mcp_tool_name(server: str, tool: str) -> str:
"""Namespaced catalog name for one MCP tool (collision-free per server)."""
return f"{_MCP_PREFIX}{_sanitize_segment(server)}__{_sanitize_segment(tool)}"
def _make_handler(
manager: McpManager,
server: str,
tool: str,
) -> Callable[..., Awaitable[str]]:
async def handler(**arguments: object) -> str:
return await manager.call(server, tool, dict(arguments))
return handler
def register_mcp_server_tools(
server: str,
tools: list[McpTool],
*,
manager: McpManager,
read_only: bool = False,
) -> list[str]:
"""Register one server's advertised tools; returns the registered names.
Tags: LOCAL (plus READ_ONLY when the server config opts in) — never YOLO or
TRUSTABLE, so host-side danger classification always sees every call.
"""
catalog = get_catalog()
source = ToolSource(kind="mcp", plugin_id=server)
names: list[str] = []
for entry in tools:
if not entry.name:
continue
tags = ToolTag.LOCAL
if read_only:
tags |= ToolTag.READ_ONLY
description = entry.description or f"MCP tool {entry.name!r} from server {server!r}"
definition = ToolDefinition(
name=mcp_tool_name(server, entry.name),
description=f"[mcp:{server}] {description}",
parameters=dict(entry.input_schema),
handler=_make_handler(manager, server, entry.name),
tags=tags,
)
catalog.register(definition, source=source)
names.append(definition.name)
return names
def purge_mcp_tools() -> int:
"""Remove previously registered MCP tools (call before re-registering)."""
return get_catalog().purge_kind("mcp")
def register_mcp_tools(manager: McpManager, config: McpConfig) -> list[str]:
"""Register every connected server's tools; replaces prior MCP entries."""
_ = purge_mcp_tools()
names: list[str] = []
for connection in manager.connections():
if not connection.connected or not connection.tools:
continue
server_cfg = config.servers.get(connection.name)
names.extend(
register_mcp_server_tools(
connection.name,
connection.tools,
manager=manager,
read_only=server_cfg.read_only if server_cfg is not None else False,
)
)
return names
+9 -1
View File
@@ -98,11 +98,19 @@ def _result(content: str) -> ToolResultEvent:
async def test_pretty_read_file_done(capsys: pytest.CaptureFixture[str]) -> None:
await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\ntwo\n")]))
out = capsys.readouterr().out
assert "* Read 'a.txt' (done)" in out
assert "* Read 'a.txt' L1-4 (done)" in out
assert "[tool]" not in out
assert "[tool ok]" not in out
async def test_pretty_read_file_numbered_no_range(capsys: pytest.CaptureFixture[str]) -> None:
"""Numbered reads carry no L header, so the summary falls back to ``(done)``."""
content = " 1|one\n 2|two\n"
await render_events(_aiter([_read_call('{"path": "a.txt", "with_lineno": true}'), _result(content)]))
out = capsys.readouterr().out
assert "* Read 'a.txt' (done)" in out
async def test_pretty_read_file_statuses(capsys: pytest.CaptureFixture[str]) -> None:
cases = [
("error: file not found: a.txt", "(file not found)"),
+230
View File
@@ -0,0 +1,230 @@
"""CLI slash ``/mcp`` command and MCP wiring in ``ReplState``."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
import tomlkit
from plyngent.cli.slash import handle_slash
from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig, McpConfig, McpServerConfig, OpenAIProvider
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.runtime.mcp_client import McpManager
if TYPE_CHECKING:
from collections.abc import AsyncIterator
_FAKE_SERVER = Path(__file__).parent.parent / "test_runtime" / "mcp_fake_server.py"
def _server_config(**overrides: object) -> McpServerConfig:
base: dict[str, object] = {
"command": sys.executable,
"args": [str(_FAKE_SERVER)],
"timeout": 5.0,
}
return McpServerConfig(**{**base, **overrides}) # type: ignore[arg-type]
@pytest.fixture
async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=False,
)
await st.new_session("t")
yield st
await memory.close()
async def test_slash_mcp_list_without_manager(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/mcp") is True
assert "no servers configured" in capsys.readouterr().out
assert await handle_slash(state, "/mcp list") is True
assert "no servers configured" in capsys.readouterr().out
async def test_slash_mcp_list_statuses(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
state.mcp_manager = manager
try:
assert await handle_slash(state, "/mcp list") is True
out = capsys.readouterr().out
assert "fake" in out
assert "connected" in out
assert "tools=3" in out
finally:
await manager.aclose()
async def test_slash_mcp_reconnect_connects_new_config(
state: ReplState,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
# The fixture's config file does not exist yet; write one with an MCP server.
payload = (
"[mcp.servers.fake]\n"
f"command = {json.dumps(sys.executable)}\n"
f"args = [{json.dumps(str(_FAKE_SERVER))}]\n"
"timeout = 5.0\n"
)
_ = (tmp_path / "plyngent.toml").write_text(payload, encoding="utf-8")
assert await handle_slash(state, "/mcp reconnect") is True
try:
assert state.mcp_manager is not None
statuses = state.mcp_manager.statuses()
assert [name for name, _, _ in statuses] == ["fake"]
assert statuses[0][1] == "connected"
assert statuses[0][2] == 3
out = capsys.readouterr().out
assert "tools=off" in out
assert "fake" in out
finally:
if state.mcp_manager is not None:
await state.mcp_manager.aclose()
async def test_mcp_tools_registered_on_tools_rebuild(state: ReplState) -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
state.mcp_manager = manager
try:
from plyngent.tools.catalog import catalog_scope
state.tools_enabled = True
with catalog_scope(empty=True):
state.rebuild_client()
registry = state.agent.tools
assert registry is not None
assert registry.get("mcp__fake__echo") is not None
assert registry.get("mcp__fake__fail") is not None
# Handler proxies to the live server subprocess.
out = await registry.execute("mcp__fake__echo", '{"text": "hi"}')
assert out == "echo: hi"
finally:
await manager.aclose()
async def test_slash_mcp_list_shows_instructions(
state: ReplState,
capsys: pytest.CaptureFixture[str],
) -> None:
guidance = "Prefer `echo` for repeating text back."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
state.mcp_manager = manager
try:
assert await handle_slash(state, "/mcp list") is True
out = capsys.readouterr().out
assert f"instructions ({len(guidance)} chars)" in out
assert guidance in out
finally:
await manager.aclose()
async def _make_tools_state(
tmp_path: Path,
*,
agent_toml: str,
manager: McpManager,
) -> tuple[ReplState, MemoryStore]:
"""ReplState with tools on + a started MCP manager (agent built from TOML)."""
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
document = tomlkit.parse(agent_toml) if agent_toml else tomlkit.document()
config = ConfigStore(path=tmp_path / "plyngent.toml", document=document)
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=True,
mcp_manager=manager,
)
return st, memory
async def test_mcp_instructions_folded_into_agent_system_prompt(tmp_path: Path) -> None:
from plyngent.tools.catalog import catalog_scope
guidance = "Prefer `echo` for repeating text back to the user."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
try:
with catalog_scope(empty=True):
state, memory = await _make_tools_state(tmp_path, agent_toml="", manager=manager)
try:
prompt = state.agent.system_prompt
assert prompt is not None
assert "MCP server 'fake' instructions:" in prompt
assert guidance in prompt
finally:
await memory.close()
finally:
await manager.aclose()
async def test_mcp_instructions_flag_off_skips_server_text(tmp_path: Path) -> None:
from plyngent.tools.catalog import catalog_scope
guidance = "Prefer `echo` for repeating text back to the user."
manager = McpManager(
McpConfig(
servers={
"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
},
disable=[],
)
)
await manager.ensure_started()
try:
with catalog_scope(empty=True):
state, memory = await _make_tools_state(
tmp_path,
agent_toml="[agent]\nmcp_instructions = false\n",
manager=manager,
)
try:
prompt = state.agent.system_prompt
assert prompt is not None
assert guidance not in prompt
assert "MCP server" not in prompt
assert "### Workspace" in prompt # host tool playbook still composed
finally:
await memory.close()
finally:
await manager.aclose()
+17
View File
@@ -19,6 +19,7 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
store = load(path)
assert store.agent_config.system_prompt == DEFAULT_SYSTEM_PROMPT
assert store.agent_config.tool_directives == DEFAULT_TOOL_DIRECTIVES
assert store.agent_config.mcp_instructions is True
assert "professional coding agent" in store.agent_config.system_prompt
assert "### Workspace" in store.agent_config.tool_directives
assert store.agent_config.max_tool_result_chars == 32_000
@@ -64,6 +65,9 @@ def test_compose_empty_combinations() -> None:
assert compose_agent_system_content("Only persona", "") == "Only persona"
assert compose_agent_system_content("", "Only tools") == "Only tools"
assert compose_agent_system_content("Persona", "Tools") == "Persona\n\nTools"
assert compose_agent_system_content("", "", "") is None
assert compose_agent_system_content("Persona", "Tools", "Extra") == "Persona\n\nTools\n\nExtra"
assert compose_agent_system_content("Persona", "", "Extra") == "Persona\n\nExtra"
def test_agent_section_parse(tmp_path: Path) -> None:
@@ -158,6 +162,19 @@ tool_directives = ""
)
def test_agent_mcp_instructions_flag(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[agent]
mcp_instructions = false
""",
encoding="utf-8",
)
store = load(path)
assert store.agent_config.mcp_instructions is False
def test_plugins_section_parse(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
+66
View File
@@ -0,0 +1,66 @@
"""[mcp] section parse: server definitions, disable gate, malformed fallback."""
from __future__ import annotations
from plyngent.config import load
def test_mcp_defaults_and_parse(tmp_path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text("", encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
assert store.mcp_config.disable == []
path.write_text(
"""
[mcp.servers.docs]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
env = { RUST_LOG = "info" }
timeout = 10.0
read_only = true
[mcp.servers.search]
command = "uvx"
args = ["mcp-server-fetch"]
[mcp]
disable = ["search"]
""",
encoding="utf-8",
)
store = load(path)
mcp = store.mcp_config
assert set(mcp.servers) == {"docs", "search"}
docs = mcp.servers["docs"]
assert docs.command == "npx"
assert docs.args == ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
assert docs.env == {"RUST_LOG": "info"}
assert docs.timeout == 10.0
assert docs.read_only is True
assert docs.url == ""
assert mcp.disable == ["search"]
def test_mcp_malformed_section_falls_back_to_defaults(tmp_path) -> None:
path = tmp_path / "c.toml"
# Unknown section key.
path.write_text("[mcp]\nenabled = true\n", encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
# Unknown server key (typo).
path.write_text('[mcp.servers.docs]\ncmds = "npx"\n', encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
# Server entry is not a table.
path.write_text("[mcp]\nservers = 3\n", encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
# Empty server name.
path.write_text('[mcp.servers.""]\ncommand = "npx"\n', encoding="utf-8")
store = load(path)
assert store.mcp_config.servers == {}
+103
View File
@@ -0,0 +1,103 @@
"""Minimal MCP stdio server used by the client tests (echo + sleep tools).
Set ``PLYNGENT_MCP_FAKE_INSTRUCTIONS`` (env) to have the server return that
text as ``InitializeResult.instructions`` (usage guidance).
"""
from __future__ import annotations
import json
import os
import sys
_FAKE_INSTRUCTIONS = os.environ.get("PLYNGENT_MCP_FAKE_INSTRUCTIONS", "")
def respond(request_id: int, result: object) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
sys.stdout.flush()
def main() -> None:
for line in sys.stdin:
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
method = payload.get("method", "")
request_id = payload.get("id")
if request_id is None:
continue # notification (notifications/initialized)
if method == "initialize":
result: dict[str, object] = {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
}
if _FAKE_INSTRUCTIONS:
result["instructions"] = _FAKE_INSTRUCTIONS
respond(int(request_id), result)
elif method == "tools/list":
respond(
int(request_id),
{
"tools": [
{
"name": "echo",
"description": "Echo the given text back.",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
},
{
"name": "fail",
"description": "Always reports a tool error.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "slow",
"description": "Sleeps past any client timeout.",
"inputSchema": {"type": "object", "properties": {}},
},
]
},
)
elif method == "tools/call":
name = (payload.get("params") or {}).get("name")
if name == "echo":
text = str((payload.get("params") or {}).get("arguments", {}).get("text", ""))
respond(
int(request_id),
{
"content": [{"type": "text", "text": f"echo: {text}"}],
"isError": False,
},
)
elif name == "fail":
respond(
int(request_id),
{"content": [{"type": "text", "text": "boom"}], "isError": True},
)
else:
import time
time.sleep(5)
respond(int(request_id), {"content": [], "isError": False})
else:
sys.stdout.write(
json.dumps(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32601, "message": f"unknown method {method}"},
}
)
+ "\n"
)
sys.stdout.flush()
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
"""MCP stdio client + manager against the bundled fake server subprocess."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from plyngent.config.models import McpConfig, McpServerConfig
from plyngent.runtime.mcp_client import (
McpConnectionError,
McpManager,
McpServerConnection,
)
_FAKE_SERVER = Path(__file__).parent / "mcp_fake_server.py"
def _server_config(**overrides: object) -> McpServerConfig:
base: dict[str, object] = {
"command": sys.executable,
"args": [str(_FAKE_SERVER)],
"timeout": 5.0,
}
return McpServerConfig(**{**base, **overrides}) # type: ignore[arg-type]
async def test_connection_handshake_lists_and_calls_tools() -> None:
connection = McpServerConnection("fake", _server_config())
await connection.start()
try:
assert connection.connected
assert connection.error is None
assert connection.instructions is None
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
echo = connection.tools[0]
assert echo.description == "Echo the given text back."
assert echo.input_schema["type"] == "object"
result = await connection.call_tool("echo", {"text": "hello"})
assert result == "echo: hello"
finally:
await connection.aclose()
async def test_initialize_captures_server_instructions() -> None:
guidance = "Use echo to repeat text back to the user."
connection = McpServerConnection(
"fake",
_server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance}),
)
await connection.start()
try:
assert connection.connected
assert connection.instructions == guidance
finally:
await connection.aclose()
async def test_tool_error_returns_error_text() -> None:
connection = McpServerConnection("fake", _server_config())
await connection.start()
try:
result = await connection.call_tool("fail", {})
assert result == "error: boom"
finally:
await connection.aclose()
async def test_request_timeout_raises() -> None:
connection = McpServerConnection("fake", _server_config(timeout=0.2))
await connection.start()
try:
with pytest.raises(McpConnectionError, match="timed out"):
await connection.call_tool("slow", {})
finally:
await connection.aclose()
async def test_spawn_failure_records_error() -> None:
connection = McpServerConnection("bad", _server_config(command="/nonexistent/plyngent-mcp-binary"))
await connection.start()
assert not connection.connected
assert connection.error is not None
assert "spawn" in connection.status
await connection.aclose()
async def test_close_terminates_subprocess() -> None:
connection = McpServerConnection("fake", _server_config())
await connection.start()
proc = connection._proc
assert proc is not None
await connection.aclose()
assert connection._proc is None
assert proc.returncode is not None
async def test_manager_start_status_and_call() -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
try:
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)]
assert manager.instructions() == []
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
finally:
await manager.aclose()
assert manager.statuses() == [("fake", "not started", 0)]
async def test_manager_instructions_after_restart() -> None:
guidance = "Always answer with a haiku."
plain = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
await plain.ensure_started()
assert plain.instructions() == []
await plain.restart(
McpConfig(
servers={"fake": _server_config(env={"PLYNGENT_MCP_FAKE_INSTRUCTIONS": guidance})},
disable=[],
)
)
await plain.ensure_started()
try:
assert plain.instructions() == [("fake", guidance)]
finally:
await plain.aclose()
async def test_manager_disable_and_restart() -> None:
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=["fake"]))
await manager.ensure_started()
assert manager.connections() == []
with pytest.raises(McpConnectionError, match="not connected"):
await manager.call("fake", "echo", {})
await manager.restart(McpConfig(servers={"fake": _server_config()}, disable=[]))
await manager.ensure_started()
assert manager.statuses() == [("fake", "connected", 3)]
await manager.aclose()
+48
View File
@@ -144,3 +144,51 @@ async def test_wait_tool_times_out(monkeypatch) -> None:
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 3}')
assert out == "waited 3s"
async def test_wait_tool_prompt_two_lines_with_reason(monkeypatch) -> None:
prompts: list[str] = []
monkeypatch.setattr(
wait_module,
"read_line_with_timeout",
lambda prompt, timeout: prompts.append(prompt) or None,
)
with temporary_backend(ScriptedBackend([])):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 5, "reason": "tests are slow"}')
assert out == "waited 5s"
assert prompts == ["Waiting for 5s (tests are slow). Press Enter to disturb.\nReason (optional): "]
async def test_wait_tool_prompt_two_lines_no_reason(monkeypatch) -> None:
prompts: list[str] = []
monkeypatch.setattr(
wait_module,
"read_line_with_timeout",
lambda prompt, timeout: prompts.append(prompt) or None,
)
with temporary_backend(ScriptedBackend([])):
registry = ToolRegistry([wait])
out = await registry.execute("wait", '{"duration": 5}')
assert out == "waited 5s"
assert prompts == ["Waiting for 5s. Press Enter to disturb.\nReason (optional): "]
def test_wait_prompt_plain_when_not_a_tty() -> None:
"""Prompt styling is stripped when stdout is not a TTY (pytest capture)."""
prompt = wait_module._wait_prompt(5, reason="tests")
assert "\x1b[" not in prompt
assert prompt == "Waiting for 5s (tests). Press Enter to disturb.\nReason (optional): "
def test_wait_prompt_colored_on_tty(monkeypatch) -> None:
"""On a TTY the prompt is styled: cyan status, yellow input prompt."""
class _FakeStdout:
def isatty(self) -> bool:
return True
monkeypatch.setattr(wait_module.sys, "stdout", _FakeStdout())
prompt = wait_module._wait_prompt(5, reason="tests")
assert "\x1b[36m" in prompt # cyan status
assert "\x1b[33m" in prompt # yellow input prompt
+93
View File
@@ -0,0 +1,93 @@
"""MCP tool registration: namespacing, tags, catalog source, purge, handlers."""
from __future__ import annotations
import pytest
from plyngent.agent.tools import ToolTag
from plyngent.runtime.mcp_client import McpTool
from plyngent.tools.catalog import catalog_scope
from plyngent.tools.mcp import mcp_tool_name, purge_mcp_tools, register_mcp_server_tools
class _StubManager:
"""Duck-typed McpManager stand-in (records calls, returns canned text)."""
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, object]]] = []
async def call(self, server: str, tool: str, arguments: dict[str, object]) -> str:
self.calls.append((server, tool, arguments))
return f"{server}:{tool}:{arguments.get('text', '')}"
def _tools() -> list[McpTool]:
return [
McpTool(
name="echo",
description="Echo text.",
input_schema={"type": "object", "properties": {"text": {"type": "string"}}},
),
McpTool(name="", description="skipped: empty name", input_schema={}),
]
def test_mcp_tool_name_sanitizes_and_namespaces() -> None:
assert mcp_tool_name("my server", "a/b c") == "mcp__my_server__a_b_c"
assert mcp_tool_name("x", "y").startswith("mcp__")
def test_register_names_tags_source_and_purge() -> None:
manager = _StubManager()
with catalog_scope(empty=True):
names = register_mcp_server_tools("docs", _tools(), manager=manager) # type: ignore[arg-type]
assert names == ["mcp__docs__echo"]
from plyngent.tools.catalog import get_catalog
entry = get_catalog().get("mcp__docs__echo")
assert entry is not None
assert entry.source.kind == "mcp"
assert entry.source.plugin_id == "docs"
assert str(entry.source) == "mcp:docs"
definition = entry.definition
assert definition.tags == ToolTag.LOCAL
assert definition.description.startswith("[mcp:docs]")
assert definition.parameters["type"] == "object"
assert "text" in definition.parameters["properties"]
assert purge_mcp_tools() == 1
assert get_catalog().get("mcp__docs__echo") is None
assert purge_mcp_tools() == 0
def test_register_read_only_flag() -> None:
manager = _StubManager()
with catalog_scope(empty=True):
register_mcp_server_tools("docs", _tools()[:1], manager=manager, read_only=True) # type: ignore[arg-type]
from plyngent.tools.catalog import get_catalog
entry = get_catalog().get("mcp__docs__echo")
assert entry is not None
assert entry.definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
def test_duplicate_registration_collides() -> None:
manager = _StubManager()
with catalog_scope(empty=True):
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
with pytest.raises(ValueError, match="collision"):
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
async def test_handler_proxies_to_manager_call() -> None:
manager = _StubManager()
with catalog_scope(empty=True):
register_mcp_server_tools("docs", _tools()[:1], manager=manager) # type: ignore[arg-type]
from plyngent.tools.catalog import get_catalog
entry = get_catalog().get("mcp__docs__echo")
assert entry is not None
result = await entry.definition.handler(text="hi") # type: ignore[union-attr]
assert result == "docs:echo:hi"
assert manager.calls == [("docs", "echo", {"text": "hi"})]