fix(webdav): drop anyio.Lock and add integration tests for tag exclusion

Addresses two points from the latest PR #764 review:

1. The anyio.Lock in get_excluded_file_paths bought nothing under
   anyio's cooperative multitasking model (single-threaded between
   awaits, raw set mutations are already safe). _resolve_one_tag now
   builds a local set of paths and appends it to a shared list — list
   append between awaits is safe without a lock — and the caller
   merges via set().union(*results) after the task group completes.
   This removes the cognitive overhead the reviewer flagged without
   changing the public API.

2. Adds tests/integration/test_tag_exclusion.py exercising the
   resolution pipeline end-to-end against a real Nextcloud instance:
   creates a system tag, tags a real file and a real directory,
   verifies get_excluded_file_paths resolves both via real PROPFIND +
   REPORT calls, and verifies is_path_excluded correctly classifies
   exact matches, descendants of tagged directories, and unrelated
   paths. Includes the disabled-feature short-circuit case.

Cleanup runs in reverse order (untag, delete files); per-run uuid
suffix avoids cross-run interference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-06 19:33:19 +02:00
co-authored by Claude Opus 4.7
parent d179ca8c8b
commit 35abfb2e3a
2 changed files with 215 additions and 19 deletions
+24 -19
View File
@@ -42,13 +42,15 @@ def get_excluded_tag_names() -> list[str]:
async def _resolve_one_tag(
tag_name: str,
webdav: WebDAVClient,
excluded: set[str],
lock: anyio.Lock,
results: list[set[str]],
) -> None:
"""Resolve a single tag's paths and merge into *excluded* under *lock*.
"""Resolve a single tag's paths and append them as a set to *results*.
Swallows its own exceptions so a failure for one tag does not abort
the surrounding task group (preserves fail-open per-tag semantics).
Each task writes to a distinct slot in the shared list — append from
cooperative tasks is safe under anyio (single-threaded between
awaits) without an explicit lock. Swallows its own exceptions so a
failure for one tag does not abort the surrounding task group
(preserves fail-open per-tag semantics).
"""
try:
tag = await webdav.get_tag_by_name(tag_name)
@@ -76,16 +78,17 @@ async def _resolve_one_tag(
)
return
async with lock:
for f in files:
path = _normalise_path(f["path"])
excluded.add(path)
if f.get("is_directory"):
logger.debug(
"Excluding directory %r (tag %r) — descendants will be hidden",
path,
tag_name,
)
paths: set[str] = set()
for f in files:
path = _normalise_path(f["path"])
paths.add(path)
if f.get("is_directory"):
logger.debug(
"Excluding directory %r (tag %r) — descendants will be hidden",
path,
tag_name,
)
results.append(paths)
async def get_excluded_file_paths(webdav: WebDAVClient) -> set[str]:
@@ -96,7 +99,9 @@ async def get_excluded_file_paths(webdav: WebDAVClient) -> set[str]:
Per-tag resolution is fanned out via ``anyio.create_task_group`` so
that the 2N network calls (1 PROPFIND + 1 REPORT per tag) run
concurrently rather than serially.
concurrently rather than serially. No lock is needed: each task
appends its own ``set`` to a shared list, and append is atomic
between awaits under anyio's cooperative single-threaded model.
**Failure mode is fail-open per tag**: if the systemtags endpoint is
unreachable or returns an error for a given tag, that tag is skipped
@@ -110,12 +115,12 @@ async def get_excluded_file_paths(webdav: WebDAVClient) -> set[str]:
if not tag_names:
return set()
excluded: set[str] = set()
lock = anyio.Lock()
results: list[set[str]] = []
async with anyio.create_task_group() as tg:
for tag_name in tag_names:
tg.start_soon(_resolve_one_tag, tag_name, webdav, excluded, lock)
tg.start_soon(_resolve_one_tag, tag_name, webdav, results)
excluded: set[str] = set().union(*results)
if excluded:
# `len(excluded)` counts directly-tagged entries — descendants of
# tagged directories are hidden too but resolved at check time.