fix(filestation): per-path serial for multi-path getinfo and delete#77
Conversation
Partial fix for #68 (the get_file_info half). modules/filestation/metadata.py:58 called SYNO.FileStation.List getinfo without specifying a version. On real DSM 7.x (which advertises v3 of FileStation.List) the request fell through to v3, which silently misinterprets the v2-style comma-joined `path` query param as a single literal path. DSM returned ONE synthetic record with that bogus path, and the handler's `len(files) == 1` branch rendered it as a single info card — even though the caller asked about multiple paths. Pin getinfo to v2 using the same negotiate_version(..., max_version=2) pattern Delete/CopyMove/Search already use. getinfo was missed when that sweep was done. Strong regression coverage: - tests/conftest.py: bump SYNO.FileStation.List max_version from 2 to 3 to match DSM 7.x reality. Without this bump, a regression that reverts the v2 pin would still send version=2 (default resolved to max_version=2) and the test would silently pass. - tests/modules/filestation/test_metadata.py::TestGetFileInfo:: test_request_pinned_to_v2: respx-captured request asserts api/method/version/path on the wire. Catches the regression even with v3 advertised. Also added tests/test_integration.py::TestMultiPathDelete (creates two folders, deletes in one multi-path call, verifies via list_files that BOTH are gone). For real-NAS / vdsm runs. The delete_files half of #68 is undiagnosed locally — Delete IS already pinned to v2, respx-level wire-dump shows correct comma-joined path, and the existing unit test passes. The bug only manifests on the user's actual DSM 7.1.1. Debug-level logs on a reproducer are the next step. #68 stays open until that's resolved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Closes #68 (both halves). vdsm CI on DSM 7.2.2 disproved the round-1 v2-pin hypothesis: the comma-joined path is mis-parsed by DSM as a single literal path even on v2 (proven by the new test_get_file_info_multiple_paths assertion failing in CI with `Path: /testshare,/writable`). Real fix matches the user's documented workaround: one DSM call per input path. Both get_file_info and delete_files now iterate paths and issue per-path requests, aggregating into the same response shape callers already expect. Refactor: - modules/filestation/metadata.py: get_file_info loops client.request for each path, accumulates `files[]`, renders single-info card for one result and table for multiple. - modules/filestation/operations.py: extracted _delete_one_path with the start → poll → stop async-task pattern; delete_files iterates paths and calls it. Per-share recycle-bin messaging unchanged. Tests: - tests/modules/filestation/test_metadata.py: replaced test_request_pinned_to_v2 with test_multipath_uses_per_path_serial_calls asserting N requests for N paths, each carrying a single path with no commas, all pinned to v2. - tests/vdsm/test_vdsm_integration.py: re-export TestMultiPathDelete so the new vdsm regression test runs in CI. Trade-off: N round-trips for N paths. Acceptable for typical small-N usage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Course correction — round-1 root cause was wrong; ship per-path serial insteadvdsm CI on the round-1 PR ( ``` DSM treated Same vdsm pattern almost certainly explains the What changed (commit
|
| Check | Result |
|---|---|
uv run pytest |
548 passed, 97 deselected, 96.05% coverage |
uv run ruff check src/ tests/ scripts/ |
clean |
uv run ruff format --check src/ tests/ scripts/ |
72 files already formatted |
uv run mypy src/ scripts/ |
clean |
Title updated; PR body's "What's NOT in this PR" section is now stale (the delete half IS in this PR). I'll update the body too if QA finds it useful.
Codecov on PR #77 flagged 2 missing patch lines: the `if not paths: error_response(...)` guard added to both get_file_info and delete_files in the per-path serial refactor. Without input, the per-path loop would silently no-op and either return an empty success message (delete) or fall through to a less precise "no file information returned" not_found (get_file_info). The guard short-circuits to a precise "No paths provided" not_found before any DSM call. Adds two regression tests asserting the guard fires with the expected error envelope (code, message, param, value). Patch coverage on the PR diff now 100%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cmeans
left a comment
There was a problem hiding this comment.
QA Round 1 — Two findings, both doc
The actual fix is solid. Round-1 hypothesis (pin getinfo to v2) was correctly disproven by vdsm CI on DSM 7.2.2; round-2 pivoted to per-path serial in both get_file_info and delete_files, matching the user's documented workaround on #68. The _delete_one_path extraction is clean, the empty-paths defensive guards are good additions, and the new wire-level test (test_multipath_uses_per_path_serial_calls) catches the right regression class. TestMultiPathDelete integration coverage re-exported to vdsm correctly. CHANGELOG entry and Dev's follow-up comment on #68 both accurately describe the per-path-serial approach.
Verification
| Check | Result |
|---|---|
uv run pytest |
550 passed, 100 deselected (+6 vs PR #73 — three new TestMultiPathDelete methods × 2 markers), 96.13% coverage |
uv run ruff check src/ tests/ scripts/ |
clean |
uv run ruff format --check src/ tests/ scripts/ |
72 files already formatted |
uv run mypy src/ scripts/ |
clean (30 files, strict-mode) |
Three new round-2 unit tests (test_multipath_uses_per_path_serial_calls, two *_empty_paths_list_returns_not_found) |
all pass |
git grep -n 'SYNO.FileStation.List' src/ |
getinfo now pinned to v2 alongside Delete/CopyMove/Search; per-path-serial loop wraps the request |
Required CI on 4a3e695 |
13/13 green (vdsm completed SUCCESS) |
| Issue #68 follow-up | Dev posted two comments on #68 — partial-fix and "both halves now addressed", so the issue thread is current |
Findings
| ID | Finding |
|---|---|
| F1 (substantive) | PR body is from round 1; the code is round 2. The PR body still describes the v2-pin-only approach with delete_files punted to a future PR — but the actual code, the CHANGELOG entry, and Dev's own 2026-05-01 03:42:56Z comment on issue #68 ("Update — both halves now addressed in PR #77") all correctly reflect the per-path-serial fix for both halves of #68. Specific stale parts of the PR body: (a) Summary opens "Partial fix for #68 — the get_file_info half." — both halves are fixed; (b) "Root cause" and "Diff" sections describe and show only the v2-pin one-line change — actual approach is the per-path serial loop; (c) "What's NOT in this PR: The delete_files half of #68 remains undiagnosed." — it's diagnosed and fixed; (d) "Bonus" framing on TestMultiPathDelete — it's no longer a bonus, it's the regression test for one of the actual fixes in this PR; (e) Manual test #2 references tests/modules/filestation/test_metadata.py::TestGetFileInfo::test_request_pinned_to_v2, which does not exist — the round-2 rename is test_multipath_uses_per_path_serial_calls; (f) Manual test #8's verification recipe describes how to test the v2-pin approach, not per-path serial; (g) "Verification I already ran" reports 548 passed — actual is 550. Per the project's doc-drift-is-substantive rule, blocking. Full rewrite of the PR body to match the round-2 implementation. The CHANGELOG entry is already a strong template for the rewrite. |
| F2 (substantive — minor) | CHANGELOG count drift. The CHANGELOG entry near the end reads 548 unit tests pass at 96.05% coverage. My local run on 4a3e695 shows 550 passed at 96.13%. The +2 empty-paths defensive-guard tests added in commit 4a3e695 ("test(filestation): cover empty-paths defensive guard") didn't trigger a CHANGELOG amendment — that commit changed test counts but not the prose that quotes them. Update to 550 unit tests pass at 96.13% coverage, or drop the specific number to make the entry resilient to future test additions on the same branch. Per the project's strict CHANGELOG-per-PR discipline, the round-2 commits should have included this amendment. |
Note (not a finding)
Held off on flipping the PR-body manual-test checkboxes because items #2 and #8 reference an approach and a test name that don't exist post-round-2. Will check them in round 2 once the body is rewritten — the tests themselves all pass, and the only reason boxes are still empty is the stale body.
Verdict
QA Failed Round 1. Both findings are documentation fixes; the code itself is in a clean place. Round 2 should be a single PR-body rewrite + a one-line CHANGELOG count tweak.
|
Applying QA Failed as the final act of round 1. Both findings are doc fixes: F1 is a comprehensive PR-body rewrite to match the round-2 per-path-serial approach (the code, CHANGELOG, and #68 comment thread are all current — only the PR body is stale). F2 is a one-line CHANGELOG test-count tweak (548 → 550). Code itself is in a clean place — 13/13 CI green, 550/550 pass, 96.13% coverage. |
Addresses QA F2 on PR #77. Round-2 commit `4a3e695` added the two empty-paths defensive-guard tests but didn't amend the CHANGELOG prose that quoted the previous count: 548 → 550, 96.05% → 96.13%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 2 — F1 + F2 addressed (doc-only fixes)F1 — PR body rewriteFull rewrite of the PR body to match the round-2 per-path-serial implementation. Specifically resolves all seven sub-points QA flagged:
Also added a "Round-1 → round-2 history" section so future readers can trace the v2-pin → per-path-serial pivot without needing to read the comment thread, and an "Acceptance criteria" section anchored on the user-visible behaviors closed by the PR. F2 — CHANGELOG countCommit No code changes this roundBoth findings were doc-only. The code on the branch is unchanged from the round-2 head ( Ready for round 2. |
cmeans
left a comment
There was a problem hiding this comment.
QA Round 2 — PASS
Both round-1 findings closed.
F1 — PR body rewrite
Comprehensive rewrite. Summary now opens **Closes #68 (both halves).** and accurately describes the per-path-serial fix. New "Round-1 → round-2 history" section preserves the disproven v2-pin hypothesis as part of the audit trail and quotes the actual vdsm CI failure that drove the pivot — useful for future readers wondering why the pin is still there. The "Diff at a glance" snippet now shows the per-path serial loop (not the v2-pin one-liner), and "What stays from round 1" cleanly separates the still-relevant pieces (defensive v2 pin, conftest max_version=3 bump, TestMultiPathDelete) from the abandoned hypothesis. Acceptance criteria restructured to match the actual fix.
Manual-test section is fully reworked:
- #2 now references the correct test name
test_multipath_uses_per_path_serial_calls(round-1 referenced the nonexistenttest_request_pinned_to_v2). - #3 is new: covers both empty-paths defensive guards.
- #7 is a thoughtful new structural-correctness check (
escape_multi_pathno longer imported inmetadata.py). - #8's regression-detect recipe is now accurate for the per-path-serial approach (revert
metadata.pytoescape_multi_path-style, expect "expected two DSM calls, got 1"). - #9 acknowledges that vdsm covers
TestMultiPathDeleteautomatically via the re-export.
F2 — CHANGELOG count tweak
dd7c30e flipped 548 unit tests pass at 96.05% coverage → 550 unit tests pass at 96.13% coverage. Single-line fix exactly as recommended.
Verification (re-run on dd7c30e)
| Check | Result |
|---|---|
uv run pytest |
550 passed, 100 deselected, 96.13% coverage |
uv run ruff check src/ tests/ scripts/ |
clean |
uv run ruff format --check src/ tests/ scripts/ |
72 files already formatted |
uv run mypy src/ scripts/ |
clean (30 files, strict-mode) |
Required CI on dd7c30e |
13/13 green (vdsm completed SUCCESS) |
PR-body manual tests 1–9 now flipped to checked.
Verdict
Ready for QA Signoff. Both findings closed; the documentation now matches the implementation, the CHANGELOG matches the test counts, and CI is fully green on the latest head. Final maintainer call.
|
Applying Ready for QA Signoff as the final act of round 2. F1 closed via comprehensive PR-body rewrite ( |
## Summary Patch release rolling up the post-0.5.0 fixes. Five PRs land in this release: - **#69** — Atomic state-file writes (closes #36). New `core/fs.py::atomic_write_text` helper; `save_state` and `_save_global_state` route through it. Eliminates torn-write windows on `state.yaml` / `global.yaml`. - **#71** — Atomic write for `cli/setup.py` interactive config (closes #70). Last user-visible non-atomic write site cascaded onto the same helper. - **#72** — README per-installer and per-OS download-breakdown badges. Mirrors mcp-clipboard's layout; all 9 new badges link to `cmeans/pypi-winnow-downloads` (the dogfooded service). - **#73** — Recycle-bin status probed per share (closes #37). Lazy `ensure_recycle_status` helper; self-correct on observation; clear-on-reauth via new `AuthManager.add_on_reauth_callback` API. Fixes incorrect "Recycle bin is enabled" messaging on shares with `#recycle` disabled. - **#77** — Per-path serial for multi-path `getinfo` and `delete` (closes #68). **Critical: fixes a v0.5.0 silent-no-op regression on `delete_files` with multi-path arrays** — caller would see `[+] Deleted N item(s)` listing every path, but none were actually deleted on real DSM 7.x. Same root cause on `get_file_info` (single synthetic record with comma-joined `path`). Both tools now issue one DSM call per input path. ## Files touched (per CLAUDE.md release procedure) - `pyproject.toml`: 0.5.0 → 0.5.1 - `server.json`: synced via `scripts/sync-server-json.py` (top-level + `packages[0].version`) - `uv.lock`: refreshed via `uv lock` - `CHANGELOG.md`: `## Unreleased` renamed to `## 0.5.1 (2026-05-01)`; fresh empty `## Unreleased` added above for the next cycle ## QA ### Manual tests 1. - [x] `python scripts/sync-server-json.py --check` — reports "in sync (0.5.1)". 2. - [x] `uv run pytest` — 550 passed, 96.13% coverage. 3. - [x] `uv run ruff check src/ tests/ scripts/` — clean. 4. - [x] `uv run ruff format --check src/ tests/ scripts/` — clean. 5. - [x] `uv run mypy src/ scripts/` — clean. 6. - [x] CHANGELOG diff is exactly: `## Unreleased` → `## 0.5.1 (2026-05-01)`, plus a fresh empty `## Unreleased` heading inserted above. No content was moved or rewritten — every entry under `## 0.5.1` already lived under `## Unreleased` on `main`. 7. - [x] After merge: tag-push triggers `publish.yml`. The `awk` extractor on `## <version>( |\()` matches `## 0.5.1 (2026-05-01)` correctly and the new empty `## Unreleased` is harmlessly walked past. ### Verification I already ran | Check | Result | |---|---| | `python scripts/sync-server-json.py --check` | in sync (0.5.1) | | `uv run pytest` | 550 passed, 100 deselected, 96.13% coverage | | `uv run ruff check src/ tests/ scripts/` | clean | | `uv run ruff format --check src/ tests/ scripts/` | 72 files already formatted | | `uv run mypy src/ scripts/` | clean (30 files, strict-mode) | | `git diff --stat HEAD~1` | 4 files: CHANGELOG.md, pyproject.toml, server.json, uv.lock | 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #84. Same root-cause family as #68 (delete + getinfo). DSM 7.x's `SYNO.FileStation.CopyMove start` does not honor the documented comma-joined multi-path format on v2 — a request with `path=/a,/b` is treated as a single literal path and silently no-ops while the task reports success. Symptom on `move_files(paths=[a, b])`: tool returns `Moved 2 item(s) ... Source files have been removed.` while no files actually move on disk; same shape on `copy_files`. Single-path moves worked in v0.5.1 because the comma-join is never constructed for a one-element list. The multi-path code path was never exercised by an integration test that asserted BOTH source and destination state, so the silent-no-op slipped through. Fix matches PR #77's approach for delete: extract `_copy_move_one_path` and iterate, issuing one DSM CopyMove task per input path inside `_copy_move`. `escape_multi_path` is no longer called there. `restore_from_recycle_bin` routes through `_copy_move` for its multi-path branch, so it inherits the fix automatically. Tests (4 new unit + 6 new integration; 1 strengthened): Unit: - TestCopyFiles::test_multipath_uses_per_path_serial_calls — N CopyMove start requests for N paths, each with a single path (no commas), all pinned to v2, results aggregate correctly. - TestMoveFiles::test_multipath_uses_per_path_serial_calls — same shape with remove_src=true asserted per-call. - test_copy_empty_paths_returns_not_found and test_move_empty_paths_returns_not_found — defensive guard added with the per-path refactor. Integration (also re-exported in tests/vdsm/test_vdsm_integration.py): - TestMultiPathMove (3 cases) — creates two folders, multi-path move, asserts both at dest AND both gone from source. - TestMultiPathCopy (3 cases) — same shape but asserts both at dest AND both still at source (the copy invariant). Strengthened: - TestWriteOperations::test_07_verify_move — previously-soft "should not be in this listing" comment converted to a hard `assert "original/" not in src_listing` so silent-no-op moves can't slip past dest-only checks. 558 unit tests pass at 96.18% coverage. ruff/mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary Closes #84. Same root-cause family as #68 (delete + getinfo). DSM 7.x's `SYNO.FileStation.CopyMove start` does **not** honor the documented comma-joined multi-path format on v2 — a request with `path=/a,/b` is treated as a single literal path and silently no-ops while the task reports success. Symptom on `move_files(paths=[a, b])`: tool returns `Moved 2 item(s) ... Source files have been removed.` while no files actually move on disk. Same shape on `copy_files`. Single-path moves worked in v0.5.1 because the comma-join is never constructed for a one-element list. The multi-path code path was never exercised by an integration test that asserted *both* source and destination state, so the silent-no-op slipped past the existing dest-only check. ## Why this slipped through (and what changed in coverage) - No multi-path move or copy integration test existed. `test_06_move_folder` did one single-path call; the post-#68 `TestMultiPathDelete` was added for delete only. - `test_07_verify_move` only asserted the file appeared at the destination — the source-side check was a comment, not an assertion. A silent no-op that returned a success string would have passed. This PR adds the missing multi-path tests for both move and copy and strengthens the single-path test to also assert source-side state. Going forward, any state-mutating integration test should follow the source-AND-dest pattern so silent-no-op regressions can't return. ## What changed - **Code:** Extracted `_copy_move_one_path` (mirroring `_delete_one_path` from #77) and wrapped `_copy_move` in a per-path loop. `escape_multi_path` is no longer called there. `restore_from_recycle_bin` routes through `_copy_move` for its multi-path branch, so it inherits the fix automatically. Added a defensive empty-list guard at the top of `_copy_move` so an empty `paths` list short-circuits with a clear error instead of returning a misleading "0 item(s)" success message. - **Pinned to v2** kept — same DSM v3 JSON-format issue as #77. - **Aggregated `processed_size`** across per-path tasks so the response keeps reporting the total byte count. ## Tests **Unit (4 new):** - `TestCopyFiles::test_multipath_uses_per_path_serial_calls` — N CopyMove `start` requests for N paths, each carrying a single path (no commas), all pinned to v2, aggregated response covers every input file. - `TestMoveFiles::test_multipath_uses_per_path_serial_calls` — same shape with `remove_src=true` asserted per-call. - `test_copy_empty_paths_returns_not_found` / `test_move_empty_paths_returns_not_found` — defensive guard added with the refactor. **Integration (6 new, also re-exported in `tests/vdsm/test_vdsm_integration.py`):** - `TestMultiPathMove` (3 cases): create two folders → multi-path move → assert BOTH at dest AND both gone from source. - `TestMultiPathCopy` (3 cases): same shape but assert BOTH at dest AND both still at source (the copy invariant). **Strengthened (1):** - `TestWriteOperations::test_07_verify_move` — previously-soft "should not be in this listing" comment converted to `assert "original/" not in src_listing` so silent-no-op moves can't slip past dest-only checks. 581 unit tests pass at 96.19% coverage on the rebased branch (post-#83 merge into main). ruff/mypy clean. ## Test plan - [x] `uv run pytest tests/modules/filestation/test_operations.py -v` — 33 pass (was 29) - [x] `uv run pytest` — full unit suite green (581 passed, 112 deselected, 96.19%) - [x] `uv run ruff check src/ tests/` - [x] `uv run ruff format --check src/ tests/` - [x] `uv run mypy src/` - [x] vdsm CI runs `TestMultiPathMove` + `TestMultiPathCopy` against DSM 7.2.2 and they pass - [ ] Smoke against real NAS (post-merge): two-file `move_files` actually moves both files; `get_file_info` confirms files at dest with non-zero size and absent at source 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary Closes #42. The "Version pinning" bullet under `## Key Conventions → DSM API Client` previously named only CopyMove, Delete, and Search as v2-pinned. Audit of `src/` (`grep -rn "max_version=2\|min(.*max_version, 2)"`) found two more pins the bullet didn't mention: - **`SYNO.FileStation.Upload`** (`src/mcp_synology/core/client.py:354`, inside `upload_file`) — Upload is a special-case multipart POST not routed through `request()`, so its v2 pin lives in the upload code path with `min(info.max_version, 2)`. v3 advertises a JSON body the multipart code can't speak. - **`SYNO.FileStation.List getinfo`** (`src/mcp_synology/modules/filestation/metadata.py:80`) — added by PR #77 to dodge the v3 multipath quirk that surfaced as #68. The bullet now lists all five APIs with file pointers and the rationale: *"removing any of these pins reintroduces silent failures on DSM 7.x — the v3 request format DSM advertises is metadata, not a mandate."* A future refactor can't innocently strip a pin and rediscover the bug. ## Audit verification ``` $ grep -rn "max_version=2\|min(.*max_version, 2)" src/ src/mcp_synology/modules/filestation/search.py:78: search_version = min(2, client.negotiate_version("SYNO.FileStation.Search", max_version=2)) src/mcp_synology/modules/filestation/metadata.py:80: getinfo_version = min(2, client.negotiate_version("SYNO.FileStation.List", max_version=2)) src/mcp_synology/modules/filestation/operations.py:309: copymove_version = min(2, client.negotiate_version("SYNO.FileStation.CopyMove", max_version=2)) src/mcp_synology/modules/filestation/operations.py:467: delete_version = min(2, client.negotiate_version("SYNO.FileStation.Delete", max_version=2)) src/mcp_synology/core/client.py:354: resolved_version = version if version is not None else min(info.max_version, 2) ``` 5 pins → 5 mentions in the convention. No undocumented pins remain. ## Files changed - `CLAUDE.md` — single bullet rewritten under `## Key Conventions → DSM API Client` - `CHANGELOG.md` — `### Changed` entry under `## Unreleased` ## Test plan - [x] CI green - [x] `grep -c "Upload\|List getinfo" CLAUDE.md` returns ≥1 - [x] No code change, so functional smoke not needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary - Closes **#95**: `create_folder` silently mangled multi-path arrays in v0.5.2 — DSM received `name=a,b` / `folder_path=/X,/X` as single literals and created one mangled folder while the tool reported `Created 1 folder(s)`. - Same root-cause family as #68 (delete + getinfo, fixed in #77) and #84 (move/copy, fixed in #85). `create_folder` was the last unfixed write tool in the family — missed when the broader array-flattening fix landed because there was no multipath unit test for it. - Fix matches the established per-path serial pattern: one DSM `CreateFolder` call per input path, aggregating `folders[]` from each response. `escape_multi_path` is no longer called there. - Adds `TestCreateFolder::test_multipath_uses_per_path_serial_calls` asserting (a) N create requests for N paths, (b) each request carries a single bare `name` and a single `folder_path` parent (no commas), and (c) the aggregated response names every input folder. The unit-test gap that let #95 ship is closed. - Files **#96** as the systematic test-coverage follow-up: per-path-serial multipath tests + vdsm coverage for every File Station write tool, so the fifth instance of this regression class can be caught in CI rather than in live usage. ## Test plan - [x] `uv run pytest -q` — 600 passed, 96.25% coverage (was 599 / 96.25%) - [x] `uv run ruff check src/ tests/` — clean - [x] `uv run ruff format --check src/ tests/` — clean - [x] `uv run mypy src/` — clean - [x] New multipath test fails on the pre-fix code (verified during TDD: `expected two CreateFolder calls (one per path), got 1` with `folder_path='/video/Show,/video/Show'`) - [ ] vdsm integration smoke (deferred — adds a dedicated multipath vdsm test for `create_folder` is part of #96) ## Notable The fix removes the now-unused `escape_multi_path` import from `operations.py` since no remaining function in that file calls it. The helper itself stays in `helpers.py` (still legitimate for any caller that needs path-as-form-param escaping that doesn't traffic in the array-flattening shape). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes #68 (both halves). DSM 7.x's
SYNO.FileStation.List getinfoandSYNO.FileStation.Delete startdo not honor the documented comma-joined multi-path format — even on v2. A request withpath=/a,/bis treated as a single literal path. Forgetinfothis surfaces as one synthetic record whosepathfield IS the literal comma-joined string (the handler'slen(files) == 1branch then renders it as a single info card). Fordelete_filesit surfaces as a successful task that no-ops on every input, returning[+] Deleted N item(s)with all paths listed but none removed.Fix: per-path serial calls. Both tools iterate
pathsand issue one DSM request per input path; results aggregate into the same response shape callers already expect (single info card for one path, table for multiple; per-share recycle-bin messaging unchanged for delete). Matches the user's documented workaround on #68. Trade-off: N round-trips for N paths, fine for typical small-N usage.Round-1 → round-2 history
Round 1 hypothesized v3-vs-v2 misinterpretation and shipped a one-line v2 pin on
getinfo. The newly-strengthened vdsm assertion (test_get_file_info_multiple_pathsagainst DSM 7.2.2) failed in CI on the round-1 head:That output is exactly the bug shape #68 reports — and it reproduced even on v2. So the real bug isn't a version-pin miss; DSM 7.x just doesn't accept comma-joined multi-path on these methods. Round 2 (commit
ddfac45) pivoted to per-path serial in bothget_file_infoanddelete_files. Round-2 follow-up commit4a3e695added defensive empty-paths guards (Codecov was flagging the new branches); commitdd7c30eupdated this PR's CHANGELOG test counts after that.Diff at a glance
```python
Before — get_file_info
normalized = [normalize_path(p) for p in paths]
path_param = escape_multi_path(normalized)
data = await client.request(
"SYNO.FileStation.List", "getinfo",
params={"path": path_param, ...},
)
files = data.get("files", [])
After — get_file_info
normalized = [normalize_path(p) for p in paths]
files: list[dict[str, Any]] = []
for p in normalized:
data = await client.request(
"SYNO.FileStation.List", "getinfo",
version=getinfo_version,
params={"path": p, ...},
)
files.extend(data.get("files", []))
```
delete_filesgot the same treatment via an extracted_delete_one_pathhelper that owns the start → poll → stop async-task pattern;delete_filesbecomes a thin loop over normalized paths plus the existing recycle-bin messaging.What stays from round 1
getinfoitself remains (now defensive parallelism with Delete/CopyMove/Search rather than the bug fix it claimed to be in round 1).tests/conftest.pySYNO.FileStation.List max_version=3bump stays (matches DSM 7.x reality; protects future tests from being fooled by a max-resolves-to-2 default).TestMultiPathDeleteintegration test (now re-exported on vdsm too).Acceptance criteria
get_file_inforeturns one record per input path on real DSM 7.x.delete_filesactually deletes every path in a multi-path call on real DSM 7.x.delete_filesunchanged from fix(filestation): probe recycle-bin status per share #73.not_founderror onpaths=[].### Fixedentry.QA
Manual tests
uv run pytest— 550 passed at 96.13% coverage.uv run pytest tests/modules/filestation/test_metadata.py::TestGetFileInfo::test_multipath_uses_per_path_serial_calls -v— passes; asserts N requests for N paths, each carrying a single path with no commas, all pinned to v2, results aggregate correctly.uv run pytest tests/modules/filestation/test_metadata.py::TestGetFileInfo::test_empty_paths_list_returns_not_found tests/modules/filestation/test_operations.py::TestDeleteFiles::test_delete_empty_paths_list_returns_not_found -v— both pass; defensive empty-paths guard fires with the expected error envelope.uv run ruff check src/ tests/ scripts/— clean.uv run ruff format --check src/ tests/ scripts/— clean.uv run mypy src/ scripts/— strict-mode clean.git grep -n 'escape_multi_path' src/—metadata.pyno longer imports it (per-path serial doesn't need multi-path escaping); onlyoperations.py(CopyMove + CreateFolder) andhelpers.py(the helper itself) reference it.metadata.pyback to a single multi-path call (path=escape_multi_path(normalized)), runtest_multipath_uses_per_path_serial_calls. It should fail withexpected two DSM calls (one per path), got 1. Restore.TestMultiPathDeleteintegration test against a real NAS to confirm multi-path delete actually deletes (requirestests/integration_config.yamlwith awritable_folder). vdsm has it covered automatically via the re-export.Verification I already ran
uv run pytestuv run ruff check src/ tests/ scripts/uv run ruff format --check src/ tests/ scripts/uv run mypy src/ scripts/4a3e695(the head before this round-2-doc-fix push)SUCCESS🤖 Generated with Claude Code