mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
14 Commits
v0.4.1
...
66c58cf156
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c58cf156 | |||
| a1455989f2 | |||
| 4f142f1db9 | |||
| 5f28a49bc1 | |||
| a4baf38a52 | |||
| c638f6a5da | |||
| ede614aa9e | |||
| 41ba0f9dda | |||
| 4916494e44 | |||
| d603615d6a | |||
| 7967095dbc | |||
| dd70d02491 | |||
| 7c377a1a53 | |||
| 0a0f05ad14 |
@@ -214,3 +214,6 @@ __marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
# codegraph (local code index)
|
||||
.codegraph/
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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``.
|
||||
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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 == {}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"})]
|
||||
Reference in New Issue
Block a user