Compare commits

..

54 Commits

Author SHA1 Message Date
marcus 7bb9b00dcc feat: v0.7.0 — inspect_container (full-path mount source)
New tool inspect_container surfaces the full configuration of a single
container as the foundation for a future GUI-container → Compose
migration workflow. Output covers image, status, restart policy,
network mode + per-network IPs, port bindings, volume mounts, env
vars, labels, entrypoint/command, links, and capabilities.

Mount paths come 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 not directly Compose-usable. Verified live against the NAS;
quirk documented in CLAUDE.md.

DSM API: SYNO.Docker.Container/get with name JSON-encoded (action
inspect does not exist and returns code 103). Hash-prefixed names
are resolved transparently, matching the convention of the other
container tools.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:59:51 +02:00
marcus 036429e9bf feat: v0.6.0 — read build_stream log instead of dropping it (#2)
DSM emits a readable plaintext build log over the build_stream 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, leaving 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. Wall-clock budget of 210 s
(under the Claude Desktop ~4 min ceiling); on timeout the partial log
is returned with a "[build_stream: timeout — stream still open
server-side]" marker so callers know the build continues server-side.
Per-chunk ReadTimeout is treated the same way. JSON error envelope,
transport-error mapping (M-4), and SID-scrubbed HTTP-error formatting
are unchanged.

