mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-15 23:31:05 +00:00
feat(auto_resource): reuse parsed resource ownership per batch (#529)
* perf(auto_resource): reuse historical ownership lookup per batch Share a lazy ownership index across routed processors and expire it at the end of each resource batch. Refresh changed days and their safe aliases after writes, deletes, and partial failures. Add regression coverage for scan counts, ownership, lookup scope isolation, and retry behavior. * fix(auto_resource): reconcile shared lookup across active batches * fix(auto_resource): refresh batch ownership from filesystem metadata * refactor(auto_resource): simplify batch ownership lookup Keep per-day ownership caches paired with pre-read metadata and refresh changed days before subsequent resources. Remove reverse-index bookkeeping, post-read stabilization retries, and recursive scope fallback. Handle late ELOOP errors on Python 3.13 while preserving other OS errors. Update regression tests for cross-resource freshness and retain safety and lifecycle coverage. * refactor(auto_resource): streamline batch lookup refresh * refactor(auto_resource): scope ownership cache to batch mutations Replace filesystem snapshots with one lazy historical lookup shared by resource processors. Reload only days changed by this invocation and discard the cache when it ends. Reuse canonical daily-note scans for symlink safety and Python 3.13 compatibility. Consolidate regression tests around the narrowed batch contract. Validation: Python 3.11, 3.12 and 3.13 core/plugin suites each passed 1407 tests; pre-commit --all-files and offline lookup/deletion comparison passed. * refactor(auto_resource): reuse initial ownership without refreshes * test(auto_resource): streamline ownership lookup regression coverage
This commit is contained in:
parent
46eca95bb9
commit
6ed97f033b
5 changed files with 817 additions and 25 deletions
|
|
@ -6,7 +6,7 @@ import inspect
|
|||
from ...components import R
|
||||
from ...enumeration import ComponentEnum
|
||||
from ..base_step import BaseStep
|
||||
from .base_auto_resource import BaseAutoResourceStep, _results_answer
|
||||
from .base_auto_resource import BaseAutoResourceStep, _resource_lookup_scope, _results_answer
|
||||
|
||||
_ProcessorSpec = str | dict
|
||||
_IndexedChange = tuple[int, dict]
|
||||
|
|
@ -169,8 +169,10 @@ class AutoResourceStep(BaseStep):
|
|||
route_counts = ", ".join(f"{spec['backend']}={len(batch)}" for spec, _, batch in routes)
|
||||
self.logger.info(f"[{self.name}] route changes={len(changes)} processors=({route_counts})")
|
||||
try:
|
||||
for spec, _, indexed_changes in routes:
|
||||
await self._dispatch_processor(spec, indexed_changes, result_slots)
|
||||
# Base lazily builds history in a scope shared by every modality.
|
||||
with _resource_lookup_scope(self.context):
|
||||
for spec, _, indexed_changes in routes:
|
||||
await self._dispatch_processor(spec, indexed_changes, result_slots)
|
||||
finally:
|
||||
# dispatch_steps merges the sub-batch into the shared context.
|
||||
# Downstream steps and the result hook must see the original batch.
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ import hashlib
|
|||
import re
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import frontmatter
|
||||
from watchfiles import Change
|
||||
|
||||
from ...components.runtime_context import RuntimeContext
|
||||
from ..base_step import BaseStep
|
||||
from ..file_io import refresh_day_index, validate_filename_component
|
||||
from ..file_io._path import is_relative_to, resolve_path
|
||||
|
|
@ -19,6 +21,40 @@ from ._evolve import now
|
|||
_SOURCE_RESOURCE_KEY = "source_resource"
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_UNSAFE_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]+')
|
||||
_LOOKUP_TABLE_KEY = "_auto_resource_lookup_table"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ResourceLookupTable:
|
||||
"""Initial source → day/path ownership, shared by this invocation's processors.
|
||||
|
||||
Build once without refreshes. Repeated sources use the original live lookup.
|
||||
None means history has not been read; an empty dict is a valid result.
|
||||
"""
|
||||
|
||||
owners: dict[str, dict[str, str]] | None = None
|
||||
seen_sources: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _resource_lookup_scope(context: RuntimeContext, *, reuse: bool = False):
|
||||
"""Share a router's index with processors, never with the next watch batch."""
|
||||
previous = context.get(_LOOKUP_TABLE_KEY)
|
||||
if reuse and isinstance(previous, _ResourceLookupTable):
|
||||
yield previous
|
||||
return
|
||||
existed = _LOOKUP_TABLE_KEY in context
|
||||
table = _ResourceLookupTable()
|
||||
context[_LOOKUP_TABLE_KEY] = table
|
||||
try:
|
||||
yield table
|
||||
finally:
|
||||
table.owners = None
|
||||
table.seen_sources.clear()
|
||||
if existed:
|
||||
context[_LOOKUP_TABLE_KEY] = previous
|
||||
else:
|
||||
del context[_LOOKUP_TABLE_KEY]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -101,6 +137,7 @@ def _sanitize_note_name(raw: str, fallback: str) -> str:
|
|||
class BaseAutoResourceStep(BaseStep):
|
||||
"""Shared source-linked daily-note lifecycle for resource processors."""
|
||||
|
||||
_resource_lookup: _ResourceLookupTable | None = None
|
||||
resource_fallback = False
|
||||
resource_suffixes: frozenset[str] = frozenset()
|
||||
router_inherit_keys = frozenset({"file_store", "language"})
|
||||
|
|
@ -230,11 +267,24 @@ class BaseAutoResourceStep(BaseStep):
|
|||
return None
|
||||
|
||||
async def _list_resource_note(self, day: str, file_path: str) -> dict | None:
|
||||
notes = await self._list_daily_notes(day)
|
||||
return self._find_resource_note(notes, file_path)
|
||||
|
||||
async def _lookup_resource_note_path(self, day: str, file_path: str) -> str | None:
|
||||
"""Reuse the initial path before processing a source, not after writing it."""
|
||||
if self._resource_lookup is not None:
|
||||
assert self._resource_lookup.owners is not None
|
||||
source = self._source_resource_link(file_path)
|
||||
return self._resource_lookup.owners.get(source, {}).get(day)
|
||||
note = await self._list_resource_note(day, file_path)
|
||||
return str(note["path"]) if note is not None else None
|
||||
|
||||
async def _list_daily_notes(self, day: str) -> list[dict]:
|
||||
"""Use the configured daily_list job for both history and targeted checks."""
|
||||
list_response = await self.run_job("daily_list", date=day)
|
||||
if not list_response.success:
|
||||
raise RuntimeError(f"daily_list failed: {list_response.answer}")
|
||||
notes = list_response.metadata.get("notes") or []
|
||||
return self._find_resource_note(notes, file_path)
|
||||
return list_response.metadata.get("notes") or []
|
||||
|
||||
def _daily_note_days(self) -> list[str]:
|
||||
"""Return safe, deterministic daily subdirectories available for lookup."""
|
||||
|
|
@ -259,12 +309,31 @@ class BaseAutoResourceStep(BaseStep):
|
|||
return sorted(days)
|
||||
|
||||
async def _find_loose_resource_day(self, file_path: str) -> str | None:
|
||||
"""Find the single daily-card owner for a root-level resource."""
|
||||
matches: list[tuple[str, str]] = []
|
||||
for day in self._daily_note_days():
|
||||
note = await self._list_resource_note(day, file_path)
|
||||
if note is not None:
|
||||
matches.append((day, str(note["path"])))
|
||||
"""Use initial ownership once per source; repeat events retain the original lookup."""
|
||||
assert self.context is not None
|
||||
self._resource_lookup = None
|
||||
with _resource_lookup_scope(self.context, reuse=True) as table:
|
||||
if file_path in table.seen_sources:
|
||||
matches = []
|
||||
for day in self._daily_note_days():
|
||||
note = await self._list_resource_note(day, file_path)
|
||||
if note is not None:
|
||||
matches.append((day, str(note["path"])))
|
||||
else:
|
||||
table.seen_sources.add(file_path)
|
||||
if table.owners is None:
|
||||
owners = {}
|
||||
for day in self._daily_note_days():
|
||||
for note in await self._list_daily_notes(day):
|
||||
source = str(note.get(_SOURCE_RESOURCE_KEY, "")).strip()
|
||||
if source:
|
||||
# Preserve daily_list's first match within each day.
|
||||
owners.setdefault(source, {}).setdefault(day, str(note["path"]))
|
||||
# Publish only after the entire initial scan succeeds.
|
||||
table.owners = owners
|
||||
self._resource_lookup = table
|
||||
source = self._source_resource_link(file_path)
|
||||
matches = sorted(table.owners.get(source, {}).items())
|
||||
if len(matches) > 1:
|
||||
paths = ", ".join(path for _, path in matches)
|
||||
raise RuntimeError(f"Multiple daily resource notes claim {file_path}: {paths}")
|
||||
|
|
@ -272,9 +341,8 @@ class BaseAutoResourceStep(BaseStep):
|
|||
|
||||
async def _prepare_resource_note(self, day: str, file_path: str, note_stem: str) -> _ResourceNoteState:
|
||||
"""Find the owned note or allocate a safe path before the first write."""
|
||||
note = await self._list_resource_note(day, file_path)
|
||||
if note is not None:
|
||||
note_path = str(note["path"])
|
||||
note_path = await self._lookup_resource_note_path(day, file_path)
|
||||
if note_path is not None:
|
||||
return _ResourceNoteState(path=note_path, created=False, before_bytes=self._note_bytes(note_path))
|
||||
|
||||
_, note_path = self._unique_daily_note_path(day, note_stem, file_path, current_path="")
|
||||
|
|
@ -435,8 +503,8 @@ class BaseAutoResourceStep(BaseStep):
|
|||
return note_path
|
||||
|
||||
async def _handle_delete(self, file_path: str, date_str: str, note_stem: str) -> None:
|
||||
note = await self._list_resource_note(date_str, file_path)
|
||||
if note is None:
|
||||
note_rel = await self._lookup_resource_note_path(date_str, file_path)
|
||||
if note_rel is None:
|
||||
self.context.response.success = True
|
||||
self.context.response.answer = f"No linked resource note to delete: {file_path}"
|
||||
self.context.response.metadata.update(
|
||||
|
|
@ -452,7 +520,6 @@ class BaseAutoResourceStep(BaseStep):
|
|||
self.logger.info(f"[{self.name}] delete skipped; no owned note file_path={file_path}")
|
||||
return
|
||||
|
||||
note_rel = str(note["path"])
|
||||
note_abs = self.workspace_path / note_rel
|
||||
note_existed = note_abs.is_file()
|
||||
self.logger.info(f"[{self.name}] delete start note={note_rel}")
|
||||
|
|
@ -491,6 +558,7 @@ class BaseAutoResourceStep(BaseStep):
|
|||
|
||||
async def _handle_change(self, file_path: str, raw_change) -> dict:
|
||||
assert self.context is not None
|
||||
self._resource_lookup = None
|
||||
# Handlers write item-scoped fields into the shared response. Start each
|
||||
# change with a fresh mapping so one result cannot inherit another's metadata.
|
||||
self.context.response.metadata = {}
|
||||
|
|
@ -587,6 +655,12 @@ class BaseAutoResourceStep(BaseStep):
|
|||
}
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
with _resource_lookup_scope(self.context, reuse=True):
|
||||
return await self._execute_changes()
|
||||
|
||||
async def _execute_changes(self):
|
||||
"""Process a sub-batch within the router's or a standalone lookup scope."""
|
||||
assert self.context is not None
|
||||
changes = self.context.get("changes")
|
||||
if not isinstance(changes, list):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Daily-note helpers: session_id validation + day-index rebuild."""
|
||||
|
||||
import datetime
|
||||
import errno
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
|
|
@ -73,16 +74,41 @@ def scan_notes(workspace_dir: Path, date: str, daily_dir: str) -> list[dict]:
|
|||
|
||||
{"path": str, "metadata": dict}
|
||||
"""
|
||||
date_dir, err = resolve_path(workspace_dir, f"{daily_dir}/{date}")
|
||||
if err or date_dir is None:
|
||||
logger.info(f"scan_notes skipped invalid daily path date={date!r} daily_dir={daily_dir!r} error={err!r}")
|
||||
return []
|
||||
if not date_dir.is_dir():
|
||||
try:
|
||||
date_dir, err = resolve_path(workspace_dir, f"{daily_dir}/{date}")
|
||||
if err or date_dir is None:
|
||||
logger.info(f"scan_notes skipped invalid daily path date={date!r} daily_dir={daily_dir!r} error={err!r}")
|
||||
return []
|
||||
if not date_dir.is_dir():
|
||||
return []
|
||||
entries = sorted(date_dir.iterdir())
|
||||
except RuntimeError:
|
||||
# Path.resolve reports symlink loops this way before Python 3.13.
|
||||
return []
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ELOOP:
|
||||
return []
|
||||
raise
|
||||
out: list[dict] = []
|
||||
for md_path in sorted(p for p in date_dir.iterdir() if p.is_file() and p.suffix == ".md"):
|
||||
for md_path in entries:
|
||||
if md_path.suffix != ".md":
|
||||
continue
|
||||
try:
|
||||
post = frontmatter.loads(md_path.read_text(encoding="utf-8"))
|
||||
target = md_path
|
||||
if md_path.is_symlink():
|
||||
target, err = resolve_path(workspace_dir, str(md_path))
|
||||
if err or target is None:
|
||||
continue
|
||||
if not target.is_file():
|
||||
continue
|
||||
except RuntimeError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ELOOP:
|
||||
continue
|
||||
raise
|
||||
try:
|
||||
post = frontmatter.loads(target.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
out.append(
|
||||
|
|
|
|||
555
tests/unit/test_auto_resource_batch_lookup.py
Normal file
555
tests/unit/test_auto_resource_batch_lookup.py
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
"""Reuse initial ownership for distinct resources and live lookup for repeated sources."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections import Counter
|
||||
from unittest.mock import patch
|
||||
|
||||
import frontmatter
|
||||
import pytest
|
||||
|
||||
from reme.components import R
|
||||
from reme.components.runtime_context import RuntimeContext
|
||||
from reme.schema import Response
|
||||
from reme.steps.evolve import base_auto_resource as resource_module
|
||||
from reme.steps.evolve.auto_resource import AutoResourceStep
|
||||
from reme.steps.evolve.auto_text_resource import AutoTextResourceStep
|
||||
from reme.steps.evolve.base_auto_resource import BaseAutoResourceStep
|
||||
|
||||
from .auto_resource_test_support import FakeVisionModel, caption_json, image_bytes
|
||||
|
||||
pytest_plugins = ("unit.auto_resource_test_plugin",)
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
_HISTORY_DAYS = ("2025-12-01", "2025-12-02", "2025-12-03")
|
||||
|
||||
|
||||
def _seed_history(env) -> None:
|
||||
"""Create older days untouched by the resource changes under test."""
|
||||
for day in _HISTORY_DAYS:
|
||||
env.write_note(f"daily/{day}/archive.md", f"[[resource/archive-{day}.txt]]")
|
||||
|
||||
|
||||
def _observe_history(env, monkeypatch) -> Counter:
|
||||
"""Observe directory walks, parsing, and daily_list, not every possible standalone stat call."""
|
||||
calls = Counter()
|
||||
daily_root = (env.workspace / "daily").resolve()
|
||||
history_text = {
|
||||
path.read_text(encoding="utf-8"): day
|
||||
for day in _HISTORY_DAYS
|
||||
if (path := daily_root / day / "archive.md").is_file()
|
||||
}
|
||||
watched_directories = {
|
||||
str(daily_root): "history_enumerations",
|
||||
**{str(daily_root / day): ("enumerate", day) for day in _HISTORY_DAYS},
|
||||
}
|
||||
original_listdir = os.listdir
|
||||
original_scandir = os.scandir
|
||||
original_loads = frontmatter.loads
|
||||
daily_list = env.app_context.jobs["daily_list"]
|
||||
|
||||
def record_enumeration(path):
|
||||
if isinstance(path, (str, bytes, os.PathLike)):
|
||||
key = watched_directories.get(os.fsdecode(path))
|
||||
if key is not None:
|
||||
calls[key] += 1
|
||||
|
||||
def counted_listdir(path="."):
|
||||
record_enumeration(path)
|
||||
return original_listdir(path)
|
||||
|
||||
def counted_scandir(path="."):
|
||||
record_enumeration(path)
|
||||
return original_scandir(path)
|
||||
|
||||
def counted_loads(text, *args, **kwargs):
|
||||
if isinstance(text, str) and text in history_text:
|
||||
calls[("parse", history_text[text])] += 1
|
||||
return original_loads(text, *args, **kwargs)
|
||||
|
||||
async def counted_daily_list(**kwargs):
|
||||
calls[("daily_list", kwargs["date"])] += 1
|
||||
return await daily_list(**kwargs)
|
||||
|
||||
# Path.iterdir uses listdir or scandir depending on the Python version.
|
||||
# Return the original iterator unchanged, without wrapping DirEntry objects.
|
||||
monkeypatch.setattr(os, "listdir", counted_listdir)
|
||||
monkeypatch.setattr(os, "scandir", counted_scandir)
|
||||
monkeypatch.setattr(frontmatter, "loads", counted_loads)
|
||||
monkeypatch.setitem(env.app_context.jobs, "daily_list", counted_daily_list)
|
||||
return calls
|
||||
|
||||
|
||||
def _assert_history_reads(calls, count=1) -> None:
|
||||
assert calls["history_enumerations"] == count
|
||||
for day in _HISTORY_DAYS:
|
||||
assert calls[("enumerate", day)] == count
|
||||
assert calls[("daily_list", day)] == count
|
||||
assert calls[("parse", day)] == count
|
||||
|
||||
|
||||
def _model() -> FakeVisionModel:
|
||||
return FakeVisionModel(caption_json("caption", "A sample image", "A red square."))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
|
||||
@pytest.mark.parametrize("resource_count", [1, 8])
|
||||
@pytest.mark.parametrize("write_notes", [False, True], ids=["missing-deletions", "real-updates"])
|
||||
async def test_loose_batch_reads_history_once(
|
||||
routed,
|
||||
resource_count,
|
||||
write_notes,
|
||||
auto_resource_env,
|
||||
monkeypatch,
|
||||
):
|
||||
"""More resources, including actual note writes, must not cause another history walk."""
|
||||
env = auto_resource_env
|
||||
_seed_history(env)
|
||||
changes = []
|
||||
notes = []
|
||||
for index in range(resource_count):
|
||||
source = f"resource/photo-{index}.png"
|
||||
if write_notes:
|
||||
env.write_binary(source, image_bytes())
|
||||
notes.append(env.write_note(f"daily/2026-01-01/photo-{index}.md", f"[[{source}]]"))
|
||||
changes.append({"change": "modified" if write_notes else "deleted", "path": source})
|
||||
before = {note: note.read_bytes() for note in notes}
|
||||
calls = _observe_history(env, monkeypatch)
|
||||
|
||||
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-01"):
|
||||
response = await env.run(env.processor(_model(), routed=routed), changes)
|
||||
|
||||
assert response.success is True
|
||||
assert response.metadata["processed"] == resource_count
|
||||
results = response.metadata["results"]
|
||||
assert [result["path"] for result in results] == [item["path"] for item in changes]
|
||||
assert all(result["metadata"]["action"] == ("modified" if write_notes else "skipped") for result in results)
|
||||
_assert_history_reads(calls)
|
||||
if write_notes:
|
||||
assert all(note.read_bytes() != previous for note, previous in before.items())
|
||||
assert all(result["metadata"]["modified"] for result in results)
|
||||
# All existing notes share one already-read day. Pre-write lookup must
|
||||
# reuse it; normal day-index rebuilds are not daily_list calls.
|
||||
assert calls[("daily_list", "2026-01-01")] == 1
|
||||
|
||||
|
||||
async def test_mixed_processors_share_history_and_keep_result_order(auto_resource_env, monkeypatch):
|
||||
"""Both processors reuse the batch lookup while returning results in input order."""
|
||||
env = auto_resource_env
|
||||
_seed_history(env)
|
||||
text_note = env.write_note("daily/2026-01-01/report.md", "[[resource/report.txt]]")
|
||||
image_note = env.write_note("daily/2026-01-01/photo.md", "[[resource/photo.png]]")
|
||||
calls = _observe_history(env, monkeypatch)
|
||||
changes = [
|
||||
{"change": "deleted", "path": "resource/report.txt"},
|
||||
{"change": "deleted", "path": "resource/photo.png"},
|
||||
]
|
||||
|
||||
response = await env.run(env.processor(_model(), routed=True), changes)
|
||||
|
||||
assert response.success is True
|
||||
results = response.metadata["results"]
|
||||
assert [result["path"] for result in results] == [item["path"] for item in changes]
|
||||
assert all(result["metadata"]["action"] == "deleted" for result in results)
|
||||
assert not text_note.exists()
|
||||
assert not image_note.exists()
|
||||
_assert_history_reads(calls)
|
||||
assert calls[("daily_list", "2026-01-01")] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("routed", [False, True], ids=["text-step", "text-only-router"])
|
||||
async def test_text_only_batch_uses_history_without_image_processor(routed, auto_resource_env, monkeypatch):
|
||||
"""A text-only pipeline can reuse history without configuring an image processor."""
|
||||
env = auto_resource_env
|
||||
_seed_history(env)
|
||||
env.app_context.registry = R.copy()
|
||||
notes = [env.write_note(f"daily/2026-01-01/report-{i}.md", f"[[resource/report-{i}.txt]]") for i in range(3)]
|
||||
calls = _observe_history(env, monkeypatch)
|
||||
options = {"app_context": env.app_context, "file_store": env.file_store}
|
||||
step = (
|
||||
AutoResourceStep(**options, dispatch_steps=["auto_text_resource_step"])
|
||||
if routed
|
||||
else AutoTextResourceStep(**options)
|
||||
)
|
||||
|
||||
response = await env.run(step, [{"change": "deleted", "path": f"resource/report-{i}.txt"} for i in range(3)])
|
||||
|
||||
assert response.success is True
|
||||
assert all(not note.exists() for note in notes)
|
||||
_assert_history_reads(calls)
|
||||
assert calls[("daily_list", "2026-01-01")] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dated", [False, True], ids=["loose-root", "dated-resource"])
|
||||
async def test_new_image_uses_live_post_write_resolution(dated, auto_resource_env, monkeypatch):
|
||||
"""Only dated input needs a pre-write daily_list; both paths read the newly written note."""
|
||||
env = auto_resource_env
|
||||
_seed_history(env)
|
||||
prefix = "resource/2026-01-01" if dated else "resource"
|
||||
source = env.write_binary(f"{prefix}/caption.png", image_bytes())
|
||||
calls = _observe_history(env, monkeypatch)
|
||||
plain_call = FakeVisionModel.__call__
|
||||
|
||||
async def observe_pre_write(model, messages, **kwargs):
|
||||
assert calls[("daily_list", "2026-01-01")] == (1 if dated else 0)
|
||||
return await plain_call(model, messages, **kwargs)
|
||||
|
||||
monkeypatch.setattr(FakeVisionModel, "__call__", observe_pre_write)
|
||||
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-01"):
|
||||
response = await env.run(
|
||||
env.processor(_model(), routed=True),
|
||||
[{"change": "added", "path": str(source)}],
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
path = response.metadata["results"][0]["metadata"]["path"]
|
||||
assert path == "daily/2026-01-01/caption.md"
|
||||
assert (env.workspace / path).is_file()
|
||||
_assert_history_reads(calls, 0 if dated else 1)
|
||||
assert calls[("daily_list", "2026-01-01")] == (2 if dated else 1)
|
||||
|
||||
|
||||
async def test_reused_step_and_context_rebuild_history_next_invocation(auto_resource_env):
|
||||
"""External edits between invocations are visible; batch state never survives the call."""
|
||||
env = auto_resource_env
|
||||
original = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
|
||||
step = env.processor(_model(), routed=True)
|
||||
changes = [{"change": "deleted", "path": "resource/photo.png"}]
|
||||
context = RuntimeContext(changes=changes, user_value="preserve")
|
||||
context_keys = set(context.data)
|
||||
|
||||
first = await step(context)
|
||||
assert first.success is True
|
||||
assert first.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/original.md"
|
||||
assert not original.exists()
|
||||
assert set(context.data) == context_keys
|
||||
assert context["changes"] is changes
|
||||
assert context["user_value"] == "preserve"
|
||||
|
||||
recreated = env.write_note("daily/2026-01-02/recreated.md", "[[resource/photo.png]]")
|
||||
second = await step(context)
|
||||
|
||||
assert second.success is True
|
||||
assert second.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-02/recreated.md"
|
||||
assert second.metadata["results"][0]["metadata"]["action"] == "deleted"
|
||||
assert not recreated.exists()
|
||||
assert set(context.data) == context_keys
|
||||
assert context["changes"] is changes
|
||||
assert context["user_value"] == "preserve"
|
||||
|
||||
|
||||
async def test_create_update_delete_recreate_maintains_ownership_across_midnight(
|
||||
auto_resource_env,
|
||||
monkeypatch,
|
||||
):
|
||||
"""A card keeps its original day until deletion permits a fresh card on today's date."""
|
||||
env = auto_resource_env
|
||||
env.write_binary("resource/caption.png", image_bytes())
|
||||
clock = {"day": "2026-01-01"}
|
||||
refresh = resource_module.refresh_day_index
|
||||
|
||||
async def refresh_and_advance(*args, **kwargs):
|
||||
result = await refresh(*args, **kwargs)
|
||||
clock["day"] = "2026-01-02"
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(BaseAutoResourceStep, "_today", lambda _step: clock["day"])
|
||||
monkeypatch.setattr(resource_module, "refresh_day_index", refresh_and_advance)
|
||||
response = await env.run(
|
||||
env.processor(_model(), routed=True),
|
||||
[{"change": change, "path": "resource/caption.png"} for change in ("added", "modified", "deleted", "added")],
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
metadata = [result["metadata"] for result in response.metadata["results"]]
|
||||
assert [item["action"] for item in metadata] == ["added", "modified", "deleted", "added"]
|
||||
assert [item["path"] for item in metadata] == [
|
||||
"daily/2026-01-01/caption.md",
|
||||
"daily/2026-01-01/caption.md",
|
||||
"daily/2026-01-01/caption.md",
|
||||
"daily/2026-01-02/caption.md",
|
||||
]
|
||||
assert not (env.workspace / "daily/2026-01-01/caption.md").exists()
|
||||
assert (env.workspace / "daily/2026-01-02/caption.md").is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("first_change", ["added", "deleted"])
|
||||
async def test_repeated_source_retries_after_partial_write_or_delete(
|
||||
first_change,
|
||||
auto_resource_env,
|
||||
monkeypatch,
|
||||
):
|
||||
"""A repeated source uses live lookup even when its previous event failed after mutation."""
|
||||
env = auto_resource_env
|
||||
env.write_binary("resource/caption.png", image_bytes())
|
||||
if first_change == "deleted":
|
||||
env.write_note("daily/2026-01-01/caption.md", "[[resource/caption.png]]")
|
||||
clock = {"day": "2026-01-01"}
|
||||
refresh = resource_module.refresh_day_index
|
||||
refresh_calls = 0
|
||||
|
||||
async def fail_first_refresh(*args, **kwargs):
|
||||
nonlocal refresh_calls
|
||||
refresh_calls += 1
|
||||
if refresh_calls == 1:
|
||||
clock["day"] = "2026-01-02"
|
||||
raise RuntimeError("day index unavailable after disk change")
|
||||
return await refresh(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(BaseAutoResourceStep, "_today", lambda _step: clock["day"])
|
||||
monkeypatch.setattr(resource_module, "refresh_day_index", fail_first_refresh)
|
||||
next_change = "modified" if first_change == "added" else "added"
|
||||
response = await env.run(
|
||||
env.processor(_model(), routed=True),
|
||||
[{"change": change, "path": "resource/caption.png"} for change in (first_change, next_change)],
|
||||
)
|
||||
|
||||
failed, recovered = response.metadata["results"]
|
||||
assert response.success is False
|
||||
assert response.metadata["modified"] is True
|
||||
assert failed["success"] is False
|
||||
assert failed["metadata"]["modified"] is True
|
||||
assert "day index unavailable" in failed["metadata"]["error"]
|
||||
assert recovered["success"] is True
|
||||
day = "2026-01-01" if first_change == "added" else "2026-01-02"
|
||||
assert recovered["metadata"]["path"] == f"daily/{day}/caption.md"
|
||||
assert recovered["metadata"]["created"] is (first_change == "deleted")
|
||||
assert (env.workspace / f"daily/{day}/caption.md").is_file()
|
||||
assert len(list((env.workspace / "daily").glob("*/caption.md"))) == 1
|
||||
|
||||
|
||||
async def test_repeated_existing_updates_keep_original_day_and_path(
|
||||
auto_resource_env,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Non-consecutive updates recognize the same source in absolute and relative form."""
|
||||
env = auto_resource_env
|
||||
_seed_history(env)
|
||||
source = env.write_binary("resource/photo.png", image_bytes())
|
||||
note = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
|
||||
env.write_binary("resource/other.png", image_bytes())
|
||||
other = env.write_note("daily/2026-01-01/other.md", "[[resource/other.png]]")
|
||||
before = note.read_bytes()
|
||||
calls = _observe_history(env, monkeypatch)
|
||||
model = _model()
|
||||
changes = [
|
||||
{"change": "modified", "path": str(source)},
|
||||
{"change": "modified", "path": "resource/other.png"},
|
||||
{"change": "modified", "path": "resource/photo.png"},
|
||||
]
|
||||
|
||||
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-02"):
|
||||
response = await env.run(env.processor(model, routed=True), changes)
|
||||
|
||||
assert response.success is True
|
||||
metadata = [result["metadata"] for result in response.metadata["results"]]
|
||||
assert [item["path"] for item in metadata] == [
|
||||
"daily/2026-01-01/original.md",
|
||||
"daily/2026-01-01/other.md",
|
||||
"daily/2026-01-01/original.md",
|
||||
]
|
||||
assert all(item["action"] == "modified" and item["created"] is False for item in metadata)
|
||||
assert len(model.calls) == 3
|
||||
assert note.read_bytes() != before
|
||||
assert other.is_file()
|
||||
assert not (env.workspace / "daily/2026-01-02").exists()
|
||||
# Unchanged paths alone cannot prove that the repeated source used live lookup.
|
||||
_assert_history_reads(calls, 2)
|
||||
|
||||
|
||||
async def test_cross_day_duplicate_only_fails_ambiguous_resource(auto_resource_env):
|
||||
"""Ambiguous ownership prevents mutation without blocking another valid resource."""
|
||||
env = auto_resource_env
|
||||
first = env.write_note("daily/2026-01-01/first.md", "[[resource/duplicate.png]]")
|
||||
second = env.write_note("daily/2026-01-02/second.md", "[[resource/duplicate.png]]")
|
||||
valid = env.write_note("daily/2026-01-02/valid.md", "[[resource/valid.png]]")
|
||||
before = {note: note.read_bytes() for note in (first, second)}
|
||||
response = await env.run(
|
||||
env.processor(_model(), routed=True),
|
||||
[
|
||||
{"change": "deleted", "path": "resource/duplicate.png"},
|
||||
{"change": "deleted", "path": "resource/valid.png"},
|
||||
],
|
||||
)
|
||||
|
||||
ambiguous, deleted = response.metadata["results"]
|
||||
assert response.success is False
|
||||
assert ambiguous["success"] is False
|
||||
assert "Multiple daily resource notes claim resource/duplicate.png" in ambiguous["metadata"]["error"]
|
||||
assert ambiguous["metadata"]["modified"] is False
|
||||
assert deleted["success"] is True
|
||||
assert deleted["metadata"]["action"] == "deleted"
|
||||
assert not valid.exists()
|
||||
assert all(note.read_bytes() == contents for note, contents in before.items())
|
||||
|
||||
|
||||
async def test_same_day_duplicates_keep_first_match_after_deletion(auto_resource_env):
|
||||
"""Deleting the first same-day match exposes the next one, preserving existing behavior."""
|
||||
env = auto_resource_env
|
||||
first = env.write_note("daily/2026-01-01/first.md", "[[resource/photo.png]]")
|
||||
second = env.write_note("daily/2026-01-01/second.md", "[[resource/photo.png]]")
|
||||
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-02"):
|
||||
response = await env.run(
|
||||
env.processor(_model(), routed=True),
|
||||
[{"change": "deleted", "path": "resource/photo.png"} for _ in range(3)],
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
metadata = [result["metadata"] for result in response.metadata["results"]]
|
||||
assert [item["action"] for item in metadata] == ["deleted", "deleted", "skipped"]
|
||||
assert [item["path"] for item in metadata[:2]] == ["daily/2026-01-01/first.md", "daily/2026-01-01/second.md"]
|
||||
assert not first.exists()
|
||||
assert not second.exists()
|
||||
|
||||
|
||||
async def test_loose_and_dated_changes_keep_separate_lookup_modes(auto_resource_env):
|
||||
"""A dated event must not inherit a loose event's cached path, including on repeated deletion."""
|
||||
env = auto_resource_env
|
||||
sources = ["resource/first.png", "resource/2026-01-02/dated.png", "resource/last.png"]
|
||||
notes = [
|
||||
env.write_note("daily/2026-01-01/first.md", f"[[{sources[0]}]]"),
|
||||
env.write_note("daily/2026-01-02/first.md", f"[[{sources[1]}]]"),
|
||||
env.write_note("daily/2026-01-02/second.md", f"[[{sources[1]}]]"),
|
||||
env.write_note("daily/2026-01-01/last.md", f"[[{sources[2]}]]"),
|
||||
]
|
||||
|
||||
response = await env.run(
|
||||
env.processor(_model(), routed=True),
|
||||
[{"change": "deleted", "path": source} for source in (sources[0], sources[1], sources[1], sources[2])],
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
metadata = [result["metadata"] for result in response.metadata["results"]]
|
||||
assert [item["action"] for item in metadata] == ["deleted"] * 4
|
||||
assert [item["path"] for item in metadata] == [note.relative_to(env.workspace).as_posix() for note in notes]
|
||||
assert all(not note.exists() for note in notes)
|
||||
|
||||
|
||||
async def test_initial_read_failure_does_not_publish_partial_history(auto_resource_env, monkeypatch):
|
||||
"""A transient history read failure makes the next item retry the initial scan."""
|
||||
env = auto_resource_env
|
||||
_seed_history(env)
|
||||
owned_note = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
|
||||
daily_list = env.app_context.jobs["daily_list"]
|
||||
failed_once = False
|
||||
|
||||
async def fail_once_mid_scan(**kwargs):
|
||||
nonlocal failed_once
|
||||
if kwargs["date"] == _HISTORY_DAYS[1] and not failed_once:
|
||||
failed_once = True
|
||||
return Response(success=False, answer="temporary history read failure")
|
||||
return await daily_list(**kwargs)
|
||||
|
||||
monkeypatch.setitem(env.app_context.jobs, "daily_list", fail_once_mid_scan)
|
||||
response = await env.run(
|
||||
env.processor(_model(), routed=True),
|
||||
[
|
||||
{"change": "deleted", "path": "resource/missing.png"},
|
||||
{"change": "deleted", "path": "resource/photo.png"},
|
||||
],
|
||||
)
|
||||
|
||||
failed, recovered = response.metadata["results"]
|
||||
assert response.success is False
|
||||
assert failed["success"] is False
|
||||
assert failed["metadata"]["modified"] is False
|
||||
assert "temporary history read failure" in failed["metadata"]["error"]
|
||||
assert recovered["success"] is True
|
||||
assert recovered["metadata"]["path"] == "daily/2026-01-01/original.md"
|
||||
assert recovered["metadata"]["action"] == "deleted"
|
||||
assert not owned_note.exists()
|
||||
|
||||
|
||||
async def test_empty_history_is_initialized_once(auto_resource_env, monkeypatch):
|
||||
"""An empty ownership table is a successful scan, not an uninitialized cache."""
|
||||
env = auto_resource_env
|
||||
(env.workspace / "daily").mkdir(exist_ok=True)
|
||||
calls = _observe_history(env, monkeypatch)
|
||||
|
||||
response = await env.run(
|
||||
env.processor(_model(), routed=True),
|
||||
[{"change": "deleted", "path": f"resource/missing-{index}.png"} for index in range(8)],
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert all(result["metadata"]["action"] == "skipped" for result in response.metadata["results"])
|
||||
assert calls["history_enumerations"] == 1
|
||||
|
||||
|
||||
async def test_router_exception_restores_context_and_next_call_rebuilds(auto_resource_env):
|
||||
"""Even an exception after dispatch cannot leak batch state into a reused context."""
|
||||
env = auto_resource_env
|
||||
original = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
|
||||
step = env.processor(_model(), routed=True)
|
||||
changes = [{"change": "deleted", "path": "resource/photo.png"}]
|
||||
context = RuntimeContext(changes=changes, user_value="preserve")
|
||||
context_keys = set(context.data)
|
||||
dispatch = step._dispatch_processor # pylint: disable=protected-access
|
||||
|
||||
async def dispatch_then_fail(*args, **kwargs):
|
||||
await dispatch(*args, **kwargs)
|
||||
raise RuntimeError("dispatch interrupted after processor completion")
|
||||
|
||||
with patch.object(step, "_dispatch_processor", side_effect=dispatch_then_fail):
|
||||
with pytest.raises(RuntimeError, match="dispatch interrupted"):
|
||||
await step(context)
|
||||
|
||||
assert not original.exists()
|
||||
assert set(context.data) == context_keys
|
||||
assert context["changes"] is changes
|
||||
assert context["user_value"] == "preserve"
|
||||
recreated = env.write_note("daily/2026-01-02/recreated.md", "[[resource/photo.png]]")
|
||||
response = await step(context)
|
||||
|
||||
assert response.success is True
|
||||
assert response.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-02/recreated.md"
|
||||
assert not recreated.exists()
|
||||
assert set(context.data) == context_keys
|
||||
|
||||
|
||||
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
|
||||
async def test_cancelled_invocation_restores_context_and_rebuilds(routed, auto_resource_env):
|
||||
"""Cancel at the model boundary after lookup, without mocking the resource processing path."""
|
||||
env = auto_resource_env
|
||||
env.write_binary("resource/photo.png", image_bytes())
|
||||
original = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
|
||||
before = original.read_bytes()
|
||||
entered = asyncio.Event()
|
||||
|
||||
# The inherited structured method deliberately raises to exercise plain-call fallback.
|
||||
class WaitingModel(FakeVisionModel): # pylint: disable=abstract-method
|
||||
"""Keep the image request pending until the invocation is cancelled."""
|
||||
|
||||
async def __call__(self, messages, **kwargs):
|
||||
del messages, kwargs
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
step = env.processor(WaitingModel("unused"), routed=routed)
|
||||
changes = [{"change": "modified", "path": "resource/photo.png"}]
|
||||
context = RuntimeContext(changes=changes, user_value="preserve")
|
||||
context_keys = set(context.data)
|
||||
task = asyncio.create_task(step(context))
|
||||
try:
|
||||
await asyncio.wait_for(entered.wait(), timeout=5)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert original.read_bytes() == before
|
||||
assert set(context.data) == context_keys
|
||||
assert context["changes"] is changes
|
||||
assert context["user_value"] == "preserve"
|
||||
original.unlink()
|
||||
recreated = env.write_note("daily/2026-01-02/recreated.md", "[[resource/photo.png]]")
|
||||
context["changes"] = [{"change": "deleted", "path": "resource/photo.png"}]
|
||||
response = await step(context)
|
||||
|
||||
assert response.success is True
|
||||
assert response.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-02/recreated.md"
|
||||
assert not recreated.exists()
|
||||
assert set(context.data) == context_keys
|
||||
135
tests/unit/test_daily_note_links.py
Normal file
135
tests/unit/test_daily_note_links.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Daily-note scans preserve internal links without reading outside the workspace."""
|
||||
|
||||
import errno
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from reme.steps.file_io import _daily_index
|
||||
|
||||
DAY = "2026-01-01"
|
||||
|
||||
|
||||
def _symlink(link: Path, target: Path, *, directory: bool = False) -> None:
|
||||
"""Skip platforms where creating a test symlink is unavailable."""
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=directory)
|
||||
except NotImplementedError as exc:
|
||||
pytest.skip(f"Symlinks unavailable: {exc}")
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EPERM, errno.EACCES, errno.ENOSYS, errno.EOPNOTSUPP}:
|
||||
pytest.skip(f"Symlinks unavailable: {exc}")
|
||||
raise
|
||||
|
||||
|
||||
@pytest.fixture(name="daily_workspace")
|
||||
def _daily_workspace(tmp_path):
|
||||
"""Keep note targets and an outside sibling under pytest's temporary directory."""
|
||||
workspace = (tmp_path / "workspace").resolve()
|
||||
day_dir = workspace / "daily" / DAY
|
||||
day_dir.mkdir(parents=True)
|
||||
(day_dir / "regular.md").write_text("---\nname: regular\n---\nBody\n", encoding="utf-8")
|
||||
return workspace, day_dir
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["internal", "escaping", "dangling", "self-loop", "mutual-loop"])
|
||||
def test_scan_note_links_keep_logical_paths_and_skip_unreadable_targets(kind, daily_workspace, monkeypatch):
|
||||
"""Unsafe or unreadable links must neither hide a valid note nor reach read_text."""
|
||||
workspace, day_dir = daily_workspace
|
||||
link = day_dir / "linked.md"
|
||||
target = workspace / "target.md"
|
||||
if kind == "escaping":
|
||||
target = workspace.parent / "outside.md"
|
||||
if kind in {"internal", "escaping"}:
|
||||
target.write_text("---\nname: linked\nsource_resource: '[[resource/photo.png]]'\n---\nBody\n", encoding="utf-8")
|
||||
elif kind == "self-loop":
|
||||
target = link
|
||||
elif kind == "mutual-loop":
|
||||
target = day_dir / "other.md"
|
||||
_symlink(link=target, target=link)
|
||||
_symlink(link, target)
|
||||
|
||||
read_paths = []
|
||||
original_read = Path.read_text
|
||||
|
||||
def record_read(path, *args, **kwargs):
|
||||
read_paths.append(path)
|
||||
return original_read(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", record_read)
|
||||
notes = _daily_index.scan_notes(workspace, DAY, "daily")
|
||||
|
||||
expected_paths = [f"daily/{DAY}/regular.md"]
|
||||
if kind == "internal":
|
||||
expected_paths.insert(0, f"daily/{DAY}/linked.md")
|
||||
assert notes[0]["metadata"]["source_resource"] == "[[resource/photo.png]]"
|
||||
assert target in read_paths
|
||||
else:
|
||||
assert read_paths == [day_dir / "regular.md"]
|
||||
assert [note["path"] for note in notes] == expected_paths
|
||||
assert link.is_symlink()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["internal", "escaping", "dangling", "self-loop", "mutual-loop"])
|
||||
def test_scan_date_directory_links(kind, daily_workspace, monkeypatch):
|
||||
"""A valid date alias retains its logical date; invalid aliases are not read."""
|
||||
workspace, day_dir = daily_workspace
|
||||
alias = day_dir.parent / "2026-01-02"
|
||||
target = day_dir
|
||||
if kind == "escaping":
|
||||
target = workspace.parent / "outside-day"
|
||||
target.mkdir()
|
||||
(target / "outside.md").write_text("---\nname: outside\n---\nBody\n", encoding="utf-8")
|
||||
elif kind == "dangling":
|
||||
target = day_dir.parent / "missing"
|
||||
elif kind == "self-loop":
|
||||
target = alias
|
||||
elif kind == "mutual-loop":
|
||||
target = day_dir.parent / "2026-01-03"
|
||||
_symlink(target, alias, directory=True)
|
||||
_symlink(alias, target, directory=True)
|
||||
|
||||
read_paths = []
|
||||
original_read = Path.read_text
|
||||
|
||||
def record_read(path, *args, **kwargs):
|
||||
read_paths.append(path)
|
||||
return original_read(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", record_read)
|
||||
notes = _daily_index.scan_notes(workspace, alias.name, "daily")
|
||||
|
||||
if kind == "internal":
|
||||
assert notes == [{"path": "daily/2026-01-02/regular.md", "metadata": {"name": "regular"}}]
|
||||
assert read_paths == [day_dir / "regular.md"]
|
||||
else:
|
||||
assert not notes
|
||||
assert not read_paths
|
||||
assert alias.is_symlink()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,error_number",
|
||||
[("is_file", errno.ELOOP), ("is_file", errno.EIO), ("iterdir", errno.EACCES)],
|
||||
)
|
||||
def test_scan_only_suppresses_loop_errors(method, error_number, daily_workspace, monkeypatch):
|
||||
"""Do not turn unrelated enumeration or stat failures into an empty history."""
|
||||
workspace, day_dir = daily_workspace
|
||||
note = day_dir / "regular.md"
|
||||
error = OSError(error_number, "injected filesystem error")
|
||||
original = getattr(Path, method)
|
||||
target = note if method == "is_file" else day_dir
|
||||
|
||||
def fail_operation(path, *args, **kwargs):
|
||||
if path == target:
|
||||
raise error
|
||||
return original(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, method, fail_operation)
|
||||
|
||||
if error_number == errno.ELOOP:
|
||||
assert not _daily_index.scan_notes(workspace, DAY, "daily")
|
||||
else:
|
||||
with pytest.raises(OSError) as captured:
|
||||
_daily_index.scan_notes(workspace, DAY, "daily")
|
||||
assert captured.value.errno == error_number
|
||||
Loading…
Add table
Reference in a new issue