feat(downloadstation): Phase 2 — 7 WRITE tools + DS error codes#105
Conversation
DS overloads the 400-series with different meanings than FileStation (e.g., DS 400 = file upload failed; FS 400 = invalid parameter). New core/downloadstation_errors.py carries DOWNLOADSTATION_ERROR_CODES plus a DownloadStationError exception subclass. error_from_code() dispatches to the DS map for any SYNO.DownloadStation.* API. Foundation for Phase 2 of the Download Station module — Phase 1 (READ tools) does not exercise this path because the read APIs use 100-series codes only.
… guards Code review caught two test gaps: - test_unknown_ds_code_falls_back_to_dsm_error was asserting only "exc is not None" — error_from_code never returns None, so this test couldn't fail. Tightened to verify the fallback returns a generic SynologyError and crucially NOT a DownloadStationError, so a future refactor that accidentally promotes unknown codes to DS errors fails loudly. - Added test_ds_api_105_routes_to_permission_error_not_session_expired and test_ds_api_106_routes_to_session_expired. Code 105 is the CLAUDE.md-mandated "permission denied — never re-auth" invariant; without this test a future early-return inside the DS dispatch branch could silently bypass the 100-series fall-through and break the re-auth contract for DS APIs.
Add seven WRITE-tier ToolInfo entries to MODULE_INFO and seven stub closures in register() pointing at NotImplementedError handler stubs in tasks.py / config.py. Each subsequent task replaces one stub with a working implementation. Register tests updated to assert all 12 tools (5 READ + 7 WRITE) are present with correct permission tiers.
Multipart POST helper for SYNO.DownloadStation.Task.create v1, modeled on the existing upload() pattern. Same re-auth-on-session-error retry semantics (codes 106/107/119 trigger one retry; 105 does NOT per the CLAUDE.md invariant), same _sid-masked debug logging, same ApiNotFoundError guard. Will be called by create_download in the downloadstation module when the caller supplies a torrent_file_path.
Replace the create_download stub with a working implementation that branches between URI (standard GET) and torrent/NZB file (multipart POST via create_download_task_with_file). Validates mutual-exclusion of inputs, file existence, and surfaces DSM errors as ToolError. Add 8 TestCreateDownload tests covering both paths and error cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DSM v1 Task.delete has no documented "remove task, keep files" mode — it always removes both. delete_data is exposed as a REQUIRED explicit parameter so the caller acknowledges the destructive side effect: True proceeds with the call; False refuses with a clear ToolError explaining why the safer mode isn't available. Also fixes DsmClient.request() debug logging to handle list-typed data responses (Task.delete returns a list, not a dict). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Siblings sharing a private _task_state_change helper. Regression-guard tests verify each tool calls the right DSM method (pause vs resume) — the shared helper would silently swap them if the wrong method string were threaded through.
DSM Task.edit v1 supports multiple fields depending on DSM version; the spec calls out priority and max-speed as candidates that need per-NAS verification before exposing. Phase 2 ships destination only — additional fields are explicit follow-up work once verified against a live NAS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the Task 8 stub with the real implementation of set_download_config: partial-update semantics (only supplied fields sent to DSM), no-op guard (raises ToolError when nothing supplied), DSM error propagation, and regression test coverage for API method name and parameter encoding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…dation schedule_plan length is validated client-side (reusing SCHEDULE_PLAN_LENGTH from helpers.py — same invariant format_schedule_grid enforces on the read side) so a bad plan surfaces a clear local error before the round-trip.
…file Following the same pattern as Phase 1's coverage lift — once each WRITE tool's stub is replaced with a real handler, the corresponding register() closure body needs an invocation test to walk the closure source lines in __init__.py, and the partial-update branches need targeted tests: - 7 new TestDownloadstationPhase2ToolInvocation tests mirror Phase 1's invocation pattern, covering tool_create_download / tool_delete_download / tool_pause_download / tool_resume_download / tool_edit_download / tool_set_download_config / tool_set_schedule. - 2 set_download_config tests for the emule_max_download/upload branches (existing tests only exercised bt + default_destination). - 1 set_schedule test for emule_enabled branch + 1 for DSM-error catch (existing tests covered length-validation and method-name, not the per-field non-schedule_plan branches or the SynologyError catch). Every DS module file now reports 100% line coverage; 727 repo tests pass at 96.26%.
The previous commit ran sed -i 's/#PR_PLACEHOLDER/#105/' globally, which substituted both the Phase 2 entry (correct) AND the ADR-0001 entry (wrong — that one actually shipped via #100). Restoring #100 on the ADR line. The ADR-0001 entry has had #PR_PLACEHOLDER on main since #100 itself merged — the original PR forgot the post-open substitution. This commit also closes that latent drift while fixing the regression I just introduced.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
cmeans
left a comment
There was a problem hiding this comment.
QA Round 1 — Pass
Head: 6ae8da3 (rebased onto post-#104 main; merge-base 872e873).
Local verification at 6ae8da3 (post uv sync --frozen --extra dev):
uv run pytest: 727 passed / 112 deselected / 17 warnings / 96.26% coverage — exact match against PR body claim.- All new module files at 100% line coverage (
tasks.py,config.py,__init__.py,helpers.py,core/downloadstation_errors.py,core/client.py). ruff check,ruff format --check,mypy src/— clean.- CI on 6ae8da3: 12 required SUCCESS / 3 SKIPPED incl.
vdsm integration testsSUCCESS.
Code review notes
Destructive-op safeguards (the key thing on a WRITE-tier PR):
delete_download:delete_data: boolis positional-required at the tool level (no default). Refusesdelete_data=Falsewith an explicit message because DSM v1Task.deletehas no "keep files" mode — turning a misleading-looking safety toggle into a deliberate acknowledgment of the destructive default. Emptytask_idslist rejected up front.edit_download: refuses an empty edit (nodestinationsupplied) withvalid=["destination"]so the future-field expansion path is self-documenting.set_download_config/set_schedule: partial-update semantics with explicit empty-field-set rejection;set_schedulevalidateslen(schedule_plan) == SCHEDULE_PLAN_LENGTHclient-side so the bad input never reaches DSM.
Error code map (core/downloadstation_errors.py):
- DS 400–408 each carry a short
message+ actionablesuggestiontuple. error_from_code()dispatches to the DS map via deferred local import (documented inline to avoid the obvious circular-import question)."DownloadStation" in api_namesubstring match handles bothSYNO.DownloadStation.*and futureSYNO.DownloadStation2.*(Phase 2 of #103).- Explicit regression tests for DS 105 →
SynologyPermissionError(NOT session-expired) and DS 106 →SessionExpiredError, guarding the CLAUDE.md "never re-auth on 105" invariant for DS calls — same shape as the existing Auth/FileStation guards. - Unknown DS code falls through to generic
SynologyError, NOT toDownloadStationError— verified bytest_unknown_ds_code_falls_back_to_generic_synology_error.
Multipart helper (DsmClient.create_download_task_with_file):
- Mirrors
upload_file()faithfully:_sidas query param (not form field);_sid+passwordmasked in debug logs; file re-opened on retry (closure pattern); single retry on session errors via_re_auth_callback. _open_file/try/finallyfh.close()keeps the file descriptor lifetime tight — finally clause runs afterhttpx.postreturns/raises.- Pins to DS Task.create v1 (the only documented multipart path).
- 3 test cases: happy path returns
task_id, DS 400 raisesDownloadStationError, session 106 triggers exactly one re-auth retry then succeeds with the new SID.
Debug-logging bug fix in DsmClient.request():
body["data"]was assumeddictand called.keys()unconditionally.Task.deletereturns alistand crashed the debug log call.- Now
isinstance(data, dict)branches the log line —listform logs item count. Non-functional path (debug only) but the crash would surface as aKeyError/AttributeErroron log emission, which is worse than the operation's actual result. Bonus catch.
CHANGELOG (## Unreleased / ### Added):
- Phase 2 entry slotted above the existing #104 entry (Added-section newest-first ordering).
- Bonus catch: ADR-0001 entry previously read
#PR_PLACEHOLDER(clobbered by an earlier sed substitution) — corrected to#100in the same PR. Verified#100is the right PR# (sed-substitution drift documented in commit6ae8da3). - Counts in entry —
62 new tests; every new module file at 100% line coverage; 727 repo tests pass at 96.26%— match actuals exactly (lesson from PR #104's R1 clearly stuck).
Operator opt-in (tests/integration_config.yaml.example):
downloadstation.permissionbumpedread → writewith a comment listing the Phase 2 tool surface that needs it. Staysenabled: falseby default.
Deferrals:
- DS2
negotiate_version(max_version=2)forTask.list/Task.getinfoandDS2.Task.getfallback — both still tracked at #103, carried forward unchanged from Phase 1. Task.editfield expansion beyonddestination— flagged in PR body as "follow-up for a future PR" pending live-NAS field verification.- CLAUDE.md "Version pinning" section update — defer per Phase 1 framing until post-#103.
Test-plan checkbox status
- Boxes 1–5 (auto + 100% coverage attestation): ticked pre-QA, every claim re-verified at 6ae8da3.
- Boxes 6–11 (manual smokes against a real DS-installed NAS — magnet URI create, .torrent file create, pause/resume/delete,
delete_data=Falserefusal,set_download_configround-trip,set_scheduleround-trip): out of QA scope — no DS-installed NAS available to QA today; intended as post-merge install smokes.
Findings: none. Zero blockers, zero substantive, zero observations.
Verdict: QA Pass. Applying Ready for QA Signoff as final act. Awaiting maintainer's QA Approved.
Summary
Second of three planned PRs from the DS module spec (Phase 1 was #104). Ships the WRITE-tier surface of the Download Station module plus supporting infrastructure.
New tools (7):
create_download— URI list (HTTP/FTP/magnet/etc.) or local.torrent/.nzbvia multipart POSTdelete_download— explicitdelete_data: boolrequired;delete_data=Falseis refused because DSM v1Task.deletehas no documented "keep files" modepause_download/resume_download— siblings sharing a private_task_state_changehelperedit_download—destinationonly (other DSMTask.editfields vary by DSM version)set_download_config— partial updates only; rates in KB/s, 0 = unlimitedset_schedule— 168-char weekly plan, length-validated client-side via the sameSCHEDULE_PLAN_LENGTHinvariantformat_schedule_griduses on the read sideNew infrastructure:
src/mcp_synology/core/downloadstation_errors.py— DS-specific 400-series error codes (DS 400 = "file upload failed"; FS 400 = "invalid parameter") witherror_from_code()dispatch to the DS map forSYNO.DownloadStation.*APIsSynologyPermissionErrorand DS 106 →SessionExpiredErrorso the CLAUDE.md "never re-auth on 105" invariant holds for DS calls tooDsmClient.create_download_task_with_file()multipart POST helper, mirroring the existingupload_file()pattern with full re-auth retryBonus fix:
DsmClient.request()debug logging was calling.keys()unconditionally onbody["data"], which crashed for endpoints (likeTask.delete) that return a list instead of a dict. Now guarded withisinstance(data, dict). Discovered duringdelete_downloadintegration.Phase 3 (BT search + RSS) and #103 (DS2
negotiate_version) remain open for follow-up.Key design decisions documented inline
delete_downloadrefusesdelete_data=Falserather than silently delete files. DSM v1 has no "remove task, keep files" mode; making the parameter explicit-required and refusing the unsafe value turns it from a misleading safety toggle into a deliberate acknowledgement of the destructive default.Known deferrals
Task.listandTask.getinfo— tracked at Phase 2 (downloadstation): use negotiate_version(max_version=2) for Task.list / Task.getinfo #103 (carried forward from Phase 1).get_download_info— also covered under Phase 2 (downloadstation): use negotiate_version(max_version=2) for Task.list / Task.getinfo #103.destination— needs per-NAS verification of which fields DSM actually accepts; follow-up for a future PR.Test plan
🤖 Generated with Claude Code