Compare commits
56 Commits
a0c1b6ed93
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bb9b00dcc | |||
| 036429e9bf | |||
| 18fe063691 | |||
| f27a5456f6 | |||
| 82e8167f67 | |||
| 4030b8d5ee | |||
| 24b97338ba | |||
| 12d532da7b | |||
| 8adcf93b6a | |||
| 3f73ed0aef | |||
| 801dbe15dc | |||
| 13e10fa52f | |||
| 6ba4c7ca92 | |||
| 8878eda0b2 | |||
| 4b8b1a0a6e | |||
| 661460bfd9 | |||
| a1a9388d88 | |||
| 4caac3a6c7 | |||
| e17a70aecf | |||
| 5b14af8ea1 | |||
| 72d5e13d59 | |||
| 21fd8e168c | |||
| ad199674e7 | |||
| a1d4b1d709 | |||
| 7b1d7be5d7 | |||
| ebe3baba78 | |||
| bafa327412 | |||
| ae36a9fbac | |||
| 7de4b56962 | |||
| 5cff7d8506 | |||
| 223075e602 | |||
| 81d5acd83e | |||
| d9f0e75d0a | |||
| 2dbcc0ba5f | |||
| 7c7e63d89a | |||
| 584d53e6e4 | |||
| c8cda5ef2b | |||
| 6fa35e1b48 | |||
| 5fe8f5bc73 | |||
| 59f7fc1d6c | |||
| 4cee16922f | |||
| 6bdd2bcb6a | |||
| a8da306ce5 | |||
| 5edd051830 | |||
| 2b1e2ead7d | |||
| b6fa547eb4 | |||
| 0b48190f99 | |||
| 24c3059e83 | |||
| 06735bb447 | |||
| b921e3a649 | |||
| c0257f6068 | |||
| dac215840e | |||
| e13324e10c | |||
| 737c816ee7 | |||
| 61cbf41900 | |||
| 81ff649ab7 |
@@ -0,0 +1 @@
|
||||
reference/
|
||||
@@ -17,3 +17,6 @@ __pycache__/
|
||||
|
||||
# Reference material (not part of this project's source)
|
||||
reference/
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/
|
||||
|
||||
+553
@@ -0,0 +1,553 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.7.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
**`inspect_container`** — new tool that returns the full configuration
|
||||
of a single container, intended as the foundation for a future
|
||||
GUI-container → Compose migration workflow.
|
||||
|
||||
- API: `SYNO.Docker.Container/get` with `name` JSON-encoded (the same
|
||||
endpoint already used by `get_container_status` and `delete_container`,
|
||||
but called directly with a focus on the migration-relevant fields).
|
||||
The DSM action `inspect` (no `get`) does not exist and returns DSM
|
||||
code 103 — use `get`.
|
||||
- Output covers: image, status / running, restart policy, network
|
||||
mode and per-network IP addresses, port bindings, volume mounts
|
||||
(with FULL `/volume1/...` host path — see DSM quirk below),
|
||||
environment variables, labels, entrypoint / command, links, and
|
||||
capabilities / privileged.
|
||||
- Hash-prefixed container names (`abcdef012345_myservice`) are resolved
|
||||
to the actual DSM name for the API call and stripped from the
|
||||
display header — same convention as the other container tools.
|
||||
|
||||
### Documented (DSM quirk)
|
||||
|
||||
**`profile.volume_bindings[].host_volume_file` is share-relative**, not
|
||||
the full host path. A live capture against a container with a
|
||||
`/volume1/docker/homeassistant` bind mount returned
|
||||
`host_volume_file = "/docker/homeassistant"` in `profile`, while
|
||||
`details.Mounts[].Source` carried the full
|
||||
`/volume1/docker/homeassistant`. For a Compose-rebuild use case the
|
||||
full path is required — `inspect_container` therefore reads mount
|
||||
sources from `details.Mounts[].Source`, not from
|
||||
`profile.volume_bindings[].host_volume_file`. Recorded in CLAUDE.md.
|
||||
|
||||
## [0.6.0] - 2026-05-18
|
||||
|
||||
### Changed
|
||||
|
||||
**`build_stream` is no longer fire-and-forget (#2).** A live stream
|
||||
capture confirmed that DSM emits a readable plaintext build log over
|
||||
the HTTP body — one short status line per step — and closes the
|
||||
connection when the build is done. The 0.2.5 implementation sent the
|
||||
request and dropped the body unread, which meant a failed image pull
|
||||
left users with nothing more than a `BUILD_FAILED` polling status and
|
||||
no actionable diagnostic.
|
||||
|
||||
- `DsmClient.trigger_build_stream` now consumes the body line by line
|
||||
and returns the collected log as a string instead of `None`. A wall-
|
||||
clock budget of 210 s (`BUILD_STREAM_BUDGET`, kept under the Claude
|
||||
Desktop ~4 min tool-call ceiling) caps the read; on timeout the
|
||||
partial log is returned with a `[build_stream: timeout — stream
|
||||
still open server-side]` marker appended so the caller knows the
|
||||
build is still going server-side. Per-chunk `ReadTimeout` is treated
|
||||
the same way — return what we have plus the marker.
|
||||
- `redeploy_project` and `create_project` now parse the returned log
|
||||
via `_parse_build_stream_log`, which classifies any line containing
|
||||
`Error response from daemon:` or ending in ` Error` as a failure.
|
||||
When the log contains errors the tools abort immediately, surface
|
||||
the daemon line(s) in the result (e.g. `Error response from daemon:
|
||||
manifest for nginx:9.9.9-nonexistent not found: manifest unknown`),
|
||||
and skip the polling step entirely — DSM has already told us the
|
||||
build is dead. The existing `BUILD_FAILED` / `ERROR` polling guard
|
||||
(M-5) stays as a second safety net for late failures where the
|
||||
stream log was clean but the container exited after start.
|
||||
- No new MCP tool: the build log is a live stream and cannot be
|
||||
re-fetched after the build ends, so it is surfaced during
|
||||
`redeploy_project` / `create_project` rather than exposed as a
|
||||
standalone `get_project_build_log` call.
|
||||
|
||||
JSON error envelope handling, transport-error mapping (M-4), and the
|
||||
SID-scrubbed HTTP-error formatting are unchanged. Closes #2.
|
||||
|
||||
Minor version bump because `redeploy_project` and `create_project`
|
||||
return materially different strings on a failed build and exit
|
||||
earlier in the failure path; signatures unchanged.
|
||||
|
||||
## [0.5.1] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- `pull_image` (#3): the 0.5.0 implementation called
|
||||
`SYNO.Docker.Registry/pull_start`, which DSM rejects with
|
||||
"Method does not exist". A live DSM API capture confirmed that
|
||||
`pull_start` actually lives on `SYNO.Docker.Image`, not
|
||||
`SYNO.Docker.Registry`. The Registry API only exposes the
|
||||
synchronous read-only methods (`search`, `tags`, `get/set/create/
|
||||
delete`, `using`). Parameters are unchanged — both `repository`
|
||||
and `tag` are still JSON-encoded — and the `Image/list` polling
|
||||
for completion detection works as before. Note: #3 is already
|
||||
closed; this is the follow-up fix.
|
||||
|
||||
## [0.5.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
**Welle B Teil 1 — Registry tools (#3, #5).** Three new `SYNO.Docker.Registry`
|
||||
tools, reverse-engineered from a live DSM API capture (the n4s4 reference
|
||||
disagrees on parameter names and methods; the live capture wins):
|
||||
|
||||
- `search_registry` (#5) — search the active Docker registry by query string.
|
||||
Uses `SYNO.Docker.Registry/search` (version 1) with the query JSON-encoded
|
||||
as `q`, plus `offset`, `limit`, and `page_size`. Renders stars, downloads,
|
||||
the official-image flag, and a truncated description per hit, and shows
|
||||
the total match count so the caller knows when to raise `limit`. No
|
||||
confirmation gate (read-only).
|
||||
- `list_image_tags` — list available tags for a repository (bonus tool).
|
||||
Uses `SYNO.Docker.Registry/tags` (version 1) with the repository
|
||||
JSON-encoded as `repo` — note the parameter name diverges from the n4s4
|
||||
reference which uses `name`. The response shape is unusual: DSM returns
|
||||
the tag list as the envelope's `data` field directly (not wrapped in a
|
||||
sub-key), so the tool accepts both shapes defensively. Output is capped
|
||||
by `limit` (default 50) because popular images like `alpine` ship 200+
|
||||
tags. No confirmation gate (read-only).
|
||||
- `pull_image` (#3) — pull an image into the local cache via
|
||||
`SYNO.Docker.Registry/pull_start` (version 1) with both `repository` and
|
||||
`tag` JSON-encoded. Requires `confirmed=True`. DSM exposes no confirmed
|
||||
`pull_status` method, so completion is detected by polling
|
||||
`SYNO.Docker.Image/list` for the new `repository:tag` pair with a 2–10 s
|
||||
backoff schedule and a 240 s overall budget (kept under the Claude
|
||||
Desktop ~4 min tool-call ceiling). A timeout returns a non-fatal "still
|
||||
running" hint pointing at `list_images` instead of raising — DSM keeps
|
||||
pulling server-side regardless. Short-circuits when the image is already
|
||||
present locally so a repeated call is cheap. Closes #3 and #5.
|
||||
|
||||
Tool count rises from 31 to 34.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `inspect_image` rendering polish (follow-up to #4 after the 0.4.2
|
||||
parameter-contract fix). Three live-NAS observations that the
|
||||
initial implementation got wrong:
|
||||
- **Header showed `?:?`** — DSM `SYNO.Docker.Image/get` returns
|
||||
`image=""` and `tag=""` when the lookup is by name:tag, so the
|
||||
response fields are unreliable. The header now echoes the user-
|
||||
supplied `image_id` (e.g. `gitea/gitea:1.26.1`), falling back to
|
||||
the sha256 `id` only if `image_id` itself were empty.
|
||||
- **Ports rendered as raw Python dicts** like
|
||||
`{'port': '22', 'protocol': 'tcp'}`. The `ports` array is actually
|
||||
a list of `{"port", "protocol"}` objects; each entry is now
|
||||
formatted as `22/tcp`.
|
||||
- **Environment rendered as raw Python dicts** like
|
||||
`{'key': 'PATH', 'value': '...'}`. The `env` array is actually a
|
||||
list of `{"key", "value"}` objects; each entry is now formatted
|
||||
as `PATH=/usr/local/...`.
|
||||
`cmd`, `entrypoint`, and `volumes` (plain string arrays) were
|
||||
already correct and are unchanged. Referencing #4 (already closed).
|
||||
|
||||
## [0.4.2] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- `inspect_image` (#4): the 0.4.0 implementation called
|
||||
`SYNO.Docker.Image/get` with `name` + `tag` + `id` parameters, which
|
||||
DSM rejected with error 114 ("invalid parameter"). Reverse-
|
||||
engineering the live API capture revealed the correct contract:
|
||||
one JSON-encoded parameter named `identity` that accepts either form:
|
||||
- `name:tag` (e.g. `"gitea/gitea:1.26.1"`)
|
||||
- `sha256:<hash>` (e.g. `"sha256:cd21e54e..."`)
|
||||
The pre-resolution lookup against `list_images` is no longer needed —
|
||||
the user input is JSON-encoded and passed straight through.
|
||||
- `inspect_image` response parsing: the endpoint does NOT return
|
||||
`details.Config.*` / `RootFS.Layers` (the Docker-engine inspect shape
|
||||
the previous code assumed). The actual top-level fields are `image`,
|
||||
`tag`, `id`, `digest`, `size`, `virtual_size`, `author`,
|
||||
`docker_version`, `cmd`, `entrypoint`, `env`, `ports`, `volumes`.
|
||||
Layer rendering is removed (the endpoint does not surface layers);
|
||||
digest, author, docker version, and volumes are now displayed.
|
||||
|
||||
## [0.4.1] - 2026-05-18
|
||||
|
||||
### Removed
|
||||
|
||||
- `pause_container` and `unpause_container` (added in 0.4.0) — live
|
||||
test against DSM revealed that Container Manager on this firmware
|
||||
does not expose pause/resume at all. The GUI action menu only offers
|
||||
Start / Stop / Force-Stop / Restart / Reset, and direct calls to
|
||||
`SYNO.Docker.Container/pause` and `/unpause` return "Method does not
|
||||
exist". Both tools have been removed rather than left as a broken
|
||||
surface. The remaining three lifecycle tools (`start_container`,
|
||||
`stop_container`, `restart_container`) are unaffected. Tool count
|
||||
drops from 33 to 31. Closes #7 (won't fix — DSM-side limitation).
|
||||
|
||||
## [0.4.0] - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
**Container lifecycle (#1, #7)** — five new tools fill the gap between
|
||||
"project" and "exec" operations. Until now the only way to stop or
|
||||
restart a single container was to stop the whole project. All five
|
||||
use `SYNO.Docker.Container` (version=1) with the same JSON-encoded
|
||||
`name` parameter pattern as `delete_container`, and route through
|
||||
`_resolve_container_name` so a clean name like `jenkins` resolves
|
||||
to DSM's hash-prefixed `f93cb8b504f7_jenkins`:
|
||||
|
||||
- `start_container` — start a stopped container (no confirmation).
|
||||
- `stop_container` — stop a running container (confirmation required).
|
||||
- `restart_container` — stop + start in one call (confirmation required).
|
||||
- `pause_container` — SIGSTOP the container's processes (confirmation
|
||||
required).
|
||||
- `unpause_container` — SIGCONT the container's processes (no
|
||||
confirmation; reversing a pause is non-destructive).
|
||||
|
||||
`stop` is live-verified against DSM; `start`, `restart`, `pause`, and
|
||||
`unpause` are implemented by symmetry on the same API surface. If any
|
||||
of them turns out to need a different parameter shape in production,
|
||||
it will be reverse-engineered via DevTools capture in a follow-up.
|
||||
|
||||
**Image inspection (#4)** — `inspect_image` returns the full image
|
||||
detail blob (layers + sizes, environment variables, exposed ports,
|
||||
entrypoint/cmd, working directory, labels) via
|
||||
`SYNO.Docker.Image/get`. Accepts the same identifier forms as
|
||||
`delete_image` — `name:tag`, registry-prefixed `ghcr.io/foo/bar:v1`,
|
||||
and bare hash — and resolves the user input against `list_images`
|
||||
first so typos produce a clean "not found" rather than an opaque DSM
|
||||
error. The response parser is defensive about wrapper shape
|
||||
(`details.<docker fields>` vs. flat) so the tool keeps working if DSM
|
||||
varies the envelope between firmware versions.
|
||||
|
||||
**System overview (#6)** — `system_overview` aggregates CPU %, RAM
|
||||
used/limit, network I/O, and block I/O across all running containers
|
||||
plus running/stopped counts. No new DSM endpoint: composed from
|
||||
`SYNO.Docker.Container/list` + `SYNO.Docker.Container/stats`,
|
||||
re-using the same Docker-formula CPU calculation as
|
||||
`container_stats`. Errors from either upstream call are non-fatal —
|
||||
the available section is rendered and the failure is surfaced under a
|
||||
`Warnings:` block.
|
||||
|
||||
## [0.3.3] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- `delete_container`: `SYNO.Docker.Container/delete` requires three
|
||||
parameters — `name` (JSON-encoded), `force=false`, and
|
||||
`preserve_profile=false`. Previously only `name` was sent (without
|
||||
JSON-encoding), causing DSM to reject the call with error 114.
|
||||
- `delete_project`: DSM does **not** reject `Project/delete` on a
|
||||
running project — it silently removes the registration and leaves
|
||||
the containers running as orphans. The connector now blocks the call
|
||||
itself when the project status is `RUNNING`, before issuing any DSM
|
||||
request, and tells the user to `stop_project` first. The previous
|
||||
implementation relied on a DSM-level rejection that never occurs in
|
||||
practice; the corresponding unit test was a false positive (mocked
|
||||
an error that real DSM never returns) and has been replaced with a
|
||||
test that asserts `Project/delete` is never called for a `RUNNING`
|
||||
project.
|
||||
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- `delete_project` — remove a Container Manager project's
|
||||
registration via `SYNO.Docker.Project/delete` with the UUID
|
||||
JSON-encoded as the `id` parameter (per DSM convention).
|
||||
Mirrors the "Delete project" action in Container Manager: only the
|
||||
registration is removed; the project folder and compose file remain
|
||||
on the NAS. The success message explicitly states the folder was
|
||||
preserved so the user is not surprised. Closes the project
|
||||
lifecycle (create → start/stop/redeploy → delete).
|
||||
Safety:
|
||||
- Project-name validation runs before any I/O.
|
||||
- A `_find_project` pre-flight returns "not found" with a clear
|
||||
message rather than letting DSM reject an unknown UUID.
|
||||
- The tool deliberately does NOT auto-stop a running project. If
|
||||
DSM rejects the delete on a `RUNNING` project, the response tells
|
||||
the user to `stop_project` first rather than silently halting
|
||||
containers under the guise of a "delete" call.
|
||||
- Requires `confirmed=True`; the preview shows name, UUID, status,
|
||||
full path, and share path so the user can verify before deleting.
|
||||
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- `create_project` — register a new Container Manager project from a
|
||||
compose YAML string. Three-step flow:
|
||||
1. Create the target folder via `SYNO.FileStation.CreateFolder` with
|
||||
`force_parent=true` (idempotent — does not fail if the folder
|
||||
already exists, and creates missing intermediate directories).
|
||||
Without this step, `SYNO.Docker.Project/create` fails with DSM
|
||||
error code 2100.
|
||||
2. `SYNO.Docker.Project/create` (form-encoded POST, JSON-encoded
|
||||
string parameters per DSM convention) returns the new project's
|
||||
UUID.
|
||||
3. `trigger_build_stream` + `_wait_for_project_running` — reuses the
|
||||
existing image-pull / start / poll machinery (including the
|
||||
`BUILD_FAILED` early-exit from welle 2).
|
||||
Defaults: `share_path` is derived from `compose_base_path` (e.g.
|
||||
`/volume1/docker` + `myapp` → `/docker/myapp`). The compose content
|
||||
is validated as YAML before any side effects. A pre-flight
|
||||
`list_projects` check rejects duplicate names with a clear message
|
||||
rather than leaving an orphaned folder on the NAS. Requires
|
||||
`confirmed=True`; the preview shows the resolved share path and the
|
||||
service count parsed from the compose content.
|
||||
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- `DsmClient.trigger_build_stream`: broaden transport-error handling
|
||||
(M-4). `httpx.ReadTimeout` is still treated as a success signal (the
|
||||
request was sent; DSM is processing it server-side), but all other
|
||||
`httpx.HTTPError` subclasses (`ConnectError`, `ConnectTimeout`,
|
||||
`WriteError`, `RemoteProtocolError`, …) are now converted into a
|
||||
`SynologyError` with a clear message. Previously these propagated as
|
||||
raw httpx exceptions and left callers with a stack trace.
|
||||
- `redeploy_project`: when build_stream (or the polling step) fails
|
||||
after the project has already been stopped, the response now
|
||||
explicitly tells the user that the project is in `STOPPED` state and
|
||||
must be recovered with `start_project` or another `redeploy_project`
|
||||
call. Previously the response suggested "use stop + start
|
||||
separately", which was misleading because stop had already happened.
|
||||
- `_wait_for_project_running`: exit the polling loop early on
|
||||
`BUILD_FAILED` / `ERROR` (M-5). DSM signals these statuses within
|
||||
seconds of a failed image pull; the old polling loop kept waiting up
|
||||
to 5 minutes for `RUNNING`. `redeploy_project` surfaces the terminal
|
||||
status with a hint to `update_image_tag` and retry when the cause is
|
||||
`BUILD_FAILED`.
|
||||
- `system_prune` preview: count unused user-created networks alongside
|
||||
dangling images and stopped containers (M-6). Built-in networks
|
||||
(`bridge`, `host`, `none`) are excluded because Docker never prunes
|
||||
them. Previously the preview noted "Unused networks: (not counted)",
|
||||
even though the underlying `SYNO.Docker.Utils/prune` call deletes
|
||||
them — users could lose networks they had not been warned about.
|
||||
|
||||
### Changed
|
||||
|
||||
- Minor version bump because `redeploy_project` and `system_prune`
|
||||
return different strings (and `redeploy_project` returns earlier on
|
||||
`BUILD_FAILED`). No tool signatures changed.
|
||||
|
||||
## [0.2.9] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- `__version__` is now derived from package metadata via
|
||||
`importlib.metadata.version()`, eliminating the drift between
|
||||
`pyproject.toml` and `src/mcp_synology_container/__init__.py` (was stuck
|
||||
at `0.1.0` since the initial release).
|
||||
- `compose.py`: reject project names that contain path separators or other
|
||||
unsafe characters before they reach `_find_compose_path`. Previously a
|
||||
name like `"../../etc"` could traverse out of `compose_base_path` when
|
||||
the project was not yet registered with Container Manager. The new
|
||||
`_validate_project_name` helper enforces `^[a-zA-Z0-9_-]+$` and is
|
||||
applied to `read_compose`, `update_compose`, `update_image_tag`, and
|
||||
`update_env_var`. Addresses M-3 from the v0.2.8 review.
|
||||
|
||||
### Docs
|
||||
|
||||
- `CHANGELOG.md` backfilled for releases 0.2.7 and 0.2.8 (entries had been
|
||||
missed during those releases).
|
||||
|
||||
## [0.2.8] - 2026-04-21
|
||||
|
||||
### Added
|
||||
|
||||
- `tests/test_dsm_client.py` — comprehensive offline test suite for
|
||||
`DsmClient`:
|
||||
- `_scrub_url` and `_error_message` pure helpers.
|
||||
- `request()` happy-path, API-not-cached, `_sid` scrubbing in
|
||||
`HTTPStatusError`, sensitive-param log masking.
|
||||
- Session re-auth retry: single-retry semantics, auth-manager-absent
|
||||
path, re-auth failure path, thundering-herd (login called once under
|
||||
concurrent 106 responses).
|
||||
- `trigger_build_stream`: SSE fire-and-forget, JSON error detection,
|
||||
`ReadTimeout` swallowing, HTTP-error scrubbing.
|
||||
- `upload_text` and `download_text` happy-path + error-response branches.
|
||||
- `_ensure_initialized` double-checked locking and negative-cache
|
||||
cooldown behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `DsmClient._ensure_initialized`: cache failed init outcomes for 60 s so
|
||||
that repeated tool calls during a credential outage (wrong password,
|
||||
IP-blocked 407, DNS failure) do not keep hammering DSM. Each caller
|
||||
receives the cached exception until the cooldown window expires, after
|
||||
which a fresh attempt is made. Adds `INIT_ERROR_COOLDOWN` module
|
||||
constant and `_init_error` / `_init_error_until` state. Addresses M4
|
||||
from the 0.2.7 review.
|
||||
|
||||
## [0.2.7] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- `DsmClient`: scrub `_sid` query-parameter values from URLs embedded in
|
||||
`httpx.HTTPStatusError` messages so the raw DSM session ID never reaches
|
||||
log output or MCP tool responses (C1).
|
||||
- `DsmClient`: re-auth lock now snapshots `_sid` before entering the lock
|
||||
and skips the redundant login if another task has already refreshed the
|
||||
session, eliminating duplicate logins on concurrent 106/107/119
|
||||
responses (M3).
|
||||
- `DsmClient.trigger_build_stream`: re-instates immediate JSON-error
|
||||
detection (regression from 0.2.6). Inspects the `Content-Type` header
|
||||
and reads a small capped prefix of the body for `application/json`
|
||||
responses to surface DSM error codes without forcing the caller into a
|
||||
multi-minute polling timeout. SSE responses remain fire-and-forget
|
||||
(C2/M8).
|
||||
- `compose.update_env_var`: parenthesise the apply-branch match condition
|
||||
so `(isinstance AND startswith) OR (entry == var_name)` no longer
|
||||
evaluates the equality branch for non-string entries — aligns the apply
|
||||
side with the preview-side detection logic (M1).
|
||||
|
||||
### Changed
|
||||
|
||||
- All 23 `@mcp.tool()` functions: strip `-> str` return annotations and
|
||||
trim docstrings to a single line (≤100 chars). FastMCP generates an
|
||||
`outputSchema` entry for every annotated tool, which roughly doubles
|
||||
the `tools/list` payload size; multi-line docstrings with
|
||||
`Args:`/`Returns:` sections add further bulk that Claude Desktop must
|
||||
parse on every connection.
|
||||
|
||||
### Chore
|
||||
|
||||
- `uv.lock` resynced (was stale at `0.2.2`).
|
||||
- `.gitignore`: exclude `.claude/` per-user Claude Code settings.
|
||||
- Mechanical `ruff check --fix` + `ruff format` cleanup (import sorting,
|
||||
unused-import removal). No functional change.
|
||||
|
||||
## [0.2.6] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- `DsmClient.trigger_build_stream`: Claude Desktop aborts tool calls after
|
||||
~4 minutes. The previous implementation read the first SSE chunk before
|
||||
returning, which could block for the entire duration of an image pull.
|
||||
Fixed by making the call truly fire-and-forget: the HTTP request is sent,
|
||||
response headers are received (HTTP status check only), then the connection
|
||||
is closed immediately without reading any SSE events. DSM continues the
|
||||
build server-side regardless. The `_json` import added in 0.2.5 is removed.
|
||||
|
||||
## [0.2.5] - 2026-04-21
|
||||
|
||||
### Changed
|
||||
|
||||
- `redeploy_project`: Replaced the delete-before-start image workaround with
|
||||
`SYNO.Docker.Project/build_stream` — the programmatic equivalent of the DSM
|
||||
"Erstellen" (Build) button, confirmed via browser DevTools capture.
|
||||
New 3-step flow for all project states:
|
||||
1. Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED)
|
||||
2. `build_stream` — DSM pulls updated images and starts the project via SSE
|
||||
3. Poll for RUNNING (timeout raised from 30 s to 5 min to accommodate image pulls)
|
||||
`build_stream` errors are now fatal (abort the redeploy with a clear message).
|
||||
|
||||
### Added
|
||||
|
||||
- `DsmClient.trigger_build_stream(project_id)` — fires a streaming GET to
|
||||
`SYNO.Docker.Project/build_stream`, reads the first SSE chunk to confirm DSM
|
||||
accepted the request, then closes the connection. The build continues
|
||||
server-side. Handles immediate JSON error responses; swallows `ReadTimeout`
|
||||
(stream still open = build running).
|
||||
|
||||
## [0.2.4] - 2026-04-21
|
||||
|
||||
### Changed
|
||||
|
||||
- `redeploy_project`: Replaced broken `SYNO.Docker.Image/pull` with a
|
||||
delete-before-start workaround. The tool now reads image tags from the
|
||||
project's compose file via FileStation, deletes each cached image before
|
||||
calling `start` (so DSM auto-pulls the latest version), then polls for
|
||||
`RUNNING`. Image deletion is non-fatal — if it fails the project still starts.
|
||||
Unified 4-step flow for all project states (RUNNING, STOPPED, BUILD_FAILED).
|
||||
|
||||
### Added
|
||||
|
||||
- `update_image_tag`: Auto-updates environment variables whose value equals
|
||||
the numeric version prefix of the old tag when the new tag shares the same
|
||||
`<digits>-<suffix>` pattern. For example, changing `2.558-jdk21` →
|
||||
`2.560-jdk21` automatically updates `JENKINS_VERSION=2.558` to
|
||||
`JENKINS_VERSION=2.560`. The preview (unconfirmed call) now lists which env
|
||||
vars will be updated. Only triggers when the variable exists and the pattern
|
||||
matches; no change for plain tags like `latest`.
|
||||
|
||||
## [0.2.3] - 2026-04-21
|
||||
|
||||
### Changed
|
||||
|
||||
- `CLAUDE.md` rewritten: removed all operator-specific infrastructure details
|
||||
(hostnames, container names, image tags, personal notes). Kept DSM API quirks,
|
||||
implementation rules, and tool inventory.
|
||||
|
||||
## [0.2.2] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- `redeploy_project` (BUILD_FAILED): Pull errors are no longer silently suppressed.
|
||||
If the image pull fails (e.g. the tag in compose.yaml does not exist on the registry),
|
||||
redeploy aborts immediately with a clear message pointing to `update_image_tag`.
|
||||
|
||||
## [0.2.1] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- `redeploy_project`: After issuing `start`, the tool now polls the project status every 2 seconds
|
||||
for up to 30 seconds until the project reaches `RUNNING`. Previously DSM returned immediately
|
||||
while containers were still starting, causing the project to appear as `exited` when checked
|
||||
right after redeploy. On timeout a warning is returned instead of an error.
|
||||
- `delete_image`: Now distinguishes between running and stopped container references.
|
||||
A stopped container holding the image produces a clear hint to use `delete_container`
|
||||
or `system_prune` instead of a generic "in use" error.
|
||||
- `redeploy_project` (BUILD_FAILED path): Added explicit image pull step before restart
|
||||
(`stop → pull → start`). Previously the old cached image could be reused.
|
||||
|
||||
### Added
|
||||
|
||||
- `delete_container` — delete a stopped container by name; refuses if container is still running;
|
||||
requires `confirmed=True`.
|
||||
|
||||
## [0.2.0] - 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
**Images**
|
||||
- `list_images` — list local images sorted by size; marks images in use and with available updates
|
||||
- `delete_image` — delete image by `name:tag` or hash; refuses if image is used by a container
|
||||
|
||||
**Container**
|
||||
- `container_stats` — live CPU %, RAM used/limit, network I/O, block I/O
|
||||
|
||||
**System**
|
||||
- `system_df` — Docker disk usage: image count/size, running/stopped containers, reclaimable space
|
||||
- `system_prune` — remove dangling images, stopped containers, and unused networks
|
||||
|
||||
**Networks**
|
||||
- `list_networks` — list all Docker networks with driver, subnet, gateway, attached containers
|
||||
- `create_network` — create a new bridge (or other driver) network
|
||||
- `delete_network` — delete a network; refuses if any container is still attached
|
||||
|
||||
### Fixed
|
||||
|
||||
- `get_container_status` now reads the correct DSM response fields (`details.State` for status/running, `profile.image` for image name) and displays IP addresses, port bindings, and mounts
|
||||
- `redeploy_project` is now status-aware: RUNNING → stop + start; STOPPED → start directly; BUILD_FAILED → force-stop + start; unknown status returns a clear error with workaround hint
|
||||
- Container names with hash prefix (e.g. `f93cb8b504f7_jenkins`) are now transparently stripped in `list_containers`, `container_stats`, `get_container_status`, `get_container_logs`, and `exec_in_container`
|
||||
|
||||
### Changed
|
||||
|
||||
- `DsmClient` gained a `post_request()` method for form-encoded POST operations required by image delete, network create/delete
|
||||
|
||||
## [0.1.0] - 2026-04-13
|
||||
|
||||
### Added
|
||||
|
||||
- Initial implementation
|
||||
- **Projects**: `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`
|
||||
- **Containers**: `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`
|
||||
- **Compose**: `read_compose`, `update_compose`, `update_image_tag`, `update_env_var`
|
||||
- **Images**: `check_image_updates`
|
||||
- DSM session management with auto re-authentication on session timeout
|
||||
- OS keyring integration for secure credential storage
|
||||
- 2FA / device token support
|
||||
- Interactive `setup` wizard and `check` connectivity command
|
||||
@@ -1,92 +1,147 @@
|
||||
# CLAUDE.md – Arbeitsanweisung für mcp-synology-container
|
||||
# mcp-synology-container
|
||||
|
||||
## Deine Aufgabe
|
||||
## Project
|
||||
|
||||
Implementiere das Projekt `mcp-synology-container` vollständig gemäß `SPEC.md`.
|
||||
|
||||
Lies `SPEC.md` jetzt vollständig, bevor du anfängst.
|
||||
`mcp-synology-container` is an MCP server for managing Docker projects on a
|
||||
Synology DiskStation via Container Manager. It exposes tools for projects,
|
||||
containers, images, compose files, networks, and system housekeeping.
|
||||
|
||||
---
|
||||
|
||||
## Referenzmaterial
|
||||
## Tech stack
|
||||
|
||||
Im Ordner `reference/` findest du zwei Referenzprojekte. Nutze sie aktiv:
|
||||
|
||||
### `reference/cmeans/`
|
||||
Geklontes Repo von `cmeans/mcp-synology` (GitHub).
|
||||
Übernimm daraus die Implementierungsmuster für:
|
||||
- Auth-Flow und 2FA-Device-Token-Flow (`auth.py`)
|
||||
- OS-Keyring-Integration (`keyring` library)
|
||||
- CLI-Struktur mit `click` (`cli.py`)
|
||||
- Config laden/speichern mit YAML (`config.py`)
|
||||
- Credential-Auflösungsreihenfolge (env vars → config → keyring)
|
||||
- `setup`-Wizard mit `rich` für formatierte Ausgabe
|
||||
- Generierung des Claude-Desktop-Config-Snippets
|
||||
|
||||
Passe alle übernommenen Muster an unseren Use-Case an.
|
||||
Kopiere keinen Code blind – verstehe ihn und adaptiere ihn.
|
||||
|
||||
### `reference/n4s4/docker_api.py`
|
||||
Einzelne Datei aus `N4S4/synology-api` (GitHub).
|
||||
Nutze sie als Referenz für die konkreten DSM API-Calls:
|
||||
- Wie `SYNO.Docker.Project` aufgerufen wird (list, start, stop)
|
||||
- Wie `SYNO.Docker.Container` aufgerufen wird (list, logs, exec)
|
||||
- Wie `SYNO.Docker.Image` aufgerufen wird (list)
|
||||
- Welche Parameter und Response-Strukturen die APIs erwarten
|
||||
|
||||
Implementiere **keinen** eigenen Wrapper um `synology-api` –
|
||||
baue einen eigenen schlanken HTTP-Client in `dsm_client.py` mit `httpx`.
|
||||
| | |
|
||||
|---|---|
|
||||
| **Language** | Python 3.12+, `uv` |
|
||||
| **Key deps** | MCP SDK, `httpx`, `keyring`, `click`, `rich` |
|
||||
| **Compose paths** | `/volume1/docker/<project>/` (default Synology layout) |
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge der Implementierung
|
||||
## Deploy workflow (after every code change)
|
||||
|
||||
Arbeite in dieser Reihenfolge. Schließe jeden Schritt vollständig ab bevor du weitermachst:
|
||||
```
|
||||
1. Claude Code commits and pushes
|
||||
2. uv tool install --reinstall git+<repo-url>
|
||||
3. Restart Claude Desktop (tray icon → Quit → relaunch)
|
||||
```
|
||||
|
||||
1. **Projektstruktur anlegen** – alle Ordner und leere `__init__.py`-Dateien, `pyproject.toml`
|
||||
2. **`config.py`** – Config laden, speichern, validieren
|
||||
3. **`auth.py`** – Keyring-Integration, Login gegen DSM API, 2FA-Device-Token-Flow
|
||||
4. **`dsm_client.py`** – HTTP-Client mit Session-Management, Auto-Re-Login
|
||||
5. **`cli.py`** – `setup`, `check`, `serve` Befehle
|
||||
6. **`modules/projects.py`** – SYNO.Docker.Project Tools
|
||||
7. **`modules/containers.py`** – SYNO.Docker.Container Tools
|
||||
8. **`modules/compose.py`** – Compose-Datei lesen/schreiben via FileStation API
|
||||
9. **`modules/images.py`** – SYNO.Docker.Image Tools
|
||||
10. **Tests** – für jeden Schritt
|
||||
11. **`README.md`** – Installationsanleitung und Tool-Übersicht
|
||||
12. **`docs/setup.md`** und **`docs/tools.md`**
|
||||
**Push retry:** the Gitea remote (`gitea.gecheckt.de`) occasionally
|
||||
returns `Unauthorized` on the first push attempt. If `git push` fails
|
||||
with an auth error, wait 1 s and retry once before reporting back.
|
||||
Only a second consecutive failure is treated as a real auth problem.
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Implementierungsregeln
|
||||
## Implemented tools (35)
|
||||
|
||||
- **Confirmation vor destruktiven Operationen:** `stop_project`, `redeploy_project`,
|
||||
`exec_in_container`, `update_image_tag`, `update_env_var`, `update_compose` müssen
|
||||
eine Bestätigung vom Nutzer einholen bevor sie ausgeführt werden. Nutze dafür den
|
||||
MCP-eigenen `confirm`-Mechanismus.
|
||||
|
||||
- **Nach Compose-Änderungen:** Immer automatisch `redeploy_project` vorschlagen.
|
||||
|
||||
- **Fehlerbehandlung:** Alle DSM API-Fehler sauber abfangen und als verständliche
|
||||
Fehlermeldung zurückgeben. Keine Python-Stacktraces zum Nutzer.
|
||||
|
||||
- **Keine Secrets in Logs:** Passwörter, Tokens und Session-IDs niemals in
|
||||
stderr-Ausgaben schreiben.
|
||||
|
||||
- **HTTPS:** `verify_ssl: true` ist der Standard. Nur auf expliziten Wunsch deaktivierbar.
|
||||
|
||||
- **Compose-Pfade:** Beide Varianten erkennen – `docker-compose.yml` und `compose.yml`.
|
||||
|
||||
- **Type Hints:** Konsequent in allen Funktionen verwenden.
|
||||
|
||||
- **Docstrings:** Für alle öffentlichen Funktionen und Klassen.
|
||||
| Category | Tools |
|
||||
|---|---|
|
||||
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project`, `delete_project` |
|
||||
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `inspect_container`, `delete_container`, `start_container`, `stop_container`, `restart_container` |
|
||||
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
|
||||
| Images | `check_image_updates`, `list_images`, `delete_image`, `inspect_image` |
|
||||
| Registry | `search_registry`, `list_image_tags`, `pull_image` |
|
||||
| Networks | `list_networks`, `create_network`, `delete_network` |
|
||||
| System | `system_df`, `system_prune`, `system_overview` |
|
||||
|
||||
---
|
||||
|
||||
## Projekt-Konventionen
|
||||
## DSM API quirks
|
||||
|
||||
- Sprache: Python 3.12+
|
||||
- Formatter: `ruff format`
|
||||
- Linter: `ruff check`
|
||||
- Tests: `pytest`
|
||||
- Alle Texte (Docstrings, Kommentare, README): Englisch
|
||||
- **Hash-prefixed container names** — DSM sometimes returns names like
|
||||
`a1b2c3d4e5f6_myservice` when the compose service name differs from
|
||||
`container_name`. All container tools strip this prefix transparently via
|
||||
`_strip_hash_prefix` / `_resolve_container_name`.
|
||||
- **Async project start** — `SYNO.Docker.Project/start` returns immediately
|
||||
while containers are still initialising. `redeploy_project` polls
|
||||
`SYNO.Docker.Project/list` every 2 s for up to 30 s after issuing start.
|
||||
- **`SYNO.Docker.Project/build_stream`** — returns a streamed plaintext
|
||||
build log (content-type `text/html`), one short line per step:
|
||||
`Container <name> Running` on success, `<svc> Error` followed by
|
||||
`Error response from daemon: <cause>` on failure. The stream closes
|
||||
when the build is done. `DsmClient.trigger_build_stream` consumes the
|
||||
body line-by-line with a 210 s wall-clock budget (under the Claude
|
||||
Desktop ~4 min ceiling) and returns the log as a string; on timeout
|
||||
the partial log is returned with a marker appended so callers know
|
||||
the build is still running server-side. `redeploy_project` and
|
||||
`create_project` grep the returned log for daemon errors and abort
|
||||
early — these errors are much more actionable than the eventual
|
||||
`BUILD_FAILED` polling status. The log is **live-only**: it cannot
|
||||
be re-fetched after the build ends, which is why no standalone
|
||||
`get_project_build_log` tool exists.
|
||||
- **Image delete** — requires a form-encoded POST with a JSON `images` array
|
||||
(confirmed via browser DevTools); uses `DsmClient.post_request()`.
|
||||
- **`SYNO.Docker.Image/pull` vs. `pull_start`** — the legacy `pull` method
|
||||
exists but behaviour varies by DSM version; not exposed as a standalone
|
||||
tool. `pull_image` uses `SYNO.Docker.Image/pull_start` (asynchronous
|
||||
pull entry point) with both `repository` and `tag` JSON-encoded. Note
|
||||
that `pull_start` lives on **`SYNO.Docker.Image`**, NOT on
|
||||
`SYNO.Docker.Registry` — the Registry API only exposes the synchronous
|
||||
read-only methods (`search`, `tags`, `get/set/create/delete`, `using`);
|
||||
calling `Registry/pull_start` returns "Method does not exist". No
|
||||
matching `pull_status` method is confirmed on either API, so completion
|
||||
is detected by polling `SYNO.Docker.Image/list` until `repository:tag`
|
||||
appears (2–10 s backoff, 240 s budget). Timeout returns a "still
|
||||
running" hint instead of raising — DSM keeps pulling server-side
|
||||
regardless of the HTTP response.
|
||||
- **`SYNO.Docker.Registry/tags`** — uses `repo` (JSON-encoded) as the
|
||||
parameter name; the n4s4 reference's `name` does not work on this DSM
|
||||
version. Returns the tag list as the envelope's `data` field directly,
|
||||
not wrapped in a sub-key.
|
||||
- **`SYNO.Docker.Volume`** — endpoint does not exist; volume management is
|
||||
not available via the DSM WebAPI.
|
||||
- **`SYNO.Docker.Registry/get`** — does not behave as documented; registry
|
||||
listing omitted.
|
||||
- **`SYNO.Docker.Container/pause` and `/unpause`** — not implemented in
|
||||
DSM Container Manager on this firmware. The action menu only offers
|
||||
start/stop/force-stop/restart/reset; calls to `pause`/`unpause` return
|
||||
"Method does not exist". `pause_container` and `unpause_container`
|
||||
were briefly shipped in 0.4.0 and removed in 0.4.1.
|
||||
- **`SYNO.Docker.Container/get` response — `profile.volume_bindings[].host_volume_file`
|
||||
is share-relative, not the full host path.** Live capture against a
|
||||
container with bind mount `/volume1/docker/homeassistant:/config`
|
||||
returned `host_volume_file = "/docker/homeassistant"` (21 chars,
|
||||
share-relative) in `profile`, while `details.Mounts[].Source` carried
|
||||
the full `/volume1/docker/homeassistant` and `details.HostConfig.Binds`
|
||||
the full `/volume1/docker/homeassistant:/config:rw`. For
|
||||
Compose-rebuild use cases the full path is required — `inspect_container`
|
||||
reads mount sources from `details.Mounts[].Source`, not from
|
||||
`profile.volume_bindings[].host_volume_file`. The DSM action `inspect`
|
||||
(no `get`) does not exist (code 103 "Method does not exist"); use `get`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation rules
|
||||
|
||||
- Confirmation required before destructive operations: `stop_project`,
|
||||
`redeploy_project`, `create_project`, `delete_project`,
|
||||
`exec_in_container`, `update_image_tag`, `update_env_var`,
|
||||
`update_compose`, `delete_container`, `stop_container`,
|
||||
`restart_container`, `pull_image`
|
||||
- After compose changes: suggest `redeploy_project`
|
||||
- DSM errors → human-readable message, no stack traces
|
||||
- No secrets in stderr output
|
||||
- Type hints and docstrings everywhere
|
||||
- Formatter: `ruff format` · Linter: `ruff check` · Tests: `pytest`
|
||||
- All text (docstrings, comments, README): English
|
||||
- **CHANGELOG.md:** every user-visible change (bug fix, new/changed
|
||||
tool, behavior change, security fix, dependency bump) gets a
|
||||
`CHANGELOG.md` entry in the same commit — under a `## [Unreleased]`
|
||||
heading between releases, which becomes `## [X.Y.Z] - YYYY-MM-DD` on
|
||||
version bump. Pure internal cleanup (renames without external callers,
|
||||
comment-only edits, ruff autofix) needs no entry. Don't ship a release
|
||||
with a stale changelog (this was the C-2 gap that caused 0.2.7 and
|
||||
0.2.8 to ship undocumented).
|
||||
- **Version consistency:** the package version lives in `pyproject.toml`
|
||||
and must stay in sync with `uv.lock` and the `[X.Y.Z]` heading in
|
||||
`CHANGELOG.md`. `src/mcp_synology_container/__init__.py` derives
|
||||
`__version__` from `importlib.metadata` and is never hand-edited.
|
||||
Every version bump touches all three files in the same commit.
|
||||
|
||||
---
|
||||
|
||||
## DSM API reference
|
||||
|
||||
- `cmeans/mcp-synology` (GitHub) — auth, keyring, CLI structure
|
||||
- `N4S4/synology-api` `docker_api.py` (GitHub) — `SYNO.Docker.*` calls
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# mcp-synology-container
|
||||
|
||||
An MCP (Model Context Protocol) server for managing Docker projects on a Synology NAS via Container Manager. Enables Claude Desktop to list, start, stop, redeploy, and modify Docker Compose projects directly.
|
||||
An MCP (Model Context Protocol) server for managing Docker on a Synology NAS via Container Manager. Enables Claude Desktop to inspect, start, stop, redeploy, and modify Docker Compose projects directly — including live resource stats, log tailing, network management, and disk usage.
|
||||
|
||||
## Features
|
||||
|
||||
- **Project management**: list, start, stop, redeploy Container Manager projects
|
||||
- **Container inspection**: list containers, view status and resource usage, fetch logs
|
||||
- **Compose file editing**: read, modify image tags, update env vars, or replace compose files entirely
|
||||
- **Image update checks**: see which images have updates available
|
||||
- **Project management**: list, start, stop, redeploy Container Manager projects (status-aware)
|
||||
- **Container inspection**: status with IPs/ports/mounts, live resource stats, log tailing, exec
|
||||
- **Compose file editing**: read, replace, update image tags and env vars
|
||||
- **Image management**: list images with sizes, check for updates, delete unused images
|
||||
- **Network management**: list, create, and delete Docker networks
|
||||
- **System overview**: disk usage (images, containers), prune dangling resources
|
||||
- **Hash-prefix handling**: transparent resolution of DSM hash-prefixed container names
|
||||
- **2FA support**: device token flow for Synology accounts with OTP enabled
|
||||
- **OS keyring integration**: credentials stored securely, never in config files
|
||||
- **Confirmation required** for all destructive operations (stop, redeploy, exec, compose writes)
|
||||
- **Confirmation required** for all destructive operations
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -121,41 +124,60 @@ Credentials from environment variables take priority over the keyring.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
See [docs/tools.md](docs/tools.md) for the full tool reference.
|
||||
23 tools across 6 categories.
|
||||
|
||||
### Projects
|
||||
|
||||
| Tool | Description | Confirmation |
|
||||
|---|---|---|
|
||||
| `list_projects` | List all Container Manager projects | — |
|
||||
| `get_project_status` | Detailed project status | — |
|
||||
| `start_project` | Start a project | — |
|
||||
| `stop_project` | Stop a project | required |
|
||||
| `redeploy_project` | Pull images + restart project | required |
|
||||
| `list_projects` | List all Container Manager projects with status and container count | — |
|
||||
| `get_project_status` | Detailed status, path, services, and container IDs | — |
|
||||
| `start_project` | Start a stopped project | — |
|
||||
| `stop_project` | Stop a running project (halts all containers) | required |
|
||||
| `redeploy_project` | Redeploy a project via DSM `build_stream` (equivalent to the DSM "Erstellen" button); pulls latest images and restarts; auto-detects current state (RUNNING/STOPPED/BUILD_FAILED) | required |
|
||||
|
||||
### Containers
|
||||
|
||||
| Tool | Description | Confirmation |
|
||||
|---|---|---|
|
||||
| `list_containers` | List containers (optionally filtered by project) | — |
|
||||
| `get_container_status` | Status, uptime, resource limits | — |
|
||||
| `get_container_logs` | Fetch container log output | — |
|
||||
| `exec_in_container` | Execute command in container | required |
|
||||
| `list_containers` | List containers, optionally filtered by project | — |
|
||||
| `get_container_status` | Status, running state, image, IP addresses, port bindings, mounts | — |
|
||||
| `get_container_logs` | Fetch log output with optional keyword filter | — |
|
||||
| `exec_in_container` | Execute a shell command inside a running container | required |
|
||||
| `container_stats` | Live CPU %, RAM used/limit, network I/O, block I/O | — |
|
||||
| `delete_container` | Delete a stopped container by name; refuses if container is still running | required |
|
||||
|
||||
### Compose Files
|
||||
|
||||
| Tool | Description | Confirmation |
|
||||
|---|---|---|
|
||||
| `read_compose` | Read the compose file of a project | — |
|
||||
| `update_image_tag` | Update image tag for a service | required |
|
||||
| `update_env_var` | Add/update an environment variable | required |
|
||||
| `update_compose` | Replace entire compose file | required |
|
||||
| `update_image_tag` | Update the image tag for a specific service | required |
|
||||
| `update_env_var` | Add or update an environment variable for a service | required |
|
||||
| `update_compose` | Replace the entire compose file | required |
|
||||
|
||||
### Images
|
||||
|
||||
| Tool | Description | Confirmation |
|
||||
|---|---|---|
|
||||
| `check_image_updates` | Check for available image updates | — |
|
||||
| `list_images` | List local images sorted by size (largest first); marks images in use and available updates | — |
|
||||
| `delete_image` | Delete a local image by `name:tag` or hash; refuses if image is in use | required |
|
||||
| `check_image_updates` | Check which images have updates available; optionally filter by project | — |
|
||||
|
||||
### System
|
||||
|
||||
| Tool | Description | Confirmation |
|
||||
|---|---|---|
|
||||
| `system_df` | Docker disk usage: image count/size, running/stopped containers, reclaimable space | — |
|
||||
| `system_prune` | Remove dangling images, stopped containers, and unused networks | required |
|
||||
|
||||
### Networks
|
||||
|
||||
| Tool | Description | Confirmation |
|
||||
|---|---|---|
|
||||
| `list_networks` | List all Docker networks with driver, subnet, gateway, and attached containers | — |
|
||||
| `create_network` | Create a new Docker network (bridge or other driver) | required |
|
||||
| `delete_network` | Delete a network; refuses if any container is attached | required |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcp-synology-container"
|
||||
version = "0.1.0"
|
||||
version = "0.7.0"
|
||||
description = "MCP server for Synology Container Manager"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
@@ -37,3 +37,8 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "TCH"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.15.10",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
"""MCP server for Synology Container Manager."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
try:
|
||||
__version__ = version("mcp-synology-container")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0+unknown"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Allow running as: python -m mcp_synology_container."""
|
||||
|
||||
from mcp_synology_container.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
@@ -43,8 +44,8 @@ def setup(verbose: bool) -> None:
|
||||
|
||||
async def _run_setup() -> None:
|
||||
"""Interactive setup flow."""
|
||||
from mcp_synology_container.auth import AuthManager, AuthenticationError
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig, CONFIG_PATH, save_config
|
||||
from mcp_synology_container.auth import AuthManager
|
||||
from mcp_synology_container.config import CONFIG_PATH, AppConfig, ConnectionConfig, save_config
|
||||
from mcp_synology_container.dsm_client import DsmClient, SynologyError
|
||||
|
||||
click.echo("=== mcp-synology-container setup ===\n")
|
||||
@@ -144,10 +145,8 @@ async def _run_setup() -> None:
|
||||
client.sid = sid
|
||||
click.echo(click.style("Login successful!", fg="green"))
|
||||
# Logout cleanly
|
||||
try:
|
||||
with contextlib.suppress(SynologyError):
|
||||
await client.request("SYNO.API.Auth", "logout", version=6, params={})
|
||||
except SynologyError:
|
||||
pass
|
||||
else:
|
||||
click.echo(click.style("Login failed: no session ID returned.", fg="red"), err=True)
|
||||
sys.exit(1)
|
||||
@@ -198,7 +197,7 @@ def check(config_path: str | None, verbose: bool) -> None:
|
||||
|
||||
async def _run_check(config_path: str | None) -> bool:
|
||||
"""Run connectivity check. Returns True on success."""
|
||||
from mcp_synology_container.auth import AuthManager, AuthenticationError
|
||||
from mcp_synology_container.auth import AuthenticationError, AuthManager
|
||||
from mcp_synology_container.config import load_config
|
||||
from mcp_synology_container.dsm_client import DsmClient, SynologyError
|
||||
|
||||
@@ -284,34 +283,47 @@ async def _run_check(config_path: str | None) -> bool:
|
||||
@click.option("--config", "-c", "config_path", type=click.Path(), help="Config file path")
|
||||
def serve(config_path: str | None) -> None:
|
||||
"""Start the MCP server in stdio mode."""
|
||||
sys.stderr.write("SERVER STARTING\n")
|
||||
sys.stderr.flush()
|
||||
_configure_logging("warning")
|
||||
asyncio.run(_run_serve(config_path))
|
||||
# Use anyio.run() — FastMCP uses anyio internally (anyio.create_task_group),
|
||||
# and anyio.run() ensures the correct backend context is set up.
|
||||
# asyncio.run() can cause issues on Windows (ProactorEventLoop + anyio).
|
||||
import anyio
|
||||
|
||||
anyio.run(_run_serve, config_path)
|
||||
|
||||
|
||||
async def _run_serve(config_path: str | None) -> None:
|
||||
"""Initialize and run the MCP server."""
|
||||
# Eagerly import all modules so any ImportError surfaces immediately on stderr
|
||||
# instead of silently killing the process.
|
||||
from mcp_synology_container.auth import AuthManager
|
||||
from mcp_synology_container.config import load_config
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
from mcp_synology_container.server import create_server
|
||||
|
||||
logger.debug("Loading config from: %s", config_path or "default path")
|
||||
try:
|
||||
config = load_config(config_path)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
click.echo(click.style(f"Config error: {e}", fg="red"), err=True)
|
||||
sys.exit(1)
|
||||
sys.stderr.write(f"Config error: {e}\n")
|
||||
sys.stderr.flush()
|
||||
return
|
||||
|
||||
# Open the HTTP client and register auth — but do NOT connect to the NAS yet.
|
||||
# Lazy init (_ensure_initialized) runs on the first tool call, so Claude Desktop
|
||||
# sees the server as ready immediately without waiting for NAS connectivity.
|
||||
async with DsmClient(config.base_url, config.connection.verify_ssl) as client:
|
||||
await client.query_api_info()
|
||||
auth = AuthManager(config)
|
||||
client.set_auth_manager(auth)
|
||||
# Login on startup
|
||||
try:
|
||||
client.sid = await auth.login(client)
|
||||
except Exception as e:
|
||||
logger.error("Initial login failed: %s", e)
|
||||
sys.exit(1)
|
||||
|
||||
mcp_server = create_server(config, client)
|
||||
logger.info("MCP server starting (stdio mode)")
|
||||
sys.stderr.write("MCP server ready\n")
|
||||
sys.stderr.flush()
|
||||
try:
|
||||
await mcp_server.run_stdio_async()
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"MCP server error: {e}\n")
|
||||
sys.stderr.flush()
|
||||
raise
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -132,10 +132,7 @@ def _validate_config(raw: dict[str, Any]) -> AppConfig:
|
||||
"""
|
||||
schema_version = raw.get("schema_version")
|
||||
if schema_version != CURRENT_SCHEMA_VERSION:
|
||||
msg = (
|
||||
f"Config schema_version is {schema_version!r}, "
|
||||
f"expected {CURRENT_SCHEMA_VERSION}."
|
||||
)
|
||||
msg = f"Config schema_version is {schema_version!r}, expected {CURRENT_SCHEMA_VERSION}."
|
||||
raise ValueError(msg)
|
||||
|
||||
conn_raw = raw.get("connection", {})
|
||||
|
||||
@@ -11,7 +11,10 @@ Thin async client wrapping Synology DSM Web API conventions:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
@@ -24,9 +27,26 @@ logger = logging.getLogger(__name__)
|
||||
# Session error codes that trigger transparent re-auth
|
||||
_SESSION_ERROR_CODES = frozenset({106, 107, 119})
|
||||
|
||||
# Cooldown after a failed init so repeated tool calls don't hammer DSM
|
||||
# (and don't escalate 407 "IP blocked" lockouts).
|
||||
INIT_ERROR_COOLDOWN = 60.0
|
||||
|
||||
# Parameters to mask in debug logging
|
||||
_SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"})
|
||||
|
||||
# Regex to strip the session-ID value from URL query strings before surfacing
|
||||
# them in error messages (HTTPStatusError embeds the full request URL).
|
||||
_SID_QUERY_RE = re.compile(r"(_sid=)[^&\s]*")
|
||||
|
||||
|
||||
def _scrub_url(url: str) -> str:
|
||||
"""Replace the value of any `_sid=...` query param with `***`.
|
||||
|
||||
Used to sanitize URLs embedded in `httpx.HTTPStatusError` messages so the
|
||||
raw DSM session ID never reaches log output or MCP tool responses.
|
||||
"""
|
||||
return _SID_QUERY_RE.sub(r"\1***", url)
|
||||
|
||||
|
||||
class SynologyError(Exception):
|
||||
"""Raised when DSM API returns a non-success response."""
|
||||
@@ -60,12 +80,6 @@ def _error_message(code: int, api: str = "") -> str:
|
||||
407: "Too many failed login attempts — account temporarily locked",
|
||||
408: "IP blocked due to excessive failed attempts",
|
||||
}
|
||||
# Docker API codes
|
||||
docker = {
|
||||
1: "Project not found",
|
||||
2: "Container not found",
|
||||
}
|
||||
|
||||
if "Auth" in api and code in auth:
|
||||
return auth[code]
|
||||
if code in common:
|
||||
@@ -98,6 +112,11 @@ class DsmClient:
|
||||
self._sid: str | None = None
|
||||
self._auth_manager: AuthManager | None = None
|
||||
self._reauth_lock = asyncio.Lock()
|
||||
self._init_lock = asyncio.Lock()
|
||||
self._initialized = False
|
||||
self._initializing = False # True while inside _ensure_initialized
|
||||
self._init_error: Exception | None = None
|
||||
self._init_error_until: float = 0.0
|
||||
logger.debug(
|
||||
"DsmClient: base_url=%s verify_ssl=%s timeout=%d",
|
||||
self._base_url,
|
||||
@@ -118,12 +137,57 @@ class DsmClient:
|
||||
"""Register the AuthManager for automatic re-login on session errors."""
|
||||
self._auth_manager = auth_manager
|
||||
|
||||
async def _ensure_initialized(self) -> None:
|
||||
"""Connect to NAS and authenticate on first use (lazy init).
|
||||
|
||||
Subsequent calls are no-ops. Thread-safe via asyncio.Lock.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
async with self._init_lock:
|
||||
if self._initialized: # re-check inside lock
|
||||
return
|
||||
# Negative cache: re-raise cached error during cooldown window.
|
||||
now = asyncio.get_event_loop().time()
|
||||
if self._init_error is not None and now < self._init_error_until:
|
||||
raise self._init_error
|
||||
self._initializing = True
|
||||
try:
|
||||
sys.stderr.write(f"[dsm] Connecting to {self._base_url}...\n")
|
||||
sys.stderr.flush()
|
||||
logger.debug("Lazy init: querying API info from %s", self._base_url)
|
||||
await self.query_api_info()
|
||||
sys.stderr.write(f"[dsm] API info OK ({len(self._api_cache)} APIs)\n")
|
||||
sys.stderr.flush()
|
||||
if self._auth_manager:
|
||||
sys.stderr.write("[dsm] Authenticating...\n")
|
||||
sys.stderr.flush()
|
||||
logger.debug("Lazy init: authenticating")
|
||||
self._sid = await self._auth_manager.login(self)
|
||||
sys.stderr.write("[dsm] Auth OK\n")
|
||||
sys.stderr.flush()
|
||||
self._initialized = True
|
||||
self._init_error = None
|
||||
self._init_error_until = 0.0
|
||||
logger.debug("Lazy init complete")
|
||||
except Exception as e:
|
||||
self._init_error = e
|
||||
self._init_error_until = now + INIT_ERROR_COOLDOWN
|
||||
logger.warning(
|
||||
"Lazy init failed (%s); will retry after %.0fs cooldown",
|
||||
type(e).__name__,
|
||||
INIT_ERROR_COOLDOWN,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._initializing = False
|
||||
|
||||
async def __aenter__(self) -> DsmClient:
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
self._http = httpx.AsyncClient(
|
||||
verify=self._verify_ssl,
|
||||
timeout=self._timeout,
|
||||
timeout=httpx.Timeout(connect=10.0, read=float(self._timeout), write=10.0, pool=5.0),
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -157,7 +221,14 @@ class DsmClient:
|
||||
|
||||
logger.debug("Querying API info from %s", url)
|
||||
resp = await http.get(url, params=params)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
url_safe = _scrub_url(str(e.request.url))
|
||||
raise SynologyError(
|
||||
f"HTTP {resp.status_code} from {url_safe}",
|
||||
code=resp.status_code,
|
||||
) from None
|
||||
body = resp.json()
|
||||
|
||||
if not body.get("success"):
|
||||
@@ -204,6 +275,12 @@ class DsmClient:
|
||||
Raises:
|
||||
SynologyError: On API errors.
|
||||
"""
|
||||
sys.stderr.write(f"[dsm] request: {api}/{method}\n")
|
||||
sys.stderr.flush()
|
||||
# Skip init guard if we are already inside _ensure_initialized (e.g. login call).
|
||||
# The API cache is populated before login, so the cache is ready at this point.
|
||||
if not self._initializing:
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
|
||||
if api not in self._api_cache:
|
||||
@@ -229,10 +306,19 @@ class DsmClient:
|
||||
# Log with sensitive fields masked
|
||||
log_params = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in req_params.items()}
|
||||
retry_tag = " (retry)" if _is_retry else ""
|
||||
logger.debug("DSM GET%s: %s/%s v%d — %s", retry_tag, api, method, resolved_version, log_params)
|
||||
logger.debug(
|
||||
"DSM GET%s: %s/%s v%d — %s", retry_tag, api, method, resolved_version, log_params
|
||||
)
|
||||
|
||||
resp = await http.get(url, params=req_params)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
url_safe = _scrub_url(str(e.request.url))
|
||||
raise SynologyError(
|
||||
f"HTTP {resp.status_code} from {url_safe}",
|
||||
code=resp.status_code,
|
||||
) from None
|
||||
body = resp.json()
|
||||
|
||||
if body.get("success"):
|
||||
@@ -245,8 +331,12 @@ class DsmClient:
|
||||
|
||||
# Transparent re-auth on session errors (one retry only)
|
||||
if code in _SESSION_ERROR_CODES and not _is_retry and self._auth_manager:
|
||||
old_sid = self._sid
|
||||
logger.info("Session error %d on %s/%s, re-authenticating...", code, api, method)
|
||||
async with self._reauth_lock:
|
||||
# Double-check: if another task already refreshed the SID while
|
||||
# we were waiting on the lock, skip the redundant login.
|
||||
if self._sid == old_sid:
|
||||
self._sid = None
|
||||
try:
|
||||
self._sid = await self._auth_manager.login(self)
|
||||
@@ -256,6 +346,246 @@ class DsmClient:
|
||||
|
||||
raise SynologyError(_error_message(code, api), code=code)
|
||||
|
||||
async def post_request(
|
||||
self,
|
||||
api: str,
|
||||
method: str,
|
||||
version: int | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Make a POST (form-encoded) request to the DSM API.
|
||||
|
||||
Identical semantics to request(), but sends params as a form body
|
||||
instead of query-string — required by some Container Manager endpoints
|
||||
(e.g. SYNO.Docker.Image/delete).
|
||||
|
||||
Args:
|
||||
api: DSM API name (e.g. "SYNO.Docker.Image").
|
||||
method: API method (e.g. "delete").
|
||||
version: API version. Defaults to maxVersion from API info.
|
||||
params: Additional form fields.
|
||||
|
||||
Returns:
|
||||
Response data dict from the "data" field of the envelope.
|
||||
|
||||
Raises:
|
||||
SynologyError: On API errors.
|
||||
"""
|
||||
sys.stderr.write(f"[dsm] post_request: {api}/{method}\n")
|
||||
sys.stderr.flush()
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
|
||||
if api not in self._api_cache:
|
||||
raise SynologyError(
|
||||
f"API '{api}' not found. Call query_api_info() first.",
|
||||
code=102,
|
||||
)
|
||||
|
||||
info = self._api_cache[api]
|
||||
resolved_version = version if version is not None else info["maxVersion"]
|
||||
url = f"{self._base_url}/webapi/{info['path']}"
|
||||
|
||||
form: dict[str, Any] = {
|
||||
"api": api,
|
||||
"version": str(resolved_version),
|
||||
"method": method,
|
||||
}
|
||||
if params:
|
||||
form.update(params)
|
||||
|
||||
query_params: dict[str, str] = {}
|
||||
if self._sid:
|
||||
query_params["_sid"] = self._sid
|
||||
|
||||
log_form = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in form.items()}
|
||||
logger.debug("DSM POST: %s/%s v%d — %s", api, method, resolved_version, log_form)
|
||||
|
||||
resp = await http.post(url, params=query_params, data=form)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
url_safe = _scrub_url(str(e.request.url))
|
||||
raise SynologyError(
|
||||
f"HTTP {resp.status_code} from {url_safe}",
|
||||
code=resp.status_code,
|
||||
) from None
|
||||
body = resp.json()
|
||||
|
||||
if body.get("success"):
|
||||
return body.get("data") or {}
|
||||
|
||||
code = body.get("error", {}).get("code", 0)
|
||||
logger.debug("DSM POST response: %s/%s — error code %d", api, method, code)
|
||||
raise SynologyError(_error_message(code, api), code=code)
|
||||
|
||||
# Wall-clock budget for consuming the build_stream body. DSM keeps the
|
||||
# connection open until the build is done (success or failure), so we
|
||||
# need to give it enough time for an image pull while staying under the
|
||||
# Claude Desktop ~4 min tool-call ceiling. When the budget is exhausted
|
||||
# the partial log is returned with a "still running" marker appended.
|
||||
BUILD_STREAM_BUDGET = 210.0
|
||||
|
||||
# Marker appended to the returned log when the stream did not finish
|
||||
# within BUILD_STREAM_BUDGET. Callers can grep for this to decide
|
||||
# whether the log they got is complete.
|
||||
BUILD_STREAM_TIMEOUT_MARKER = "[build_stream: timeout — stream still open server-side]"
|
||||
|
||||
async def trigger_build_stream(self, project_id: str) -> str:
|
||||
"""Trigger SYNO.Docker.Project/build_stream and return the full log text.
|
||||
|
||||
This is the proper way to force an image pull and project restart in DSM
|
||||
Container Manager (confirmed via browser DevTools). The endpoint
|
||||
returns a streamed plaintext response (one short status line per step,
|
||||
e.g. ``Container <name> Running`` on success or ``<svc> Error`` followed
|
||||
by ``Error response from daemon: <cause>`` on failure). DSM closes the
|
||||
stream when the build is done.
|
||||
|
||||
The body is consumed line-by-line and returned as a single string so
|
||||
callers (redeploy_project, create_project) can surface the real cause
|
||||
of a failed build instead of waiting for the polling step to report
|
||||
``BUILD_FAILED`` with no context.
|
||||
|
||||
Error detection precedence:
|
||||
1. HTTP status (4xx/5xx) → SynologyError, body not consumed.
|
||||
2. JSON content-type → DSM rejected the request before streaming
|
||||
(e.g. project locked, invalid id); a ``success: false`` envelope is
|
||||
raised as SynologyError. Malformed JSON is treated as accepted.
|
||||
3. Streamed plaintext → returned verbatim; per-line failure parsing
|
||||
is the caller's responsibility (look for ``Error response from
|
||||
daemon:`` or ``<svc> Error``).
|
||||
|
||||
Args:
|
||||
project_id: Project UUID from SYNO.Docker.Project/list.
|
||||
|
||||
Returns:
|
||||
The collected build log as a single string (may be empty if DSM
|
||||
returned a non-JSON empty body). Appended with
|
||||
:attr:`BUILD_STREAM_TIMEOUT_MARKER` when the wall-clock budget
|
||||
ran out before DSM closed the stream — in that case the build is
|
||||
still running server-side and the caller's polling step is the
|
||||
authoritative status source.
|
||||
|
||||
Raises:
|
||||
SynologyError: If DSM rejects the build with a JSON error body, or
|
||||
if the HTTP response status indicates a transport-level error.
|
||||
"""
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
api = "SYNO.Docker.Project"
|
||||
|
||||
if api not in self._api_cache:
|
||||
raise SynologyError(f"API '{api}' not found. Call query_api_info() first.", code=102)
|
||||
|
||||
info = self._api_cache[api]
|
||||
url = f"{self._base_url}/webapi/{info['path']}"
|
||||
params: dict[str, Any] = {
|
||||
"api": api,
|
||||
"version": "1",
|
||||
"method": "build_stream",
|
||||
"id": project_id,
|
||||
}
|
||||
if self._sid:
|
||||
params["_sid"] = self._sid
|
||||
|
||||
sys.stderr.write(f"[dsm] trigger_build_stream: project={project_id}\n")
|
||||
sys.stderr.flush()
|
||||
logger.debug("build_stream: project_id=%s", project_id)
|
||||
|
||||
# Wall-clock deadline for the streamed body. Individual chunks get a
|
||||
# generous per-read timeout because DSM may pause between status lines
|
||||
# during a slow image pull.
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + self.BUILD_STREAM_BUDGET
|
||||
|
||||
try:
|
||||
async with http.stream(
|
||||
"GET",
|
||||
url,
|
||||
params=params,
|
||||
timeout=httpx.Timeout(
|
||||
connect=10.0,
|
||||
read=60.0,
|
||||
write=10.0,
|
||||
pool=5.0,
|
||||
),
|
||||
) as resp:
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
url_safe = _scrub_url(str(e.request.url))
|
||||
raise SynologyError(
|
||||
f"HTTP {resp.status_code} from {url_safe}",
|
||||
code=resp.status_code,
|
||||
) from None
|
||||
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
# DSM rejected the build before streaming — read the JSON
|
||||
# error body (capped at ~4 KB; DSM error envelopes are tiny).
|
||||
body = b""
|
||||
async for chunk in resp.aiter_bytes():
|
||||
body += chunk
|
||||
if len(body) >= 4096:
|
||||
break
|
||||
try:
|
||||
parsed = json.loads(body.decode("utf-8", errors="replace"))
|
||||
except json.JSONDecodeError:
|
||||
# Malformed response — treat as accepted and let the
|
||||
# caller's polling surface any real failure.
|
||||
return ""
|
||||
if not parsed.get("success", True):
|
||||
code = parsed.get("error", {}).get("code", 0)
|
||||
raise SynologyError(_error_message(code, api), code=code)
|
||||
# success=true with JSON content-type: no streamed log.
|
||||
return ""
|
||||
|
||||
# Streamed plaintext (DSM uses text/html for build_stream).
|
||||
# Collect line-by-line until DSM closes the stream or the
|
||||
# wall-clock budget runs out.
|
||||
lines: list[str] = []
|
||||
timed_out = False
|
||||
try:
|
||||
async for raw_line in resp.aiter_lines():
|
||||
# Strip Windows line endings; aiter_lines already
|
||||
# splits on \n but preserves trailing \r on some httpx
|
||||
# versions.
|
||||
line = raw_line.rstrip("\r\n")
|
||||
if line:
|
||||
lines.append(line)
|
||||
if loop.time() >= deadline:
|
||||
timed_out = True
|
||||
break
|
||||
except httpx.ReadTimeout:
|
||||
# A chunk didn't arrive within the per-read window. DSM is
|
||||
# still building server-side; surface what we have.
|
||||
timed_out = True
|
||||
|
||||
log_text = "\n".join(lines)
|
||||
if timed_out:
|
||||
log_text = (
|
||||
f"{log_text}\n{self.BUILD_STREAM_TIMEOUT_MARKER}"
|
||||
if log_text
|
||||
else self.BUILD_STREAM_TIMEOUT_MARKER
|
||||
)
|
||||
return log_text
|
||||
except httpx.ReadTimeout:
|
||||
# Headers not received within the connect/read window, but the GET
|
||||
# was already sent. DSM received it and started the build; return
|
||||
# the timeout marker so the caller knows there's no log yet.
|
||||
return self.BUILD_STREAM_TIMEOUT_MARKER
|
||||
except httpx.HTTPError as e:
|
||||
# Other transport-level failures (ConnectError, ConnectTimeout,
|
||||
# WriteError, RemoteProtocolError, …) mean DSM never received the
|
||||
# build request. Surface a clear SynologyError instead of letting
|
||||
# the raw httpx exception bubble up — the caller (redeploy_project)
|
||||
# has typically already stopped the project and needs to know that
|
||||
# the build did not start.
|
||||
raise SynologyError(
|
||||
f"build_stream transport error: {type(e).__name__}: {e}",
|
||||
code=0,
|
||||
) from None
|
||||
|
||||
async def upload_text(
|
||||
self,
|
||||
dest_folder: str,
|
||||
@@ -278,6 +608,7 @@ class DsmClient:
|
||||
Response data dict.
|
||||
"""
|
||||
api = "SYNO.FileStation.Upload"
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
|
||||
if api not in self._api_cache:
|
||||
@@ -299,7 +630,13 @@ class DsmClient:
|
||||
if self._sid:
|
||||
query_params["_sid"] = self._sid
|
||||
|
||||
logger.debug("DSM POST: %s/upload v%d — path=%s filename=%s", api, resolved_version, dest_folder, filename)
|
||||
logger.debug(
|
||||
"DSM POST: %s/upload v%d — path=%s filename=%s",
|
||||
api,
|
||||
resolved_version,
|
||||
dest_folder,
|
||||
filename,
|
||||
)
|
||||
|
||||
encoded = content.encode("utf-8")
|
||||
resp = await http.post(
|
||||
@@ -309,7 +646,14 @@ class DsmClient:
|
||||
files={"file": (filename, encoded, "text/plain")},
|
||||
timeout=httpx.Timeout(60.0),
|
||||
)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
url_safe = _scrub_url(str(e.request.url))
|
||||
raise SynologyError(
|
||||
f"HTTP {resp.status_code} from {url_safe}",
|
||||
code=resp.status_code,
|
||||
) from None
|
||||
body = resp.json()
|
||||
|
||||
if body.get("success"):
|
||||
@@ -328,6 +672,7 @@ class DsmClient:
|
||||
File content as string.
|
||||
"""
|
||||
api = "SYNO.FileStation.Download"
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
|
||||
if api not in self._api_cache:
|
||||
@@ -351,7 +696,14 @@ class DsmClient:
|
||||
logger.debug("DSM GET: %s/download v%d — %s", api, resolved_version, log_params)
|
||||
|
||||
resp = await http.get(url, params=params, timeout=httpx.Timeout(60.0))
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
url_safe = _scrub_url(str(e.request.url))
|
||||
raise SynologyError(
|
||||
f"HTTP {resp.status_code} from {url_safe}",
|
||||
code=resp.status_code,
|
||||
) from None
|
||||
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
|
||||
@@ -7,17 +7,55 @@ Supported filenames: docker-compose.yml, docker-compose.yaml, compose.yml, compo
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
from mcp_synology_container.modules.projects import _find_project
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VOLUME_PREFIX_RE = re.compile(r"^/volume\d+")
|
||||
|
||||
# Project names are used as path components for FileStation lookups when the
|
||||
# project is not yet registered with Container Manager. Restrict to a safe
|
||||
# subset so a malicious name like "../../etc" cannot escape compose_base_path.
|
||||
_PROJECT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||||
|
||||
|
||||
def _to_filestation_path(path: str) -> str:
|
||||
"""Strip /volumeN prefix so paths work with the FileStation API.
|
||||
|
||||
The Docker/Container Manager API returns raw filesystem paths like
|
||||
/volume1/docker/myapp, but FileStation expects /docker/myapp.
|
||||
"""
|
||||
return _VOLUME_PREFIX_RE.sub("", path)
|
||||
|
||||
|
||||
def _validate_project_name(project_name: str) -> str | None:
|
||||
"""Return an error message if project_name is unsafe, else None.
|
||||
|
||||
Allowed characters: letters, digits, underscore, hyphen. Anything else
|
||||
(including '/', '\\', '..', whitespace, empty string) is rejected because
|
||||
the name flows into FileStation path construction when the project is
|
||||
not yet known to Container Manager.
|
||||
"""
|
||||
if not project_name or not _PROJECT_NAME_RE.match(project_name):
|
||||
return (
|
||||
f"Error: invalid project name {project_name!r}. "
|
||||
"Allowed: letters, digits, '_' and '-' (no slashes, dots, or spaces)."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Recognized compose file names (in priority order)
|
||||
_COMPOSE_FILENAMES = [
|
||||
"docker-compose.yml",
|
||||
@@ -27,25 +65,44 @@ _COMPOSE_FILENAMES = [
|
||||
]
|
||||
|
||||
|
||||
def _extract_version_prefix(tag: str) -> str | None:
|
||||
"""Extract the leading numeric segment from a versioned image tag.
|
||||
|
||||
Returns the numeric prefix before the first ``-`` when the tag has the
|
||||
form ``<digits[.digits...]>-<suffix>`` (e.g. ``2.558-jdk21`` → ``"2.558"``).
|
||||
Returns ``None`` for tags that do not match this pattern (e.g. ``"latest"``,
|
||||
``"1.25"`` without a suffix, or empty strings).
|
||||
|
||||
Args:
|
||||
tag: Image tag string to inspect.
|
||||
|
||||
Returns:
|
||||
Numeric prefix string, or None if the pattern does not match.
|
||||
"""
|
||||
m = re.match(r"^(\d[\d.]*)-.+", tag)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all compose file management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def read_compose(project_name: str) -> str:
|
||||
"""Read the compose file of a project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the Container Manager project.
|
||||
|
||||
Returns:
|
||||
The compose file content as YAML text.
|
||||
"""
|
||||
async def read_compose(project_name: str):
|
||||
"""Read the compose file (YAML) for a project."""
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
project = await _find_project(client, project_name)
|
||||
raw = (
|
||||
project.get("path", f"{config.compose_base_path}/{project_name}")
|
||||
if project
|
||||
else f"{config.compose_base_path}/{project_name}"
|
||||
)
|
||||
searched = _to_filestation_path(raw)
|
||||
return (
|
||||
f"No compose file found for project '{project_name}'.\n"
|
||||
f"Looked in {config.compose_base_path}/{project_name}/ for: "
|
||||
+ ", ".join(_COMPOSE_FILENAMES)
|
||||
f"Looked in {searched}/ for: " + ", ".join(_COMPOSE_FILENAMES)
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -61,17 +118,10 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
service_name: str,
|
||||
new_tag: str,
|
||||
confirmed: bool = False,
|
||||
) -> str:
|
||||
"""Update the image tag of a service in the compose file.
|
||||
|
||||
After confirming, suggests running redeploy_project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the Container Manager project.
|
||||
service_name: Name of the service within the compose file.
|
||||
new_tag: New image tag (e.g. "latest", "1.2.3").
|
||||
confirmed: Must be True to proceed. Set to True to confirm the change.
|
||||
"""
|
||||
):
|
||||
"""Update a service's image tag in the compose file. Requires confirmed=True."""
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
return f"No compose file found for project '{project_name}'."
|
||||
@@ -104,16 +154,61 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
|
||||
new_image = f"{image_name}:{new_tag}"
|
||||
|
||||
# Detect version env vars that should be auto-updated alongside the tag.
|
||||
# Applies when both tags share the pattern <digits>-<suffix>:
|
||||
# e.g. 2.558-jdk21 → 2.560-jdk21 auto-updates JENKINS_VERSION=2.558 → 2.560.
|
||||
old_version = _extract_version_prefix(current_tag)
|
||||
new_version = _extract_version_prefix(new_tag)
|
||||
version_vars: list[str] = [] # env var names that will be auto-updated
|
||||
if old_version and new_version and old_version != new_version:
|
||||
env = services[service_name].get("environment") or []
|
||||
if isinstance(env, list):
|
||||
for entry in env:
|
||||
if isinstance(entry, str) and "=" in entry:
|
||||
k, v = entry.split("=", 1)
|
||||
if v == old_version:
|
||||
version_vars.append(k)
|
||||
elif isinstance(env, dict):
|
||||
for k, v in env.items():
|
||||
if str(v) == old_version:
|
||||
version_vars.append(k)
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"About to update service '{service_name}' in project '{project_name}':\n"
|
||||
f" Before: {current_image}\n"
|
||||
f" After: {new_image}\n\n"
|
||||
f"Call this tool again with confirmed=True to apply the change."
|
||||
lines = [
|
||||
f"About to update service '{service_name}' in project '{project_name}':",
|
||||
f" Before: {current_image}",
|
||||
f" After: {new_image}",
|
||||
]
|
||||
if version_vars:
|
||||
lines.append(
|
||||
" Auto-update env var(s): "
|
||||
+ ", ".join(f"{k}: {old_version} → {new_version}" for k in version_vars)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Call this tool again with confirmed=True to apply the change.")
|
||||
return "\n".join(lines)
|
||||
|
||||
services[service_name]["image"] = new_image
|
||||
new_content = yaml.dump(compose, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||
|
||||
# Apply auto-updates to version env vars.
|
||||
if old_version and new_version and old_version != new_version:
|
||||
env = services[service_name].get("environment") or []
|
||||
if isinstance(env, list):
|
||||
for i, entry in enumerate(env):
|
||||
if isinstance(entry, str) and "=" in entry:
|
||||
k, v = entry.split("=", 1)
|
||||
if v == old_version and k in version_vars:
|
||||
env[i] = f"{k}={new_version}"
|
||||
services[service_name]["environment"] = env
|
||||
elif isinstance(env, dict):
|
||||
for k in version_vars:
|
||||
if k in env:
|
||||
env[k] = new_version
|
||||
services[service_name]["environment"] = env
|
||||
|
||||
new_content = yaml.dump(
|
||||
compose, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
)
|
||||
|
||||
folder_path = path.rsplit("/", 1)[0]
|
||||
filename = path.rsplit("/", 1)[1]
|
||||
@@ -122,11 +217,20 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
except Exception as e:
|
||||
return f"Error writing compose file: {e}"
|
||||
|
||||
return (
|
||||
f"Updated '{service_name}' image in '{project_name}':\n"
|
||||
f" {current_image} → {new_image}\n\n"
|
||||
result_lines = [
|
||||
f"Updated '{service_name}' image in '{project_name}':",
|
||||
f" {current_image} → {new_image}",
|
||||
]
|
||||
if version_vars:
|
||||
result_lines.append(
|
||||
" Auto-updated env var(s): "
|
||||
+ ", ".join(f"{k}={new_version}" for k in version_vars)
|
||||
)
|
||||
result_lines.append("")
|
||||
result_lines.append(
|
||||
f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change."
|
||||
)
|
||||
return "\n".join(result_lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def update_env_var(
|
||||
@@ -135,18 +239,10 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
var_name: str,
|
||||
var_value: str,
|
||||
confirmed: bool = False,
|
||||
) -> str:
|
||||
"""Add or update an environment variable in a service's compose definition.
|
||||
|
||||
After confirming, suggests running redeploy_project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the Container Manager project.
|
||||
service_name: Name of the service within the compose file.
|
||||
var_name: Environment variable name.
|
||||
var_value: New value for the variable.
|
||||
confirmed: Must be True to proceed. Set to True to confirm the change.
|
||||
"""
|
||||
):
|
||||
"""Add or update an env var in a service's compose definition. Requires confirmed=True."""
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
return f"No compose file found for project '{project_name}'."
|
||||
@@ -172,7 +268,7 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
# Determine previous value and build description
|
||||
old_value: str | None = None
|
||||
if isinstance(env_list, list):
|
||||
for i, entry in enumerate(env_list):
|
||||
for entry in env_list:
|
||||
if isinstance(entry, str) and entry.startswith(f"{var_name}="):
|
||||
old_value = entry.split("=", 1)[1]
|
||||
break
|
||||
@@ -203,11 +299,9 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
new_entry = f"{var_name}={var_value}"
|
||||
updated = False
|
||||
for i, entry in enumerate(env_list):
|
||||
if isinstance(entry, str) and entry.startswith(f"{var_name}="):
|
||||
env_list[i] = new_entry
|
||||
updated = True
|
||||
break
|
||||
elif entry == var_name:
|
||||
if isinstance(entry, str) and (
|
||||
entry.startswith(f"{var_name}=") or entry == var_name
|
||||
):
|
||||
env_list[i] = new_entry
|
||||
updated = True
|
||||
break
|
||||
@@ -220,7 +314,9 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
else:
|
||||
service["environment"] = [f"{var_name}={var_value}"]
|
||||
|
||||
new_content = yaml.dump(compose, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||
new_content = yaml.dump(
|
||||
compose, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
)
|
||||
|
||||
folder_path = path.rsplit("/", 1)[0]
|
||||
filename = path.rsplit("/", 1)[1]
|
||||
@@ -229,8 +325,9 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
except Exception as e:
|
||||
return f"Error writing compose file: {e}"
|
||||
|
||||
verb = "Updated" if action == "update" else "Added"
|
||||
return (
|
||||
f"{'Updated' if action == 'update' else 'Added'} env var in '{service_name}' ({project_name}):\n"
|
||||
f"{verb} env var in '{service_name}' ({project_name}):\n"
|
||||
f" {var_name}={var_value}\n\n"
|
||||
f"Tip: Run redeploy_project('{project_name}', confirmed=True) to apply the change."
|
||||
)
|
||||
@@ -240,17 +337,10 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
project_name: str,
|
||||
new_content: str,
|
||||
confirmed: bool = False,
|
||||
) -> str:
|
||||
"""Replace the entire compose file with new content.
|
||||
|
||||
Validates that the content is valid YAML before writing.
|
||||
After confirming, suggests running redeploy_project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the Container Manager project.
|
||||
new_content: Complete new content for the compose file (must be valid YAML).
|
||||
confirmed: Must be True to proceed. Set to True to confirm the overwrite.
|
||||
"""
|
||||
):
|
||||
"""Replace the entire compose file with new YAML content. Requires confirmed=True."""
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
# Validate YAML before anything else
|
||||
try:
|
||||
parsed = yaml.safe_load(new_content)
|
||||
@@ -290,35 +380,56 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
)
|
||||
|
||||
|
||||
async def _find_compose_path(
|
||||
client: DsmClient, config: AppConfig, project_name: str
|
||||
) -> str | None:
|
||||
async def _find_compose_path(client: DsmClient, config: AppConfig, project_name: str) -> str | None:
|
||||
"""Find the compose file path for a project.
|
||||
|
||||
Tries each recognized filename under {compose_base_path}/{project_name}/.
|
||||
Resolves the project's real directory via SYNO.Docker.Project list,
|
||||
then probes each recognised filename under that directory.
|
||||
Falls back to {compose_base_path}/{project_name} when the project
|
||||
cannot be found in Container Manager.
|
||||
|
||||
Args:
|
||||
client: DsmClient instance.
|
||||
config: AppConfig with compose_base_path.
|
||||
config: AppConfig with compose_base_path (used as fallback).
|
||||
project_name: Project name.
|
||||
|
||||
Returns:
|
||||
Full path to the compose file if found, None otherwise.
|
||||
"""
|
||||
project = await _find_project(client, project_name)
|
||||
if project is not None:
|
||||
base = project.get("path", "").rstrip("/")
|
||||
else:
|
||||
base = f"{config.compose_base_path}/{project_name}"
|
||||
logger.debug(
|
||||
"Project '%s' not found via API, falling back to base path: %s",
|
||||
project_name,
|
||||
base,
|
||||
)
|
||||
|
||||
# FileStation API requires paths without the /volumeN prefix.
|
||||
fs_base = _to_filestation_path(base)
|
||||
|
||||
# List the directory once and match against known filenames.
|
||||
# getinfo returns {"files": [{"code": 408, ...}]} for missing paths
|
||||
# (truthy but erroneous), so listing the directory is more reliable.
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.FileStation.List",
|
||||
"list",
|
||||
params={"folder_path": fs_base, "additional": "[]"},
|
||||
)
|
||||
names_present = {f.get("name", "") for f in data.get("files", [])}
|
||||
sys.stderr.write(f"[compose] files in {fs_base}: {sorted(names_present)}\n")
|
||||
sys.stderr.flush()
|
||||
except Exception as e:
|
||||
logger.debug("Could not list directory '%s': %s", fs_base, e)
|
||||
names_present = set()
|
||||
|
||||
for filename in _COMPOSE_FILENAMES:
|
||||
path = f"{base}/{filename}"
|
||||
try:
|
||||
# Try to list the file; if it exists, return the path
|
||||
await client.request(
|
||||
"SYNO.FileStation.Info",
|
||||
"get",
|
||||
params={"path": path, "additional": "[]"},
|
||||
)
|
||||
if filename in names_present:
|
||||
path = f"{fs_base}/{filename}"
|
||||
logger.debug("Found compose file: %s", path)
|
||||
return path
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
@@ -2,28 +2,71 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches DSM hash-prefixed container names like "f93cb8b504f7_jenkins"
|
||||
_HASH_PREFIX_RE = re.compile(r"^[a-f0-9]{12}_(.+)$")
|
||||
|
||||
|
||||
def _strip_hash_prefix(name: str) -> str:
|
||||
"""Strip 12-char hex hash prefix from container names.
|
||||
|
||||
DSM sometimes returns names like 'f93cb8b504f7_jenkins' when the
|
||||
service name in compose.yaml differs from container_name. Returns the
|
||||
clean name (also strips a leading slash if present).
|
||||
"""
|
||||
clean = name.lstrip("/")
|
||||
match = _HASH_PREFIX_RE.match(clean)
|
||||
return match.group(1) if match else clean
|
||||
|
||||
|
||||
async def _resolve_container_name(client: DsmClient, user_name: str) -> str:
|
||||
"""Resolve a user-supplied name to the actual DSM container name.
|
||||
|
||||
Needed because DSM may store the container as 'f93cb8b504f7_jenkins'
|
||||
while the user passes 'jenkins'. Falls back to user_name unchanged if
|
||||
the list cannot be fetched or no match is found.
|
||||
|
||||
Args:
|
||||
client: DsmClient instance.
|
||||
user_name: Name as provided by the user (may or may not have prefix).
|
||||
|
||||
Returns:
|
||||
Actual container name as registered in DSM.
|
||||
"""
|
||||
clean_user = _strip_hash_prefix(user_name)
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||
)
|
||||
for c in data.get("containers", []):
|
||||
actual = c.get("name", "")
|
||||
if actual == user_name or _strip_hash_prefix(actual) == clean_user:
|
||||
return actual
|
||||
except Exception:
|
||||
pass
|
||||
return user_name
|
||||
|
||||
|
||||
def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all container management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_containers(project_name: str | None = None) -> str:
|
||||
"""List containers, optionally filtered by project name.
|
||||
|
||||
Args:
|
||||
project_name: Optional project name to filter containers.
|
||||
If omitted, lists all containers.
|
||||
"""
|
||||
async def list_containers(project_name: str | None = None):
|
||||
"""List all containers, optionally filtered by project name."""
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
@@ -40,16 +83,16 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
# Filter by project if specified
|
||||
if project_name:
|
||||
containers = [
|
||||
c for c in containers
|
||||
if c.get("project_name") == project_name
|
||||
or _container_in_project(c, project_name)
|
||||
c
|
||||
for c in containers
|
||||
if c.get("project_name") == project_name or _container_in_project(c, project_name)
|
||||
]
|
||||
if not containers:
|
||||
return f"No containers found for project '{project_name}'."
|
||||
|
||||
lines = [f"Containers ({len(containers)} total):", ""]
|
||||
for container in sorted(containers, key=lambda c: c.get("name", "")):
|
||||
name = container.get("name", "?")
|
||||
name = _strip_hash_prefix(container.get("name", "?"))
|
||||
state = container.get("status", container.get("state", "?"))
|
||||
image = container.get("image", "?")
|
||||
lines.append(f" {name}")
|
||||
@@ -60,17 +103,15 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
@mcp.tool()
|
||||
async def get_container_status(container_name: str) -> str:
|
||||
"""Get detailed status, uptime, and resource usage of a container.
|
||||
|
||||
Args:
|
||||
container_name: Name of the container to inspect.
|
||||
"""
|
||||
async def get_container_status(container_name: str):
|
||||
"""Get detailed status, uptime, ports, and mounts for a container."""
|
||||
# SYNO.Docker.Container/get accepts the clean name (no hash prefix).
|
||||
clean_name = _strip_hash_prefix(container_name)
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"get",
|
||||
params={"name": container_name},
|
||||
params={"name": clean_name},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error getting container '{container_name}': {e}"
|
||||
@@ -78,23 +119,18 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
if not data:
|
||||
return f"Container '{container_name}' not found."
|
||||
|
||||
return _format_container_detail(container_name, data)
|
||||
return _format_container_detail(clean_name, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def get_container_logs(
|
||||
container_name: str,
|
||||
tail: int = 100,
|
||||
keyword: str | None = None,
|
||||
) -> str:
|
||||
"""Get log output from a container.
|
||||
|
||||
Args:
|
||||
container_name: Name of the container.
|
||||
tail: Number of recent log lines to return (default 100).
|
||||
keyword: Optional keyword to filter log lines.
|
||||
"""
|
||||
):
|
||||
"""Get recent log output from a container, with optional keyword filter."""
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
params: dict[str, Any] = {
|
||||
"name": container_name,
|
||||
"name": resolved_name,
|
||||
"limit": tail,
|
||||
"offset": 0,
|
||||
"sort_dir": "DESC",
|
||||
@@ -116,7 +152,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
return f"No logs found for container '{container_name}'."
|
||||
|
||||
total = data.get("total", len(logs))
|
||||
header = f"Logs for {container_name} (showing {len(logs)} of {total}):\n"
|
||||
display_name = _strip_hash_prefix(container_name)
|
||||
header = f"Logs for {display_name} (showing {len(logs)} of {total}):\n"
|
||||
|
||||
# Logs are returned in DESC order, reverse for chronological display
|
||||
lines = []
|
||||
@@ -129,22 +166,91 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
|
||||
return header + "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def container_stats(container_name: str):
|
||||
"""Get live CPU, memory, network, and block I/O stats for a container."""
|
||||
try:
|
||||
data = await client.request("SYNO.Docker.Container", "stats")
|
||||
except Exception as e:
|
||||
return f"Error fetching container stats: {e}"
|
||||
|
||||
if not data:
|
||||
return "No stats data returned."
|
||||
|
||||
# Response is a dict keyed by container ID hash; each entry has "name"
|
||||
# with a leading slash (e.g. "/jenkins") and may have a hash prefix.
|
||||
clean_query = _strip_hash_prefix(container_name)
|
||||
target: dict[str, Any] | None = None
|
||||
for entry in data.values():
|
||||
entry_name = _strip_hash_prefix(entry.get("name", ""))
|
||||
if entry_name == clean_query:
|
||||
target = entry
|
||||
break
|
||||
|
||||
if target is None:
|
||||
available = ", ".join(_strip_hash_prefix(v.get("name", "?")) for v in data.values())
|
||||
return f"Container '{container_name}' not found in stats. Available: {available}"
|
||||
|
||||
# ── CPU % ────────────────────────────────────────────────────────────
|
||||
cpu_stats = target.get("cpu_stats", {})
|
||||
precpu_stats = target.get("precpu_stats", {})
|
||||
cpu_usage = cpu_stats.get("cpu_usage", {})
|
||||
precpu_usage = precpu_stats.get("cpu_usage", {})
|
||||
|
||||
cpu_delta = cpu_usage.get("total_usage", 0) - precpu_usage.get("total_usage", 0)
|
||||
system_delta = cpu_stats.get("system_cpu_usage", 0) - precpu_stats.get(
|
||||
"system_cpu_usage", 0
|
||||
)
|
||||
online_cpus = cpu_stats.get("online_cpus") or len(cpu_usage.get("percpu_usage") or [1])
|
||||
|
||||
if system_delta > 0 and cpu_delta >= 0:
|
||||
cpu_pct = (cpu_delta / system_delta) * online_cpus * 100.0
|
||||
else:
|
||||
cpu_pct = 0.0
|
||||
|
||||
# ── Memory ───────────────────────────────────────────────────────────
|
||||
mem_stats = target.get("memory_stats", {})
|
||||
mem_usage = mem_stats.get("usage", 0)
|
||||
mem_limit = mem_stats.get("limit", 0)
|
||||
|
||||
# ── Network I/O ──────────────────────────────────────────────────────
|
||||
net_rx = 0
|
||||
net_tx = 0
|
||||
for iface in (target.get("networks") or {}).values():
|
||||
net_rx += iface.get("rx_bytes", 0)
|
||||
net_tx += iface.get("tx_bytes", 0)
|
||||
|
||||
# ── Block I/O ────────────────────────────────────────────────────────
|
||||
blk_read = 0
|
||||
blk_write = 0
|
||||
for entry_io in target.get("blkio_stats", {}).get("io_service_bytes_recursive") or []:
|
||||
op = entry_io.get("op", "").lower()
|
||||
val = entry_io.get("value", 0)
|
||||
if op == "read":
|
||||
blk_read += val
|
||||
elif op == "write":
|
||||
blk_write += val
|
||||
|
||||
# ── Format ───────────────────────────────────────────────────────────
|
||||
from mcp_synology_container.modules.images import _human_size # reuse helper
|
||||
|
||||
mem_limit_str = f" / {_human_size(mem_limit)}" if mem_limit else ""
|
||||
lines = [
|
||||
f"Stats for {container_name}:",
|
||||
f" CPU: {cpu_pct:.2f}% ({online_cpus} CPUs)",
|
||||
f" Memory: {_human_size(mem_usage)}{mem_limit_str}",
|
||||
f" Net I/O: rx {_human_size(net_rx)} / tx {_human_size(net_tx)}",
|
||||
f" Block I/O: read {_human_size(blk_read)} / write {_human_size(blk_write)}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def exec_in_container(
|
||||
container_name: str,
|
||||
command: str,
|
||||
confirmed: bool = False,
|
||||
) -> str:
|
||||
"""Execute a command in a running container.
|
||||
|
||||
This executes a shell command inside the container. Use with caution.
|
||||
Requires confirmation before executing.
|
||||
|
||||
Args:
|
||||
container_name: Name of the container.
|
||||
command: Shell command to execute.
|
||||
confirmed: Must be True to proceed. Set to True to confirm execution.
|
||||
"""
|
||||
):
|
||||
"""Execute a shell command inside a running container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"About to run in container '{container_name}':\n"
|
||||
@@ -152,12 +258,13 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"exec",
|
||||
params={
|
||||
"name": container_name,
|
||||
"name": resolved_name,
|
||||
"command": command,
|
||||
},
|
||||
)
|
||||
@@ -175,6 +282,133 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_container(container_name: str, confirmed: bool = False):
|
||||
"""Delete a stopped container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would delete container '{container_name}'.\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"get",
|
||||
params={"name": resolved_name},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting container '{container_name}': {e}"
|
||||
|
||||
if not data:
|
||||
return f"Container '{container_name}' not found."
|
||||
|
||||
details = data.get("details", {}) or {}
|
||||
state = details.get("State", {}) or {}
|
||||
running = state.get("Running", False)
|
||||
|
||||
if running:
|
||||
return (
|
||||
f"Cannot delete '{display_name}': container is still running.\n"
|
||||
f"Stop the container first with stop_project or stop_container."
|
||||
)
|
||||
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"delete",
|
||||
params={
|
||||
"name": json.dumps(resolved_name),
|
||||
"force": "false",
|
||||
"preserve_profile": "false",
|
||||
},
|
||||
)
|
||||
return f"Deleted container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error deleting '{display_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def start_container(container_name: str):
|
||||
"""Start a stopped container."""
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"start",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Started container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error starting container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def stop_container(container_name: str, confirmed: bool = False):
|
||||
"""Stop a running container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would stop container '{container_name}'.\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"stop",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Stopped container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error stopping container '{container_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def inspect_container(container_name: str):
|
||||
"""Inspect a container — image, ports, mounts (full host paths), env, labels, network."""
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"get",
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting container '{container_name}': {e}"
|
||||
|
||||
if not data:
|
||||
return f"Container '{container_name}' not found."
|
||||
|
||||
return _format_container_inspect(display_name, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def restart_container(container_name: str, confirmed: bool = False):
|
||||
"""Restart a container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would restart container '{container_name}'.\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
resolved_name = await _resolve_container_name(client, container_name)
|
||||
display_name = _strip_hash_prefix(resolved_name)
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"restart",
|
||||
version=1,
|
||||
params={"name": json.dumps(resolved_name)},
|
||||
)
|
||||
return f"Restarted container '{display_name}'."
|
||||
except Exception as e:
|
||||
return f"Error restarting container '{container_name}': {e}"
|
||||
|
||||
|
||||
def _container_in_project(container: dict[str, Any], project_name: str) -> bool:
|
||||
"""Check if a container belongs to a project based on its labels."""
|
||||
@@ -185,31 +419,201 @@ def _container_in_project(container: dict[str, Any], project_name: str) -> bool:
|
||||
|
||||
|
||||
def _format_container_detail(name: str, data: dict[str, Any]) -> str:
|
||||
"""Format container inspect data as human-readable text."""
|
||||
state = data.get("State", {}) or {}
|
||||
config = data.get("Config", {}) or {}
|
||||
host_config = data.get("HostConfig", {}) or {}
|
||||
"""Format container inspect data as human-readable text.
|
||||
|
||||
SYNO.Docker.Container/get returns two top-level keys:
|
||||
- "details": Docker Engine inspect format (State, NetworkSettings, Mounts, …)
|
||||
- "profile": DSM format (image, port_bindings, …)
|
||||
"""
|
||||
details: dict[str, Any] = data.get("details", {}) or {}
|
||||
profile: dict[str, Any] = data.get("profile", {}) or {}
|
||||
|
||||
state: dict[str, Any] = details.get("State", {}) or {}
|
||||
status_str = state.get("Status", "?")
|
||||
running = state.get("Running", False)
|
||||
image_str = profile.get("image", "?")
|
||||
|
||||
lines = [
|
||||
f"Container: {name}",
|
||||
f" Status: {state.get('Status', '?')}",
|
||||
f" Running: {state.get('Running', False)}",
|
||||
f" Image: {config.get('Image', '?')}",
|
||||
f" Status: {status_str}",
|
||||
f" Running: {running}",
|
||||
f" Image: {image_str}",
|
||||
]
|
||||
|
||||
if state.get("StartedAt"):
|
||||
lines.append(f" Started: {state.get('StartedAt')}")
|
||||
if state.get("FinishedAt") and not state.get("Running"):
|
||||
lines.append(f" Finished: {state.get('FinishedAt')}")
|
||||
lines.append(f" Started: {state['StartedAt']}")
|
||||
if state.get("FinishedAt") and not running:
|
||||
lines.append(f" Finished: {state['FinishedAt']}")
|
||||
lines.append(f" Exit code: {state.get('ExitCode', '?')}")
|
||||
|
||||
memory = host_config.get("Memory", 0)
|
||||
if memory:
|
||||
mb = memory // (1024 * 1024)
|
||||
lines.append(f" Memory limit: {mb} MiB")
|
||||
# IP addresses from all attached networks
|
||||
networks: dict[str, Any] = (details.get("NetworkSettings", {}) or {}).get("Networks", {}) or {}
|
||||
for net_name, net in networks.items():
|
||||
ip = (net or {}).get("IPAddress", "")
|
||||
if ip:
|
||||
lines.append(f" IP ({net_name}): {ip}")
|
||||
|
||||
env = config.get("Env", []) or []
|
||||
if env:
|
||||
lines.append(f" Env vars: {len(env)}")
|
||||
# Port bindings from DSM profile
|
||||
port_bindings: list[dict[str, Any]] = profile.get("port_bindings", []) or []
|
||||
if port_bindings:
|
||||
lines.append(" Ports:")
|
||||
for pb in port_bindings:
|
||||
host = pb.get("host_port", "?")
|
||||
ctr = pb.get("container_port", "?")
|
||||
proto = pb.get("type", "tcp")
|
||||
lines.append(f" {host} → {ctr}/{proto}")
|
||||
|
||||
# Mounts from Docker Engine inspect
|
||||
mounts: list[dict[str, Any]] = details.get("Mounts", []) or []
|
||||
if mounts:
|
||||
lines.append(" Mounts:")
|
||||
for m in mounts:
|
||||
src = m.get("Source", "?")
|
||||
dst = m.get("Destination", "?")
|
||||
mtype = m.get("Type", "")
|
||||
rw = "" if m.get("RW", True) else " [ro]"
|
||||
type_tag = f" ({mtype})" if mtype else ""
|
||||
lines.append(f" {src} → {dst}{type_tag}{rw}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_container_inspect(name: str, data: dict[str, Any]) -> str:
|
||||
"""Format SYNO.Docker.Container/get for the migration-oriented inspect view.
|
||||
|
||||
Reads the volume host path from ``details.Mounts[].Source`` (full
|
||||
``/volume1/...`` path), NOT from ``profile.volume_bindings[].host_volume_file``
|
||||
— the latter is share-relative (e.g. ``/docker/foo`` for ``/volume1/docker/foo``)
|
||||
and is not directly Compose-usable. See CLAUDE.md DSM quirks.
|
||||
"""
|
||||
details: dict[str, Any] = data.get("details", {}) or {}
|
||||
profile: dict[str, Any] = data.get("profile", {}) or {}
|
||||
|
||||
state: dict[str, Any] = details.get("State", {}) or {}
|
||||
host_config: dict[str, Any] = details.get("HostConfig", {}) or {}
|
||||
|
||||
image_str = profile.get("image") or details.get("Config", {}).get("Image", "?")
|
||||
status_str = state.get("Status", "?")
|
||||
running = state.get("Running", False)
|
||||
restart_count = details.get("RestartCount", 0)
|
||||
|
||||
lines = [
|
||||
f"Container: {name}",
|
||||
f" Image: {image_str}",
|
||||
f" Status: {status_str} (running={running})",
|
||||
]
|
||||
if restart_count:
|
||||
lines.append(f" Restarts: {restart_count}")
|
||||
|
||||
# ── Restart policy ──────────────────────────────────────────────────────
|
||||
restart_policy = host_config.get("RestartPolicy", {}) or {}
|
||||
policy_name = restart_policy.get("Name", "")
|
||||
if policy_name:
|
||||
max_retry = restart_policy.get("MaximumRetryCount", 0)
|
||||
suffix = f" (max {max_retry})" if policy_name == "on-failure" and max_retry else ""
|
||||
lines.append(f" Restart: {policy_name}{suffix}")
|
||||
elif profile.get("enable_restart_policy"):
|
||||
lines.append(" Restart: enabled")
|
||||
|
||||
# ── Network ─────────────────────────────────────────────────────────────
|
||||
net_mode = profile.get("network_mode") or host_config.get("NetworkMode", "")
|
||||
use_host = profile.get("use_host_network", False)
|
||||
if use_host:
|
||||
lines.append(" Network: host")
|
||||
elif net_mode:
|
||||
lines.append(f" Network: {net_mode}")
|
||||
|
||||
networks: dict[str, Any] = (details.get("NetworkSettings", {}) or {}).get("Networks", {}) or {}
|
||||
for net_name, net in networks.items():
|
||||
ip = (net or {}).get("IPAddress", "")
|
||||
if ip:
|
||||
lines.append(f" {net_name}: {ip}")
|
||||
|
||||
# ── Ports ───────────────────────────────────────────────────────────────
|
||||
port_bindings: list[dict[str, Any]] = profile.get("port_bindings", []) or []
|
||||
if port_bindings:
|
||||
lines.append("")
|
||||
lines.append(f"Ports ({len(port_bindings)}):")
|
||||
for pb in port_bindings:
|
||||
host = pb.get("host_port", "?")
|
||||
ctr = pb.get("container_port", "?")
|
||||
proto = pb.get("type", "tcp")
|
||||
lines.append(f" {host} → {ctr}/{proto}")
|
||||
|
||||
# ── Mounts ──────────────────────────────────────────────────────────────
|
||||
# Use details.Mounts[].Source — it's the full /volume1/... path.
|
||||
# profile.volume_bindings[].host_volume_file is share-relative and not
|
||||
# Compose-usable (DSM quirk; see CLAUDE.md).
|
||||
mounts: list[dict[str, Any]] = details.get("Mounts", []) or []
|
||||
if mounts:
|
||||
lines.append("")
|
||||
lines.append(f"Mounts ({len(mounts)}):")
|
||||
for m in mounts:
|
||||
src = m.get("Source", "?")
|
||||
dst = m.get("Destination", "?")
|
||||
mtype = m.get("Type", "")
|
||||
rw = "" if m.get("RW", True) else " [ro]"
|
||||
type_tag = f" ({mtype})" if mtype else ""
|
||||
lines.append(f" {src} → {dst}{type_tag}{rw}")
|
||||
|
||||
# ── Environment ─────────────────────────────────────────────────────────
|
||||
env_vars: list[Any] = profile.get("env_variables") or []
|
||||
if env_vars:
|
||||
lines.append("")
|
||||
lines.append(f"Environment ({len(env_vars)}):")
|
||||
for var in env_vars:
|
||||
if isinstance(var, dict):
|
||||
lines.append(f" {var.get('key', '?')}={var.get('value', '')}")
|
||||
else:
|
||||
lines.append(f" {var}")
|
||||
|
||||
# ── Labels ──────────────────────────────────────────────────────────────
|
||||
labels: dict[str, Any] = profile.get("labels") or {}
|
||||
if labels:
|
||||
lines.append("")
|
||||
lines.append(f"Labels ({len(labels)}):")
|
||||
for key in sorted(labels):
|
||||
lines.append(f" {key}={labels[key]}")
|
||||
|
||||
# ── Command / Entrypoint ────────────────────────────────────────────────
|
||||
cfg = details.get("Config", {}) or {}
|
||||
entrypoint = cfg.get("Entrypoint") or []
|
||||
cmd = cfg.get("Cmd") or profile.get("cmd_v2") or profile.get("cmd") or ""
|
||||
|
||||
if entrypoint:
|
||||
ep_str = (
|
||||
" ".join(str(x) for x in entrypoint)
|
||||
if isinstance(entrypoint, list)
|
||||
else str(entrypoint)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"Entrypoint: {ep_str}")
|
||||
if cmd:
|
||||
cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd)
|
||||
if not entrypoint:
|
||||
lines.append("")
|
||||
lines.append(f"Cmd: {cmd_str}")
|
||||
|
||||
# ── Links ───────────────────────────────────────────────────────────────
|
||||
links: list[Any] = profile.get("links") or []
|
||||
if links:
|
||||
lines.append("")
|
||||
lines.append(f"Links ({len(links)}):")
|
||||
for link in links:
|
||||
lines.append(f" {link}")
|
||||
|
||||
# ── Capabilities / Privileged ───────────────────────────────────────────
|
||||
cap_add = profile.get("CapAdd") or host_config.get("CapAdd") or []
|
||||
cap_drop = profile.get("CapDrop") or host_config.get("CapDrop") or []
|
||||
privileged = profile.get("privileged") or host_config.get("Privileged", False)
|
||||
if cap_add or cap_drop or privileged:
|
||||
lines.append("")
|
||||
lines.append("Security:")
|
||||
if privileged:
|
||||
lines.append(" privileged=true")
|
||||
if cap_add:
|
||||
lines.append(f" CapAdd: {', '.join(str(c) for c in cap_add)}")
|
||||
if cap_drop:
|
||||
lines.append(f" CapDrop: {', '.join(str(c) for c in cap_drop)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1,32 +1,375 @@
|
||||
"""MCP tools for SYNO.Docker.Image: list and check for updates."""
|
||||
"""MCP tools for SYNO.Docker.Image: list, check updates, delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _human_size(size_bytes: int) -> str:
|
||||
"""Convert byte count to human-readable string (GiB / MiB / KiB)."""
|
||||
if size_bytes >= 1024**3:
|
||||
return f"{size_bytes / 1024**3:.1f} GiB"
|
||||
if size_bytes >= 1024**2:
|
||||
return f"{size_bytes / 1024**2:.0f} MiB"
|
||||
if size_bytes >= 1024:
|
||||
return f"{size_bytes / 1024:.0f} KiB"
|
||||
return f"{size_bytes} B"
|
||||
|
||||
|
||||
def _format_created(ts: int) -> str:
|
||||
"""Format a Unix timestamp as a UTC date string."""
|
||||
if not ts:
|
||||
return "unknown"
|
||||
try:
|
||||
return datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d")
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
async def _filter_images_for_project(
|
||||
client: DsmClient,
|
||||
project_name: str,
|
||||
all_images: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filter images to those used by a specific project.
|
||||
|
||||
Fetches project details and cross-references container image IDs.
|
||||
Falls back to returning all images if project details are unavailable.
|
||||
|
||||
Args:
|
||||
client: DsmClient instance.
|
||||
project_name: Project name to filter for.
|
||||
all_images: Full list of images from SYNO.Docker.Image.
|
||||
|
||||
Returns:
|
||||
Subset of images used by the project.
|
||||
"""
|
||||
try:
|
||||
list_data = await client.request("SYNO.Docker.Project", "list")
|
||||
projects: dict[str, Any] = list_data if isinstance(list_data, dict) else {}
|
||||
project_entry = next((p for p in projects.values() if p.get("name") == project_name), None)
|
||||
|
||||
if not project_entry:
|
||||
return []
|
||||
|
||||
project_id = project_entry.get("id", "")
|
||||
detail_data = await client.request(
|
||||
"SYNO.Docker.Project",
|
||||
"get",
|
||||
params={"id": project_id},
|
||||
)
|
||||
|
||||
containers = detail_data.get("containers", []) or []
|
||||
image_ids: set[str] = set()
|
||||
image_names: set[str] = set()
|
||||
|
||||
for container in containers:
|
||||
img_id = container.get("Image", "")
|
||||
if img_id:
|
||||
image_ids.add(img_id)
|
||||
cfg_image = container.get("Config", {}).get("Image", "")
|
||||
if cfg_image:
|
||||
name = cfg_image.split(":")[0] if ":" in cfg_image else cfg_image
|
||||
image_names.add(name)
|
||||
|
||||
result = []
|
||||
for img in all_images:
|
||||
img_id = img.get("id", "")
|
||||
repo = img.get("repository", "")
|
||||
if img_id in image_ids or repo in image_names:
|
||||
result.append(img)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Could not filter images for project '%s': %s", project_name, e)
|
||||
return all_images
|
||||
|
||||
|
||||
def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all image management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def check_image_updates(project_name: str | None = None) -> str:
|
||||
"""Check for available image updates for a project or all images.
|
||||
async def list_images():
|
||||
"""List local Docker images sorted by size, showing tag, date, and in-use status."""
|
||||
try:
|
||||
img_data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error listing images: {e}"
|
||||
|
||||
Queries the local image list and reports which images have the
|
||||
'upgradable' flag set by the NAS registry check.
|
||||
images: list[dict[str, Any]] = img_data.get("images", [])
|
||||
if not images:
|
||||
return "No local images found."
|
||||
|
||||
Args:
|
||||
project_name: Optional project name to filter images.
|
||||
If omitted, checks all locally available images.
|
||||
"""
|
||||
# Collect image IDs in use by containers
|
||||
in_use_ids: set[str] = set()
|
||||
try:
|
||||
ctr_data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||
)
|
||||
for ctr in ctr_data.get("containers", []):
|
||||
img_id = ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "")
|
||||
if img_id:
|
||||
in_use_ids.add(img_id)
|
||||
except Exception as e:
|
||||
logger.debug("Could not fetch containers for in-use check: %s", e)
|
||||
|
||||
# Sort by size descending
|
||||
images_sorted = sorted(images, key=lambda x: x.get("size", 0), reverse=True)
|
||||
|
||||
total_size = sum(img.get("size", 0) for img in images)
|
||||
lines = [f"Local images ({len(images)} total, {_human_size(total_size)}):", ""]
|
||||
|
||||
for img in images_sorted:
|
||||
repo = img.get("repository", "<none>")
|
||||
tags = img.get("tags") or ["<none>"]
|
||||
tag_str = ", ".join(tags)
|
||||
size = _human_size(img.get("size", 0))
|
||||
created = _format_created(img.get("created", 0))
|
||||
img_id = img.get("id", "")
|
||||
used_marker = " [in use]" if img_id in in_use_ids else ""
|
||||
upgradable = " [update available]" if img.get("upgradable") else ""
|
||||
lines.append(f" {repo}:{tag_str} {size} {created}{used_marker}{upgradable}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_image(image_id: str, confirmed: bool = False):
|
||||
"""Delete a local image by name:tag or hash. Requires confirmed=True; refuses if in use."""
|
||||
# Parse name and tag using the last ":" as separator so that
|
||||
# registry-prefixed images (e.g. "ghcr.io/foo/bar:v1") are handled
|
||||
# correctly. rpartition returns ("", "", original) when ":" is absent.
|
||||
name, sep, tag = image_id.rpartition(":")
|
||||
if not sep:
|
||||
# No ":" found — bare name without explicit tag
|
||||
name = image_id
|
||||
tag = "latest"
|
||||
|
||||
# Fetch the local image list for size reporting and in-use detection
|
||||
try:
|
||||
img_data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error fetching image list: {e}"
|
||||
|
||||
images: list[dict[str, Any]] = img_data.get("images", [])
|
||||
|
||||
# Locate the target image by name+tag or hash prefix
|
||||
is_hash = image_id.startswith("sha256:") or (len(image_id) >= 12 and ":" not in image_id)
|
||||
target: dict[str, Any] | None = None
|
||||
|
||||
for img in images:
|
||||
if is_hash:
|
||||
img_hash = img.get("id", "")
|
||||
if img_hash == image_id or img_hash.startswith(image_id):
|
||||
target = img
|
||||
break
|
||||
else:
|
||||
repo = img.get("repository", "")
|
||||
img_tags = img.get("tags") or []
|
||||
if repo == name and tag in img_tags:
|
||||
target = img
|
||||
break
|
||||
|
||||
if target is None:
|
||||
return f"Image '{image_id}' not found locally."
|
||||
|
||||
# Resolve display info from the found image
|
||||
repo = target.get("repository", name)
|
||||
img_tags = target.get("tags") or [tag]
|
||||
display_name = f"{repo}:{img_tags[0]}"
|
||||
size_str = _human_size(target.get("size", 0))
|
||||
img_hash = target.get("id", "")
|
||||
|
||||
# Check if image is in use by any container
|
||||
in_use_running: list[str] = []
|
||||
in_use_stopped: list[str] = []
|
||||
try:
|
||||
ctr_data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||
)
|
||||
for ctr in ctr_data.get("containers", []):
|
||||
ctr_img_id = ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "")
|
||||
hash_prefix = img_hash[:12] if img_hash else ""
|
||||
if img_hash and (
|
||||
ctr_img_id == img_hash or (hash_prefix and ctr_img_id.startswith(hash_prefix))
|
||||
):
|
||||
ctr_name = ctr.get("name") or ctr.get("Names", ["?"])[0]
|
||||
status = ctr.get("status", ctr.get("state", "")).lower()
|
||||
if status == "running":
|
||||
in_use_running.append(ctr_name)
|
||||
else:
|
||||
in_use_stopped.append(ctr_name)
|
||||
except Exception as e:
|
||||
logger.debug("Could not fetch containers for in-use check: %s", e)
|
||||
|
||||
if in_use_running:
|
||||
return (
|
||||
f"Cannot delete '{display_name}': image is used by running container(s): "
|
||||
+ ", ".join(in_use_running)
|
||||
)
|
||||
|
||||
if in_use_stopped:
|
||||
stopped_name = in_use_stopped[0]
|
||||
return (
|
||||
f"Cannot delete '{display_name}': image is used by stopped container "
|
||||
f"'{stopped_name}'.\n"
|
||||
f"Delete the container first or run system_prune to clean up stopped containers."
|
||||
)
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would delete {display_name} ({size_str}).\n"
|
||||
f"Call delete_image(image_id={image_id!r}, confirmed=True) to confirm."
|
||||
)
|
||||
|
||||
# DSM Container Manager expects a POST with version=1 and an
|
||||
# "images" JSON array — confirmed via browser DevTools capture.
|
||||
# Format: images=[{"repository": "nginx", "tags": ["1.24"]}]
|
||||
delete_repo = repo
|
||||
delete_tag = img_tags[0] if img_tags else tag
|
||||
images_param = json.dumps([{"repository": delete_repo, "tags": [delete_tag]}])
|
||||
sys.stderr.write(
|
||||
f"[delete_image] POST SYNO.Docker.Image/delete v1 images={images_param!r}\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
try:
|
||||
await client.post_request(
|
||||
"SYNO.Docker.Image",
|
||||
"delete",
|
||||
version=1,
|
||||
params={"images": images_param},
|
||||
)
|
||||
except Exception as e:
|
||||
code = getattr(e, "code", "?")
|
||||
sys.stderr.write(f"[delete_image] failed: {e} (DSM code {code})\n")
|
||||
sys.stderr.flush()
|
||||
return f"Error deleting '{display_name}': {e} [DSM code {code}]"
|
||||
|
||||
return f"Deleted {display_name} — {size_str} freed."
|
||||
|
||||
@mcp.tool()
|
||||
async def inspect_image(image_id: str):
|
||||
"""Inspect a local image by name:tag or sha256 hash — shows config, env, ports."""
|
||||
# SYNO.Docker.Image/get version=1 expects the parameter "identity"
|
||||
# (JSON-encoded). It accepts both `name:tag` and `sha256:<hash>` —
|
||||
# no pre-resolution needed. Confirmed via DSM API capture.
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"get",
|
||||
version=1,
|
||||
params={"identity": json.dumps(image_id)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting image '{image_id}': {e}"
|
||||
|
||||
if not isinstance(data, dict) or not data:
|
||||
return f"Image '{image_id}' not found."
|
||||
|
||||
img_hash = data.get("id") or ""
|
||||
# The DSM Image/get response leaves `image` and `tag` empty when the
|
||||
# lookup is by name:tag (live-confirmed against the NAS), so the
|
||||
# response fields are unreliable for the header. Prefer the user-
|
||||
# supplied image_id, then fall back to the id hash.
|
||||
display_name = image_id or img_hash or "?"
|
||||
digest = data.get("digest") or ""
|
||||
size_val = data.get("size") or 0
|
||||
virtual_size_val = data.get("virtual_size") or 0
|
||||
author = data.get("author") or ""
|
||||
docker_version = data.get("docker_version") or ""
|
||||
cmd = data.get("cmd") or []
|
||||
entrypoint = data.get("entrypoint") or []
|
||||
env_list: list[Any] = data.get("env") or []
|
||||
ports: list[Any] = data.get("ports") or []
|
||||
volumes: list[Any] = data.get("volumes") or []
|
||||
|
||||
lines = [f"Image: {display_name}"]
|
||||
if img_hash:
|
||||
lines.append(f" Hash: {img_hash}")
|
||||
if digest:
|
||||
lines.append(f" Digest: {digest}")
|
||||
if size_val:
|
||||
lines.append(f" Size: {_human_size(size_val)}")
|
||||
if virtual_size_val and virtual_size_val != size_val:
|
||||
lines.append(f" Virtual: {_human_size(virtual_size_val)}")
|
||||
if author:
|
||||
lines.append(f" Author: {author}")
|
||||
if docker_version:
|
||||
lines.append(f" Docker: {docker_version}")
|
||||
|
||||
if entrypoint:
|
||||
ep_str = (
|
||||
" ".join(str(x) for x in entrypoint)
|
||||
if isinstance(entrypoint, list)
|
||||
else str(entrypoint)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f" Entrypoint: {ep_str}")
|
||||
if cmd:
|
||||
cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd)
|
||||
if not entrypoint:
|
||||
lines.append("")
|
||||
lines.append(f" Cmd: {cmd_str}")
|
||||
|
||||
if ports:
|
||||
lines.append("")
|
||||
lines.append(f"Exposed ports ({len(ports)}):")
|
||||
for port in ports:
|
||||
# Live shape: {"port": "22", "protocol": "tcp"}.
|
||||
if isinstance(port, dict):
|
||||
port_num = port.get("port", "?")
|
||||
proto = port.get("protocol", "tcp")
|
||||
lines.append(f" {port_num}/{proto}")
|
||||
else:
|
||||
lines.append(f" {port}")
|
||||
|
||||
if volumes:
|
||||
lines.append("")
|
||||
lines.append(f"Volumes ({len(volumes)}):")
|
||||
for vol in volumes:
|
||||
lines.append(f" {vol}")
|
||||
|
||||
if env_list:
|
||||
lines.append("")
|
||||
lines.append(f"Environment ({len(env_list)}):")
|
||||
for var in env_list:
|
||||
# Live shape: {"key": "PATH", "value": "/usr/local/sbin"}.
|
||||
if isinstance(var, dict):
|
||||
key = var.get("key", "?")
|
||||
value = var.get("value", "")
|
||||
lines.append(f" {key}={value}")
|
||||
else:
|
||||
lines.append(f" {var}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def check_image_updates(project_name: str | None = None):
|
||||
"""Check which local images have updates available (upgradable flag from NAS registry)."""
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
@@ -40,7 +383,6 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
if not images:
|
||||
return "No images found."
|
||||
|
||||
# If project_name given, cross-reference with project containers
|
||||
if project_name:
|
||||
images = await _filter_images_for_project(client, project_name, images)
|
||||
if not images:
|
||||
@@ -75,71 +417,3 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
lines.append("All images are up to date.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _filter_images_for_project(
|
||||
client: DsmClient,
|
||||
project_name: str,
|
||||
all_images: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filter images to those used by a specific project.
|
||||
|
||||
Fetches project details and cross-references container image IDs.
|
||||
Falls back to name-based matching if project details unavailable.
|
||||
|
||||
Args:
|
||||
client: DsmClient instance.
|
||||
project_name: Project name to filter for.
|
||||
all_images: Full list of images from SYNO.Docker.Image.
|
||||
|
||||
Returns:
|
||||
Subset of images used by the project.
|
||||
"""
|
||||
# Get project details to find used images
|
||||
try:
|
||||
# Find project by name
|
||||
list_data = await client.request("SYNO.Docker.Project", "list")
|
||||
projects: dict[str, Any] = list_data if isinstance(list_data, dict) else {}
|
||||
project_entry = next(
|
||||
(p for p in projects.values() if p.get("name") == project_name), None
|
||||
)
|
||||
|
||||
if not project_entry:
|
||||
return []
|
||||
|
||||
project_id = project_entry.get("id", "")
|
||||
# Get project detail which includes container image info
|
||||
detail_data = await client.request(
|
||||
"SYNO.Docker.Project",
|
||||
"get",
|
||||
params={"id": project_id},
|
||||
)
|
||||
|
||||
containers = detail_data.get("containers", []) or []
|
||||
image_ids: set[str] = set()
|
||||
image_names: set[str] = set()
|
||||
|
||||
for container in containers:
|
||||
img_id = container.get("Image", "")
|
||||
if img_id:
|
||||
image_ids.add(img_id)
|
||||
cfg_image = container.get("Config", {}).get("Image", "")
|
||||
if cfg_image:
|
||||
# Strip tag for name matching
|
||||
name = cfg_image.split(":")[0] if ":" in cfg_image else cfg_image
|
||||
image_names.add(name)
|
||||
|
||||
# Match images
|
||||
result = []
|
||||
for img in all_images:
|
||||
img_id = img.get("id", "")
|
||||
repo = img.get("repository", "")
|
||||
if img_id in image_ids or repo in image_names:
|
||||
result.append(img)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Could not filter images for project '%s': %s", project_name, e)
|
||||
# Fallback: return all images
|
||||
return all_images
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""MCP tools for SYNO.Docker.Network: list, create, delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_networks(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all network management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_networks():
|
||||
"""List all Docker networks with driver, subnet, gateway, and attached containers."""
|
||||
try:
|
||||
data = await client.request("SYNO.Docker.Network", "list")
|
||||
except Exception as e:
|
||||
return f"Error listing networks: {e}"
|
||||
|
||||
networks: list[dict[str, Any]] = data.get("network", [])
|
||||
if not networks:
|
||||
return "No networks found."
|
||||
|
||||
lines = [f"Networks ({len(networks)} total):", ""]
|
||||
for net in sorted(networks, key=lambda n: n.get("name", "")):
|
||||
name = net.get("name", "?")
|
||||
driver = net.get("driver", "?")
|
||||
subnet = net.get("subnet") or "—"
|
||||
gateway = net.get("gateway") or "—"
|
||||
ipv6 = "IPv6" if net.get("enable_ipv6") else ""
|
||||
containers: list[str] = net.get("containers") or []
|
||||
|
||||
lines.append(f" {name}")
|
||||
lines.append(f" Driver: {driver}{(' ' + ipv6) if ipv6 else ''}")
|
||||
lines.append(f" Subnet: {subnet}")
|
||||
lines.append(f" Gateway: {gateway}")
|
||||
if containers:
|
||||
lines.append(f" Containers ({len(containers)}): {', '.join(containers)}")
|
||||
else:
|
||||
lines.append(" Containers: none")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
@mcp.tool()
|
||||
async def create_network(
|
||||
name: str,
|
||||
driver: str = "bridge",
|
||||
subnet: str | None = None,
|
||||
gateway: str | None = None,
|
||||
ip_range: str | None = None,
|
||||
enable_ipv6: bool = False,
|
||||
confirmed: bool = False,
|
||||
):
|
||||
"""Create a Docker network with optional subnet/gateway/IPv6. Requires confirmed=True."""
|
||||
details = [f" Name: {name}", f" Driver: {driver}"]
|
||||
if subnet:
|
||||
details.append(f" Subnet: {subnet}")
|
||||
if gateway:
|
||||
details.append(f" Gateway: {gateway}")
|
||||
if ip_range:
|
||||
details.append(f" IP range:{ip_range}")
|
||||
if enable_ipv6:
|
||||
details.append(" IPv6: enabled")
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
"Preview: would create network:\n"
|
||||
+ "\n".join(details)
|
||||
+ f"\n\nCall create_network(name={name!r}, confirmed=True) to confirm."
|
||||
)
|
||||
|
||||
# DevTools-confirmed POST format: all string/bool params as json.dumps.
|
||||
params: dict[str, Any] = {
|
||||
"name": json.dumps(name),
|
||||
"driver": driver,
|
||||
"enable_ipv6": json.dumps(enable_ipv6),
|
||||
"disable_masquerade": json.dumps(False),
|
||||
}
|
||||
if subnet is not None:
|
||||
params["subnet"] = json.dumps(subnet)
|
||||
if gateway is not None:
|
||||
params["gateway"] = json.dumps(gateway)
|
||||
if ip_range is not None:
|
||||
params["iprange"] = json.dumps(ip_range)
|
||||
|
||||
try:
|
||||
result = await client.post_request("SYNO.Docker.Network", "create", params=params)
|
||||
except Exception as e:
|
||||
return f"Error creating network '{name}': {e}"
|
||||
|
||||
net_id = result.get("id", "")
|
||||
id_str = f" (ID: {net_id[:12]})" if net_id else ""
|
||||
return f"Network '{name}' created{id_str}."
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_network(name: str, confirmed: bool = False):
|
||||
"""Delete a Docker network. Requires confirmed=True; refuses if containers are attached."""
|
||||
# Fetch network list to validate existence and check for attached containers
|
||||
try:
|
||||
data = await client.request("SYNO.Docker.Network", "list")
|
||||
except Exception as e:
|
||||
return f"Error fetching network list: {e}"
|
||||
|
||||
networks: list[dict[str, Any]] = data.get("network", [])
|
||||
target = next((n for n in networks if n.get("name") == name), None)
|
||||
|
||||
if target is None:
|
||||
return f"Network '{name}' not found."
|
||||
|
||||
attached: list[str] = target.get("containers") or []
|
||||
if attached:
|
||||
return (
|
||||
f"Cannot delete network '{name}': "
|
||||
f"{len(attached)} container(s) attached: {', '.join(attached)}"
|
||||
)
|
||||
|
||||
if not confirmed:
|
||||
driver = target.get("driver", "?")
|
||||
subnet = target.get("subnet") or "—"
|
||||
return (
|
||||
f"Preview: would delete network '{name}' (driver={driver}, subnet={subnet}).\n"
|
||||
f"Call delete_network(name={name!r}, confirmed=True) to confirm."
|
||||
)
|
||||
|
||||
# DevTools-confirmed POST format: method="remove", full network object
|
||||
# as a JSON array in the "networks" parameter. The object must include
|
||||
# all fields from the list response plus "_key" set to the network ID.
|
||||
net_id = target.get("id", "")
|
||||
network_obj: dict[str, Any] = {
|
||||
"containers": target.get("containers") or [],
|
||||
"driver": target.get("driver", ""),
|
||||
"enable_ipv6": target.get("enable_ipv6", False),
|
||||
"ipv6_gateway": target.get("ipv6_gateway", ""),
|
||||
"ipv6_subnet": target.get("ipv6_subnet", ""),
|
||||
"ipv6_iprange": target.get("ipv6_iprange", ""),
|
||||
"gateway": target.get("gateway", ""),
|
||||
"id": net_id,
|
||||
"iprange": target.get("iprange", ""),
|
||||
"name": name,
|
||||
"subnet": target.get("subnet", ""),
|
||||
"disable_masquerade": target.get("disable_masquerade", False),
|
||||
"_key": net_id,
|
||||
}
|
||||
|
||||
try:
|
||||
await client.post_request(
|
||||
"SYNO.Docker.Network",
|
||||
"remove",
|
||||
params={"networks": json.dumps([network_obj])},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error deleting network '{name}': {e}"
|
||||
|
||||
return f"Network '{name}' deleted."
|
||||
@@ -1,28 +1,74 @@
|
||||
"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy."""
|
||||
"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy, create."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POLL_INTERVAL = 2 # seconds between status checks
|
||||
_POLL_TIMEOUT = 30 # seconds for ordinary start polling
|
||||
_BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be slow)
|
||||
|
||||
# Statuses that mean "stop polling now — this redeploy is not coming back."
|
||||
# DSM signals these typically within seconds of build_stream when the image
|
||||
# pull or container start fails; without an early exit the caller would wait
|
||||
# the full _BUILD_POLL_TIMEOUT for nothing.
|
||||
_TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"})
|
||||
|
||||
# Substrings that mark a line in the build_stream log as a failure. Live
|
||||
# DSM capture: image-pull or container-start errors surface as either a
|
||||
# bare "<svc> Error" status line OR a more verbose "Error response from
|
||||
# daemon: <cause>" line emitted right after it. Both forms are treated as
|
||||
# build failures by `_parse_build_stream_log`.
|
||||
_BUILD_DAEMON_ERROR = "Error response from daemon:"
|
||||
|
||||
|
||||
def _parse_build_stream_log(log: str) -> tuple[list[str], list[str]]:
|
||||
"""Split a build_stream log into (error_lines, info_lines).
|
||||
|
||||
A line is classified as an error when it contains "Error response from
|
||||
daemon:" or ends with " Error" (the per-service status DSM emits when
|
||||
a container fails to start). Everything else — including success
|
||||
markers like "Container <name> Running" — is treated as info.
|
||||
|
||||
Args:
|
||||
log: Raw log text returned by ``DsmClient.trigger_build_stream``.
|
||||
|
||||
Returns:
|
||||
Tuple of (errors, info) lines with empty lines stripped.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
info: list[str] = []
|
||||
for raw in log.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if _BUILD_DAEMON_ERROR in line or line.endswith(" Error"):
|
||||
errors.append(line)
|
||||
else:
|
||||
info.append(line)
|
||||
return errors, info
|
||||
|
||||
|
||||
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all project management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_projects() -> str:
|
||||
"""List all Container Manager projects with their current status.
|
||||
|
||||
Returns a formatted table of projects including name, status, path,
|
||||
and container count.
|
||||
"""
|
||||
async def list_projects():
|
||||
"""List all Container Manager projects with name, status, path, and container count."""
|
||||
try:
|
||||
data = await client.request("SYNO.Docker.Project", "list")
|
||||
except Exception as e:
|
||||
@@ -33,7 +79,7 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return "No projects found."
|
||||
|
||||
lines = ["Projects:", ""]
|
||||
for project_id, proj in sorted(projects.items(), key=lambda x: x[1].get("name", "")):
|
||||
for _project_id, proj in sorted(projects.items(), key=lambda x: x[1].get("name", "")):
|
||||
name = proj.get("name", "?")
|
||||
status = proj.get("status", "?")
|
||||
path = proj.get("path", "?")
|
||||
@@ -47,12 +93,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
@mcp.tool()
|
||||
async def get_project_status(project_name: str) -> str:
|
||||
"""Get detailed status of a specific project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to inspect.
|
||||
"""
|
||||
async def get_project_status(project_name: str):
|
||||
"""Get detailed status and container list for a specific project."""
|
||||
project = await _find_project(client, project_name)
|
||||
if project is None:
|
||||
return f"Project '{project_name}' not found."
|
||||
@@ -60,12 +102,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return _format_project_detail(project)
|
||||
|
||||
@mcp.tool()
|
||||
async def start_project(project_name: str) -> str:
|
||||
"""Start a Container Manager project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to start.
|
||||
"""
|
||||
async def start_project(project_name: str):
|
||||
"""Start a Container Manager project."""
|
||||
project = await _find_project(client, project_name)
|
||||
if project is None:
|
||||
return f"Project '{project_name}' not found."
|
||||
@@ -82,16 +120,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return f"Error starting project '{project_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def stop_project(project_name: str, confirmed: bool = False) -> str:
|
||||
"""Stop a running Container Manager project.
|
||||
|
||||
This operation stops all containers in the project.
|
||||
Requires confirmation before executing.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to stop.
|
||||
confirmed: Must be True to proceed. Set to True to confirm the stop operation.
|
||||
"""
|
||||
async def stop_project(project_name: str, confirmed: bool = False):
|
||||
"""Stop all containers in a project. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Stopping project '{project_name}' will halt all its containers.\n"
|
||||
@@ -114,22 +144,12 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return f"Error stopping project '{project_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def redeploy_project(project_name: str, confirmed: bool = False) -> str:
|
||||
"""Redeploy a project: pull latest images, stop, and restart.
|
||||
|
||||
This operation will briefly take the project offline.
|
||||
Requires confirmation before executing.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to redeploy.
|
||||
confirmed: Must be True to proceed. Set to True to confirm the redeploy.
|
||||
"""
|
||||
async def redeploy_project(project_name: str, confirmed: bool = False):
|
||||
"""Pull latest images and restart a project via build_stream. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Redeploying project '{project_name}' will:\n"
|
||||
f" 1. Pull latest images\n"
|
||||
f" 2. Stop all containers\n"
|
||||
f" 3. Restart with new images\n\n"
|
||||
f"Redeploying project '{project_name}' will stop and restart all its "
|
||||
f"containers, pulling the latest images.\n\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
@@ -138,46 +158,340 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return f"Project '{project_name}' not found."
|
||||
|
||||
project_id = project.get("id", "")
|
||||
results = []
|
||||
status = (project.get("status") or "").upper()
|
||||
|
||||
if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", ""):
|
||||
return (
|
||||
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
|
||||
f"Workaround: use stop_project + start_project separately."
|
||||
)
|
||||
|
||||
results: list[str] = []
|
||||
# Track whether we issued a stop that DSM accepted. Used to give the
|
||||
# caller an accurate recovery hint if a later step (build_stream)
|
||||
# fails — the project would be left in STOPPED state.
|
||||
stop_was_issued = False
|
||||
|
||||
try:
|
||||
# Step 1: Pull latest images via build (triggers compose pull)
|
||||
results.append("Step 1/3: Pulling latest images...")
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Project",
|
||||
"build",
|
||||
params={"id": project_id, "force": "true"},
|
||||
)
|
||||
results.append(" Images pulled.")
|
||||
except Exception as e:
|
||||
results.append(f" Warning: pull step failed ({e}), continuing with restart.")
|
||||
# ── Step 1: Stop ──────────────────────────────────────────────────
|
||||
if status == "STOPPED":
|
||||
results.append("Step 1/3: Project is STOPPED — skipping stop.")
|
||||
elif status in ("BUILD_FAILED", ""):
|
||||
results.append("Step 1/3: Stopping failed build...")
|
||||
with contextlib.suppress(Exception):
|
||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||
stop_was_issued = True
|
||||
results.append(" Stopped.")
|
||||
else: # RUNNING
|
||||
results.append("Step 1/3: Stopping project...")
|
||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||
stop_was_issued = True
|
||||
results.append(" Stopped.")
|
||||
|
||||
# Step 2: Stop the project
|
||||
results.append("Step 2/3: Stopping project...")
|
||||
await client.request(
|
||||
"SYNO.Docker.Project",
|
||||
"stop",
|
||||
params={"id": project_id},
|
||||
# ── Step 2: build_stream (pull images + start) ────────────────────
|
||||
results.append("Step 2/3: Triggering image pull and project start (build_stream)...")
|
||||
build_log = await client.trigger_build_stream(project_id)
|
||||
build_errors, _ = _parse_build_stream_log(build_log)
|
||||
if build_errors:
|
||||
# Live build_stream reported a daemon error (e.g. manifest
|
||||
# not found, container exited). Surface the cause now —
|
||||
# no need to wait for polling to flip the project to
|
||||
# BUILD_FAILED, and the daemon line is much more
|
||||
# actionable than the bare status.
|
||||
results.append(" Build failed — DSM reported:")
|
||||
results.extend(f" {line}" for line in build_errors)
|
||||
results.append(
|
||||
f"\nProject '{project_name}' redeploy aborted (build_stream errors)."
|
||||
)
|
||||
results.append(" Project stopped.")
|
||||
|
||||
# Step 3: Start the project
|
||||
results.append("Step 3/3: Starting project...")
|
||||
await client.request(
|
||||
"SYNO.Docker.Project",
|
||||
"start",
|
||||
params={"id": project_id},
|
||||
if stop_was_issued:
|
||||
results.append(
|
||||
f"Note: project '{project_name}' was stopped before this error and is "
|
||||
f"now in STOPPED state. Run start_project('{project_name}') or retry "
|
||||
f"redeploy_project to recover."
|
||||
)
|
||||
results.append(" Project started.")
|
||||
return "\n".join(results)
|
||||
results.append(" Build request accepted by DSM.")
|
||||
|
||||
# ── Step 3: Poll ──────────────────────────────────────────────────
|
||||
results.append(
|
||||
f"Step 3/3: Waiting for project to reach RUNNING state "
|
||||
f"(up to {_BUILD_POLL_TIMEOUT}s)..."
|
||||
)
|
||||
final_status = await _wait_for_project_running(
|
||||
client, project_name, timeout=_BUILD_POLL_TIMEOUT
|
||||
)
|
||||
if final_status == "RUNNING":
|
||||
results.append(" Project is RUNNING.")
|
||||
results.append(f"\nProject '{project_name}' redeployed successfully.")
|
||||
elif final_status in _TERMINAL_FAILURE_STATUSES:
|
||||
# M-5: DSM signalled a hard failure during polling. The
|
||||
# build_stream log above was clean, so this is a late
|
||||
# failure (e.g. container exited after start).
|
||||
results.append(f" Redeploy failed — project status is '{final_status}'.")
|
||||
if final_status == "BUILD_FAILED":
|
||||
results.append(
|
||||
" Check the image tag in the compose file "
|
||||
"(update_image_tag) and retry redeploy_project."
|
||||
)
|
||||
results.append(
|
||||
f"\nProject '{project_name}' redeploy aborted (status: {final_status})."
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
f" Warning: project status is '{final_status}' after "
|
||||
f"{_BUILD_POLL_TIMEOUT}s. "
|
||||
f"Containers may still be starting — check with get_project_status."
|
||||
)
|
||||
results.append(f"\nProject '{project_name}' build issued (status: {final_status}).")
|
||||
|
||||
except Exception as e:
|
||||
results.append(f"Error during redeploy: {e}")
|
||||
if stop_was_issued:
|
||||
# M-4: build_stream (or polling) failed AFTER we stopped the
|
||||
# project. The project is now in STOPPED state and the caller
|
||||
# needs to know that — the previous "use stop + start"
|
||||
# workaround was misleading because stop already happened.
|
||||
results.append(
|
||||
f"Note: project '{project_name}' was stopped before this error and is "
|
||||
f"now in STOPPED state. Run start_project('{project_name}') or retry "
|
||||
f"redeploy_project to recover."
|
||||
)
|
||||
else:
|
||||
results.append("Workaround: use stop_project + start_project separately.")
|
||||
|
||||
return "\n".join(results)
|
||||
|
||||
@mcp.tool()
|
||||
async def create_project(
|
||||
project_name: str,
|
||||
compose_content: str,
|
||||
share_path: str | None = None,
|
||||
confirmed: bool = False,
|
||||
):
|
||||
"""Create a new Container Manager project from compose YAML. Requires confirmed=True."""
|
||||
# Lazy import avoids a circular dependency between projects.py and compose.py.
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.compose import (
|
||||
_to_filestation_path,
|
||||
_validate_project_name,
|
||||
)
|
||||
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
|
||||
# Parse compose YAML up-front so a malformed input is rejected before
|
||||
# any side effects (folder creation, project registration).
|
||||
try:
|
||||
parsed = yaml.safe_load(compose_content)
|
||||
except yaml.YAMLError as e:
|
||||
return f"Invalid YAML content: {e}"
|
||||
if not isinstance(parsed, dict) or "services" not in parsed:
|
||||
return "Invalid compose file: must be a YAML document with a 'services' key."
|
||||
services = parsed.get("services") or {}
|
||||
service_count = len(services) if isinstance(services, dict) else 0
|
||||
|
||||
# Resolve share_path. When the caller omits it, derive it from
|
||||
# compose_base_path (e.g. "/volume1/docker" + "myapp" → "/docker/myapp").
|
||||
if share_path is None:
|
||||
parent_share = _to_filestation_path(config.compose_base_path).rstrip("/")
|
||||
resolved_share_path = f"{parent_share}/{project_name}"
|
||||
folder_name = project_name
|
||||
else:
|
||||
resolved_share_path = share_path.rstrip("/")
|
||||
parent_share, _, folder_name = resolved_share_path.rpartition("/")
|
||||
if not parent_share:
|
||||
parent_share = "/"
|
||||
if not folder_name:
|
||||
return f"Invalid share_path '{share_path}': missing folder name."
|
||||
|
||||
# Check for an existing project with the same name BEFORE creating
|
||||
# the folder — avoids leaving an orphaned directory on the NAS.
|
||||
existing = await _find_project(client, project_name)
|
||||
if existing is not None:
|
||||
return (
|
||||
f"Project '{project_name}' already exists "
|
||||
f"(status: {existing.get('status', '?')}, path: {existing.get('path', '?')})."
|
||||
)
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"About to create new project '{project_name}':\n"
|
||||
f" Share path: {resolved_share_path}\n"
|
||||
f" Services: {service_count}\n\n"
|
||||
f"Call this tool again with confirmed=True to apply."
|
||||
)
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
# ── Step 1: Create the target folder via FileStation ──────────────────
|
||||
# force_parent=true makes the call idempotent: it does not fail if the
|
||||
# folder already exists, and it creates any missing intermediate
|
||||
# directories. Without this step Docker.Project/create fails with
|
||||
# error code 2100 ("target folder issue").
|
||||
results.append("Step 1/3: Creating target folder...")
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.FileStation.CreateFolder",
|
||||
"create",
|
||||
version=2,
|
||||
params={
|
||||
"folder_path": json.dumps(parent_share),
|
||||
"name": json.dumps(folder_name),
|
||||
"force_parent": "true",
|
||||
},
|
||||
)
|
||||
results.append(f" Folder ready: {resolved_share_path}")
|
||||
except SynologyError as e:
|
||||
return (
|
||||
f"Error creating folder for project '{project_name}': {e}\n"
|
||||
f" Attempted path: {parent_share}/{folder_name}"
|
||||
)
|
||||
|
||||
# ── Step 2: Register the project with Container Manager ───────────────
|
||||
results.append("Step 2/3: Registering project with Container Manager...")
|
||||
try:
|
||||
data = await client.post_request(
|
||||
"SYNO.Docker.Project",
|
||||
"create",
|
||||
version=1,
|
||||
params={
|
||||
"name": json.dumps(project_name),
|
||||
"share_path": json.dumps(resolved_share_path),
|
||||
"content": json.dumps(compose_content),
|
||||
"enable_service_portal": json.dumps(False),
|
||||
"service_portal_name": json.dumps(""),
|
||||
"service_portal_port": 0,
|
||||
"service_portal_protocol": json.dumps("http"),
|
||||
},
|
||||
)
|
||||
except SynologyError as e:
|
||||
if e.code == 2100:
|
||||
return (
|
||||
f"Project creation failed — target folder issue (DSM error 2100).\n"
|
||||
f" Share path: {resolved_share_path}\n"
|
||||
f" Folder was created in step 1 but DSM rejected it. "
|
||||
f"Verify the share exists and the user has write access."
|
||||
)
|
||||
return f"Error registering project '{project_name}': {e}"
|
||||
|
||||
project_id = (data.get("id") if isinstance(data, dict) else "") or ""
|
||||
if not project_id:
|
||||
return (
|
||||
f"Project registered but DSM returned no project ID. "
|
||||
f"Check list_projects to confirm — response was: {data!r}"
|
||||
)
|
||||
results.append(f" Registered (id={project_id}).")
|
||||
|
||||
# ── Step 3: Trigger the build (pull images + start containers) ────────
|
||||
results.append("Step 3/3: Triggering build_stream (image pull and start)...")
|
||||
try:
|
||||
build_log = await client.trigger_build_stream(project_id)
|
||||
except Exception as e:
|
||||
results.append(f" Error triggering build: {e}")
|
||||
results.append(
|
||||
f"\nProject '{project_name}' is registered but was not started. "
|
||||
f"Run redeploy_project('{project_name}', confirmed=True) to retry."
|
||||
)
|
||||
return "\n".join(results)
|
||||
|
||||
build_errors, _ = _parse_build_stream_log(build_log)
|
||||
if build_errors:
|
||||
results.append(" Build failed — DSM reported:")
|
||||
results.extend(f" {line}" for line in build_errors)
|
||||
results.append(
|
||||
f"\nProject '{project_name}' is registered but failed to build. "
|
||||
f"Fix the compose content (e.g. update_image_tag) and run "
|
||||
f"redeploy_project('{project_name}', confirmed=True) to retry."
|
||||
)
|
||||
return "\n".join(results)
|
||||
results.append(" Build request accepted by DSM.")
|
||||
|
||||
results.append(
|
||||
f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..."
|
||||
)
|
||||
final_status = await _wait_for_project_running(
|
||||
client, project_name, timeout=_BUILD_POLL_TIMEOUT
|
||||
)
|
||||
if final_status == "RUNNING":
|
||||
results.append(" Project is RUNNING.")
|
||||
results.append(f"\nProject '{project_name}' created and started successfully.")
|
||||
elif final_status in _TERMINAL_FAILURE_STATUSES:
|
||||
results.append(f" Build failed — project status is '{final_status}'.")
|
||||
if final_status == "BUILD_FAILED":
|
||||
results.append(
|
||||
" Check the image tag(s) in the compose content "
|
||||
"(update_image_tag) and retry redeploy_project."
|
||||
)
|
||||
results.append(
|
||||
f"\nProject '{project_name}' is registered but failed to start "
|
||||
f"(status: {final_status})."
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
f" Warning: project status is '{final_status}' after "
|
||||
f"{_BUILD_POLL_TIMEOUT}s. "
|
||||
f"Containers may still be starting — check with get_project_status."
|
||||
)
|
||||
results.append(f"\nProject '{project_name}' created (final status: {final_status}).")
|
||||
|
||||
return "\n".join(results)
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_project(project_name: str, confirmed: bool = False):
|
||||
"""Remove a project registration from Container Manager. Requires confirmed=True."""
|
||||
# Lazy import: see create_project for why this avoids a circular dep.
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.compose import _validate_project_name
|
||||
|
||||
if (err := _validate_project_name(project_name)) is not None:
|
||||
return err
|
||||
|
||||
project = await _find_project(client, project_name)
|
||||
if project is None:
|
||||
return f"Project '{project_name}' not found."
|
||||
|
||||
project_id = project.get("id", "")
|
||||
status = (project.get("status") or "?").upper()
|
||||
path = project.get("path", "?")
|
||||
share_path = project.get("share_path", "?")
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"About to delete project registration '{project_name}':\n"
|
||||
f" UUID: {project_id}\n"
|
||||
f" Status: {status}\n"
|
||||
f" Path: {path}\n"
|
||||
f" Share path: {share_path}\n\n"
|
||||
f"Note: only the Container Manager registration is removed — "
|
||||
f"the folder and compose file will remain on the NAS.\n\n"
|
||||
f"Call this tool again with confirmed=True to proceed."
|
||||
)
|
||||
|
||||
# Guard: DSM does NOT reject a delete call on a running project — it
|
||||
# removes the registration silently and leaves the containers running
|
||||
# as orphans. Reject here so we never put the NAS into that state.
|
||||
if status == "RUNNING":
|
||||
return (
|
||||
f"Error: project '{project_name}' is RUNNING. "
|
||||
f"Stop it first with stop_project, then delete_project."
|
||||
)
|
||||
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Project",
|
||||
"delete",
|
||||
version=1,
|
||||
params={"id": json.dumps(project_id)},
|
||||
)
|
||||
except SynologyError as e:
|
||||
return f"Error deleting project '{project_name}': {e}"
|
||||
|
||||
return (
|
||||
f"Project '{project_name}' deleted (registration removed).\n"
|
||||
f"Note: the project folder {share_path} was NOT deleted — "
|
||||
f"its files remain on the NAS."
|
||||
)
|
||||
|
||||
|
||||
async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
|
||||
"""Find a project by name from the list.
|
||||
@@ -201,6 +515,45 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _wait_for_project_running(
|
||||
client: DsmClient,
|
||||
name: str,
|
||||
timeout: int = _POLL_TIMEOUT,
|
||||
interval: int = _POLL_INTERVAL,
|
||||
) -> str:
|
||||
"""Poll until the project reaches RUNNING status or timeout expires.
|
||||
|
||||
Args:
|
||||
client: DsmClient instance.
|
||||
name: Project name to watch.
|
||||
timeout: Maximum seconds to wait (default 30).
|
||||
interval: Seconds between polls (default 2).
|
||||
|
||||
Returns:
|
||||
Final project status string (may not be "RUNNING" on timeout).
|
||||
"""
|
||||
elapsed = 0
|
||||
while elapsed < timeout:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
project = await _find_project(client, name)
|
||||
if project is None:
|
||||
continue
|
||||
current = (project.get("status") or "").upper()
|
||||
logger.debug("Polling '%s': status=%s elapsed=%ds", name, current, elapsed)
|
||||
if current == "RUNNING":
|
||||
return current
|
||||
if current in _TERMINAL_FAILURE_STATUSES:
|
||||
# DSM has reported a hard failure (e.g. image pull failed,
|
||||
# container exited immediately). Returning early lets the
|
||||
# caller surface the real cause instead of waiting out the
|
||||
# full timeout.
|
||||
return current
|
||||
# Return whatever status we last saw (or UNKNOWN on repeated failures)
|
||||
project = await _find_project(client, name)
|
||||
return (project.get("status") or "UNKNOWN").upper() if project else "UNKNOWN"
|
||||
|
||||
|
||||
def _format_project_detail(project: dict[str, Any]) -> str:
|
||||
"""Format project details as human-readable text."""
|
||||
lines = [
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""MCP tools for SYNO.Docker.Registry: search, tags, pull."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pull polling: backoff schedule capped at 10 s between checks. Total budget
|
||||
# stays under the Claude Desktop ~4 min tool-call ceiling.
|
||||
_PULL_POLL_TIMEOUT = 240.0
|
||||
_PULL_POLL_INTERVALS: tuple[float, ...] = (2.0, 3.0, 5.0, 8.0, 10.0)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 80) -> str:
|
||||
"""Truncate text to limit with an ellipsis."""
|
||||
text = (text or "").replace("\n", " ").replace("\r", " ").strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
async def _image_present(client: DsmClient, repository: str, tag: str) -> bool:
|
||||
"""Return True if repository:tag is in the local image list."""
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Pull polling: image list failed: %s", e)
|
||||
return False
|
||||
|
||||
images: list[dict[str, Any]] = data.get("images", []) if isinstance(data, dict) else []
|
||||
for img in images:
|
||||
repo = img.get("repository", "")
|
||||
tags = img.get("tags") or []
|
||||
if repo == repository and tag in tags:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all registry management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def search_registry(query: str, limit: int = 20):
|
||||
"""Search the active Docker registry for images matching query."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
elif limit > 100:
|
||||
limit = 100
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Registry",
|
||||
"search",
|
||||
version=1,
|
||||
params={
|
||||
"q": json.dumps(query),
|
||||
"offset": "0",
|
||||
"limit": str(limit),
|
||||
"page_size": str(limit),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error searching registry for '{query}': {e}"
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
if isinstance(data, dict):
|
||||
results = data.get("data", []) or []
|
||||
total = int(data.get("total", 0) or 0)
|
||||
|
||||
if not results:
|
||||
return f"No results found for '{query}'."
|
||||
|
||||
lines = [f"Search results for '{query}' ({len(results)} of {total} total):", ""]
|
||||
lines.append(" Stars Downloads Name (official) Description")
|
||||
for hit in results:
|
||||
name = hit.get("name", "?")
|
||||
description = _truncate(hit.get("description", ""))
|
||||
stars = int(hit.get("star_count", 0) or 0)
|
||||
downloads = int(hit.get("downloads", 0) or 0)
|
||||
official = " [official]" if hit.get("is_official") else ""
|
||||
lines.append(f" {stars:>5} {downloads:>9} {name}{official}")
|
||||
if description:
|
||||
lines.append(f" {description}")
|
||||
|
||||
if total > len(results):
|
||||
lines.append("")
|
||||
lines.append(f"Showing {len(results)} of {total}; raise limit to see more.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def list_image_tags(repository: str, limit: int = 50):
|
||||
"""List available tags for a registry image (e.g. 'nginx', 'grafana/grafana')."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Registry",
|
||||
"tags",
|
||||
version=1,
|
||||
params={"repo": json.dumps(repository)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error fetching tags for '{repository}': {e}"
|
||||
|
||||
# DSM returns the tag list as the envelope's `data` field directly.
|
||||
# When empty, DsmClient.request() coerces to {} via `or {}`, so we
|
||||
# accept both shapes here.
|
||||
if isinstance(data, list):
|
||||
entries: list[Any] = data
|
||||
elif isinstance(data, dict):
|
||||
# Defensive: some DSM versions wrap as {"tags": [...]}.
|
||||
entries = data.get("tags") or data.get("data") or []
|
||||
else:
|
||||
entries = []
|
||||
|
||||
tags: list[str] = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict):
|
||||
tag = entry.get("tag")
|
||||
if isinstance(tag, str) and tag:
|
||||
tags.append(tag)
|
||||
elif isinstance(entry, str):
|
||||
tags.append(entry)
|
||||
|
||||
if not tags:
|
||||
return f"No tags found for '{repository}'."
|
||||
|
||||
total = len(tags)
|
||||
shown = tags[:limit]
|
||||
lines = [f"Tags for '{repository}' ({total} total):", ""]
|
||||
lines.extend(f" {t}" for t in shown)
|
||||
|
||||
if total > limit:
|
||||
lines.append("")
|
||||
lines.append(f"Showing first {limit} of {total}; raise limit to see more.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def pull_image(repository: str, tag: str = "latest", confirmed: bool = False):
|
||||
"""Pull image from the active registry. Requires confirmed=True; polls for completion."""
|
||||
target = f"{repository}:{tag}"
|
||||
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Preview: would pull {target} from the active registry.\n"
|
||||
f"Call pull_image(repository={repository!r}, tag={tag!r}, "
|
||||
"confirmed=True) to proceed."
|
||||
)
|
||||
|
||||
# Short-circuit: if the image already exists locally, no pull needed.
|
||||
if await _image_present(client, repository, tag):
|
||||
return f"{target} is already present locally — nothing to pull."
|
||||
|
||||
# `pull_start` lives on SYNO.Docker.Image, NOT SYNO.Docker.Registry
|
||||
# (live DSM capture, confirmed). The Registry API only exposes the
|
||||
# synchronous read-only methods (search, tags, get/set/create/delete,
|
||||
# using). Calling Registry/pull_start returns "Method does not exist".
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"pull_start",
|
||||
version=1,
|
||||
params={
|
||||
"repository": json.dumps(repository),
|
||||
"tag": json.dumps(tag),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error starting pull for '{target}': {e}"
|
||||
|
||||
# Async pull — DSM exposes no confirmed pull_status method, so we poll
|
||||
# Image/list for the new tag. Backoff schedule capped at 10 s; total
|
||||
# budget under the Claude Desktop ~4 min tool-call ceiling.
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + _PULL_POLL_TIMEOUT
|
||||
attempt = 0
|
||||
while loop.time() < deadline:
|
||||
interval = _PULL_POLL_INTERVALS[min(attempt, len(_PULL_POLL_INTERVALS) - 1)]
|
||||
remaining = deadline - loop.time()
|
||||
await asyncio.sleep(min(interval, max(remaining, 0.0)))
|
||||
if await _image_present(client, repository, tag):
|
||||
return f"Pulled {target} successfully."
|
||||
attempt += 1
|
||||
|
||||
return (
|
||||
f"Pull of {target} started, still running — "
|
||||
f"verify later with list_images or check_image_updates."
|
||||
)
|
||||
@@ -0,0 +1,319 @@
|
||||
"""MCP tools for Docker system-level operations: disk usage and prune."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp_synology_container.modules.images import _human_size
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_container.config import AppConfig
|
||||
from mcp_synology_container.dsm_client import DsmClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Built-in Docker networks are never removed by `docker network prune`
|
||||
# regardless of attached-container count. Skip them when counting what
|
||||
# the prune will actually delete.
|
||||
_BUILTIN_NETWORKS = frozenset({"bridge", "host", "none"})
|
||||
|
||||
|
||||
def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all system-level tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def system_df():
|
||||
"""Show Docker disk usage: image count/size and container running/stopped counts."""
|
||||
errors: list[str] = []
|
||||
|
||||
# ── Images ───────────────────────────────────────────────────────────
|
||||
images: list[dict[str, Any]] = []
|
||||
try:
|
||||
img_data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
images = img_data.get("images", [])
|
||||
except Exception as e:
|
||||
errors.append(f"images: {e}")
|
||||
|
||||
# ── Containers ───────────────────────────────────────────────────────
|
||||
containers: list[dict[str, Any]] = []
|
||||
try:
|
||||
ctr_data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||
)
|
||||
containers = ctr_data.get("containers", [])
|
||||
except Exception as e:
|
||||
errors.append(f"containers: {e}")
|
||||
|
||||
# ── Compute image stats ───────────────────────────────────────────────
|
||||
# An image is "in use" if any container references its ID
|
||||
in_use_ids: set[str] = set()
|
||||
for ctr in containers:
|
||||
img_id = ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "")
|
||||
if img_id:
|
||||
in_use_ids.add(img_id)
|
||||
|
||||
total_image_size = sum(img.get("size", 0) for img in images)
|
||||
reclaimable_size = sum(
|
||||
img.get("size", 0) for img in images if img.get("id", "") not in in_use_ids
|
||||
)
|
||||
reclaimable_count = sum(1 for img in images if img.get("id", "") not in in_use_ids)
|
||||
|
||||
# ── Compute container stats ───────────────────────────────────────────
|
||||
running = sum(1 for c in containers if c.get("status") in ("running", "up"))
|
||||
stopped = len(containers) - running
|
||||
|
||||
# ── Format output ─────────────────────────────────────────────────────
|
||||
lines = ["Docker Disk Usage", ""]
|
||||
|
||||
# Images table
|
||||
lines.append(f" Images: {len(images):>4} total {_human_size(total_image_size):>10}")
|
||||
rec_str = _human_size(reclaimable_size)
|
||||
lines.append(f" {reclaimable_count:>4} unused {rec_str:>10} (reclaimable)")
|
||||
|
||||
# Containers table
|
||||
lines.append("")
|
||||
lines.append(f" Containers: {len(containers):>4} total")
|
||||
lines.append(f" {running:>4} running")
|
||||
lines.append(f" {stopped:>4} stopped (reclaimable via system_prune)")
|
||||
|
||||
if errors:
|
||||
lines.append("")
|
||||
lines.append("Warnings:")
|
||||
for err in errors:
|
||||
lines.append(f" {err}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def system_overview():
|
||||
"""Aggregated CPU, memory, network, and block I/O across all running containers."""
|
||||
errors: list[str] = []
|
||||
|
||||
# ── Container list (for running/stopped counts) ──────────────────────
|
||||
containers: list[dict[str, Any]] = []
|
||||
try:
|
||||
ctr_data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||
)
|
||||
containers = ctr_data.get("containers", []) or []
|
||||
except Exception as e:
|
||||
errors.append(f"list: {e}")
|
||||
|
||||
running = sum(1 for c in containers if c.get("status") in ("running", "up"))
|
||||
stopped = len(containers) - running
|
||||
|
||||
# ── Stats (for aggregation) ──────────────────────────────────────────
|
||||
stats_data: dict[str, Any] = {}
|
||||
try:
|
||||
stats_data = await client.request("SYNO.Docker.Container", "stats") or {}
|
||||
except Exception as e:
|
||||
errors.append(f"stats: {e}")
|
||||
|
||||
total_cpu_pct = 0.0
|
||||
total_mem_usage = 0
|
||||
total_mem_limit = 0
|
||||
total_net_rx = 0
|
||||
total_net_tx = 0
|
||||
total_blk_read = 0
|
||||
total_blk_write = 0
|
||||
aggregated_count = 0
|
||||
|
||||
for entry in stats_data.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
aggregated_count += 1
|
||||
|
||||
# CPU % — Docker formula, mirrors container_stats logic
|
||||
cpu_stats = entry.get("cpu_stats", {}) or {}
|
||||
precpu_stats = entry.get("precpu_stats", {}) or {}
|
||||
cpu_usage = cpu_stats.get("cpu_usage", {}) or {}
|
||||
precpu_usage = precpu_stats.get("cpu_usage", {}) or {}
|
||||
|
||||
cpu_delta = cpu_usage.get("total_usage", 0) - precpu_usage.get("total_usage", 0)
|
||||
system_delta = cpu_stats.get("system_cpu_usage", 0) - precpu_stats.get(
|
||||
"system_cpu_usage", 0
|
||||
)
|
||||
online_cpus = cpu_stats.get("online_cpus") or len(cpu_usage.get("percpu_usage") or [1])
|
||||
if system_delta > 0 and cpu_delta >= 0:
|
||||
total_cpu_pct += (cpu_delta / system_delta) * online_cpus * 100.0
|
||||
|
||||
# Memory
|
||||
mem_stats = entry.get("memory_stats", {}) or {}
|
||||
total_mem_usage += mem_stats.get("usage", 0)
|
||||
total_mem_limit += mem_stats.get("limit", 0)
|
||||
|
||||
# Network I/O
|
||||
for iface in (entry.get("networks") or {}).values():
|
||||
if not isinstance(iface, dict):
|
||||
continue
|
||||
total_net_rx += iface.get("rx_bytes", 0)
|
||||
total_net_tx += iface.get("tx_bytes", 0)
|
||||
|
||||
# Block I/O
|
||||
for entry_io in (entry.get("blkio_stats", {}) or {}).get(
|
||||
"io_service_bytes_recursive"
|
||||
) or []:
|
||||
if not isinstance(entry_io, dict):
|
||||
continue
|
||||
op = (entry_io.get("op") or "").lower()
|
||||
val = entry_io.get("value", 0)
|
||||
if op == "read":
|
||||
total_blk_read += val
|
||||
elif op == "write":
|
||||
total_blk_write += val
|
||||
|
||||
# ── Format output ────────────────────────────────────────────────────
|
||||
lines = ["Docker System Overview", ""]
|
||||
lines.append(" Containers:")
|
||||
lines.append(f" {running:>3} running")
|
||||
lines.append(f" {stopped:>3} stopped")
|
||||
|
||||
if aggregated_count > 0:
|
||||
mem_limit_str = _human_size(total_mem_limit) if total_mem_limit else "—"
|
||||
lines.append("")
|
||||
lines.append(" Aggregated (running containers):")
|
||||
lines.append(f" CPU: {total_cpu_pct:.2f}%")
|
||||
lines.append(f" Memory: {_human_size(total_mem_usage)} / {mem_limit_str}")
|
||||
lines.append(
|
||||
f" Net I/O: rx {_human_size(total_net_rx)} / tx {_human_size(total_net_tx)}"
|
||||
)
|
||||
lines.append(
|
||||
f" Block I/O: read {_human_size(total_blk_read)}"
|
||||
f" / write {_human_size(total_blk_write)}"
|
||||
)
|
||||
|
||||
if errors:
|
||||
lines.append("")
|
||||
lines.append(" Warnings:")
|
||||
for err in errors:
|
||||
lines.append(f" {err}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def system_prune(confirmed: bool = False):
|
||||
"""Remove unused Docker resources (images, stopped containers). Requires confirmed=True."""
|
||||
# ── Gather preview data ───────────────────────────────────────────────
|
||||
dangling_images: list[dict[str, Any]] = []
|
||||
stopped_containers: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
img_data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
ctr_data = await client.request(
|
||||
"SYNO.Docker.Container",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "type": "all"},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error fetching resource list: {e}"
|
||||
|
||||
images: list[dict[str, Any]] = img_data.get("images", [])
|
||||
containers: list[dict[str, Any]] = ctr_data.get("containers", [])
|
||||
|
||||
# Images in use by any container
|
||||
in_use_ids: set[str] = {
|
||||
ctr.get("image_id") or ctr.get("ImageID") or ctr.get("Image", "") for ctr in containers
|
||||
} - {""}
|
||||
|
||||
# Dangling = untagged (<none>) or unused
|
||||
for img in images:
|
||||
tags = img.get("tags") or []
|
||||
is_untagged = not tags or tags == ["<none>"]
|
||||
is_unused = img.get("id", "") not in in_use_ids
|
||||
if is_untagged or is_unused:
|
||||
dangling_images.append(img)
|
||||
|
||||
for ctr in containers:
|
||||
if ctr.get("status") not in ("running", "up"):
|
||||
stopped_containers.append(ctr)
|
||||
|
||||
dangling_size = sum(img.get("size", 0) for img in dangling_images)
|
||||
|
||||
if not confirmed:
|
||||
# M-6: also enumerate networks that would be removed so the
|
||||
# preview matches the actual prune scope. Networks are only
|
||||
# fetched in preview mode — the prune call itself doesn't
|
||||
# need them.
|
||||
unused_networks: list[dict[str, Any]] = []
|
||||
try:
|
||||
net_data = await client.request("SYNO.Docker.Network", "list")
|
||||
for net in net_data.get("network", []) or []:
|
||||
name = net.get("name", "")
|
||||
attached = net.get("containers") or []
|
||||
if not attached and name not in _BUILTIN_NETWORKS:
|
||||
unused_networks.append(net)
|
||||
except Exception as e:
|
||||
logger.debug("Could not fetch networks for prune preview: %s", e)
|
||||
|
||||
lines = ["system_prune — preview (nothing deleted yet):", ""]
|
||||
lines.append(
|
||||
f" Dangling/unused images: {len(dangling_images)} ({_human_size(dangling_size)})"
|
||||
)
|
||||
for img in dangling_images[:10]:
|
||||
repo = img.get("repository", "<none>")
|
||||
tags = img.get("tags") or ["<none>"]
|
||||
lines.append(f" - {repo}:{tags[0]}")
|
||||
if len(dangling_images) > 10:
|
||||
lines.append(f" … and {len(dangling_images) - 10} more")
|
||||
|
||||
lines.append(f" Stopped containers: {len(stopped_containers)}")
|
||||
for ctr in stopped_containers[:10]:
|
||||
lines.append(f" - {ctr.get('name', '?')}")
|
||||
if len(stopped_containers) > 10:
|
||||
lines.append(f" … and {len(stopped_containers) - 10} more")
|
||||
|
||||
lines.append(f" Unused networks: {len(unused_networks)}")
|
||||
for net in unused_networks[:10]:
|
||||
driver = net.get("driver", "?")
|
||||
lines.append(f" - {net.get('name', '?')} ({driver})")
|
||||
if len(unused_networks) > 10:
|
||||
lines.append(f" … and {len(unused_networks) - 10} more")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Call system_prune(confirmed=True) to free ~{_human_size(dangling_size)}."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Execute prune ─────────────────────────────────────────────────────
|
||||
try:
|
||||
result = await client.request("SYNO.Docker.Utils", "prune")
|
||||
except Exception as e:
|
||||
return f"Error running system prune: {e}"
|
||||
|
||||
# Parse reclaimed space from response (field names vary by DSM version)
|
||||
reclaimed = (
|
||||
result.get("SpaceReclaimed")
|
||||
or result.get("space_reclaimed")
|
||||
or result.get("reclaimed")
|
||||
or 0
|
||||
)
|
||||
|
||||
lines = ["system_prune — completed.", ""]
|
||||
if reclaimed:
|
||||
lines.append(f" Space reclaimed: {_human_size(int(reclaimed))}")
|
||||
else:
|
||||
lines.append(" Space reclaimed: (not reported by DSM)")
|
||||
|
||||
# Surface any containers/images counts from the response
|
||||
for key in ("ContainersDeleted", "ImagesDeleted", "VolumesDeleted", "NetworksDeleted"):
|
||||
val = result.get(key)
|
||||
if val is not None:
|
||||
label = key.replace("Deleted", " deleted")
|
||||
lines.append(f" {label}: {len(val) if isinstance(val, list) else val}")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -26,15 +26,21 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
|
||||
"""
|
||||
mcp = FastMCP("mcp-synology-container")
|
||||
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
register_projects(mcp, config, client)
|
||||
register_containers(mcp, config, client)
|
||||
register_compose(mcp, config, client)
|
||||
register_images(mcp, config, client)
|
||||
register_system(mcp, config, client)
|
||||
register_networks(mcp, config, client)
|
||||
register_registry(mcp, config, client)
|
||||
|
||||
logger.info("MCP server configured with all tool modules")
|
||||
return mcp
|
||||
|
||||
+16
-9
@@ -1,9 +1,10 @@
|
||||
"""Tests for auth.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from mcp_synology_container.auth import AuthManager, AuthenticationError
|
||||
import pytest
|
||||
|
||||
from mcp_synology_container.auth import AuthenticationError, AuthManager
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
|
||||
@@ -34,8 +35,10 @@ def test_resolve_credentials_no_credentials(monkeypatch):
|
||||
config = make_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
with patch("keyring.get_password", return_value=None):
|
||||
with pytest.raises(AuthenticationError, match="No credentials found"):
|
||||
with (
|
||||
patch("keyring.get_password", return_value=None),
|
||||
pytest.raises(AuthenticationError, match="No credentials found"),
|
||||
):
|
||||
auth.resolve_credentials()
|
||||
|
||||
|
||||
@@ -123,8 +126,10 @@ async def test_login_2fa_required():
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.side_effect = SynologyError("2FA required", code=403)
|
||||
|
||||
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
|
||||
with pytest.raises(AuthenticationError, match="2FA is required"):
|
||||
with (
|
||||
patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)),
|
||||
pytest.raises(AuthenticationError, match="2FA is required"),
|
||||
):
|
||||
await auth.login(mock_client)
|
||||
|
||||
|
||||
@@ -136,8 +141,10 @@ async def test_login_no_sid_returned():
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request.return_value = {} # No 'sid' key
|
||||
|
||||
with patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)):
|
||||
with pytest.raises(AuthenticationError, match="no session ID"):
|
||||
with (
|
||||
patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)),
|
||||
pytest.raises(AuthenticationError, match="no session ID"),
|
||||
):
|
||||
await auth.login(mock_client)
|
||||
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_synology_container.config import (
|
||||
AppConfig,
|
||||
ConnectionConfig,
|
||||
_validate_config,
|
||||
_merge_env_overrides,
|
||||
_validate_config,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
"""Tests for modules/compose.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from mcp_synology_container.modules.compose import _validate_project_name
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
@@ -14,6 +16,7 @@ def make_mock_mcp():
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
@@ -21,6 +24,7 @@ def make_mock_mcp():
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
@@ -45,14 +49,30 @@ services:
|
||||
"""
|
||||
|
||||
|
||||
def make_compose_client(content: str, filename: str = "docker-compose.yml") -> AsyncMock:
|
||||
"""Create an AsyncMock client pre-configured for compose tests.
|
||||
|
||||
FileStation.List returns a single file entry so that _find_compose_path
|
||||
can locate the compose file. All other requests return {}.
|
||||
download_text returns the provided content.
|
||||
"""
|
||||
client = AsyncMock()
|
||||
|
||||
async def _request(api, method, **kwargs):
|
||||
if api == "SYNO.FileStation.List":
|
||||
return {"files": [{"name": filename}]}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = _request
|
||||
client.download_text.return_value = content
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_compose():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
# Simulate FileStation.Info success for the first filename
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
client = make_compose_client(SAMPLE_COMPOSE)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
@@ -69,6 +89,7 @@ async def test_read_compose_not_found():
|
||||
client = AsyncMock()
|
||||
# Simulate all FileStation.Info calls failing
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client.request.side_effect = SynologyError("not found", code=408)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
@@ -82,10 +103,7 @@ async def test_read_compose_not_found():
|
||||
async def test_update_image_tag_requires_confirmation():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
@@ -100,10 +118,7 @@ async def test_update_image_tag_requires_confirmation():
|
||||
async def test_update_image_tag_confirmed():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
@@ -122,10 +137,7 @@ async def test_update_image_tag_confirmed():
|
||||
async def test_update_image_tag_service_not_found():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
@@ -138,10 +150,7 @@ async def test_update_image_tag_service_not_found():
|
||||
async def test_update_env_var_new_var_list_format():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
@@ -158,10 +167,7 @@ async def test_update_env_var_new_var_list_format():
|
||||
async def test_update_env_var_update_existing_list():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
@@ -179,10 +185,7 @@ async def test_update_env_var_update_existing_list():
|
||||
async def test_update_env_var_dict_format():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
client.download_text.return_value = SAMPLE_COMPOSE
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
@@ -199,8 +202,8 @@ async def test_update_env_var_dict_format():
|
||||
async def test_update_compose_invalid_yaml():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
# YAML validation happens before any file I/O — no compose file needed
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
@@ -213,7 +216,6 @@ async def test_update_compose_missing_services_key():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
@@ -225,12 +227,241 @@ async def test_update_compose_missing_services_key():
|
||||
async def test_update_compose_requires_confirmation():
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_compose"]("myapp", SAMPLE_COMPOSE, confirmed=False)
|
||||
assert "confirmed=True" in result
|
||||
client.upload_text.assert_not_called()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Auto-version-update in update_image_tag
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SAMPLE_COMPOSE_VERSIONED = """
|
||||
services:
|
||||
jenkins:
|
||||
image: jenkins/jenkins:2.558-jdk21
|
||||
environment:
|
||||
- JENKINS_VERSION=2.558
|
||||
- JAVA_OPTS=-Xmx512m
|
||||
"""
|
||||
|
||||
SAMPLE_COMPOSE_VERSIONED_DICT_ENV = """
|
||||
services:
|
||||
jenkins:
|
||||
image: jenkins/jenkins:2.558-jdk21
|
||||
environment:
|
||||
JENKINS_VERSION: "2.558"
|
||||
JAVA_OPTS: -Xmx512m
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_auto_updates_version_env_var_list():
|
||||
"""Tag 2.558-jdk21 → 2.560-jdk21 must also update JENKINS_VERSION=2.558 → 2.560."""
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE_VERSIONED)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("myapp", "jenkins", "2.560-jdk21", confirmed=True)
|
||||
assert "jenkins/jenkins:2.558-jdk21 → jenkins/jenkins:2.560-jdk21" in result
|
||||
assert "JENKINS_VERSION=2.560" in result
|
||||
client.upload_text.assert_called_once()
|
||||
|
||||
uploaded = client.upload_text.call_args[0][2]
|
||||
parsed = yaml.safe_load(uploaded)
|
||||
assert parsed["services"]["jenkins"]["image"] == "jenkins/jenkins:2.560-jdk21"
|
||||
env = parsed["services"]["jenkins"]["environment"]
|
||||
assert "JENKINS_VERSION=2.560" in env
|
||||
assert "JENKINS_VERSION=2.558" not in env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_auto_updates_version_env_var_dict():
|
||||
"""Dict-format env: JENKINS_VERSION value matching old prefix must be updated."""
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE_VERSIONED_DICT_ENV)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("myapp", "jenkins", "2.560-jdk21", confirmed=True)
|
||||
assert "JENKINS_VERSION=2.560" in result
|
||||
|
||||
uploaded = client.upload_text.call_args[0][2]
|
||||
parsed = yaml.safe_load(uploaded)
|
||||
assert parsed["services"]["jenkins"]["environment"]["JENKINS_VERSION"] == "2.560"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_no_auto_update_without_version_suffix():
|
||||
"""Tag without numeric-prefix pattern (e.g. 'latest') must not touch env vars."""
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE_VERSIONED)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("myapp", "jenkins", "latest", confirmed=True)
|
||||
assert "jenkins/jenkins:2.558-jdk21 → jenkins/jenkins:latest" in result
|
||||
# No auto-update mention expected
|
||||
assert "Auto-updated" not in result
|
||||
|
||||
uploaded = client.upload_text.call_args[0][2]
|
||||
parsed = yaml.safe_load(uploaded)
|
||||
env = parsed["services"]["jenkins"]["environment"]
|
||||
# JENKINS_VERSION must be unchanged
|
||||
assert "JENKINS_VERSION=2.558" in env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_preview_shows_auto_update():
|
||||
"""Unconfirmed call must preview auto-update of matching env vars."""
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = make_compose_client(SAMPLE_COMPOSE_VERSIONED)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("myapp", "jenkins", "2.560-jdk21", confirmed=False)
|
||||
assert "confirmed=True" in result
|
||||
assert "JENKINS_VERSION" in result
|
||||
assert "2.558" in result
|
||||
assert "2.560" in result
|
||||
client.upload_text.assert_not_called()
|
||||
|
||||
|
||||
def test_extract_version_prefix():
|
||||
"""Unit tests for _extract_version_prefix helper."""
|
||||
from mcp_synology_container.modules.compose import _extract_version_prefix
|
||||
|
||||
assert _extract_version_prefix("2.558-jdk21") == "2.558"
|
||||
assert _extract_version_prefix("1.2.3-alpine") == "1.2.3"
|
||||
assert _extract_version_prefix("2-slim") == "2"
|
||||
assert _extract_version_prefix("latest") is None
|
||||
assert _extract_version_prefix("1.24") is None # no suffix
|
||||
assert _extract_version_prefix("") is None
|
||||
assert _extract_version_prefix("v2.0-rc1") is None # starts with 'v'
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# _validate_project_name — path-traversal guard
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"myapp",
|
||||
"MyApp",
|
||||
"my-app",
|
||||
"my_app",
|
||||
"app123",
|
||||
"A",
|
||||
"1",
|
||||
"snake_case-and-dash_42",
|
||||
],
|
||||
)
|
||||
def test_validate_project_name_accepts_safe_names(name: str) -> None:
|
||||
assert _validate_project_name(name) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"", # empty
|
||||
"../etc", # parent traversal
|
||||
"../../etc/passwd", # multi-level traversal
|
||||
"foo/../bar", # embedded traversal
|
||||
"foo/bar", # forward slash
|
||||
"foo\\bar", # backslash
|
||||
".", # bare dot
|
||||
"..", # bare dotdot
|
||||
".hidden", # leading dot
|
||||
"foo.bar", # dot inside (.yaml extensions, etc.)
|
||||
"foo bar", # whitespace
|
||||
" foo", # leading space
|
||||
"foo ", # trailing space
|
||||
"foo\tbar", # tab
|
||||
"foo\nbar", # newline
|
||||
"foo;rm", # shell metachar
|
||||
"foo|bar",
|
||||
"foo&bar",
|
||||
"foo*bar",
|
||||
"foo?bar",
|
||||
"foo:bar",
|
||||
"foo'bar",
|
||||
'foo"bar',
|
||||
"foo$bar",
|
||||
"foo`bar",
|
||||
"café", # non-ASCII letter
|
||||
],
|
||||
)
|
||||
def test_validate_project_name_rejects_unsafe_names(name: str) -> None:
|
||||
msg = _validate_project_name(name)
|
||||
assert msg is not None
|
||||
assert "invalid project name" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_compose_rejects_traversal_name() -> None:
|
||||
"""Traversal name must be rejected before any DSM call."""
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["read_compose"]("../../etc")
|
||||
assert "invalid project name" in result
|
||||
client.request.assert_not_called()
|
||||
client.download_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_compose_rejects_traversal_name() -> None:
|
||||
"""update_compose with a traversal name must not validate YAML or upload."""
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_compose"](
|
||||
"foo/../bar", "services:\n web:\n image: nginx\n", confirmed=True
|
||||
)
|
||||
assert "invalid project name" in result
|
||||
client.upload_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_tag_rejects_traversal_name() -> None:
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_image_tag"]("foo/bar", "web", "1.25", confirmed=True)
|
||||
assert "invalid project name" in result
|
||||
client.upload_text.assert_not_called()
|
||||
client.download_text.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_env_var_rejects_traversal_name() -> None:
|
||||
from mcp_synology_container.modules.compose import register_compose
|
||||
|
||||
client = AsyncMock()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_compose(mcp, make_config(), client)
|
||||
|
||||
result = await tools["update_env_var"]("..", "web", "FOO", "bar", confirmed=True)
|
||||
assert "invalid project name" in result
|
||||
client.upload_text.assert_not_called()
|
||||
client.download_text.assert_not_called()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tests for modules/containers.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
@@ -12,6 +13,7 @@ def make_mock_mcp():
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
@@ -19,6 +21,7 @@ def make_mock_mcp():
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
@@ -48,6 +51,18 @@ SAMPLE_CONTAINERS_DATA = {
|
||||
]
|
||||
}
|
||||
|
||||
# Container data where DSM returns hash-prefixed names
|
||||
HASH_PREFIXED_CONTAINERS_DATA = {
|
||||
"containers": [
|
||||
{
|
||||
"name": "f93cb8b504f7_jenkins",
|
||||
"status": "running",
|
||||
"image": "jenkins/jenkins:lts",
|
||||
"project_name": "frostiq",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
SAMPLE_LOGS_DATA = {
|
||||
"logs": [
|
||||
{
|
||||
@@ -171,3 +186,893 @@ async def test_exec_in_container_confirmed():
|
||||
result = await tools["exec_in_container"]("myapp_web", "ls /app", confirmed=True)
|
||||
assert "file1.py" in result
|
||||
assert "Exit code: 0" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# container_stats
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Realistic stats snapshot for a container named "glance"
|
||||
SAMPLE_STATS = {
|
||||
"ee220111cff2": {
|
||||
"id": "ee220111cff2",
|
||||
"name": "/glance",
|
||||
"cpu_stats": {
|
||||
"cpu_usage": {
|
||||
"total_usage": 1_022_653_189,
|
||||
"percpu_usage": [286394951, 245078386, 304613157, 186566695],
|
||||
},
|
||||
"system_cpu_usage": 990_015_100_000_000,
|
||||
"online_cpus": 4,
|
||||
},
|
||||
"precpu_stats": {
|
||||
"cpu_usage": {"total_usage": 1_000_000_000},
|
||||
"system_cpu_usage": 989_000_000_000_000,
|
||||
},
|
||||
"memory_stats": {
|
||||
"usage": 21_127_168,
|
||||
"limit": 4_079_349_760,
|
||||
},
|
||||
"networks": {
|
||||
"eth0": {"rx_bytes": 876, "tx_bytes": 0},
|
||||
"eth1": {"rx_bytes": 1024, "tx_bytes": 512},
|
||||
},
|
||||
"blkio_stats": {
|
||||
"io_service_bytes_recursive": [
|
||||
{"op": "Read", "value": 4096},
|
||||
{"op": "Write", "value": 8192},
|
||||
{"op": "Total", "value": 12288},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_found():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_STATS
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["container_stats"]("glance")
|
||||
assert "glance" in result
|
||||
assert "CPU" in result
|
||||
assert "%" in result
|
||||
assert "Memory" in result
|
||||
assert "Net I/O" in result
|
||||
assert "Block I/O" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# delete_container
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_container_preview():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_container"]("myapp_web", confirmed=False)
|
||||
assert "Preview" in result
|
||||
assert "myapp_web" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_container_not_found():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = None
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_container"]("nonexistent", confirmed=True)
|
||||
assert "not found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_container_running_blocked():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {
|
||||
"details": {"State": {"Running": True, "Status": "running"}},
|
||||
"profile": {"image": "nginx:latest"},
|
||||
}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_container"]("myapp_web", confirmed=True)
|
||||
assert "running" in result.lower()
|
||||
assert "Cannot delete" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_container_stopped_confirmed():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {
|
||||
"details": {"State": {"Running": False, "Status": "exited"}},
|
||||
"profile": {"image": "nginx:latest"},
|
||||
}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_container"]("myapp_web", confirmed=True)
|
||||
assert "Deleted" in result
|
||||
assert "myapp_web" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_container_sends_required_params():
|
||||
"""SYNO.Docker.Container/delete must include name (json-encoded), force=false,
|
||||
and preserve_profile=false — without them DSM returns error 114."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {
|
||||
"details": {"State": {"Running": False, "Status": "exited"}},
|
||||
"profile": {"image": "nginx:latest"},
|
||||
}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
await tools["delete_container"]("myapp_web", confirmed=True)
|
||||
|
||||
# Find the delete call (second call — first is Container/get)
|
||||
delete_calls = [c for c in client.request.call_args_list if c.args[1] == "delete"]
|
||||
assert len(delete_calls) == 1
|
||||
params = delete_calls[0].kwargs.get("params") or {}
|
||||
assert params["name"] == '"myapp_web"'
|
||||
assert params["force"] == "false"
|
||||
assert params["preserve_profile"] == "false"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_cpu_calculation():
|
||||
"""CPU% is computed via the standard Docker formula."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_STATS
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["container_stats"]("glance")
|
||||
|
||||
# cpu_delta = 1_022_653_189 - 1_000_000_000 = 22_653_189
|
||||
# system_delta = 990_015_100_000_000 - 989_000_000_000_000 = 1_015_100_000_000
|
||||
# cpu_pct = (22_653_189 / 1_015_100_000_000) * 4 * 100 ≈ 0.0089 %
|
||||
assert "0.00" in result or "%" in result # value near zero but formatted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_memory_human_readable():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_STATS
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["container_stats"]("glance")
|
||||
# 21_127_168 bytes ≈ 20 MiB; limit 4_079_349_760 ≈ 3.8 GiB
|
||||
assert "MiB" in result or "GiB" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_name_with_slash():
|
||||
"""Container name matching strips leading slash from DSM response."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_STATS
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
# Should match even if called without the leading slash
|
||||
result = await tools["container_stats"]("glance")
|
||||
assert "not found" not in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_not_found():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_STATS
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["container_stats"]("nonexistent")
|
||||
assert "not found" in result.lower()
|
||||
assert "glance" in result # shows available containers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API error", code=102)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["container_stats"]("glance")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Bug 1: hash-prefix stripping
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_strip_hash_prefix_strips_prefix():
|
||||
from mcp_synology_container.modules.containers import _strip_hash_prefix
|
||||
|
||||
assert _strip_hash_prefix("f93cb8b504f7_jenkins") == "jenkins"
|
||||
|
||||
|
||||
def test_strip_hash_prefix_no_prefix():
|
||||
from mcp_synology_container.modules.containers import _strip_hash_prefix
|
||||
|
||||
assert _strip_hash_prefix("jenkins") == "jenkins"
|
||||
|
||||
|
||||
def test_strip_hash_prefix_leading_slash():
|
||||
from mcp_synology_container.modules.containers import _strip_hash_prefix
|
||||
|
||||
assert _strip_hash_prefix("/jenkins") == "jenkins"
|
||||
|
||||
|
||||
def test_strip_hash_prefix_slash_with_hash():
|
||||
from mcp_synology_container.modules.containers import _strip_hash_prefix
|
||||
|
||||
assert _strip_hash_prefix("/f93cb8b504f7_jenkins") == "jenkins"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_containers_strips_hash_prefix():
|
||||
"""list_containers must display the clean name without the hash prefix."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = HASH_PREFIXED_CONTAINERS_DATA
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_containers"]()
|
||||
assert "jenkins" in result
|
||||
assert "f93cb8b504f7_jenkins" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_strips_hash_prefix():
|
||||
"""container_stats must match even when DSM returns a hash-prefixed name."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
stats_with_hash = {
|
||||
"abc123": {
|
||||
"name": "/f93cb8b504f7_jenkins",
|
||||
"cpu_stats": {
|
||||
"cpu_usage": {"total_usage": 500_000, "percpu_usage": [500_000]},
|
||||
"system_cpu_usage": 100_000_000_000,
|
||||
"online_cpus": 1,
|
||||
},
|
||||
"precpu_stats": {
|
||||
"cpu_usage": {"total_usage": 0},
|
||||
"system_cpu_usage": 0,
|
||||
},
|
||||
"memory_stats": {"usage": 1024, "limit": 2048},
|
||||
"networks": {},
|
||||
"blkio_stats": {"io_service_bytes_recursive": []},
|
||||
}
|
||||
}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = stats_with_hash
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
# User passes clean name; must still match the hash-prefixed entry
|
||||
result = await tools["container_stats"]("jenkins")
|
||||
assert "not found" not in result.lower()
|
||||
assert "CPU" in result
|
||||
|
||||
|
||||
DSM_CONTAINER_RESPONSE = {
|
||||
"details": {
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"StartedAt": "2025-01-01T10:00:00Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z",
|
||||
"ExitCode": 0,
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Networks": {
|
||||
"bridge": {"IPAddress": "172.17.0.2"},
|
||||
}
|
||||
},
|
||||
"Mounts": [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/volume1/docker/jenkins",
|
||||
"Destination": "/var/jenkins_home",
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
"profile": {
|
||||
"image": "jenkins/jenkins:2.558-jdk21",
|
||||
"port_bindings": [
|
||||
{"host_port": 8080, "container_port": 8080, "type": "tcp"},
|
||||
{"host_port": 50000, "container_port": 50000, "type": "tcp"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_status_uses_clean_name():
|
||||
"""get_container_status strips hash prefix and calls get with clean name."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
assert kwargs["params"]["name"] == "jenkins"
|
||||
return DSM_CONTAINER_RESPONSE
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
# User passes hash-prefixed name → stripped before get call
|
||||
result = await tools["get_container_status"]("f93cb8b504f7_jenkins")
|
||||
assert "running" in result
|
||||
assert "f93cb8b504f7" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_status_details_profile_structure():
|
||||
"""get_container_status reads status from details.State, image from profile."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = DSM_CONTAINER_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["get_container_status"]("jenkins")
|
||||
assert "running" in result
|
||||
assert "jenkins/jenkins:2.558-jdk21" in result
|
||||
assert "True" in result # Running field
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_status_shows_ip():
|
||||
"""get_container_status shows IP address from NetworkSettings."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = DSM_CONTAINER_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["get_container_status"]("jenkins")
|
||||
assert "172.17.0.2" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_status_shows_ports():
|
||||
"""get_container_status shows port bindings from profile."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = DSM_CONTAINER_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["get_container_status"]("jenkins")
|
||||
assert "8080" in result
|
||||
assert "50000" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_status_shows_mounts():
|
||||
"""get_container_status shows mount paths from details.Mounts."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = DSM_CONTAINER_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["get_container_status"]("jenkins")
|
||||
assert "/volume1/docker/jenkins" in result
|
||||
assert "/var/jenkins_home" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# inspect_container
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Live-captured-style response (homeassistant) — proves that
|
||||
# details.Mounts[].Source carries the full /volume1/... path while
|
||||
# profile.volume_bindings[].host_volume_file is share-relative.
|
||||
INSPECT_RESPONSE = {
|
||||
"details": {
|
||||
"Config": {
|
||||
"Image": "homeassistant/home-assistant:stable",
|
||||
"Entrypoint": ["/init"],
|
||||
"Cmd": None,
|
||||
"Env": ["TZ=Europe/Berlin"],
|
||||
},
|
||||
"HostConfig": {
|
||||
"RestartPolicy": {"Name": "always", "MaximumRetryCount": 0},
|
||||
"NetworkMode": "frostiq_net",
|
||||
"Privileged": False,
|
||||
"CapAdd": None,
|
||||
"CapDrop": None,
|
||||
"Binds": ["/volume1/docker/homeassistant:/config:rw"],
|
||||
},
|
||||
"Mounts": [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/volume1/docker/homeassistant",
|
||||
"Destination": "/config",
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
"NetworkSettings": {
|
||||
"Networks": {
|
||||
"frostiq_net": {"IPAddress": "172.18.0.5"},
|
||||
}
|
||||
},
|
||||
"RestartCount": 0,
|
||||
"State": {"Status": "running", "Running": True},
|
||||
},
|
||||
"profile": {
|
||||
"image": "homeassistant/home-assistant:stable",
|
||||
"name": "homeassistant",
|
||||
"enable_restart_policy": True,
|
||||
"network_mode": "frostiq_net",
|
||||
"use_host_network": False,
|
||||
"port_bindings": [{"container_port": 8123, "host_port": 8123, "type": "tcp"}],
|
||||
# Share-relative — must NOT be the path the tool reports.
|
||||
"volume_bindings": [
|
||||
{
|
||||
"host_volume_file": "/docker/homeassistant",
|
||||
"mount_point": "/config",
|
||||
"type": "rw",
|
||||
"is_directory": True,
|
||||
}
|
||||
],
|
||||
"env_variables": [
|
||||
{"key": "TZ", "value": "Europe/Berlin"},
|
||||
{"key": "LANG", "value": "C.UTF-8"},
|
||||
],
|
||||
"labels": {"io.hass.type": "core", "io.hass.version": "2026.2.3"},
|
||||
"links": [],
|
||||
"cmd": "",
|
||||
"cmd_v2": "",
|
||||
"privileged": False,
|
||||
"CapAdd": None,
|
||||
"CapDrop": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_uses_full_host_path():
|
||||
"""inspect_container must report /volume1/docker/... (full) — not
|
||||
/docker/... (share-relative) — for volume mounts. The compose-rebuild
|
||||
workflow depends on the full host path."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": [{"name": "homeassistant"}]}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
return INSPECT_RESPONSE
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("homeassistant")
|
||||
assert "/volume1/docker/homeassistant" in result
|
||||
# The share-relative shortcut must not appear as a mount source.
|
||||
assert " /docker/homeassistant " not in result # not as standalone path
|
||||
assert "→ /config" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_shows_core_fields():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = INSPECT_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("homeassistant")
|
||||
# Header
|
||||
assert "Container: homeassistant" in result
|
||||
assert "homeassistant/home-assistant:stable" in result
|
||||
assert "running" in result
|
||||
# Restart policy
|
||||
assert "always" in result
|
||||
# Network
|
||||
assert "frostiq_net" in result
|
||||
assert "172.18.0.5" in result
|
||||
# Ports
|
||||
assert "8123" in result
|
||||
# Env
|
||||
assert "TZ=Europe/Berlin" in result
|
||||
# Labels
|
||||
assert "io.hass.version=2026.2.3" in result
|
||||
# Entrypoint
|
||||
assert "/init" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_calls_get_with_json_name():
|
||||
"""inspect_container must send name= as a JSON-encoded string (DSM
|
||||
Container/get is documented to accept both but json.dumps keeps the
|
||||
convention shared with start/stop/restart/delete)."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": [{"name": "homeassistant"}]}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
seen["params"] = kwargs.get("params")
|
||||
return INSPECT_RESPONSE
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
await tools["inspect_container"]("homeassistant")
|
||||
assert seen["params"] == {"name": '"homeassistant"'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_resolves_hash_prefix():
|
||||
"""If DSM stores the container as 'abcdef012345_homeassistant', a user
|
||||
request for 'homeassistant' must resolve to the prefixed name and the
|
||||
displayed header must show the clean name."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": [{"name": "abcdef012345_homeassistant"}]}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
assert kwargs["params"]["name"] == '"abcdef012345_homeassistant"'
|
||||
return INSPECT_RESPONSE
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("homeassistant")
|
||||
assert "Container: homeassistant" in result
|
||||
assert "abcdef012345" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_not_found():
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": []}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
return None
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("ghost")
|
||||
assert "not found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_container_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": [{"name": "homeassistant"}]}
|
||||
if api == "SYNO.Docker.Container" and method == "get":
|
||||
raise SynologyError("API error", code=102)
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_container"]("homeassistant")
|
||||
assert "Error" in result
|
||||
assert "homeassistant" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_logs_resolves_hash_prefix():
|
||||
"""get_container_logs resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return HASH_PREFIXED_CONTAINERS_DATA
|
||||
if api == "SYNO.Docker.Container.Log" and method == "get":
|
||||
assert kwargs["params"]["name"] == "f93cb8b504f7_jenkins"
|
||||
return {
|
||||
"logs": [{"created": "2025-01-01", "stream": "stdout", "text": "started"}],
|
||||
"total": 1,
|
||||
}
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["get_container_logs"]("jenkins")
|
||||
assert "started" in result
|
||||
assert "f93cb8b504f7" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_container_resolves_hash_prefix():
|
||||
"""exec_in_container resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return HASH_PREFIXED_CONTAINERS_DATA
|
||||
if api == "SYNO.Docker.Container" and method == "exec":
|
||||
assert kwargs["params"]["name"] == "f93cb8b504f7_jenkins"
|
||||
return {"output": "ok", "exit_code": 0}
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["exec_in_container"]("jenkins", "echo ok", confirmed=True)
|
||||
assert "ok" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_stats_no_precpu_graceful():
|
||||
"""When precpu_stats has no system_cpu_usage (first poll), CPU% = 0."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
stats_no_pre = {
|
||||
"abc123": {
|
||||
"name": "/myapp",
|
||||
"cpu_stats": {
|
||||
"cpu_usage": {"total_usage": 500_000, "percpu_usage": [500_000]},
|
||||
"system_cpu_usage": 100_000_000_000,
|
||||
"online_cpus": 1,
|
||||
},
|
||||
"precpu_stats": {
|
||||
"cpu_usage": {"total_usage": 0},
|
||||
# system_cpu_usage absent → system_delta = 0
|
||||
},
|
||||
"memory_stats": {"usage": 1024, "limit": 2048},
|
||||
"networks": {},
|
||||
"blkio_stats": {"io_service_bytes_recursive": []},
|
||||
}
|
||||
}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = stats_no_pre
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools["container_stats"]("myapp")
|
||||
assert "0.00%" in result # graceful fallback to 0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Lifecycle: start / stop / restart
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Tools that don't have a confirmation gate
|
||||
_NO_CONFIRM_LIFECYCLE = [
|
||||
("start_container", "start", "Started"),
|
||||
]
|
||||
|
||||
# Tools that require confirmed=True
|
||||
_CONFIRM_LIFECYCLE = [
|
||||
("stop_container", "stop", "Stopped", "stop"),
|
||||
("restart_container", "restart", "Restarted", "restart"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name, dsm_method, success_word", _NO_CONFIRM_LIFECYCLE)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_no_confirm_calls_dsm(tool_name, dsm_method, success_word):
|
||||
"""start_container calls DSM directly with json-encoded name."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools[tool_name]("myapp_web")
|
||||
|
||||
# Find the matching lifecycle call (a list() may also occur via _resolve_container_name)
|
||||
calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method]
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.args[0] == "SYNO.Docker.Container"
|
||||
assert call.kwargs["version"] == 1
|
||||
assert call.kwargs["params"] == {"name": '"myapp_web"'}
|
||||
assert success_word in result
|
||||
assert "myapp_web" in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name, dsm_method, success_word, _action", _CONFIRM_LIFECYCLE)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_confirmed_calls_dsm(tool_name, dsm_method, success_word, _action):
|
||||
"""stop/restart call DSM with confirmed=True using json-encoded name."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools[tool_name]("myapp_web", confirmed=True)
|
||||
|
||||
calls = [c for c in client.request.call_args_list if c.args[1] == dsm_method]
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.args[0] == "SYNO.Docker.Container"
|
||||
assert call.kwargs["version"] == 1
|
||||
assert call.kwargs["params"] == {"name": '"myapp_web"'}
|
||||
assert success_word in result
|
||||
assert "myapp_web" in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name, _dsm_method, _success_word, action", _CONFIRM_LIFECYCLE)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_preview_without_confirmation(
|
||||
tool_name, _dsm_method, _success_word, action
|
||||
):
|
||||
"""stop/restart without confirmed=True must NOT call DSM."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools[tool_name]("myapp_web")
|
||||
assert "Preview" in result
|
||||
assert action in result
|
||||
assert "myapp_web" in result
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name, dsm_method, kwargs",
|
||||
[
|
||||
("start_container", "start", {}),
|
||||
("stop_container", "stop", {"confirmed": True}),
|
||||
("restart_container", "restart", {"confirmed": True}),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_resolves_hash_prefix(tool_name, dsm_method, kwargs):
|
||||
"""All lifecycle tools resolve 'jenkins' to 'f93cb8b504f7_jenkins' for the DSM call."""
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
async def mock_request(api, method, **kw):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return HASH_PREFIXED_CONTAINERS_DATA
|
||||
if api == "SYNO.Docker.Container" and method == dsm_method:
|
||||
assert kw["params"]["name"] == '"f93cb8b504f7_jenkins"'
|
||||
assert kw["version"] == 1
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
# User passes the clean name; resolved to the hash-prefixed name for DSM,
|
||||
# but the display name in the success message must be hash-stripped.
|
||||
result = await tools[tool_name]("jenkins", **kwargs)
|
||||
assert "jenkins" in result
|
||||
assert "f93cb8b504f7" not in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name, kwargs, error_verb",
|
||||
[
|
||||
("start_container", {}, "starting"),
|
||||
("stop_container", {"confirmed": True}, "stopping"),
|
||||
("restart_container", {"confirmed": True}, "restarting"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_api_error(tool_name, kwargs, error_verb):
|
||||
"""API errors during lifecycle ops return a human-readable error message."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.containers import register_containers
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API error", code=102)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_containers(mcp, make_config(), client)
|
||||
|
||||
result = await tools[tool_name]("myapp_web", **kwargs)
|
||||
assert "Error" in result
|
||||
assert error_verb in result
|
||||
assert "myapp_web" in result
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tests for modules/images.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
@@ -12,6 +13,7 @@ def make_mock_mcp():
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
@@ -19,6 +21,7 @@ def make_mock_mcp():
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
@@ -32,6 +35,7 @@ SAMPLE_IMAGES = {
|
||||
"repository": "nginx",
|
||||
"tags": ["1.24"],
|
||||
"size": 50 * 1024 * 1024,
|
||||
"created": 1700000000,
|
||||
"upgradable": True,
|
||||
},
|
||||
{
|
||||
@@ -39,6 +43,7 @@ SAMPLE_IMAGES = {
|
||||
"repository": "postgres",
|
||||
"tags": ["15"],
|
||||
"size": 80 * 1024 * 1024,
|
||||
"created": 1700000000,
|
||||
"upgradable": False,
|
||||
},
|
||||
{
|
||||
@@ -46,11 +51,660 @@ SAMPLE_IMAGES = {
|
||||
"repository": "redis",
|
||||
"tags": ["7"],
|
||||
"size": 30 * 1024 * 1024,
|
||||
"created": 1700000000,
|
||||
"upgradable": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
SAMPLE_CONTAINERS = {
|
||||
"containers": [
|
||||
{"name": "my-nginx", "image_id": "sha256:aaaa", "status": "running"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# list_images
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_sorted_by_size():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
# postgres (80 MiB) should appear before nginx (50 MiB) before redis (30 MiB)
|
||||
pos_postgres = result.index("postgres")
|
||||
pos_nginx = result.index("nginx")
|
||||
pos_redis = result.index("redis")
|
||||
assert pos_postgres < pos_nginx < pos_redis
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_shows_in_use():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
assert "[in use]" in result
|
||||
assert "[update available]" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_no_images():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return {"images": []}
|
||||
return {"containers": []}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
assert "No local images found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API unavailable", code=102)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_container_error_graceful():
|
||||
"""Container list failure must not prevent image listing."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
raise SynologyError("containers unavailable", code=102)
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
assert "postgres" in result # images still listed
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# delete_image
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_preview():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="redis:7")
|
||||
assert "Preview" in result
|
||||
assert "redis:7" in result
|
||||
# Should not have called the delete method
|
||||
calls = [str(c) for c in client.request.call_args_list]
|
||||
assert not any("delete" in c for c in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_confirmed():
|
||||
import json
|
||||
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="redis:7", confirmed=True)
|
||||
assert "Deleted" in result
|
||||
assert "redis:7" in result
|
||||
assert "freed" in result
|
||||
|
||||
# post_request must be called with images JSON param, not name/tag/id
|
||||
client.post_request.assert_called_once()
|
||||
params = client.post_request.call_args.kwargs.get("params", {})
|
||||
images = json.loads(params["images"])
|
||||
assert images == [{"repository": "redis", "tags": ["7"]}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_not_found():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="nonexistent:latest", confirmed=True)
|
||||
assert "not found" in result
|
||||
client.post_request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_in_use_by_running_blocked():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS # nginx is in use (running)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="nginx:1.24", confirmed=True)
|
||||
assert "Cannot delete" in result
|
||||
assert "running" in result.lower()
|
||||
assert "my-nginx" in result
|
||||
client.post_request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_in_use_by_stopped_blocked():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {
|
||||
"containers": [
|
||||
{"name": "stopped-nginx", "image_id": "sha256:aaaa", "status": "exited"}
|
||||
]
|
||||
}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="nginx:1.24", confirmed=True)
|
||||
assert "Cannot delete" in result
|
||||
assert "stopped" in result.lower()
|
||||
assert "stopped-nginx" in result
|
||||
assert "system_prune" in result
|
||||
client.post_request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_by_hash():
|
||||
import json
|
||||
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="sha256:cccc", confirmed=True)
|
||||
assert "Deleted" in result
|
||||
assert "redis" in result
|
||||
|
||||
# Verify images param uses the resolved name+tag (not the hash)
|
||||
call_kwargs = client.post_request.call_args
|
||||
params = call_kwargs.kwargs.get("params") or {}
|
||||
images = json.loads(params.get("images", "[]"))
|
||||
assert images == [{"repository": "redis", "tags": ["7"]}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_registry_prefixed_name():
|
||||
"""Registry-prefixed image names (e.g. ghcr.io/foo/bar:v1) must split at last ':'."""
|
||||
import json
|
||||
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
registry_images = {
|
||||
"images": [
|
||||
{
|
||||
"id": "sha256:dddd",
|
||||
"repository": "ghcr.io/open-webui/open-webui",
|
||||
"tags": ["v0.8.10"],
|
||||
"size": 100 * 1024 * 1024,
|
||||
"created": 1700000000,
|
||||
"upgradable": False,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return registry_images
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](
|
||||
image_id="ghcr.io/open-webui/open-webui:v0.8.10", confirmed=True
|
||||
)
|
||||
assert "Deleted" in result
|
||||
assert "open-webui" in result
|
||||
|
||||
# images param must use full registry-prefixed repository name
|
||||
call_kwargs = client.post_request.call_args
|
||||
params = call_kwargs.kwargs.get("params") or {}
|
||||
images = json.loads(params.get("images", "[]"))
|
||||
assert images == [{"repository": "ghcr.io/open-webui/open-webui", "tags": ["v0.8.10"]}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(side_effect=SynologyError("delete failed", code=114))
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="redis:7", confirmed=True)
|
||||
assert "Error" in result
|
||||
assert "114" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# inspect_image
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Authoritative DSM SYNO.Docker.Image/get response shape (v1).
|
||||
# Live-captured peculiarities:
|
||||
# - `image` and `tag` come back as empty strings when the lookup is
|
||||
# by name:tag — the header therefore has to fall back to image_id.
|
||||
# - `ports` is an array of {"port", "protocol"} dicts, NOT strings.
|
||||
# - `env` is an array of {"key", "value"} dicts, NOT strings.
|
||||
# - `volumes` and `cmd`/`entrypoint` are plain string arrays.
|
||||
# - There is no `layers` field on this endpoint.
|
||||
SAMPLE_INSPECT = {
|
||||
"image": "",
|
||||
"tag": "",
|
||||
"id": "sha256:aaaa1234567890abcdef",
|
||||
"digest": "sha256:digestabcdef",
|
||||
"size": 50 * 1024 * 1024,
|
||||
"virtual_size": 50 * 1024 * 1024,
|
||||
"author": "NGINX Team",
|
||||
"docker_version": "20.10.0",
|
||||
"cmd": ["nginx", "-g", "daemon off;"],
|
||||
"entrypoint": ["/docker-entrypoint.sh"],
|
||||
"env": [
|
||||
{"key": "NGINX_VERSION", "value": "1.24"},
|
||||
{"key": "PATH", "value": "/usr/local/sbin"},
|
||||
],
|
||||
"ports": [
|
||||
{"port": "80", "protocol": "tcp"},
|
||||
{"port": "443", "protocol": "tcp"},
|
||||
],
|
||||
"volumes": ["/var/cache/nginx"],
|
||||
}
|
||||
|
||||
|
||||
def _make_inspect_client(inspect_payload=None):
|
||||
"""Build a mock DsmClient that returns inspect_payload for SYNO.Docker.Image/get."""
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "get":
|
||||
return inspect_payload if inspect_payload is not None else SAMPLE_INSPECT
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_by_name_tag():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "nginx" in result
|
||||
assert "1.24" in result
|
||||
assert "MiB" in result # size formatted via _human_size
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_by_hash():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
# Inspect data shaped for redis returned by hash lookup.
|
||||
# image/tag come back empty (matches live DSM behavior) — display
|
||||
# falls back to image_id, then to the id hash.
|
||||
redis_inspect = {
|
||||
"image": "",
|
||||
"tag": "",
|
||||
"id": "sha256:ccccredis",
|
||||
"digest": "sha256:rdigest",
|
||||
"size": 30 * 1024 * 1024,
|
||||
"virtual_size": 30 * 1024 * 1024,
|
||||
"cmd": ["redis-server"],
|
||||
"entrypoint": [],
|
||||
"env": [],
|
||||
"ports": [{"port": "6379", "protocol": "tcp"}],
|
||||
"volumes": [],
|
||||
}
|
||||
client = _make_inspect_client(inspect_payload=redis_inspect)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="sha256:cccc")
|
||||
# The header echoes the user-supplied image_id; "redis" surfaces via the cmd line.
|
||||
assert "sha256:cccc" in result
|
||||
assert "redis" in result
|
||||
assert "6379/tcp" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_header_uses_image_id_when_fields_empty():
|
||||
"""DSM Image/get returns image='' and tag='' for name:tag lookups — the header
|
||||
must fall back to the user-supplied image_id, NOT render '?:?'."""
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client() # SAMPLE_INSPECT has image='' / tag=''
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="gitea/gitea:1.26.1")
|
||||
assert "Image: gitea/gitea:1.26.1" in result
|
||||
assert "?:?" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_header_falls_back_to_id_when_image_id_empty():
|
||||
"""If image_id were empty (defensive — should not happen in practice), the
|
||||
header falls back to the sha256 id field."""
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client() # SAMPLE_INSPECT.id == sha256:aaaa1234567890abcdef
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="")
|
||||
assert "Image: sha256:aaaa1234567890abcdef" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_uses_identity_param():
|
||||
"""DSM SYNO.Docker.Image/get expects parameter 'identity' (JSON-encoded), version=1.
|
||||
|
||||
Using name/tag/id (the old shape) returned DSM error 114 — this test guards
|
||||
against regressing to that contract.
|
||||
"""
|
||||
import json
|
||||
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
await tools["inspect_image"](image_id="nginx:1.24")
|
||||
|
||||
get_calls = [
|
||||
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
|
||||
]
|
||||
assert len(get_calls) == 1
|
||||
call = get_calls[0]
|
||||
assert call.kwargs.get("version") == 1
|
||||
params = call.kwargs.get("params") or {}
|
||||
assert params == {"identity": json.dumps("nginx:1.24")}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_empty_response_treated_as_not_found():
|
||||
"""An empty response dict surfaces as a clean 'not found' message."""
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client(inspect_payload={})
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="bogus:latest")
|
||||
assert "not found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_env_vars():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "NGINX_VERSION=1.24" in result
|
||||
assert "PATH=/usr/local/sbin" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_exposed_ports():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "80/tcp" in result
|
||||
assert "443/tcp" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_volumes():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "/var/cache/nginx" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_entrypoint_cmd():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "/docker-entrypoint.sh" in result
|
||||
assert "nginx" in result
|
||||
assert "daemon off;" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_shows_digest_author_docker_version():
|
||||
"""Identity-block fields specific to the DSM Image/get response are surfaced."""
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = _make_inspect_client()
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "sha256:digestabcdef" in result
|
||||
assert "NGINX Team" in result
|
||||
assert "20.10.0" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_registry_prefixed():
|
||||
"""A registry-prefixed identifier like 'ghcr.io/foo/bar:v1' is passed through verbatim
|
||||
in the JSON-encoded identity parameter."""
|
||||
import json
|
||||
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
registry_inspect = {
|
||||
# image / tag come back empty for name:tag lookups (live DSM behavior).
|
||||
"image": "",
|
||||
"tag": "",
|
||||
"id": "sha256:dddd",
|
||||
"digest": "sha256:gdigest",
|
||||
"size": 100 * 1024 * 1024,
|
||||
"virtual_size": 100 * 1024 * 1024,
|
||||
"cmd": ["/app"],
|
||||
"entrypoint": [],
|
||||
"env": [],
|
||||
"ports": [],
|
||||
"volumes": [],
|
||||
}
|
||||
client = _make_inspect_client(inspect_payload=registry_inspect)
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="ghcr.io/foo/bar:v1")
|
||||
assert "ghcr.io/foo/bar" in result
|
||||
assert "v1" in result
|
||||
|
||||
# The full identifier (with ':') is JSON-encoded into a single string —
|
||||
# registry-prefixed names are not split into name + tag.
|
||||
get_calls = [
|
||||
c for c in client.request.call_args_list if c.args[:2] == ("SYNO.Docker.Image", "get")
|
||||
]
|
||||
assert get_calls
|
||||
params = get_calls[0].kwargs.get("params") or {}
|
||||
assert params.get("identity") == json.dumps("ghcr.io/foo/bar:v1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_image_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "get":
|
||||
raise SynologyError("inspect failed", code=120)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["inspect_image"](image_id="nginx:1.24")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# check_image_updates (existing tests preserved)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_all():
|
||||
@@ -75,7 +729,13 @@ async def test_check_image_updates_all_up_to_date():
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"], "size": 50 * 1024 * 1024, "upgradable": False},
|
||||
{
|
||||
"id": "sha256:aaaa",
|
||||
"repository": "nginx",
|
||||
"tags": ["1.24"],
|
||||
"size": 50 * 1024 * 1024,
|
||||
"upgradable": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -102,8 +762,8 @@ async def test_check_image_updates_no_images():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_api_error():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API unavailable", code=102)
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Tests for modules/networks.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_NETWORKS = {
|
||||
"network": [
|
||||
{
|
||||
"id": "b741915823aa",
|
||||
"name": "vault_default",
|
||||
"driver": "bridge",
|
||||
"subnet": "172.22.0.0/16",
|
||||
"gateway": "172.22.0.1",
|
||||
"iprange": "",
|
||||
"enable_ipv6": False,
|
||||
"containers": ["vault"],
|
||||
},
|
||||
{
|
||||
"id": "2fc7ebae3901",
|
||||
"name": "host",
|
||||
"driver": "host",
|
||||
"subnet": "",
|
||||
"gateway": "",
|
||||
"iprange": "",
|
||||
"enable_ipv6": False,
|
||||
"containers": [],
|
||||
},
|
||||
{
|
||||
"id": "aabbcc112233",
|
||||
"name": "my_bridge",
|
||||
"driver": "bridge",
|
||||
"subnet": "10.10.0.0/24",
|
||||
"gateway": "10.10.0.1",
|
||||
"iprange": "",
|
||||
"enable_ipv6": False,
|
||||
"containers": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# list_networks
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_networks_shows_all():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_NETWORKS
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_networks"]()
|
||||
assert "vault_default" in result
|
||||
assert "host" in result
|
||||
assert "my_bridge" in result
|
||||
assert "3 total" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_networks_shows_subnet_and_gateway():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_NETWORKS
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_networks"]()
|
||||
assert "172.22.0.0/16" in result
|
||||
assert "172.22.0.1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_networks_shows_attached_containers():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_NETWORKS
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_networks"]()
|
||||
assert "vault" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_networks_empty():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {"network": []}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_networks"]()
|
||||
assert "No networks found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_networks_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API error", code=102)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_networks"]()
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# create_network
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_network_preview():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["create_network"](name="mynet", driver="bridge")
|
||||
assert "Preview" in result
|
||||
assert "mynet" in result
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
client.post_request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_network_confirmed():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={"id": "deadbeef1234567890"})
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["create_network"](
|
||||
name="mynet", driver="bridge", subnet="192.168.100.0/24", confirmed=True
|
||||
)
|
||||
assert "created" in result
|
||||
assert "mynet" in result
|
||||
|
||||
params = client.post_request.call_args.kwargs.get("params", {})
|
||||
assert json.loads(params["name"]) == "mynet"
|
||||
assert params["driver"] == "bridge"
|
||||
assert json.loads(params["subnet"]) == "192.168.100.0/24"
|
||||
assert json.loads(params["enable_ipv6"]) is False
|
||||
assert json.loads(params["disable_masquerade"]) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_network_with_ipv6():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={"id": "abc123"})
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
await tools["create_network"](name="ipv6net", enable_ipv6=True, confirmed=True)
|
||||
|
||||
params = client.post_request.call_args.kwargs.get("params", {})
|
||||
assert json.loads(params["enable_ipv6"]) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_network_optional_params_not_sent():
|
||||
"""subnet/gateway/ip_range must not appear in params when not provided."""
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
await tools["create_network"](name="bare", confirmed=True)
|
||||
|
||||
params = client.post_request.call_args.kwargs.get("params", {})
|
||||
assert "subnet" not in params
|
||||
assert "gateway" not in params
|
||||
assert "iprange" not in params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_network_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.post_request = AsyncMock(side_effect=SynologyError("create failed", code=100))
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["create_network"](name="mynet", confirmed=True)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# delete_network
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_network_preview():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_NETWORKS
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_network"](name="my_bridge")
|
||||
assert "Preview" in result
|
||||
assert "my_bridge" in result
|
||||
assert "confirmed=True" in result
|
||||
# Only the list GET was called; post_request must not be called
|
||||
client.request.assert_called_once()
|
||||
assert client.request.call_args.args[1] == "list"
|
||||
client.post_request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_network_confirmed():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_NETWORKS
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_network"](name="my_bridge", confirmed=True)
|
||||
assert "deleted" in result
|
||||
assert "my_bridge" in result
|
||||
|
||||
# post_request must use method "remove" with networks JSON array
|
||||
post_call = client.post_request.call_args
|
||||
assert post_call.args[1] == "remove"
|
||||
params = post_call.kwargs.get("params", {})
|
||||
networks_list = json.loads(params["networks"])
|
||||
assert len(networks_list) == 1
|
||||
obj = networks_list[0]
|
||||
assert obj["name"] == "my_bridge"
|
||||
assert obj["id"] == "aabbcc112233"
|
||||
assert obj["_key"] == "aabbcc112233"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_network_not_found():
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_NETWORKS
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_network"](name="nonexistent", confirmed=True)
|
||||
assert "not found" in result
|
||||
client.post_request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_network_blocked_by_containers():
|
||||
"""Deletion must be refused when containers are attached."""
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_NETWORKS
|
||||
client.post_request = AsyncMock(return_value={})
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
# vault_default has ["vault"] attached
|
||||
result = await tools["delete_network"](name="vault_default", confirmed=True)
|
||||
assert "Cannot delete" in result
|
||||
assert "vault" in result
|
||||
client.post_request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_network_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.networks import register_networks
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SAMPLE_NETWORKS
|
||||
client.post_request = AsyncMock(side_effect=SynologyError("remove failed", code=100))
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_networks(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_network"](name="my_bridge", confirmed=True)
|
||||
assert "Error" in result
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Tests for modules/projects.py."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from mcp_synology_container.modules.projects import _find_project, _format_project_detail
|
||||
|
||||
|
||||
SAMPLE_PROJECTS = {
|
||||
"uuid-1": {
|
||||
"id": "uuid-1",
|
||||
@@ -83,8 +83,8 @@ def test_format_project_detail_no_containers():
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_tool():
|
||||
"""Test list_projects tool via function registration."""
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
@@ -100,6 +100,7 @@ async def test_list_projects_tool():
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
register_projects(MockMCP(), config, client)
|
||||
@@ -113,8 +114,8 @@ async def test_list_projects_tool():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_project_requires_confirmation():
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
@@ -128,6 +129,7 @@ async def test_stop_project_requires_confirmation():
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
register_projects(MockMCP(), config, client)
|
||||
@@ -139,8 +141,8 @@ async def test_stop_project_requires_confirmation():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_project_requires_confirmation():
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
@@ -154,6 +156,7 @@ async def test_redeploy_project_requires_confirmation():
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
register_projects(MockMCP(), config, client)
|
||||
@@ -161,3 +164,862 @@ async def test_redeploy_project_requires_confirmation():
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=False)
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Bug 2: status-aware redeploy
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_projects_tools(client):
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
from mcp_synology_container.modules.projects import register_projects
|
||||
|
||||
config = AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
register_projects(MockMCP(), config, client)
|
||||
return tools
|
||||
|
||||
|
||||
def project_list(status: str) -> dict:
|
||||
return {
|
||||
"uuid-1": {
|
||||
"id": "uuid-1",
|
||||
"name": "myapp",
|
||||
"status": status,
|
||||
"path": "/volume1/docker/myapp",
|
||||
"containerIds": ["abc123"],
|
||||
"services": [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def make_stateful_redeploy_mock(
|
||||
initial_status: str,
|
||||
stop_raises=None,
|
||||
build_stream_raises=None,
|
||||
build_stream_log: str = "",
|
||||
):
|
||||
"""Create a stateful client mock for redeploy tests.
|
||||
|
||||
Returns (client, calls_list). After ``trigger_build_stream`` is called,
|
||||
subsequent ``list`` calls return RUNNING so the polling loop terminates
|
||||
immediately. asyncio.sleep is NOT patched here — patch it at call-site.
|
||||
"""
|
||||
client = AsyncMock()
|
||||
calls = []
|
||||
build_done = False
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
calls.append((api, method))
|
||||
if method == "stop" and stop_raises:
|
||||
raise stop_raises
|
||||
if method == "list":
|
||||
return project_list("RUNNING") if build_done else project_list(initial_status)
|
||||
return {}
|
||||
|
||||
async def mock_trigger_build_stream(project_id):
|
||||
nonlocal build_done
|
||||
calls.append(("SYNO.Docker.Project", "build_stream"))
|
||||
if build_stream_raises:
|
||||
raise build_stream_raises
|
||||
build_done = True # After build_stream, polling returns RUNNING
|
||||
return build_stream_log
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_trigger_build_stream)
|
||||
return client, calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_running_project():
|
||||
"""RUNNING project: stop → build_stream → poll until RUNNING."""
|
||||
client, calls = make_stateful_redeploy_mock("RUNNING")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "stop" in methods
|
||||
assert "build_stream" in methods
|
||||
assert methods.index("stop") < methods.index("build_stream")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_stopped_project_skips_stop():
|
||||
"""STOPPED project: skip stop, call build_stream directly; polls until RUNNING."""
|
||||
client, calls = make_stateful_redeploy_mock("STOPPED")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "stop" not in methods
|
||||
assert "build_stream" in methods
|
||||
assert "STOPPED" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_failed_project():
|
||||
"""BUILD_FAILED project: stop (suppressed) → build_stream → poll until RUNNING."""
|
||||
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "stop" in methods
|
||||
assert "build_stream" in methods
|
||||
assert methods.index("stop") < methods.index("build_stream")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_failed_stop_error_nonfatal():
|
||||
"""BUILD_FAILED: stop failure is non-fatal — build_stream must still be called."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"BUILD_FAILED",
|
||||
stop_raises=SynologyError("already stopped", code=2101),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" in result
|
||||
methods = [m for _, m in calls]
|
||||
assert "build_stream" in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_stream_error_aborts():
|
||||
"""If build_stream raises, redeploy must abort with a clear error message."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"RUNNING",
|
||||
build_stream_raises=SynologyError("build failed", code=114),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" not in result
|
||||
assert "build failed" in result or "Error during redeploy" in result
|
||||
# Polling must not have been called after build_stream failure
|
||||
methods = [m for _, m in calls]
|
||||
list_calls = [m for m in methods if m == "list"]
|
||||
assert len(list_calls) <= 1 # at most the initial find_project call
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_poll_timeout():
|
||||
"""If project never reaches RUNNING after build_stream, a warning is emitted."""
|
||||
client = AsyncMock()
|
||||
build_done = False
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
# Before build: RUNNING (so initial status check is valid)
|
||||
# After build: STARTING (simulate stuck containers)
|
||||
return project_list("STARTING") if build_done else project_list("RUNNING")
|
||||
return {}
|
||||
|
||||
async def mock_build_stream(project_id):
|
||||
nonlocal build_done
|
||||
build_done = True
|
||||
return ""
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
|
||||
with (
|
||||
patch("mcp_synology_container.modules.projects.asyncio.sleep"),
|
||||
patch("mcp_synology_container.modules.projects._BUILD_POLL_TIMEOUT", 1),
|
||||
patch("mcp_synology_container.modules.projects._POLL_INTERVAL", 1),
|
||||
):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "Warning" in result
|
||||
assert "redeployed successfully" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_unknown_status_returns_error():
|
||||
"""Unknown status must return a clear error with a workaround hint."""
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
return project_list("UPDATING")
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.trigger_build_stream = AsyncMock(return_value="")
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "UPDATING" in result
|
||||
assert "Workaround" in result or "stop_project" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# M-4: clear recovery hint when build_stream fails after stop succeeded
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_stream_transport_error_shows_stopped_recovery_hint():
|
||||
"""M-4: build_stream transport error after RUNNING-stop must tell the user the
|
||||
project is now STOPPED and recommend start_project / retry."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"RUNNING",
|
||||
build_stream_raises=SynologyError(
|
||||
"build_stream transport error: ConnectError: nas offline", code=0
|
||||
),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
# No raw stack trace — clean message
|
||||
assert "transport error" in result
|
||||
assert "ConnectError" in result
|
||||
# The recovery hint must point at the actual situation
|
||||
assert "STOPPED" in result
|
||||
assert "start_project" in result
|
||||
# Old misleading workaround text must NOT appear
|
||||
assert "stop_project + start_project separately" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_build_stream_error_on_stopped_project_keeps_old_workaround():
|
||||
"""If the project was STOPPED to begin with, no stop was issued, so the
|
||||
'STOPPED recovery' hint is NOT appropriate — keep the original workaround."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"STOPPED",
|
||||
build_stream_raises=SynologyError("build failed", code=114),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "build failed" in result or "Error during redeploy" in result
|
||||
# Stop was never issued; new recovery hint should not appear
|
||||
assert "was stopped before this error" not in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Issue #2: surface build_stream daemon errors directly
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_build_stream_log_extracts_errors_and_info():
|
||||
"""The helper splits a streamed build log into error and info lines."""
|
||||
from mcp_synology_container.modules.projects import _parse_build_stream_log
|
||||
|
||||
log = (
|
||||
"Container vault Pulling\n"
|
||||
"nginx Error\n"
|
||||
"Error response from daemon: manifest for nginx:9.9.9 not found: "
|
||||
"manifest unknown\n"
|
||||
"\n"
|
||||
"Container vault Running\n"
|
||||
)
|
||||
|
||||
errors, info = _parse_build_stream_log(log)
|
||||
|
||||
assert errors == [
|
||||
"nginx Error",
|
||||
("Error response from daemon: manifest for nginx:9.9.9 not found: manifest unknown"),
|
||||
]
|
||||
assert info == ["Container vault Pulling", "Container vault Running"]
|
||||
|
||||
|
||||
def test_parse_build_stream_log_clean_log_no_errors():
|
||||
from mcp_synology_container.modules.projects import _parse_build_stream_log
|
||||
|
||||
log = "Container vault Running\nContainer vault-db Running\n"
|
||||
errors, info = _parse_build_stream_log(log)
|
||||
|
||||
assert errors == []
|
||||
assert info == ["Container vault Running", "Container vault-db Running"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_surfaces_build_stream_daemon_error():
|
||||
"""build_stream log containing 'Error response from daemon:' aborts redeploy
|
||||
with that line visible — and skips the polling step entirely."""
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"RUNNING",
|
||||
build_stream_log=(
|
||||
"nginx Error\n"
|
||||
"Error response from daemon: manifest for nginx:9.9.9-nonexistent "
|
||||
"not found: manifest unknown\n"
|
||||
),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "Build failed" in result
|
||||
assert "Error response from daemon: manifest for nginx:9.9.9-nonexistent" in result
|
||||
assert "redeploy aborted" in result
|
||||
assert "redeployed successfully" not in result
|
||||
|
||||
# Recovery hint must appear because the project was stopped first.
|
||||
assert "was stopped before this error" in result
|
||||
|
||||
# The polling step must NOT run — build_stream errors short-circuit.
|
||||
methods = [m for _, m in calls]
|
||||
list_calls = [m for m in methods if m == "list"]
|
||||
assert len(list_calls) == 1 # only the initial _find_project lookup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_clean_log_proceeds_to_polling():
|
||||
"""A clean build_stream log keeps the original happy-path behavior."""
|
||||
client, calls = make_stateful_redeploy_mock(
|
||||
"RUNNING",
|
||||
build_stream_log="Container myapp Pulling\nContainer myapp Running\n",
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "redeployed successfully" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_surfaces_build_stream_daemon_error():
|
||||
"""build_stream log with daemon error → registered-but-failed-to-build hint."""
|
||||
client, calls = make_create_project_client(
|
||||
build_stream_log=(
|
||||
"web Error\n"
|
||||
"Error response from daemon: manifest for nginx:0.0.0-bad not found: "
|
||||
"manifest unknown\n"
|
||||
),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "Build failed" in result
|
||||
assert "Error response from daemon: manifest for nginx:0.0.0-bad" in result
|
||||
assert "is registered but failed to build" in result
|
||||
assert "redeploy_project" in result
|
||||
assert "created and started successfully" not in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# M-5: polling exits early on BUILD_FAILED / ERROR
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_project_running_returns_early_on_build_failed():
|
||||
"""_wait_for_project_running must exit as soon as DSM reports BUILD_FAILED,
|
||||
not wait the full timeout."""
|
||||
from mcp_synology_container.modules.projects import _wait_for_project_running
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
return project_list("BUILD_FAILED")
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
# 100s timeout, 2s interval — if the early-exit isn't there the test
|
||||
# would still terminate quickly because sleep is mocked, but the call
|
||||
# count assertion below catches a non-exiting loop.
|
||||
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
|
||||
|
||||
assert result == "BUILD_FAILED"
|
||||
# Only a few list() calls — exit was on the first poll iteration.
|
||||
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
|
||||
assert len(list_calls) <= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_project_running_returns_early_on_error():
|
||||
from mcp_synology_container.modules.projects import _wait_for_project_running
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
return project_list("ERROR")
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await _wait_for_project_running(client, "myapp", timeout=100, interval=2)
|
||||
|
||||
assert result == "ERROR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeploy_surfaces_build_failed_with_hint():
|
||||
"""When polling reports BUILD_FAILED, redeploy_project must include a clear
|
||||
hint to inspect the image tag and retry."""
|
||||
client = AsyncMock()
|
||||
build_done = False
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if method == "list":
|
||||
return project_list("BUILD_FAILED") if build_done else project_list("RUNNING")
|
||||
return {}
|
||||
|
||||
async def mock_build_stream(project_id):
|
||||
nonlocal build_done
|
||||
build_done = True
|
||||
return "" # Clean stream log — failure surfaces via polling.
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "Redeploy failed" in result
|
||||
assert "BUILD_FAILED" in result
|
||||
assert "update_image_tag" in result
|
||||
assert "redeployed successfully" not in result
|
||||
# Polling must have exited early, not run to the full timeout.
|
||||
list_calls = [c for c in client.request.call_args_list if c.args[1] == "list"]
|
||||
# Generous upper bound — early exit means handful of polls, not hundreds.
|
||||
assert len(list_calls) <= 5
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# create_project
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
SIMPLE_COMPOSE = """
|
||||
services:
|
||||
web:
|
||||
image: nginx:1.25
|
||||
worker:
|
||||
image: redis:7
|
||||
"""
|
||||
|
||||
|
||||
def make_create_project_client(
|
||||
*,
|
||||
existing_projects: dict | None = None,
|
||||
create_folder_raises: Exception | None = None,
|
||||
create_project_raises: Exception | None = None,
|
||||
build_stream_raises: Exception | None = None,
|
||||
build_stream_log: str = "",
|
||||
project_id: str = "uuid-new",
|
||||
final_status: str = "RUNNING",
|
||||
):
|
||||
"""Build a stateful mock client for create_project tests.
|
||||
|
||||
Tracks:
|
||||
- whether Docker.Project/create has been called (so post_create_calls
|
||||
to /list return the newly-registered project at `final_status`)
|
||||
- which API/method/version each call used
|
||||
"""
|
||||
client = AsyncMock()
|
||||
calls: list[tuple[str, str, dict]] = []
|
||||
project_created = False
|
||||
|
||||
async def mock_request(api, method, version=None, params=None, **kwargs):
|
||||
calls.append((api, method, dict(params or {})))
|
||||
if api == "SYNO.Docker.Project" and method == "list":
|
||||
if project_created:
|
||||
return {
|
||||
project_id: {
|
||||
"id": project_id,
|
||||
"name": "newapp",
|
||||
"status": final_status,
|
||||
"path": "/volume1/docker/newapp",
|
||||
"containerIds": [],
|
||||
"services": [],
|
||||
}
|
||||
}
|
||||
return existing_projects or {}
|
||||
if api == "SYNO.FileStation.CreateFolder":
|
||||
if create_folder_raises:
|
||||
raise create_folder_raises
|
||||
return {}
|
||||
return {}
|
||||
|
||||
async def mock_post_request(api, method, version=None, params=None, **kwargs):
|
||||
nonlocal project_created
|
||||
calls.append((api, f"POST:{method}", dict(params or {})))
|
||||
if api == "SYNO.Docker.Project" and method == "create":
|
||||
if create_project_raises:
|
||||
raise create_project_raises
|
||||
project_created = True
|
||||
return {"id": project_id}
|
||||
return {}
|
||||
|
||||
async def mock_build_stream(pid):
|
||||
calls.append(("SYNO.Docker.Project", "build_stream", {"id": pid}))
|
||||
if build_stream_raises:
|
||||
raise build_stream_raises
|
||||
return build_stream_log
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
client.post_request.side_effect = mock_post_request
|
||||
client.trigger_build_stream = AsyncMock(side_effect=mock_build_stream)
|
||||
return client, calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_preview_only():
|
||||
"""Without confirmed=True, no side effects — return a preview with service count."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE)
|
||||
|
||||
assert "confirmed=True" in result
|
||||
assert "newapp" in result
|
||||
assert "Services: 2" in result
|
||||
assert "/docker/newapp" in result
|
||||
# No CreateFolder, no Project/create, no build_stream
|
||||
methods = [m for _, m, _ in calls]
|
||||
assert "create" not in methods # FileStation.CreateFolder
|
||||
assert "POST:create" not in methods
|
||||
assert "build_stream" not in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_invalid_name():
|
||||
"""Path-traversal-style names are rejected before any I/O."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("../escape", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "invalid project name" in result.lower()
|
||||
# No API calls at all
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_invalid_yaml():
|
||||
"""Malformed compose content is rejected before any I/O."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("newapp", "this: is: not: yaml: [", confirmed=True)
|
||||
|
||||
assert "Invalid YAML" in result or "Invalid compose" in result
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rejects_compose_without_services():
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("newapp", "version: '3'\n", confirmed=True)
|
||||
|
||||
assert "services" in result.lower()
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_already_exists():
|
||||
"""If a project with the given name already exists, abort without creating anything."""
|
||||
existing = {
|
||||
"uuid-1": {
|
||||
"id": "uuid-1",
|
||||
"name": "newapp",
|
||||
"status": "RUNNING",
|
||||
"path": "/volume1/docker/newapp",
|
||||
"containerIds": [],
|
||||
"services": [],
|
||||
}
|
||||
}
|
||||
client, calls = make_create_project_client(existing_projects=existing)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "already exists" in result
|
||||
assert "RUNNING" in result
|
||||
# Only the list call should have happened
|
||||
methods = [m for _, m, _ in calls]
|
||||
assert "create" not in methods
|
||||
assert "POST:create" not in methods
|
||||
client.trigger_build_stream.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_happy_path():
|
||||
"""confirmed=True with no existing project: folder → create → build_stream → RUNNING."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "created and started successfully" in result
|
||||
|
||||
# Verify all three steps fired in the correct order
|
||||
summarised = [(api, method) for api, method, _ in calls]
|
||||
assert ("SYNO.FileStation.CreateFolder", "create") in summarised
|
||||
assert ("SYNO.Docker.Project", "POST:create") in summarised
|
||||
assert ("SYNO.Docker.Project", "build_stream") in summarised
|
||||
cf_idx = summarised.index(("SYNO.FileStation.CreateFolder", "create"))
|
||||
cp_idx = summarised.index(("SYNO.Docker.Project", "POST:create"))
|
||||
bs_idx = summarised.index(("SYNO.Docker.Project", "build_stream"))
|
||||
assert cf_idx < cp_idx < bs_idx
|
||||
|
||||
# Verify JSON-encoding of CreateFolder params
|
||||
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
|
||||
assert cf_params["folder_path"] == '"/docker"'
|
||||
assert cf_params["name"] == '"newapp"'
|
||||
assert cf_params["force_parent"] == "true"
|
||||
|
||||
# Verify JSON-encoding of Docker.Project/create params
|
||||
cp_params = next(
|
||||
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
|
||||
)
|
||||
assert cp_params["name"] == '"newapp"'
|
||||
assert cp_params["share_path"] == '"/docker/newapp"'
|
||||
assert cp_params["enable_service_portal"] == "false"
|
||||
assert cp_params["service_portal_port"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_explicit_share_path():
|
||||
"""Caller-supplied share_path overrides the derived default."""
|
||||
client, calls = make_create_project_client()
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"](
|
||||
"newapp",
|
||||
SIMPLE_COMPOSE,
|
||||
share_path="/projects/custom/newapp",
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
assert "created and started successfully" in result
|
||||
cf_params = next(p for api, m, p in calls if api == "SYNO.FileStation.CreateFolder")
|
||||
assert cf_params["folder_path"] == '"/projects/custom"'
|
||||
assert cf_params["name"] == '"newapp"'
|
||||
cp_params = next(
|
||||
p for api, m, p in calls if api == "SYNO.Docker.Project" and m == "POST:create"
|
||||
)
|
||||
assert cp_params["share_path"] == '"/projects/custom/newapp"'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_error_2100_surfaces_hint():
|
||||
"""DSM error 2100 on Project/create returns a clear 'target folder' message."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_create_project_client(
|
||||
create_project_raises=SynologyError("Folder issue", code=2100),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "2100" in result
|
||||
assert "target folder" in result.lower()
|
||||
# build_stream must NOT have been called after a failed Project/create
|
||||
client.trigger_build_stream.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_build_stream_failure_keeps_registration():
|
||||
"""If build_stream fails AFTER successful Project/create, the user is told the
|
||||
project is registered-but-not-started and pointed at redeploy_project."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
|
||||
client, calls = make_create_project_client(
|
||||
build_stream_raises=SynologyError("transport error", code=0),
|
||||
)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||
result = await tools["create_project"]("newapp", SIMPLE_COMPOSE, confirmed=True)
|
||||
|
||||
assert "registered but was not started" in result
|
||||
assert "redeploy_project" in result
|
||||
assert "created and started successfully" not in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# delete_project
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_delete_project_client(
|
||||
*,
|
||||
project: dict | None = None,
|
||||
delete_raises: Exception | None = None,
|
||||
):
|
||||
"""Stateful mock client for delete_project tests.
|
||||
|
||||
- `project`: the project dict returned by Project/list. None → no
|
||||
project registered (simulates the "not found" case).
|
||||
- `delete_raises`: optional exception raised when Project/delete is
|
||||
called (used to simulate DSM refusing to delete a running project).
|
||||
"""
|
||||
client = AsyncMock()
|
||||
calls: list[tuple[str, str, dict]] = []
|
||||
project_deleted = False
|
||||
|
||||
async def mock_request(api, method, version=None, params=None, **kwargs):
|
||||
nonlocal project_deleted
|
||||
calls.append((api, method, dict(params or {})))
|
||||
if api == "SYNO.Docker.Project" and method == "list":
|
||||
if project is None or project_deleted:
|
||||
return {}
|
||||
return {project["id"]: project}
|
||||
if api == "SYNO.Docker.Project" and method == "delete":
|
||||
if delete_raises:
|
||||
raise delete_raises
|
||||
project_deleted = True
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
return client, calls
|
||||
|
||||
|
||||
SAMPLE_PROJECT_RUNNING = {
|
||||
"id": "uuid-abc",
|
||||
"name": "myapp",
|
||||
"status": "RUNNING",
|
||||
"path": "/volume1/docker/myapp",
|
||||
"share_path": "/docker/myapp",
|
||||
"containerIds": ["c1"],
|
||||
"services": [],
|
||||
}
|
||||
|
||||
SAMPLE_PROJECT_STOPPED = {
|
||||
**SAMPLE_PROJECT_RUNNING,
|
||||
"status": "STOPPED",
|
||||
"containerIds": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_preview_only():
|
||||
"""confirmed=False: no Project/delete call; preview shows UUID and warns about folder."""
|
||||
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("myapp")
|
||||
|
||||
assert "confirmed=True" in result
|
||||
assert "uuid-abc" in result
|
||||
assert "myapp" in result
|
||||
assert "/docker/myapp" in result
|
||||
# No delete call
|
||||
methods = [m for _, m, _ in calls]
|
||||
assert "delete" not in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_not_found():
|
||||
"""If the project isn't registered, return a clear 'not found' message — no delete."""
|
||||
client, calls = make_delete_project_client(project=None)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("ghost", confirmed=True)
|
||||
|
||||
assert "not found" in result
|
||||
assert "ghost" in result
|
||||
methods = [m for _, m, _ in calls]
|
||||
assert "delete" not in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_rejects_invalid_name():
|
||||
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("../escape", confirmed=True)
|
||||
|
||||
assert "invalid project name" in result.lower()
|
||||
# Not even a list call
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_happy_path():
|
||||
"""confirmed=True with a stopped project: UUID is json.dumps'd; success message
|
||||
mentions both 'deleted' and the surviving folder path."""
|
||||
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "deleted" in result
|
||||
assert "registration removed" in result
|
||||
assert "/docker/myapp" in result
|
||||
assert "NOT deleted" in result
|
||||
|
||||
delete_call = next(
|
||||
(a, m, p) for a, m, p in calls if a == "SYNO.Docker.Project" and m == "delete"
|
||||
)
|
||||
_api, _method, params = delete_call
|
||||
# The UUID must arrive JSON-encoded per the reverse-engineered DSM convention.
|
||||
assert params["id"] == '"uuid-abc"'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_running_blocked_connector_side():
|
||||
"""Live test showed that DSM does NOT reject Project/delete on a running project —
|
||||
it silently orphans the containers. The connector must therefore block the call
|
||||
itself when the project is RUNNING, without ever calling client.request(delete)."""
|
||||
client, calls = make_delete_project_client(project=SAMPLE_PROJECT_RUNNING)
|
||||
tools = make_projects_tools(client)
|
||||
|
||||
result = await tools["delete_project"]("myapp", confirmed=True)
|
||||
|
||||
assert "RUNNING" in result
|
||||
assert "stop_project" in result
|
||||
# The delete endpoint must NOT have been called — no orphaned containers.
|
||||
delete_calls = [m for _, m, _ in calls if m == "delete"]
|
||||
assert delete_calls == []
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Tests for modules/registry.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# search_registry — Issue #5
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SEARCH_RESPONSE = {
|
||||
"data": [
|
||||
{
|
||||
"name": "caddy",
|
||||
"description": (
|
||||
"Caddy 2 is a powerful, enterprise-ready, open source web "
|
||||
"server with automatic HTTPS written in Go."
|
||||
),
|
||||
"downloads": 650989718,
|
||||
"is_automated": False,
|
||||
"is_official": True,
|
||||
"star_count": 881,
|
||||
"registry": "https://registry.hub.docker.com",
|
||||
},
|
||||
{
|
||||
"name": "abiosoft/caddy",
|
||||
"description": "Caddy is a lightweight, general-purpose web server.",
|
||||
"downloads": 111974910,
|
||||
"is_automated": True,
|
||||
"is_official": False,
|
||||
"star_count": 289,
|
||||
"registry": "https://registry.hub.docker.com",
|
||||
},
|
||||
],
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"page_size": 20,
|
||||
"total": 6647,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_renders_hits():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SEARCH_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="caddy")
|
||||
|
||||
assert "caddy" in result
|
||||
assert "abiosoft/caddy" in result
|
||||
assert "[official]" in result
|
||||
assert "881" in result # stars
|
||||
assert "650989718" in result # downloads
|
||||
assert "2 of 6647" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_uses_json_encoded_query():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = SEARCH_RESPONSE
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
await tools["search_registry"](query="nginx", limit=10)
|
||||
|
||||
call = client.request.call_args
|
||||
assert call.args[0] == "SYNO.Docker.Registry"
|
||||
assert call.args[1] == "search"
|
||||
assert call.kwargs.get("version") == 1
|
||||
params = call.kwargs.get("params", {})
|
||||
assert json.loads(params["q"]) == "nginx"
|
||||
assert params["offset"] == "0"
|
||||
assert params["limit"] == "10"
|
||||
assert params["page_size"] == "10"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_empty():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {"data": [], "total": 0}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="does-not-exist")
|
||||
assert "No results" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_registry_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("Auth failure", code=119)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["search_registry"](query="caddy")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# list_image_tags — bonus
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_array_response():
|
||||
"""DSM returns the envelope's data field directly as a list."""
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = [
|
||||
{"tag": "latest"},
|
||||
{"tag": "3.20"},
|
||||
{"tag": "3.19"},
|
||||
]
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine")
|
||||
|
||||
assert "alpine" in result
|
||||
assert "latest" in result
|
||||
assert "3.20" in result
|
||||
assert "3.19" in result
|
||||
assert "3 total" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_uses_json_repo_param():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.return_value = [{"tag": "latest"}]
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
await tools["list_image_tags"](repository="grafana/grafana")
|
||||
|
||||
call = client.request.call_args
|
||||
assert call.args[0] == "SYNO.Docker.Registry"
|
||||
assert call.args[1] == "tags"
|
||||
assert call.kwargs.get("version") == 1
|
||||
params = call.kwargs.get("params", {})
|
||||
assert json.loads(params["repo"]) == "grafana/grafana"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_limit_truncates():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
tags = [{"tag": f"v{i}"} for i in range(120)]
|
||||
client = AsyncMock()
|
||||
client.request.return_value = tags
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine", limit=10)
|
||||
assert "120 total" in result
|
||||
assert "Showing first 10 of 120" in result
|
||||
# Tag #10 must not appear (only v0..v9)
|
||||
assert " v9" in result
|
||||
assert " v10" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_empty():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
# Empty list → DsmClient.request coerces to {} via `or {}`
|
||||
client.request.return_value = {}
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="nonexistent/image")
|
||||
assert "No tags found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_image_tags_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("Boom", code=100)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_image_tags"](repository="alpine")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# pull_image — Issue #3
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_preview():
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24")
|
||||
assert "Preview" in result
|
||||
assert "nginx:1.24" in result
|
||||
assert "confirmed=True" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_already_present():
|
||||
"""If image is already in Image/list, no pull_start call is made."""
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||
]
|
||||
}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "already present" in result
|
||||
|
||||
# Only the Image/list pre-check was called; no pull_start of any kind.
|
||||
calls = client.request.call_args_list
|
||||
assert all(c.args[1] != "pull_start" for c in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_confirmed_success(monkeypatch):
|
||||
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
|
||||
import mcp_synology_container.modules.registry as registry_mod
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
# Make polling instant
|
||||
async def fake_sleep(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||
|
||||
state = {"pulled": False}
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
if state["pulled"]:
|
||||
return {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||
]
|
||||
}
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Image" and method == "pull_start":
|
||||
state["pulled"] = True
|
||||
return {}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "Pulled" in result
|
||||
assert "nginx:1.24" in result
|
||||
|
||||
# Verify pull_start was invoked on SYNO.Docker.Image (not Registry)
|
||||
# with JSON-encoded params.
|
||||
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
|
||||
assert len(pull_calls) == 1
|
||||
assert pull_calls[0].args[0] == "SYNO.Docker.Image"
|
||||
params = pull_calls[0].kwargs.get("params", {})
|
||||
assert json.loads(params["repository"]) == "nginx"
|
||||
assert json.loads(params["tag"]) == "1.24"
|
||||
assert pull_calls[0].kwargs.get("version") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_timeout(monkeypatch):
|
||||
"""If the image never appears, the tool returns a still-running hint."""
|
||||
import mcp_synology_container.modules.registry as registry_mod
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
# Make polling instant
|
||||
async def fake_sleep(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||
# Shrink the budget so the loop exits quickly
|
||||
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Image" and method == "pull_start":
|
||||
return {}
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "still running" in result
|
||||
assert "nginx:1.24" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_start_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.registry import register_registry
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Image" and method == "pull_start":
|
||||
raise SynologyError("Permission denied", code=105)
|
||||
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = mock_request
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_registry(mcp, make_config(), client)
|
||||
|
||||
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||
assert "Error starting pull" in result
|
||||
@@ -0,0 +1,602 @@
|
||||
"""Tests for modules/system.py."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
|
||||
class MockMCP:
|
||||
def tool(self):
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_IMAGES = {
|
||||
"images": [
|
||||
{
|
||||
"id": "sha256:aaaa",
|
||||
"repository": "nginx",
|
||||
"tags": ["1.24"],
|
||||
"size": 50 * 1024 * 1024,
|
||||
},
|
||||
{
|
||||
"id": "sha256:bbbb",
|
||||
"repository": "postgres",
|
||||
"tags": ["15"],
|
||||
"size": 80 * 1024 * 1024,
|
||||
},
|
||||
{
|
||||
"id": "sha256:cccc",
|
||||
"repository": "<none>",
|
||||
"tags": [],
|
||||
"size": 10 * 1024 * 1024,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
SAMPLE_CONTAINERS = {
|
||||
"containers": [
|
||||
{"name": "web", "status": "running", "image_id": "sha256:aaaa"},
|
||||
{"name": "db", "status": "running", "image_id": "sha256:bbbb"},
|
||||
{"name": "old", "status": "stopped", "image_id": "sha256:aaaa"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# system_df
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_df_shows_image_stats():
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_df"]()
|
||||
assert "Images" in result
|
||||
assert "3" in result # total images
|
||||
assert "Containers" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_df_reclaimable():
|
||||
"""Unused images (not referenced by any container) are counted as reclaimable."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_df"]()
|
||||
# sha256:cccc (<none>) is not referenced by any container → reclaimable
|
||||
assert "reclaimable" in result
|
||||
assert "unused" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_df_running_vs_stopped():
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_df"]()
|
||||
assert "running" in result
|
||||
assert "stopped" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_df_image_api_error_graceful():
|
||||
"""Container data is still shown even when image API fails."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
raise SynologyError("image API down", code=102)
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_df"]()
|
||||
# Should still show container section and a warning
|
||||
assert "Containers" in result or "Warnings" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_df_no_images():
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return {"images": []}
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_df"]()
|
||||
assert "0" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# system_prune
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_preview():
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"]()
|
||||
assert "preview" in result.lower()
|
||||
assert "confirmed=True" in result
|
||||
# prune API must NOT be called
|
||||
calls = [c.args[:2] for c in client.request.call_args_list]
|
||||
assert ("SYNO.Docker.Utils", "prune") not in calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_preview_lists_dangling():
|
||||
"""Preview names dangling/unused images and stopped containers."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"]()
|
||||
# <none>:<none> is dangling
|
||||
assert "<none>" in result
|
||||
# "old" container is stopped
|
||||
assert "old" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_confirmed():
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Utils" and method == "prune":
|
||||
return {"SpaceReclaimed": 10 * 1024 * 1024}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"](confirmed=True)
|
||||
assert "completed" in result.lower()
|
||||
assert "MiB" in result or "reclaimed" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_confirmed_no_space_reported():
|
||||
"""Prune works even when DSM doesn't report reclaimed bytes."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Utils" and method == "prune":
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"](confirmed=True)
|
||||
assert "completed" in result.lower()
|
||||
assert "not reported" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Utils" and method == "prune":
|
||||
raise SynologyError("prune failed", code=100)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"](confirmed=True)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# M-6: system_prune preview now counts unused networks
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
SAMPLE_NETWORKS_FOR_PRUNE = {
|
||||
"network": [
|
||||
# User-created, no containers attached → will be pruned
|
||||
{"name": "orphan_net", "driver": "bridge", "containers": []},
|
||||
# User-created, in use → must NOT be counted
|
||||
{
|
||||
"name": "myapp_default",
|
||||
"driver": "bridge",
|
||||
"containers": ["web", "db"],
|
||||
},
|
||||
# Built-in networks: Docker never prunes these even if empty
|
||||
{"name": "bridge", "driver": "bridge", "containers": []},
|
||||
{"name": "host", "driver": "host", "containers": []},
|
||||
{"name": "none", "driver": "null", "containers": []},
|
||||
# Another user-created empty network
|
||||
{"name": "legacy_net", "driver": "bridge", "containers": []},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_preview_counts_unused_networks() -> None:
|
||||
"""M-6: preview must enumerate user-created networks with no containers,
|
||||
skipping the three built-in networks (bridge/host/none)."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Network":
|
||||
return SAMPLE_NETWORKS_FOR_PRUNE
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"]()
|
||||
|
||||
# Two unused user-created networks; the three built-ins must not appear.
|
||||
assert "Unused networks: 2" in result
|
||||
assert "orphan_net" in result
|
||||
assert "legacy_net" in result
|
||||
# Built-in network names must not appear in the prune preview.
|
||||
assert " - bridge " not in result
|
||||
assert " - host " not in result
|
||||
assert " - none " not in result
|
||||
# Network with containers must not be listed.
|
||||
assert "myapp_default" not in result
|
||||
# Old "not counted" placeholder must be gone.
|
||||
assert "not counted" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_prune_preview_network_fetch_failure_is_nonfatal() -> None:
|
||||
"""If the network list fetch fails, the preview still works (0 networks)."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Network":
|
||||
raise SynologyError("network list failed", code=100)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_prune"]()
|
||||
# Preview still renders; networks count falls back to 0.
|
||||
assert "preview" in result.lower()
|
||||
assert "Unused networks: 0" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# system_overview
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Two-container stats snapshot used to verify aggregation arithmetic.
|
||||
SAMPLE_STATS_OVERVIEW = {
|
||||
"aaa111": {
|
||||
"id": "aaa111",
|
||||
"name": "/web",
|
||||
"cpu_stats": {
|
||||
"cpu_usage": {"total_usage": 2_000_000_000, "percpu_usage": [1, 2]},
|
||||
"system_cpu_usage": 1_000_000_000_000,
|
||||
"online_cpus": 2,
|
||||
},
|
||||
"precpu_stats": {
|
||||
"cpu_usage": {"total_usage": 1_000_000_000},
|
||||
"system_cpu_usage": 999_000_000_000,
|
||||
},
|
||||
"memory_stats": {"usage": 100 * 1024 * 1024, "limit": 1024 * 1024 * 1024},
|
||||
"networks": {
|
||||
"eth0": {"rx_bytes": 1_000_000, "tx_bytes": 500_000},
|
||||
},
|
||||
"blkio_stats": {
|
||||
"io_service_bytes_recursive": [
|
||||
{"op": "Read", "value": 10 * 1024 * 1024},
|
||||
{"op": "Write", "value": 5 * 1024 * 1024},
|
||||
]
|
||||
},
|
||||
},
|
||||
"bbb222": {
|
||||
"id": "bbb222",
|
||||
"name": "/db",
|
||||
"cpu_stats": {
|
||||
"cpu_usage": {"total_usage": 3_000_000_000, "percpu_usage": [1, 2]},
|
||||
"system_cpu_usage": 1_000_000_000_000,
|
||||
"online_cpus": 2,
|
||||
},
|
||||
"precpu_stats": {
|
||||
"cpu_usage": {"total_usage": 2_000_000_000},
|
||||
"system_cpu_usage": 999_000_000_000,
|
||||
},
|
||||
"memory_stats": {"usage": 200 * 1024 * 1024, "limit": 2 * 1024 * 1024 * 1024},
|
||||
"networks": {
|
||||
"eth0": {"rx_bytes": 2_000_000, "tx_bytes": 1_000_000},
|
||||
},
|
||||
"blkio_stats": {
|
||||
"io_service_bytes_recursive": [
|
||||
{"op": "Read", "value": 20 * 1024 * 1024},
|
||||
{"op": "Write", "value": 7 * 1024 * 1024},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_aggregates_stats():
|
||||
"""Sums CPU%, memory, net I/O, and block I/O across all containers in stats."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
return SAMPLE_STATS_OVERVIEW
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
|
||||
# Headline section exists
|
||||
assert "Docker System Overview" in result
|
||||
assert "Aggregated" in result
|
||||
|
||||
# CPU per container: (1e9 / 1e9) * 2 * 100 = 200% each → sum ≈ 400%
|
||||
assert "400.00%" in result
|
||||
|
||||
# Memory total usage = 100 MiB + 200 MiB = 300 MiB; limit = 1 GiB + 2 GiB = 3 GiB
|
||||
assert "300 MiB" in result
|
||||
assert "3.0 GiB" in result
|
||||
|
||||
# Net I/O present (rx/tx sums non-zero)
|
||||
assert "Net I/O" in result
|
||||
assert "rx " in result
|
||||
assert "tx " in result
|
||||
|
||||
# Block I/O: read = 30 MiB, write = 12 MiB
|
||||
assert "30 MiB" in result
|
||||
assert "12 MiB" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_running_vs_stopped_count():
|
||||
"""Counts running and stopped containers from the list response."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
return SAMPLE_STATS_OVERVIEW
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
|
||||
# SAMPLE_CONTAINERS has 2 running ("web", "db") and 1 stopped ("old")
|
||||
assert "running" in result
|
||||
assert "stopped" in result
|
||||
assert " 2 running" in result
|
||||
assert " 1 stopped" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_no_containers():
|
||||
"""Empty container list and empty stats — shows zero counts, no aggregation block."""
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return {"containers": []}
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
assert " 0 running" in result
|
||||
assert " 0 stopped" in result
|
||||
# No aggregation block when there are no stats entries
|
||||
assert "Aggregated" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_container_list_error_graceful():
|
||||
"""list fails, stats works → still shows aggregated stats and a warning."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
raise SynologyError("list failed", code=102)
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
return SAMPLE_STATS_OVERVIEW
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
# Counts default to 0 (list failed), but aggregation still runs
|
||||
assert "Warnings" in result
|
||||
assert "list" in result
|
||||
assert "Aggregated" in result
|
||||
assert "400.00%" in result # stats still aggregated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_overview_stats_error_graceful():
|
||||
"""stats fails, list works → still shows counts and a warning."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.system import register_system
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Container" and method == "list":
|
||||
return SAMPLE_CONTAINERS
|
||||
if api == "SYNO.Docker.Container" and method == "stats":
|
||||
raise SynologyError("stats failed", code=102)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_system(mcp, make_config(), client)
|
||||
|
||||
result = await tools["system_overview"]()
|
||||
# Counts still displayed
|
||||
assert " 2 running" in result
|
||||
assert " 1 stopped" in result
|
||||
# Warning surfaced for the stats failure
|
||||
assert "Warnings" in result
|
||||
assert "stats" in result
|
||||
# No aggregation block — stats unavailable
|
||||
assert "Aggregated" not in result
|
||||
@@ -0,0 +1,897 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-classes"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-context"
|
||||
version = "6.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-functools"
|
||||
version = "4.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jeepney"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "25.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jaraco-classes" },
|
||||
{ name = "jaraco-context" },
|
||||
{ name = "jaraco-functools" },
|
||||
{ name = "jeepney", marker = "sys_platform == 'linux'" },
|
||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.27.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp-synology-container"
|
||||
version = "0.7.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "httpx" },
|
||||
{ name = "keyring" },
|
||||
{ name = "mcp" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "keyring", specifier = ">=25.0.0" },
|
||||
{ name = "mcp", specifier = ">=1.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "rich", specifier = ">=13.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.15.10" }]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "more-itertools"
|
||||
version = "11.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468, upload-time = "2026-04-13T10:51:35.571Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441, upload-time = "2026-04-13T09:06:33.813Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/d2/206c72ad47071559142a35f71efc29eb16448a4a5ae9487230ab8e4e292b/pydantic_core-2.46.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66ccedb02c934622612448489824955838a221b3a35875458970521ef17b2f9c", size = 2117060, upload-time = "2026-04-13T09:04:47.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/2c/7a53b33f91c8b77e696b1a6aa3bed609bf9374bdc0f8dcda681bc7d922b8/pydantic_core-2.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a44f27f4d2788ef9876ec47a43739b118c5904d74f418f53398f6ced3bbcacf2", size = 1951802, upload-time = "2026-04-13T09:05:34.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/20/90e548c1f6d38800ef11c915881525770ce270d8e5e887563ff046a08674/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26a1032bcce6ca4b4670eb3f7d8195bd0a8b8f255f1307823e217ca3cfa7c27", size = 1976621, upload-time = "2026-04-13T09:04:03.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/3c/9c5810ca70b60c623488cdd80f7e9ee1a0812df81e97098b64788719860f/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b8d1412f725060527e56675904b17a2d421dddcf861eecf7c75b9dda47921a4", size = 2056721, upload-time = "2026-04-13T09:04:40.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/a3/d6e5f4cdec84278431c75540f90838c9d0a4dfe9402a8f3902073660ff28/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc3d1569edd859cabaa476cabce9eecd05049a7966af7b4a33b541bfd4ca1104", size = 2239634, upload-time = "2026-04-13T09:03:52.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/42/ef58aacf330d8de6e309d62469aa1f80e945eaf665929b4037ac1bfcebc1/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38108976f2d8afaa8f5067fd1390a8c9f5cc580175407cda636e76bc76e88054", size = 2315739, upload-time = "2026-04-13T09:05:04.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/86/c63b12fafa2d86a515bfd1840b39c23a49302f02b653161bf9c3a0566c50/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5a06d8ed01dad5575056b5187e5959b336793c6047920a3441ee5b03533836", size = 2098169, upload-time = "2026-04-13T09:07:27.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/19/b5b33a2f6be4755b21a20434293c4364be255f4c1a108f125d101d4cc4ee/pydantic_core-2.46.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:04017ace142da9ce27cafd423a480872571b5c7e80382aec22f7d715ca8eb870", size = 2170830, upload-time = "2026-04-13T09:04:39.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/ae/7559f99a29b7d440012ddb4da897359304988a881efaca912fd2f655652e/pydantic_core-2.46.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2629ad992ed1b1c012e6067f5ffafd3336fcb9b54569449fabb85621f1444ed3", size = 2203901, upload-time = "2026-04-13T09:04:01.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/0e/b0ef945a39aeb4ac58da316813e1106b7fbdfbf20ac141c1c27904355ac5/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3068b1e7bd986aebc88f6859f8353e72072538dcf92a7fb9cf511a0f61c5e729", size = 2191789, upload-time = "2026-04-13T09:06:39.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/f4/830484e07188c1236b013995818888ab93bab8fd88aa9689b1d8fd22220d/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:1e366916ff69ff700aa9326601634e688581bc24c5b6b4f8738d809ec7d72611", size = 2344423, upload-time = "2026-04-13T09:05:12.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/ba/e455c18cbdc333177af754e740be4fe9d1de173d65bbe534daf88da02ac0/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:485a23e8f4618a1b8e23ac744180acde283fffe617f96923d25507d5cade62ec", size = 2384037, upload-time = "2026-04-13T09:06:24.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/1f/b35d20d73144a41e78de0ae398e60fdd8bed91667daa1a5a92ab958551ba/pydantic_core-2.46.0-cp312-cp312-win32.whl", hash = "sha256:520940e1b702fe3b33525d0351777f25e9924f1818ca7956447dabacf2d339fd", size = 1967068, upload-time = "2026-04-13T09:05:23.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/84/4b6252e9606e8295647b848233cc4137ee0a04ebba8f0f9fb2977655b38c/pydantic_core-2.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:90d2048e0339fa365e5a66aefe760ddd3b3d0a45501e088bc5bc7f4ed9ff9571", size = 2071008, upload-time = "2026-04-13T09:05:21.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/95/d08eb508d4d5560ccbd226ee5971e5ef9b749aba9b413c0c4ed6e406d4f6/pydantic_core-2.46.0-cp312-cp312-win_arm64.whl", hash = "sha256:a70247649b7dffe36648e8f34be5ce8c5fa0a27ff07b071ea780c20a738c05ce", size = 2036634, upload-time = "2026-04-13T09:05:48.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/05/ab3b0742bad1d51822f1af0c4232208408902bdcfc47601f3b812e09e6c2/pydantic_core-2.46.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a05900c37264c070c683c650cbca8f83d7cbb549719e645fcd81a24592eac788", size = 2116814, upload-time = "2026-04-13T09:04:12.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/08/30b43d9569d69094a0899a199711c43aa58fce6ce80f6a8f7693673eb995/pydantic_core-2.46.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de8e482fd4f1e3f36c50c6aac46d044462615d8f12cfafc6bebeaa0909eea22", size = 1951867, upload-time = "2026-04-13T09:04:02.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a0/bf9a1ba34537c2ed3872a48195291138fdec8fe26c4009776f00d63cf0c8/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c525ecf8a4cdf198327b65030a7d081867ad8e60acb01a7214fff95cf9832d47", size = 1977040, upload-time = "2026-04-13T09:06:16.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/70/0ba03c20e1e118219fc18c5417b008b7e880f0e3fb38560ec4465984d471/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f14581aeb12e61542ce73b9bfef2bca5439d65d9ab3efe1a4d8e346b61838f9b", size = 2055284, upload-time = "2026-04-13T09:05:25.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/cf/1e320acefbde7fb7158a9e5def55e0adf9a4634636098ce28dc6b978e0d3/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c108067f2f7e190d0dbd81247d789ec41f9ea50ccd9265a3a46710796ac60530", size = 2238896, upload-time = "2026-04-13T09:05:01.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f5/ea8ba209756abe9eba891bb0ef3772b4c59a894eb9ad86cd5bd0dd4e3e52/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ac10967e9a7bb1b96697374513f9a1a90a59e2fb41566b5e00ee45392beac59", size = 2314353, upload-time = "2026-04-13T09:06:07.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/f8/5885350203b72e96438eee7f94de0d8f0442f4627237ca8ef75de34db1cd/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7897078fe8a13b73623c0955dfb2b3d2c9acb7177aac25144758c9e5a5265aaa", size = 2098522, upload-time = "2026-04-13T09:04:23.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/88/5930b0e828e371db5a556dd3189565417ddc3d8316bb001058168aadcf5f/pydantic_core-2.46.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e69ce405510a419a082a78faed65bb4249cfb51232293cc675645c12f7379bf7", size = 2168757, upload-time = "2026-04-13T09:07:12.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/75/63d563d3035a0548e721c38b5b69fd5626fdd51da0f09ff4467503915b82/pydantic_core-2.46.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3", size = 2202518, upload-time = "2026-04-13T09:05:44.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/53/1958eacbfddc41aadf5ae86dd85041bf054b675f34a2fa76385935f96070/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ee1547a6b8243e73dd10f585555e5a263395e55ce6dea618a078570a1e889aef", size = 2190148, upload-time = "2026-04-13T09:06:56.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/17/098cc6d3595e4623186f2bc6604a6195eb182e126702a90517236391e9ce/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c3dc68dcf62db22a18ddfc3ad4960038f72b75908edc48ae014d7ac8b391d57a", size = 2342925, upload-time = "2026-04-13T09:04:17.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/a7/abdb924620b1ac535c690b36ad5b8871f376104090f8842c08625cecf1d3/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:004a2081c881abfcc6854a4623da6a09090a0d7c1398a6ae7133ca1256cee70b", size = 2383167, upload-time = "2026-04-13T09:04:52.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c9/2ddd10f50e4b7350d2574629a0f53d8d4eb6573f9c19a6b43e6b1487a31d/pydantic_core-2.46.0-cp313-cp313-win32.whl", hash = "sha256:59d24ec8d5eaabad93097525a69d0f00f2667cb353eb6cda578b1cfff203ceef", size = 1965660, upload-time = "2026-04-13T09:06:05.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e7/1efc38ed6f2680c032bcefa0e3ebd496a8c77e92dfdb86b07d0f2fc632b1/pydantic_core-2.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:71186dad5ac325c64d68fe0e654e15fd79802e7cc42bc6f0ff822d5ad8b1ab25", size = 2069563, upload-time = "2026-04-13T09:07:14.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1e/a325b4989e742bf7e72ed35fa124bc611fd76539c9f8cd2a9a7854473533/pydantic_core-2.46.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4503f3213f723842c9a3b53955c88a9cfbd0b288cbd1c1ae933aebeec4a1b4", size = 2034966, upload-time = "2026-04-13T09:04:21.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/3b/914891d384cdbf9a6f464eb13713baa22ea1e453d4da80fb7da522079370/pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca", size = 2113349, upload-time = "2026-04-13T09:04:59.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/95/3a0c6f65e231709fb3463e32943c69d10285cb50203a2130a4732053a06d/pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940", size = 1949170, upload-time = "2026-04-13T09:06:09.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/63/d845c36a608469fe7bee226edeff0984c33dbfe7aecd755b0e7ab5a275c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb", size = 1977914, upload-time = "2026-04-13T09:04:56.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/6f/f2e7a7f85931fb31671f5378d1c7fc70606e4b36d59b1b48e1bd1ef5d916/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b", size = 2050538, upload-time = "2026-04-13T09:05:06.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/97/f4aa7181dd9a16dd9059a99fc48fdab0c2aab68307283a5c04cf56de68c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7", size = 2236294, upload-time = "2026-04-13T09:07:03.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/c1/6a5042fc32765c87101b500f394702890af04239c318b6002cfd627b710d/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655", size = 2312954, upload-time = "2026-04-13T09:06:11.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e4/566101a561492ce8454f0844ca29c3b675a6b3a7b3ff577db85ed05c8c50/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7", size = 2102533, upload-time = "2026-04-13T09:06:58.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/ac/adc11ee1646a5c4dd9abb09a00e7909e6dc25beddc0b1310ca734bb9b48e/pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644", size = 2169447, upload-time = "2026-04-13T09:04:11.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/73/408e686b45b82d28ac19e8229e07282254dbee6a5d24c5c7cf3cf3716613/pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e", size = 2200672, upload-time = "2026-04-13T09:03:54.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/3b/807d5b035ec891b57b9079ce881f48263936c37bd0d154a056e7fd152afb/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5", size = 2188293, upload-time = "2026-04-13T09:07:07.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ed/719b307516285099d1196c52769fdbe676fd677da007b9c349ae70b7226d/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae", size = 2335023, upload-time = "2026-04-13T09:04:05.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/90/8718e4ae98c4e8a7325afdc079be82be1e131d7a47cb6c098844a9531ffe/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2", size = 2377155, upload-time = "2026-04-13T09:06:18.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/dc/7172789283b963f81da2fc92b186e22de55687019079f71c4d570822502b/pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5", size = 1963078, upload-time = "2026-04-13T09:05:30.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/69/03a7ea4b6264def3a44eabf577528bcec2f49468c5698b2044dea54dc07e/pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e", size = 2068439, upload-time = "2026-04-13T09:04:57.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/eb/1c3afcfdee2ab6634b802ab0a0f1966df4c8b630028ec56a1cb0a710dc58/pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f", size = 2026470, upload-time = "2026-04-13T09:05:08.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/30/1177dde61b200785c4739665e3aa03a9d4b2c25d2d0408b07d585e633965/pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928", size = 2107447, upload-time = "2026-04-13T09:05:46.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/60/4e0f61f99bdabbbc309d364a2791e1ba31e778a4935bc43391a7bdec0744/pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e", size = 1926927, upload-time = "2026-04-13T09:06:20.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d0/67f89a8269152c1d6eaa81f04e75a507372ebd8ca7382855a065222caa80/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655", size = 1966613, upload-time = "2026-04-13T09:07:05.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/07/8dfdc3edc78f29a80fb31f366c50203ec904cff6a4c923599bf50ac0d0ff/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa", size = 2032902, upload-time = "2026-04-13T09:06:42.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/2a/111c5e8fe24f99c46bcad7d3a82a8f6dbc738066e2c72c04c71f827d8c78/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce", size = 2244456, upload-time = "2026-04-13T09:05:36.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/7c/cfc5d11c15a63ece26e148572c77cfbb2c7f08d315a7b63ef0fe0711d753/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d", size = 2294535, upload-time = "2026-04-13T09:06:01.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/2c/f0d744e3dab7bd026a3f4670a97a295157cff923a2666d30a15a70a7e3d0/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72", size = 2104621, upload-time = "2026-04-13T09:04:34.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/64/e7cc4698dc024264d214b51d5a47a2404221b12060dd537d76f831b2120a/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827", size = 2130718, upload-time = "2026-04-13T09:04:26.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a8/224e655fec21f7d4441438ad2ecaccb33b5a3876ce7bb2098c74a49efc14/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d", size = 2180738, upload-time = "2026-04-13T09:05:50.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/7b/b3025618ed4c4e4cbaa9882731c19625db6669896b621760ea95bc1125ef/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea", size = 2171222, upload-time = "2026-04-13T09:07:29.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/e3/68170aa1d891920af09c1f2f34df61dc5ff3a746400027155523e3400e89/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2", size = 2320040, upload-time = "2026-04-13T09:06:35.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/1b/5e65807001b84972476300c1f49aea2b4971b7e9fffb5c2654877dadd274/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e", size = 2377062, upload-time = "2026-04-13T09:07:39.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/03/48caa9dd5f28f7662bd52bff454d9a451f6b7e5e4af95e289e5e170749c9/pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32", size = 1951028, upload-time = "2026-04-13T09:04:20.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ed/e97ff55fe28c0e6e3cba641d622b15e071370b70e5f07c496b07b65db7c9/pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59", size = 2048519, upload-time = "2026-04-13T09:05:10.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/51/e0db8267a287994546925f252e329eeae4121b1e77e76353418da5a3adf0/pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5", size = 2026791, upload-time = "2026-04-13T09:04:37.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0c/106ed5cc50393d90523f09adcc50d05e42e748eb107dc06aea971137f02d/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:bc0e2fefe384152d7da85b5c2fe8ce2bf24752f68a58e3f3ea42e28a29dfdeb2", size = 2104968, upload-time = "2026-04-13T09:06:26.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/71/b494cef3165e3413ee9bbbb5a9eedc9af0ea7b88d8638beef6c2061b110e/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a2ab0e785548be1b4362a62c4004f9217598b7ee465f1f420fc2123e2a5b5b02", size = 1940442, upload-time = "2026-04-13T09:06:29.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3e/a4d578c8216c443e26a1124f8c1e07c0654264ce5651143d3883d85ff140/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16d45aecb18b8cba1c68eeb17c2bb2d38627ceed04c5b30b882fc9134e01f187", size = 1999672, upload-time = "2026-04-13T09:04:42.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c1/9114560468685525a21770138382fd0cb849aaf351ff2c7b97f760d121e0/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5078f6c377b002428e984259ac327ef8902aacae6c14b7de740dd4869a491501", size = 2154533, upload-time = "2026-04-13T09:04:50.868Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secretstorage"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "jeepney" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.44.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user