ReMe/reme/steps/evolve/base_auto_resource.py
WQS 6ed97f033b
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
2026-09-14 16:55:42 +08:00

700 lines
30 KiB
Python

"""Shared lifecycle and helpers for automatic resource processors."""
import hashlib
import re
from abc import abstractmethod
from collections.abc import Mapping
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
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)
class _ResourceNoteState:
"""Snapshot a linked note, or a collision-free path reserved for a new one."""
path: str
created: bool
before_bytes: bytes | None
def _compute_note_stem(filename: str) -> str:
"""Return the daily note stem for a resource filename."""
return PurePosixPath(filename).stem
def _parse_resource_path(file_path: str, resource_dir: str) -> tuple[str, str]:
"""Extract (date, filename) from a resource path like 'resource/2026-06-06/report.pdf'.
Returns (date_str, filename) where filename may contain subdirectories.
"""
parts = PurePosixPath(file_path).parts
# Strip leading resource_dir prefix
prefix_parts = PurePosixPath(resource_dir).parts
if parts[: len(prefix_parts)] != prefix_parts:
return "", ""
parts = parts[len(prefix_parts) :]
# First segment is date, rest is filename
date_str = parts[0] if parts else ""
if not _DATE_RE.match(date_str):
return "", ""
filename = str(PurePosixPath(*parts[1:])) if len(parts) > 1 else ""
return date_str, filename
def _loose_resource_filename(file_path: str, resource_dir: str) -> str:
"""Return filename for a root-level resource path like 'resource/report.txt'."""
parts = PurePosixPath(file_path).parts
prefix_parts = PurePosixPath(resource_dir).parts
if parts[: len(prefix_parts)] != prefix_parts:
return ""
rest = parts[len(prefix_parts) :]
if len(rest) != 1:
return ""
filename = rest[0]
return "" if filename in ("", ".", "..") else filename
def _results_answer(results: list[dict], processed_answer: str) -> str:
"""Return the actual per-change answer while preserving a batch fallback."""
answers = [str(item.get("answer") or "").strip() for item in results]
answers = [item for item in answers if item]
if len(answers) == 1:
return answers[0]
if len(answers) > 1:
return "\n\n".join(f"{index}. {answer}" for index, answer in enumerate(answers, start=1))
return processed_answer
def _source_suffix(file_path: str) -> str:
"""Return a short stable suffix for source-path collision handling."""
return hashlib.sha1(file_path.encode("utf-8")).hexdigest()[:8]
def _sanitize_note_name(raw: str, fallback: str) -> str:
"""Return a safe single filename component from an LLM-suggested name."""
name = str(raw or "").strip()
name = _UNSAFE_FILENAME_CHARS.sub("-", name)
name = re.sub(r"\s+", " ", name).strip(" .")
if not name:
name = str(fallback or "").strip()
name = _UNSAFE_FILENAME_CHARS.sub("-", name).strip(" .")
if not name or validate_filename_component(name, kind="name"):
name = f"resource-{_source_suffix(fallback or raw or 'note')}"
if validate_filename_component(name, kind="name"):
name = f"resource-{_source_suffix(name)}"
return name
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"})
@classmethod
def matches_change(cls, change: Mapping[str, Any]) -> bool:
"""Return whether this processor accepts a change before fallback.
The predicate must stay synchronous and file-system independent because
deleted resources no longer exist when the router evaluates them.
"""
file_path = change.get("path") or change.get("file_path", "")
return Path(str(file_path)).suffix.lower() in cls.resource_suffixes
def _normalize_change(self, raw) -> Change | None:
if isinstance(raw, Change):
return raw
if isinstance(raw, str):
return Change.__members__.get(raw)
return None
def _today(self) -> str:
tz = self.app_context.app_config.timezone if self.app_context is not None else None
return now(tz).strftime("%Y-%m-%d")
def _daily_note_path(self, day: str, name: str) -> str:
return f"{self.config_value('daily_dir')}/{day}/{name}.md"
def _resource_directory(self) -> tuple[str, Path]:
"""Return the workspace-relative identity and resolved resource root."""
workspace = self.workspace_path.resolve()
configured = str(self.config_value("resource_dir"))
resolved, error = resolve_path(workspace, configured)
if error or resolved is None:
raise ValueError(f"invalid resource_dir {configured!r}: {error or 'cannot resolve path'}")
logical = Path(configured)
if logical.is_absolute():
for workspace_variant in (self.workspace_path.absolute(), workspace):
try:
logical = logical.relative_to(workspace_variant)
break
except ValueError:
continue
else:
raise ValueError("resource_dir must stay inside the workspace")
return logical.as_posix(), resolved
def _resolve_resource_source(self, raw_path: str) -> tuple[str, Path, str]:
"""Return a logical resource path, safe read path, and logical resource root.
The logical path is kept for ``source_resource`` provenance. The
resolved path is used for stat/read so a symlink cannot escape the
configured resource directory.
"""
value = str(raw_path or "").strip()
if not value:
raise ValueError("resource path is required")
supplied = Path(value)
if any(part in {".", ".."} for part in supplied.parts):
raise ValueError(f"resource path cannot contain '.' or '..': {value!r}")
workspace = self.workspace_path.resolve()
resource_dir, resource_root = self._resource_directory()
resolved, path_error = resolve_path(workspace, value)
if path_error or resolved is None:
raise ValueError(f"invalid resource path {value!r}: {path_error or 'cannot resolve path'}")
if not is_relative_to(resolved, resource_root):
raise ValueError("resource path must stay inside the configured resource directory")
if supplied.is_absolute():
try:
logical = supplied.relative_to(self.workspace_path.absolute())
except ValueError:
try:
logical = supplied.relative_to(workspace)
except ValueError as exc:
raise ValueError("resource path must stay inside the workspace") from exc
else:
logical = supplied
resource_logical = Path(resource_dir)
try:
logical.relative_to(resource_logical)
except ValueError as exc:
raise ValueError("resource path must stay inside the configured resource directory") from exc
return logical.as_posix(), resolved, resource_dir
@staticmethod
def _source_resource_link(file_path: str) -> str:
return f"[[{file_path}]]"
def _frontmatter(self, path: str) -> dict:
post = frontmatter.loads((self.file_store.workspace_path / path).read_text(encoding="utf-8"))
return dict(post.metadata or {})
def _note_bytes(self, path: str) -> bytes | None:
note_path = self.file_store.workspace_path / path
if not note_path.is_file():
return None
return note_path.read_bytes()
def _note_modified(self, before_path: str, before_bytes: bytes | None, after_path: str) -> bool:
if not after_path:
return False
after_bytes = self._note_bytes(after_path)
if after_bytes is None:
return before_bytes is not None
return after_path != before_path or before_bytes != after_bytes
async def _refresh_day_index(self, day: str) -> dict:
"""Refresh and return the derived daily index for a resource-note change."""
daily_dir = self.config_value("daily_dir")
self.logger.info(f"[{self.name}] refresh index start date={day} daily_dir={daily_dir}")
index_payload = await refresh_day_index(self.file_store, day, daily_dir)
self.logger.info(f"[{self.name}] refresh index done date={day}")
return index_payload
def _find_resource_note(self, notes: list[dict], file_path: str) -> dict | None:
"""Return only a note explicitly owned by ``file_path``."""
source = self._source_resource_link(file_path)
for note in notes:
if str(note.get(_SOURCE_RESOURCE_KEY, "")).strip() == source:
return note
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}")
return list_response.metadata.get("notes") or []
def _daily_note_days(self) -> list[str]:
"""Return safe, deterministic daily subdirectories available for lookup."""
workspace = self.workspace_path.resolve()
daily_dir = str(self.config_value("daily_dir"))
daily_root, error = resolve_path(workspace, daily_dir)
if error or daily_root is None:
raise ValueError(f"invalid daily_dir {daily_dir!r}: {error or 'cannot resolve path'}")
if not daily_root.is_dir():
return []
days = []
for entry in daily_root.iterdir():
if not _DATE_RE.fullmatch(entry.name):
continue
try:
resolved, path_error = resolve_path(workspace, f"{daily_dir}/{entry.name}")
if not path_error and resolved is not None and resolved.is_dir():
days.append(entry.name)
except (OSError, RuntimeError):
# Broken or cyclic links must not prevent lookup in other days.
continue
return sorted(days)
async def _find_loose_resource_day(self, file_path: str) -> str | None:
"""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}")
return matches[0][0] if matches else None
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_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="")
return _ResourceNoteState(path=note_path, created=True, before_bytes=None)
async def _resolve_written_note(
self,
state: _ResourceNoteState,
day: str,
file_path: str,
) -> str | None:
"""Resolve a processor write without claiming a pre-existing same-stem note."""
if not state.created:
if self._note_bytes(state.path) is None:
raise RuntimeError(f"linked resource note disappeared: {state.path}")
return state.path
note = await self._list_resource_note(day, file_path)
if note is not None:
return str(note["path"])
if self._note_bytes(state.path) is None:
return None
# The path was absent when this invocation allocated it, so a note
# created there without provenance can be repaired conservatively. A
# different explicit owner is always a conflict and is never claimed.
source = str(self._frontmatter(state.path).get(_SOURCE_RESOURCE_KEY, "")).strip()
expected = self._source_resource_link(file_path)
if source and source != expected:
raise RuntimeError(f"resource note path is owned by another source: {state.path}")
return state.path
async def _ensure_resource_frontmatter(self, path: str, file_path: str) -> None:
metadata = {_SOURCE_RESOURCE_KEY: self._source_resource_link(file_path)}
current = self._frontmatter(path)
if all(current.get(key) == value for key, value in metadata.items()):
return
response = await self.run_job(
"frontmatter_update",
path=path,
metadata=metadata,
)
if not response.success:
raise RuntimeError(f"frontmatter_update failed: {response.answer}")
async def _set_frontmatter_name(self, path: str, name: str) -> None:
if self._frontmatter(path).get("name") == name:
return
response = await self.run_job("frontmatter_update", path=path, metadata={"name": name})
if not response.success:
raise RuntimeError(f"frontmatter_update failed: {response.answer}")
def _unique_daily_note_path(self, day: str, name: str, file_path: str, current_path: str) -> tuple[str, str]:
"""Return a collision-free (name, path), preserving current_path when possible."""
target_path = self._daily_note_path(day, name)
target_abs = self.file_store.workspace_path / target_path
if target_path == current_path or not target_abs.exists():
return name, target_path
suffixed = f"{name}--{_source_suffix(file_path)}"
target_path = self._daily_note_path(day, suffixed)
target_abs = self.file_store.workspace_path / target_path
if target_path == current_path or not target_abs.exists():
return suffixed, target_path
for index in range(2, 100):
candidate = f"{suffixed}-{index}"
target_path = self._daily_note_path(day, candidate)
target_abs = self.file_store.workspace_path / target_path
if target_path == current_path or not target_abs.exists():
return candidate, target_path
raise RuntimeError(f"cannot allocate unique note name for: {name!r}")
async def _rename_from_frontmatter_name(
self,
path: str,
day: str,
file_path: str,
fallback_name: str,
*,
allow_rename: bool,
) -> str:
meta = self._frontmatter(path)
current_name = PurePosixPath(path).stem
suggested_name = str(meta.get("name", "")).strip()
if not allow_rename:
name = _sanitize_note_name(current_name, fallback_name)
if suggested_name != name:
await self._set_frontmatter_name(path, name)
return path
name = _sanitize_note_name(suggested_name, fallback_name)
name, target_path = self._unique_daily_note_path(day, name, file_path, path)
if suggested_name != name:
await self._set_frontmatter_name(path, name)
if target_path == path:
return path
move_response = await self.run_job(
"move",
src_path=path,
dst_path=target_path,
overwrite=False,
retarget=True,
)
if not move_response.success:
raise RuntimeError(f"move failed: {move_response.answer}")
return target_path
async def _finalize_resource_note(
self,
state: _ResourceNoteState,
day: str,
file_path: str,
note_stem: str,
added: bool,
) -> str | None:
"""Resolve, source-link, rename, index, and report one processor write."""
staged_bytes = self._note_bytes(state.path)
self.context.response.metadata.update(
{
"path": state.path if staged_bytes is not None else None,
"created": state.created and staged_bytes is not None,
"modified": self._note_modified(state.path, state.before_bytes, state.path),
},
)
note_path = await self._resolve_written_note(state, day, file_path)
if note_path is None:
self.context.response.metadata.update({"path": None, "created": False, "modified": False})
return None
modified = self._note_modified(state.path, state.before_bytes, note_path)
self.context.response.metadata.update({"path": note_path, "created": state.created, "modified": modified})
await self._ensure_resource_frontmatter(note_path, file_path)
note_path = await self._rename_from_frontmatter_name(
note_path,
day,
file_path,
note_stem,
allow_rename=state.created,
)
modified = self._note_modified(state.path, state.before_bytes, note_path)
self.context.response.metadata.update({"path": note_path, "created": state.created, "modified": modified})
index_payload = await self._refresh_day_index(day)
self.context.response.metadata.update(
{
"path": note_path,
"created": state.created,
"modified": modified,
"session_id": note_stem,
"source_resource": self._source_resource_link(file_path),
"action": "added" if added else "modified",
"index": index_payload,
},
)
return note_path
async def _handle_delete(self, file_path: str, date_str: str, note_stem: str) -> 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(
{
"path": None,
"session_id": note_stem,
"source_resource": self._source_resource_link(file_path),
"action": "skipped",
"reason": "resource_note_not_found",
"modified": False,
},
)
self.logger.info(f"[{self.name}] delete skipped; no owned note file_path={file_path}")
return
note_abs = self.workspace_path / note_rel
note_existed = note_abs.is_file()
self.logger.info(f"[{self.name}] delete start note={note_rel}")
if note_existed:
note_abs.unlink()
self.logger.info(f"[{self.name}] Deleted file: {note_rel}")
self.context.response.metadata.update(
{
"path": note_rel,
"session_id": note_stem,
"source_resource": self._source_resource_link(file_path),
"action": "deleted",
"modified": note_existed,
},
)
await self.file_store.delete([note_rel])
self.logger.info(f"[{self.name}] catalog delete done note={note_rel}")
index_payload = await self._refresh_day_index(date_str)
self.context.response.success = True
self.context.response.answer = f"Deleted resource note: {note_rel}"
self.context.response.metadata["index"] = index_payload
@abstractmethod
async def _handle_upsert(
self,
file_path: str,
date_str: str,
note_stem: str,
added: bool,
source_path: Path,
) -> None:
"""Interpret one added or modified resource into its daily note."""
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 = {}
if not file_path:
self.context.response.success = False
self.context.response.answer = "Missing file_path"
self.logger.warning(f"[{self.name}] missing file_path change={raw_change!r}")
return {"success": False, "path": file_path, "change": raw_change, "answer": self.context.response.answer}
change = self._normalize_change(raw_change)
if change is None:
self.context.response.success = False
self.context.response.answer = f"Invalid change type: {raw_change}"
self.logger.warning(f"[{self.name}] invalid change file_path={file_path} change={raw_change!r}")
return {"success": False, "path": file_path, "change": raw_change, "answer": self.context.response.answer}
try:
file_path, source_path, resource_dir = self._resolve_resource_source(file_path)
except ValueError as exc:
self.context.response.success = False
self.context.response.answer = str(exc)
self.context.response.metadata.update(
{"path": str(file_path), "action": "failed", "error": str(exc), "modified": False},
)
self.logger.warning(f"[{self.name}] invalid resource path file_path={file_path!r} error={exc}")
return {
"success": False,
"path": str(file_path),
"change": change.name,
"answer": self.context.response.answer,
"metadata": dict(self.context.response.metadata),
}
loose_filename = _loose_resource_filename(file_path, resource_dir)
if loose_filename:
existing_day = await self._find_loose_resource_day(file_path)
date_str, filename = existing_day or self._today(), loose_filename
self.logger.info(
f"[{self.name}] loose resource file_path={file_path} date={date_str} existing={bool(existing_day)}",
)
else:
date_str, filename = _parse_resource_path(file_path, resource_dir)
if not date_str or not filename:
self.context.response.success = False
self.context.response.answer = f"Cannot parse date/filename from: {file_path}"
self.logger.warning(f"[{self.name}] parse path failed file_path={file_path} resource_dir={resource_dir}")
return {"success": False, "path": file_path, "change": change.name, "answer": self.context.response.answer}
note_stem = _compute_note_stem(filename)
self.logger.info(f"[{self.name}] {change.name} file_path={file_path} note_stem={note_stem}")
if change == Change.deleted:
await self._handle_delete(file_path, date_str, note_stem)
else:
await self._handle_upsert(
file_path,
date_str,
note_stem,
change == Change.added,
source_path,
)
return {
"success": self.context.response.success,
"path": file_path,
"change": change.name,
"answer": self.context.response.answer,
"metadata": dict(self.context.response.metadata),
}
def _failed_change_result(self, file_path: str, raw_change, exc: Exception) -> dict:
"""Convert one unexpected processor error into an item-scoped result."""
assert self.context is not None
file_path = str(file_path or "")
if Path(file_path).is_absolute():
file_path = self.to_workspace_relative(file_path)
change = self._normalize_change(raw_change)
change_name = change.name if change is not None else str(raw_change)
answer = f"Failed to process resource: {file_path}: {exc}"
metadata = dict(self.context.response.metadata)
metadata.setdefault("path", file_path)
metadata.setdefault("modified", False)
metadata.update({"action": "failed", "error": str(exc)})
self.context.response.success = False
self.context.response.answer = answer
self.context.response.metadata = metadata
self.logger.exception(f"[{self.name}] resource failed file_path={file_path} error={exc}")
return {
"success": False,
"path": file_path,
"change": change_name,
"answer": answer,
"metadata": dict(metadata),
}
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):
self.context.response.success = False
self.context.response.answer = "AutoResourceStep requires changes: list[dict]"
self.logger.warning(f"[{self.name}] invalid changes payload type={type(changes).__name__}")
return self.context.response
self.logger.info(f"[{self.name}] start changes={len(changes)}")
results = []
for index, item in enumerate(changes, start=1):
if not isinstance(item, dict):
self.logger.warning(f"[{self.name}] skip invalid change item index={index} type={type(item).__name__}")
continue
self.logger.info(f"[{self.name}] process change {index}/{len(changes)}")
file_path = item.get("path") or item.get("file_path", "")
raw_change = item.get("change", "")
self.context.response.metadata = {}
try:
result = await self._handle_change(file_path, raw_change)
except Exception as exc: # pylint: disable=broad-except
result = self._failed_change_result(file_path, raw_change, exc)
results.append(result)
success_count = sum(1 for item in results if item.get("success"))
self.context.response.success = success_count == len(changes)
processed_answer = f"Processed {success_count}/{len(changes)} resource change(s)"
self.context.response.answer = _results_answer(results, processed_answer)
self.context.response.metadata["processed"] = len(results)
self.context.response.metadata["results"] = results
self.context.response.metadata["modified"] = any(
bool((item.get("metadata") or {}).get("modified")) for item in results
)
self.logger.info(
f"[{self.name}] done success={success_count}/{len(changes)} "
f"processed={len(results)} modified={self.context.response.metadata['modified']}",
)
return self.context.response