redeploy_project and create_project now parse the returned log via
_parse_build_stream_log (any line containing "Error response from
daemon:" or ending in " Error" counts as a failure). On a failed log
the tools abort immediately, surface the daemon line(s) in the result
(e.g. "Error response from daemon: manifest for nginx:9.9.9 not
found: manifest unknown"), and skip the polling step. The BUILD_FAILED
polling guard (M-5) stays as a second safety net for late failures
where the stream 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.

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.

Tests: streamed-log collection, daemon-error log, header ReadTimeout
marker, per-chunk ReadTimeout partial log, wall-clock budget
truncation, _parse_build_stream_log unit tests, redeploy/create end-
to-end behavior with a failing log.

Closes #2

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:58:55 +02:00
marcus 18fe063691 fix: v0.5.1 — pull_image API (Image/pull_start, not Registry/pull_start)
The 0.5.0 prompt mis-attributed the API: pull_start lives on
SYNO.Docker.Image, not SYNO.Docker.Registry (live DSM capture).
search and tags ARE correctly on Registry; only pull_start belongs
to Image. Registry/pull_start returns "Method does not exist".

Parameters are unchanged (repository + tag both JSON-encoded), and
the Image/list polling for completion detection is untouched.

Tests updated to assert SYNO.Docker.Image/pull_start. CLAUDE.md
DSM-quirks section consolidates the Image vs. Registry split so
this trap is documented for future surface additions. References
#3 (already closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:32:13 +02:00
marcus f27a5456f6 feat: v0.5.0 — welle B Teil 1 (registry tools: search, tags, pull)
Three new SYNO.Docker.Registry tools, reverse-engineered from a live
DSM API capture (n4s4 reference disagrees on param names and methods).

- search_registry (#5): SYNO.Docker.Registry/search v1 with JSON-encoded
  q, plus offset/limit/page_size. Renders stars, downloads, official
  flag, truncated description, and total match count. Read-only.
- list_image_tags: SYNO.Docker.Registry/tags v1 with JSON-encoded repo
  (not name — DSM live capture diverges from n4s4). Response shape is
  unusual: tag list comes back as the envelope's data field directly.
  Output capped by limit (default 50); accepts both list and dict
  response shapes defensively. Read-only.
- pull_image (#3): SYNO.Docker.Registry/pull_start v1 with both
  repository and tag JSON-encoded. Async pull — no pull_status method
  confirmed on this DSM, so completion is detected by polling
  SYNO.Docker.Image/list (2–10 s backoff, 240 s budget under the
  Claude Desktop ~4 min tool-call ceiling). Timeout returns a
  non-fatal "still running" hint. Short-circuits when the image is
  already present locally. Confirmation gate required.

Tool count: 31 → 34. CLAUDE.md confirmation list updated. New DSM
quirks documented for pull_start (no pull_status) and tags (repo
param name, top-level data array).

Closes #3
Closes #5

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:25:59 +02:00
marcus 82e8167f67 fix: v0.4.3 — inspect_image rendering polish (follow-up to #4)
Three live-NAS observations that the 0.4.2 implementation got wrong:

1. Header showed "?:?" — DSM Image/get returns image="" and tag=""
   for name:tag lookups; the response fields are unreliable. The
   header now echoes the user-supplied image_id, falling back to the
   sha256 id only when image_id itself is empty.

2. Ports printed as raw Python dicts ({'port':'22','protocol':'tcp'}).
   The ports array is a list of {port, protocol} objects — each entry
   now renders as "22/tcp".

3. Environment printed as raw Python dicts ({'key':'PATH','value':...}).
   The env array is a list of {key, value} objects — each entry now
   renders as "PATH=/usr/local/...".

cmd, entrypoint, and volumes (plain string arrays) were already
correct and are untouched. Tests updated to match the live shape;
two new tests guard the header fallback. References #4 (already
closed by 0.4.2 — no need to reopen for a rendering polish).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:13:42 +02:00
marcus 4030b8d5ee fix: v0.4.2 — inspect_image used wrong DSM parameter contract
Live DSM API capture revealed the actual SYNO.Docker.Image/get
contract: a single JSON-encoded parameter named `identity` that
accepts both `name:tag` and `sha256:<hash>` forms. The 0.4.0 code
passed `name` + `tag` + `id` and was rejected by DSM with error 114.

Response shape also corrected — the endpoint returns flat top-level
fields (image, tag, id, digest, size, virtual_size, author,
docker_version, cmd, entrypoint, env, ports, volumes), NOT the
Docker-engine inspect shape with details.Config.* + RootFS.Layers
that the previous implementation assumed. Layer rendering removed;
digest / author / docker_version / volumes are now displayed.

Pre-resolution against list_images is no longer needed — the user
input goes straight into `identity` JSON-encoded.

Closes #4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:07:53 +02:00
marcus 24b97338ba fix: v0.4.1 — remove pause_container / unpause_container (DSM unsupported)
Live test on this DSM firmware: SYNO.Docker.Container has no pause/
unpause method ("Method does not exist"). The Container Manager GUI
action menu only exposes Start / Stop / Force-Stop / Restart / Reset —
pause/resume simply isn't a feature here.

The two tools were briefly shipped in 0.4.0 (implemented by symmetry
with the verified stop call) and have now been removed rather than
left as a broken surface. The remaining lifecycle tools
(start_container, stop_container, restart_container) are unaffected.

Tool count: 33 → 31. Closes #7 (won't fix — DSM limitation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:52:52 +02:00
marcus 12d532da7b feat: v0.4.0 — welle A (8 new tools: container lifecycle, inspect_image, system_overview)
Closes #1, #4, #6, #7.

Container lifecycle (#1, #7):
- start_container, stop_container, restart_container, pause_container,
  unpause_container — all via SYNO.Docker.Container with JSON-encoded
  name parameter, routed through _resolve_container_name for hash-
  prefix resolution. stop is live-verified; the other four are
  implemented by symmetry on the same API surface.

inspect_image (#4):
- Returns full image detail (layers, env, ports, entrypoint/cmd,
  labels) via SYNO.Docker.Image/get. Accepts name:tag, registry-
  prefixed names, and bare hashes. Defensive response parsing
  handles both wrapped (details.*) and flat envelopes.

system_overview (#6):
- Aggregates CPU %, RAM, network and block I/O across all running
  containers plus running/stopped counts. No new DSM endpoint —
  composed from list + stats, reusing the container_stats CPU
  formula. Per-source errors are non-fatal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:40:11 +02:00
marcus 8adcf93b6a fix: v0.3.3 — delete_container params (error 114) + delete_project orphan guard
Bug 1 — delete_container (DSM error 114):
SYNO.Docker.Container/delete requires three parameters: name
(JSON-encoded), force=false, and preserve_profile=false. Previously
only a bare `name` string was sent, causing DSM to reject the call
with error 114. Added the two missing fields and JSON-encode name to
match the DSM convention. The connector-side running-container guard
is unchanged; force stays hard-coded to false.

Bug 2 — delete_project orphan containers:
Production test revealed that DSM does NOT reject Project/delete on a
running project — it silently removes the registration and leaves the
containers running without any project context. The previous
implementation tried to handle this via a caught SynologyError that
never actually fires. Fix: check the project status from _find_project
connector-side before issuing any DSM call; if RUNNING, return an
error pointing at stop_project. The delete request is never sent for
a running project.

The corresponding unit test (test_delete_project_running_returns_stop_hint)
was a false positive — it mocked a DSM rejection that real DSM never
produces. Replaced with test_delete_project_running_blocked_connector_side
which asserts that client.request("delete") is never called when the
project is RUNNING.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:38:36 +02:00
marcus 3f73ed0aef feat: v0.3.2 — delete_project tool
Closes the project lifecycle (create → start/stop/redeploy → delete).
The tool calls SYNO.Docker.Project/delete with the UUID JSON-encoded
as the `id` parameter (per DSM convention) and removes only the
Container Manager registration — the project folder and compose
file remain on the NAS. This mirrors DSM's own "Delete project"
behaviour, not a bug; the success message states the folder was
preserved so the user is not surprised.

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.
- No automatic stop. If the project is RUNNING and DSM rejects
  the delete, the response tells the user to `stop_project` first
  rather than silently halting containers under the guise of a
  "delete" call.
- Requires confirmed=True; preview shows name, UUID, status, full
  path, and share path so the user can verify before deleting.

Tests cover preview-only, not-found, invalid-name, happy path
(verifies the UUID is JSON-encoded in the delete call), and the
running-project rejection path that surfaces the stop_project hint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:29:09 +02:00
marcus 801dbe15dc feat: v0.3.1 — create_project tool
Adds `create_project` for registering a new Container Manager project
from a compose YAML string. Three-step flow that mirrors the DSM
"Create Project" wizard:

  1. SYNO.FileStation.CreateFolder with force_parent=true (idempotent
     — does not fail if the folder already exists, and creates missing
     intermediate directories). Without this step, Docker.Project/create
     fails with DSM error 2100.
  2. SYNO.Docker.Project/create (form-encoded POST; JSON-encoded string
     parameters per DSM convention) returns the new project UUID.
  3. trigger_build_stream + _wait_for_project_running, reusing the
     existing image-pull / start / poll machinery (including the
     BUILD_FAILED early-exit from welle 2).

Safety:
- Project-name validation (Welle-1 regex) runs before any I/O.
- Compose content is YAML-parsed and must contain a top-level
  `services` key 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.
- share_path defaults to compose_base_path + project_name (e.g.
  /volume1/docker + myapp → /docker/myapp); a caller-supplied value
  overrides it.
- Requires confirmed=True; the preview shows the resolved share path
  and the service count parsed from the compose content.
- DSM error 2100 surfaces as "target folder issue" with the attempted
  path. A build_stream failure after a successful Project/create tells
  the user the project is registered-but-not-started and points at
  redeploy_project for recovery.

Tests cover preview-only, already-exists, happy path (with parameter
JSON-encoding assertions), explicit share_path, malformed YAML,
missing services key, invalid project name, error 2100, and
build_stream failure after registration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:13:18 +02:00
marcus 13e10fa52f feat: v0.3.0 — review welle 2 (M-4, M-5, M-6)
Three resilience and honesty fixes from the v0.2.8 review. Minor
version bump because redeploy_project and system_prune return
different strings.

M-4: trigger_build_stream now converts every non-ReadTimeout
httpx.HTTPError (ConnectError, ConnectTimeout, WriteError,
RemoteProtocolError, ...) into a SynologyError with a clear
message. Previously only ReadTimeout was handled; everything else
propagated as a raw httpx exception. redeploy_project now tracks
whether stop was actually issued and, when build_stream fails after
a successful stop, tells the user the project is in STOPPED state
and recommends start_project / retry rather than the misleading
"use stop + start separately" workaround.

M-5: _wait_for_project_running exits early on BUILD_FAILED / ERROR
(new _TERMINAL_FAILURE_STATUSES frozenset). 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
now surfaces the terminal status with a BUILD_FAILED-specific hint
to update_image_tag.

M-6: system_prune preview now enumerates user-created networks
that have no containers attached (excluding the three built-in
networks bridge/host/none, which Docker never prunes). Previously
the preview noted "Unused networks: (not counted)" even though
SYNO.Docker.Utils/prune does delete them — users could lose
networks they had not been warned about.

Tests:
- 2 new dsm_client tests: ConnectError and RemoteProtocolError
  both raise SynologyError, not raw httpx exceptions.
- 2 new project tests: recovery hint after stop+build_stream
  failure (RUNNING case); old workaround retained for the
  STOPPED case where no stop was issued.
- 3 new polling tests: BUILD_FAILED and ERROR each trigger early
  exit; redeploy_project surfaces BUILD_FAILED with update_image_tag
  hint.
- 2 new system_prune preview tests: counts unused networks
  correctly, excludes built-ins; network-fetch failure is non-fatal.

245 tests pass. ruff check + ruff format clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:57:20 +02:00
marcus 6ba4c7ca92 chore: ruff cleanup — fix 7 long-standing lint findings
Mechanical, no behavior change. `ruff check src/ tests/` now passes
with zero findings.

- cli.py:147 (SIM105) — replace `try/except SynologyError/pass` around
  the cleanup logout with `contextlib.suppress(SynologyError)`.
- compose.py:271 (B007) — drop the unused `i` from the env_list
  preview-detection loop (the apply loop below still uses enumerate).
- compose.py:329 (E501) — extract `verb = "Updated" if … else "Added"`
  into a local before the return so the f-string fits in 100 cols.
- images.py:237 (E501) — extract `stopped_name = in_use_stopped[0]`
  before the return and split the message across two f-strings.
- test_auth.py:38, 127, 140 (SIM117) — combine nested `with patch(…):`
  / `with pytest.raises(…):` into single parenthesised with-statements.

236 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:15:50 +02:00
marcus 8878eda0b2 docs: CLAUDE.md — codify changelog-update rule
Add an explicit rule that every user-visible change updates
CHANGELOG.md in the same commit, under an `## [Unreleased]` heading
between releases. References the C-2 gap (0.2.7 and 0.2.8 shipped
without changelog entries) so the motivation is durable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:11:40 +02:00
marcus 4b8b1a0a6e docs: CLAUDE.md — push-retry rule and version-consistency invariant
- Document that git push to gitea.gecheckt.de occasionally returns
  Unauthorized on the first attempt and must be retried once after a
  1 s wait before reporting an auth failure.
- Codify the version-consistency invariant: pyproject.toml, uv.lock,
  and the latest CHANGELOG heading must move together;
  src/mcp_synology_container/__init__.py reads __version__ from
  importlib.metadata and is never hand-edited.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:09:30 +02:00
marcus 661460bfd9 fix: v0.2.9 — review welle 1 (C-1, C-2, M-3)
C-1: __version__ now derived from package metadata via
importlib.metadata.version() so pyproject.toml is the single source of
truth. Previously stuck at "0.1.0" since the initial release.

C-2: Backfill CHANGELOG entries for 0.2.7 and 0.2.8 (both releases had
shipped without changelog updates) and add a 0.2.9 entry covering this
welle.

M-3: Reject project names containing 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. Adds _validate_project_name
(regex ^[a-zA-Z0-9_-]+$, applied in read_compose, update_compose,
update_image_tag, update_env_var) plus parametrized tests for valid and
unsafe names and one rejection test per tool. 236 tests pass.

Also: ruff format autofix on three pre-existing files (cli.py,
config.py, test_config.py) — cosmetic only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:07:00 +02:00
marcus a1a9388d88 test: v0.2.8 — comprehensive test suite for dsm_client
Adds tests/test_dsm_client.py covering:
- _scrub_url and _error_message pure helpers
- DsmClient.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 M4 negative-cache
  cooldown behavior

Addresses C3 from the 0.2.7 review; paired with 4caac3a for 0.2.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:43 +02:00
marcus 4caac3a6c7 fix: v0.2.8 — init cooldown to prevent repeated failed-login hammering
Cache failed `_ensure_initialized` outcomes for 60 seconds so that
repeated tool calls during a credential outage (wrong password,
IP-blocked 407, DNS failure) don't keep hammering DSM. Each caller
gets the same exception raised from the cache until the cooldown
window expires, after which a fresh attempt is made.

- Adds INIT_ERROR_COOLDOWN module constant (60.0 s).
- Adds self._init_error / self._init_error_until state on DsmClient.
- Re-raises cached error inside the init lock, emits warning log on
  cache entry.

Addresses M4 from the 0.2.7 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:06:35 +02:00
marcus e17a70aecf chore: sync uv.lock to 0.2.7; ignore .claude/ local settings
- uv.lock: package version bumped 0.2.2 → 0.2.7 (was stale).
- .gitignore: exclude .claude/ (Claude Code per-user settings, not shared).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:51:18 +02:00
marcus 5b14af8ea1 chore: ruff autofix — import sorting, remove unused imports
Mechanical cleanup via `ruff check --fix` + `ruff format`:
- cli.py, test_auth.py: import sorting (isort convention)
- cli.py: remove unused AuthenticationError import in _run_setup
- config.py: remove unused `field` import
- test_auth.py: remove unused MagicMock import
- test_config.py: remove unused Path import

No functional change. All 131 tests remain green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:51:12 +02:00
marcus 72d5e13d59 fix: compose — correct operator precedence in update_env_var apply branch
The apply-side match condition was parsed as
  `(isinstance AND startswith) OR (entry == var_name)`
which evaluated the equality branch for non-string entries, diverging
from the preview-side detection logic just above. Adding parentheses
around the two string-match clauses aligns apply with preview (M1).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:38:56 +02:00
marcus 21fd8e168c fix: dsm_client — scrub session IDs, fix re-auth race, detect build_stream JSON errors
- Scrub `_sid` from URL in HTTPStatusError messages to prevent session-ID
  leaks into MCP tool responses (C1).
- Re-auth lock now double-checks the SID snapshot to avoid duplicate logins
  on concurrent 106/107/119 responses (M3).
- `trigger_build_stream` again detects immediate JSON error responses from
  DSM (regression from 0.2.6); SSE streams remain fire-and-forget (C2/M8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:37:02 +02:00
marcus ad199674e7 fix: v0.2.7 — remove -> str annotations and trim docstrings to reduce tools/list payload
FastMCP generates outputSchema for every tool with a return annotation,
roughly doubling the tools/list payload size. Multi-line docstrings with
Args/Returns sections add further bulk that Claude Desktop must parse.

- Strip -> str from all 23 @mcp.tool() functions
- Trim every tool docstring to a single descriptive line (≤100 chars)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:04:17 +02:00
marcus a1d4b1d709 docs: update README — 23 tools, delete_container, redeploy build_stream description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:52:46 +02:00
marcus 7b1d7be5d7 fix: v0.2.6 — trigger_build_stream truly fire-and-forget
Claude Desktop times out tool calls after ~4 minutes. The previous
implementation read the first SSE chunk before returning, which could
block for the entire image-pull duration.

Now: send the GET request, wait for HTTP response headers (status
check only), close the connection immediately — never read SSE events.
DSM starts the build on receipt of the request and continues
server-side. ReadTimeout on headers is caught and ignored (request
already sent). Removes the _json import added in 0.2.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:44:25 +02:00
marcus ebe3baba78 feat: v0.2.5 — redeploy via build_stream (proper DSM image pull)
Replace the delete-before-start workaround with the real mechanism:
SYNO.Docker.Project/build_stream is what the DSM "Erstellen" button calls
(confirmed via DevTools). It pulls updated images and starts the project.

DsmClient.trigger_build_stream(project_id): fires a streaming GET to
build_stream, reads the first SSE chunk to confirm DSM accepted the
request, then closes. ReadTimeout is swallowed (build running server-side).
Immediate JSON error responses are parsed and raised as SynologyError.

redeploy_project simplified from 4 steps to 3:
  1. Stop (skip for STOPPED, suppress for BUILD_FAILED)
  2. trigger_build_stream — DSM pulls images + starts project
  3. Poll for RUNNING (timeout raised from 30s → 5min for large pulls)
build_stream errors are now fatal (abort with clear message).

Removes _read_compose_images_for_project, _try_delete_image and their
json/yaml/re imports — no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:32:03 +02:00
marcus bafa327412 feat: v0.2.4 — image delete workaround + auto-version env-var update
redeploy_project: replace broken SYNO.Docker.Image/pull with a unified
4-step delete-before-start flow for all project states (RUNNING, STOPPED,
BUILD_FAILED). Reads image tags from the project's compose.yaml via
FileStation before stopping, deletes each cached image (non-fatal), then
starts the project so DSM auto-pulls the latest version. Polls for RUNNING
as before.

update_image_tag: auto-update env vars whose value equals the numeric
version prefix of the old tag when the new tag shares the same
<digits>-<suffix> pattern (e.g. JENKINS_VERSION=2.558 → 2.560 when tag
changes 2.558-jdk21 → 2.560-jdk21). Preview mode lists the pending
auto-updates. Only triggers when the var exists and the pattern matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 07:57:57 +02:00
marcus ae36a9fbac v0.2.3: scrub operator-specific details from CLAUDE.md
Remove hostnames, concrete container names, image tags, personal notes,
and the completed task backlog. Replace with generic DSM quirks reference,
implementation rules, and tool inventory — suitable for a public connector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 07:26:56 +02:00
marcus 7de4b56962 v0.2.2: BUILD_FAILED pull failure aborts redeploy with clear message
Remove contextlib.suppress from the image pull step in the BUILD_FAILED
redeploy path. A failed pull (e.g. non-existent tag) now immediately
returns an actionable error pointing to update_image_tag instead of
silently continuing and starting the project with stale/missing image.

Also bumps version 0.2.1 → 0.2.2 and adds CHANGELOG entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 07:22:36 +02:00
marcus 5cff7d8506 v0.2.1: redeploy_project post-start polling (30s timeout)
DSM starts containers asynchronously - start_project returns immediately
while containers are still initialising. Adds _wait_for_project_running:
polls SYNO.Docker.Project/list every 2s up to 30s after issuing start.
Reports RUNNING on success; emits a warning instead of failure on timeout
so callers can still verify with get_project_status.

Applies to all three redeploy paths (RUNNING, STOPPED, BUILD_FAILED).

Also bumps version 0.2.0 → 0.2.1 and adds CHANGELOG entry.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-21 07:17:36 +02:00
marcus 223075e602 Fix Jenkins-update flow: redeploy_project pull + delete_image safety + delete_container
Bug fixes from product test:
1. redeploy_project: BUILD_FAILED now includes explicit image pull (stop → pull → start)
2. delete_image: Distinguishes running vs stopped containers, suggests system_prune for stopped refs
3. New tool delete_container: Verify stopped state before deletion, confirmation required

Tests added for all three paths plus stopped-container edge cases.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-21 07:09:21 +02:00
marcus 81d5acd83e Release 0.2.0: README, CHANGELOG, version bump
- README: complete 22-tool reference table across 6 categories;
  updated feature list to reflect all additions and bug fixes
- CHANGELOG: added with [0.2.0] and [0.1.0] entries
- pyproject.toml: 0.1.0 → 0.2.0
- CLAUDE.md: corrected tool count 17 → 22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:11:08 +02:00
marcus d9f0e75d0a Fix get_container_status: clean name + dual-format response handling
DSM response has two top-level keys:
  details → Docker Engine inspect (State, NetworkSettings, Mounts)
  profile → DSM format (image, port_bindings)

_format_container_detail now reads:
  Status/Running/StartedAt from details.State
  Image               from profile.image
  IP addresses        from details.NetworkSettings.Networks
  Port bindings       from profile.port_bindings
  Mounts              from details.Mounts

Also: debug_container_response tool removed, json/sys imports cleaned up.
27 container tests all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:59:50 +02:00
marcus 2dbcc0ba5f Add debug_container_response tool for DSM response inspection
Temporary debug tool that returns the raw SYNO.Docker.Container/get
response as JSON directly in the MCP tool output — visible in chat
without needing to inspect Claude Desktop logs.

To be removed once get_container_status is fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:45:04 +02:00
marcus 7c7e63d89a Add full JSON dump to get_container_status diagnostic logging
Replaces key-only log with complete json.dumps output (up to 2000 chars)
so the actual DSM response structure is visible in Claude Desktop logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:38:00 +02:00
marcus 584d53e6e4 Fix get_container_status: clean name + dual-format response handling
- get_container_status now strips hash prefix from user input and calls
  SYNO.Docker.Container/get with the clean name (e.g. 'jenkins'), not the
  hash-prefixed form — the get endpoint accepts only the clean name
- _format_container_detail: unwraps 'container' wrapper key if present
  (DSM may return {"container": {State, Config, ...}} at the data level)
- Flat-format fallback: reads lowercase 'status'/'image' fields when
  Docker Engine nested format (State/Config) is absent
- Diagnostic stderr logging for data_keys, unwrap, status, image
- 25 container tests all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:49:30 +02:00
marcus c8cda5ef2b Fix container hash-prefix + status-aware redeploy
Bug 1: Container name hash-prefix (e.g. f93cb8b504f7_jenkins)
- _strip_hash_prefix(): strips 12-char hex prefix and leading slash
- _resolve_container_name(): looks up actual DSM name from container list
- Applied in list_containers (display), container_stats (matching),
  get_container_status/get_container_logs/exec_in_container (lookup)

Bug 2: redeploy_project DSM 2101/1202 on wrong project state
- Fetch project status before acting
- RUNNING     → stop then start
- STOPPED     → start directly (nothing to stop)
- BUILD_FAILED → suppress stop error, then start
- Other       → return error with workaround hint

36 tests all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:43:02 +02:00
marcus 6fa35e1b48 Remove pull_image + list_registries; mark Gruppen 6+7 as entfällt
DSM methods for SYNO.Docker.Image/pull and SYNO.Docker.Registry/get
did not behave as expected in production testing against the NAS.
Tools deregistered, modules deleted, tests removed, CLAUDE.md updated.
Tool count: 19 → 17.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:03:17 +02:00
marcus 5fe8f5bc73 Add pull_image + list_registries; remove Gruppe 5 (no Volume API)
- pull_image: SYNO.Docker.Image/pull with repository+tag split via
  rpartition; polls image list every 3 s until image appears, 120 s timeout
- list_registries: SYNO.Docker.Registry/get; shows name, URL, active marker
- Gruppe 5 (Volumes) removed from roadmap — SYNO.Docker.Volume does not exist
- CLAUDE.md: tool count 17 → 19, Volumes section removed
- 28 tests all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:28:45 +02:00
marcus 59f7fc1d6c Fix create_network + delete_network: POST with DevTools-confirmed format
create_network:
  - Switch from GET request() to post_request()
  - name, subnet, gateway, iprange → json.dumps(value) per DSM convention
  - disable_masquerade=json.dumps(False) added as required fixed param

delete_network:
  - Switch from GET request() to post_request() with method "remove"
    (was "delete" — wrong method name)
  - Send full network object from list response as
    networks=json.dumps([{...}]) including all DSM fields plus _key=id
    (ipv6_gateway, ipv6_subnet, ipv6_iprange, disable_masquerade with
    safe defaults for fields absent from the list response)

Tests updated: mock post_request, assert correct method/params structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:56:21 +02:00
marcus 4cee16922f Add list_networks, create_network, delete_network tools (Gruppe 4)
list_networks:
  SYNO.Docker.Network/list → shows name, driver, subnet, gateway,
  attached containers. Response key is "network" (not "networks").

create_network(name, driver, subnet, gateway, ip_range, enable_ipv6, confirmed):
  Dry-run preview without confirmed=True. Passes enable_ipv6 as
  json.dumps(bool) per N4S4 reference. Optional params (subnet,
  gateway, iprange) omitted from request when not provided.

delete_network(name, confirmed):
  Validates network exists and has no attached containers before
  deleting. Clear error listing attached container names if blocked.

15 unit tests covering all paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:42:10 +02:00
marcus 6bdd2bcb6a Add system_df and system_prune tools (Gruppe 3)
system_df:
  Assembles disk-usage report from SYNO.Docker.Image/list and
  SYNO.Docker.Container/list. Reports image count/size/reclaimable
  (images not referenced by any container), container running/stopped.
  Gracefully degrades when one API is unavailable.

system_prune:
  Without confirmed=True: lists dangling/unused images and stopped
  containers with sizes (dry-run preview).
  With confirmed=True: calls SYNO.Docker.Utils/prune and reports
  reclaimed space from the response (SpaceReclaimed field).

10 unit tests: stats counts, reclaimable detection, preview content,
confirmed execution, missing-response-field graceful handling, API error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:36:49 +02:00
marcus a8da306ce5 Add container_stats tool (Gruppe 2); remove rename_container
container_stats(container_name) calls SYNO.Docker.Container/stats,
locates the entry by name (stripping the DSM-added leading slash),
and reports:
  - CPU %  (standard Docker formula: cpu_delta / system_delta * cpus * 100)
  - Memory used / limit  (human-readable)
  - Network I/O rx / tx  (summed across all interfaces)
  - Block I/O read / write  (from io_service_bytes_recursive)

Gracefully handles first-poll (precpu system_cpu_usage absent → 0%).
7 unit tests covering: found, CPU formula, memory format, slash-strip,
not-found, API error, no-precpu fallback.

rename_container removed: DSM Container Manager offers no rename API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:29:17 +02:00
marcus 5edd051830 Fix delete_image: POST with images JSON array (DevTools-confirmed format)
DSM Container Manager rejects name+tag and sha256 id params (error 114).
Browser DevTools capture shows the correct call is:

  POST SYNO.Docker.Image / delete / version=1
  images=[{"repository":"nouchka/sqlite3","tags":["latest"]}]

Changes:
- Add DsmClient.post_request() for form-encoded POST requests
- delete_image now calls post_request with version=1 and the images
  JSON array built from the resolved repository name and tag
- Remove unused docker error-code dict from _error_message()
- Tests mock post_request and assert the images param content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:19:22 +02:00
marcus 2b1e2ead7d Fix delete_image: use sha256 image ID instead of name+tag
DSM SYNO.Docker.Image/delete returns error 114 when called with name+tag.
The API expects the sha256 hash from the image list (field "id") as the
"id" parameter.

- Look up the sha256 hash from SYNO.Docker.Image/list (already fetched
  for the in-use check), then pass params={"id": img_hash}
- Guard against missing hash with a clear error message
- Updated tests to assert id param is sent, name/tag are absent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:03:18 +02:00
marcus b6fa547eb4 Add diagnostic logging to delete_image for DSM error 114
- Log api, method, name, tag to stderr immediately before the DSM call
- Log the DSM error code on failure (visible in Claude Desktop stderr)
- Include DSM error code in the return message so it appears in chat

This makes the exact request params and the DSM code visible without
needing debug-level logging enabled, to help diagnose why error 114
("Invalid API call") is returned by SYNO.Docker.Image/delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:58:50 +02:00
marcus 0b48190f99 Anpassungen 2026-04-13 17:55:08 +02:00
marcus 24c3059e83 Fix delete_image: use rpartition for name:tag splitting
partition(":") split at the first colon, so registry-prefixed images
like "ghcr.io/open-webui/open-webui:v0.8.10" produced name="ghcr"
instead of the correct repository name, causing DSM error 114.

rpartition(":") always splits at the last colon, correctly handling:
- plain images: "nginx:1.24" → name="nginx", tag="1.24"
- namespaced: "nouchka/sqlite3:latest" → correct split
- registry URLs: "ghcr.io/foo/bar:v1" → name="ghcr.io/foo/bar", tag="v1"
- no tag: "nginx" → name="nginx", tag="latest" (fallback)

Added regression test verifying correct params sent to DSM delete API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:51:02 +02:00
marcus 06735bb447 Add list_images and delete_image tools (Gruppe 1)
- list_images: lists all local Docker images sorted by size desc,
  shows size (human-readable), creation date, in-use marker, and
  update-available marker; gracefully handles container list failure
- delete_image: accepts name:tag or image hash, blocks deletion when
  image is in use by a container, requires confirmed=True to execute;
  default shows a dry-run preview
- 16 unit tests covering all paths (mock DSM client)
- ruff format + check clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:44:57 +02:00
marcus b921e3a649 Fix compose path: strip /volumeN prefix for FileStation API
Container Manager returns raw filesystem paths (/volume1/docker/...),
but SYNO.FileStation.* APIs expect paths without the volume prefix
(/docker/...). Add _to_filestation_path() to strip /volumeN and apply
it in _find_compose_path before any FileStation call.

Also switch directory probe from getinfo (returns truthy files array
with embedded code:408 for missing paths) to list (empty files array
for non-existent directories), and apply the same prefix stripping to
the error message shown when no compose file is found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:07:47 +02:00
marcus c0257f6068 Fix compose file probe: use FileStation.List/getinfo instead of FileStation.Info/get
SYNO.FileStation.Info/get is a system-info API and returns success
regardless of whether a path exists, so the probe always returned the
first candidate (docker-compose.yml) even when only compose.yaml
was present. SYNO.FileStation.List/getinfo returns {"files": [...]}
with an empty list for non-existent paths, enabling correct detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:59:27 +02:00
marcus dac215840e Fix compose path: resolve real project path via list_projects API
_find_compose_path was constructing {compose_base_path}/{project_name}
which didn't match the actual NAS path (e.g. /volume1/docker/frostiq/jenkins).
Now calls _find_project() first and uses project["path"] as the base
directory, with the old constructed path as a fallback only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:55:34 +02:00
marcus e13324e10c Fix deadlock in lazy init: _ensure_initialized calling login via request()
login() called client.request() which called _ensure_initialized() which
tried to re-acquire _init_lock — deadlocking forever. Fix: set
_initializing=True while inside the init critical section so request()
skips the _ensure_initialized() guard when called from within init
(safe because query_api_info() already populated the API cache).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:49:26 +02:00
marcus 737c816ee7 Add stderr diagnostics and structured httpx timeout to dsm_client
- Write progress markers to stderr at each lazy-init step so Claude
  Desktop logs show exactly where a timeout occurs
- Replace flat timeout=30 integer with httpx.Timeout(connect=10,
  read=30, write=10, pool=5) to fail fast on connection issues
  instead of waiting up to 90 s across three sequential requests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:38:37 +02:00
29 changed files with 9397 additions and 480 deletions
+1
View File
@@ -0,0 +1 @@
reference/
+3
View File
@@ -17,3 +17,6 @@ __pycache__/
# Reference material (not part of this project's source)
reference/
# Claude Code local settings
.claude/
+553
View File
@@ -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 210 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
+128 -73
View File
@@ -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 (210 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
+42 -20
View File
@@ -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
View File
@@ -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",
]
+6 -1
View File
@@ -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"
+6 -6
View File
@@ -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
@@ -291,6 +290,7 @@ def serve(config_path: str | None) -> None:
# 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)
+2 -5
View File
@@ -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", {})
+355 -26
View File
@@ -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:
@@ -100,6 +114,9 @@ class DsmClient:
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,
@@ -130,20 +147,47 @@ class DsmClient:
async with self._init_lock:
if self._initialized: # re-check inside lock
return
logger.debug("Lazy init: querying API info from %s", self._base_url)
await self.query_api_info()
if self._auth_manager:
logger.debug("Lazy init: authenticating")
self._sid = await self._auth_manager.login(self)
self._initialized = True
logger.debug("Lazy init complete")
# 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
@@ -177,7 +221,14 @@ class DsmClient:
logger.debug("Querying API info from %s", url)
resp = await http.get(url, params=params)
resp.raise_for_status()
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"):
@@ -224,7 +275,12 @@ class DsmClient:
Raises:
SynologyError: On API errors.
"""
await self._ensure_initialized()
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:
@@ -250,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)
resp.raise_for_status()
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"):
@@ -266,17 +331,261 @@ 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:
self._sid = None
try:
self._sid = await self._auth_manager.login(self)
except Exception as e:
raise SynologyError(f"Re-authentication failed: {e}", code=code) from e
# 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)
except Exception as e:
raise SynologyError(f"Re-authentication failed: {e}", code=code) from e
return await self.request(api, method, version, params, _is_retry=True)
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,
@@ -321,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(
@@ -331,7 +646,14 @@ class DsmClient:
files={"file": (filename, encoded, "text/plain")},
timeout=httpx.Timeout(60.0),
)
resp.raise_for_status()
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"):
@@ -374,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))
resp.raise_for_status()
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:
+191 -80
View File
@@ -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.
"""
base = f"{config.compose_base_path}/{project_name}"
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
+462 -58
View File
@@ -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)
+352 -78
View File
@@ -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."
+425 -72
View File
@@ -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"},
# ── 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: 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(" Images pulled.")
except Exception as e:
results.append(f" Warning: pull step failed ({e}), continuing with restart.")
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."
)
return "\n".join(results)
results.append(" Build request accepted by DSM.")
# Step 2: Stop the project
results.append("Step 2/3: Stopping project...")
await client.request(
"SYNO.Docker.Project",
"stop",
params={"id": project_id},
# ── Step 3: Poll ──────────────────────────────────────────────────
results.append(
f"Step 3/3: Waiting for project to reach RUNNING state "
f"(up to {_BUILD_POLL_TIMEOUT}s)..."
)
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},
final_status = await _wait_for_project_running(
client, project_name, timeout=_BUILD_POLL_TIMEOUT
)
results.append(" Project started.")
results.append(f"\nProject '{project_name}' redeployed successfully.")
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)
+8 -2
View File
@@ -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
+19 -12
View File
@@ -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,9 +35,11 @@ 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"):
auth.resolve_credentials()
with (
patch("keyring.get_password", return_value=None),
pytest.raises(AuthenticationError, match="No credentials found"),
):
auth.resolve_credentials()
def test_resolve_credentials_from_keyring(monkeypatch):
@@ -123,9 +126,11 @@ 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"):
await auth.login(mock_client)
with (
patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)),
pytest.raises(AuthenticationError, match="2FA is required"),
):
await auth.login(mock_client)
@pytest.mark.asyncio
@@ -136,9 +141,11 @@ 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"):
await auth.login(mock_client)
with (
patch.object(auth, "resolve_credentials", return_value=("user", "pass", None)),
pytest.raises(AuthenticationError, match="no session ID"),
):
await auth.login(mock_client)
@pytest.mark.asyncio
+1 -2
View File
@@ -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
+266 -35
View File
@@ -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()
+906 -1
View File
@@ -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
+663 -3
View File
@@ -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)
+346
View File
@@ -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
+867 -5
View File
@@ -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 == []
+387
View File
@@ -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
+602
View File
@@ -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
Generated
+897
View File
@@ -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" },
]