fix(file_io): auto appending suffix for all related steps (#430)

* fix(file_io): auto appending suffix for all related steps

* fix(file_io): covering boundary cases of potential directory input
This commit is contained in:
imrewce 2026-08-11 11:09:28 +08:00 • committed by GitHub
parent 3095564313
commit 58276f740b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 370 additions and 29 deletions

View file

@ -66,6 +66,17 @@ def is_relative_to(path: Path, parent: Path) -> bool:
return False
def display_path(workspace_path: Path, target: Path) -> str:
"""Return ``target`` as a workspace-relative POSIX path for user-facing messages.
Falls back to the absolute string when ``target`` is not under ``workspace_path``.
"""
try:
return target.relative_to(workspace_path).as_posix()
except ValueError:
return str(target)
# pylint: disable=too-many-return-statements
def resolve_path(
workspace_path: Path,

View file

@ -14,7 +14,7 @@ from pathlib import Path
import frontmatter
from ._file_io import get_path_lock
from ._path import resolve_path
from ._path import display_path, gate_md, resolve_path
from ..base_step import BaseStep
from ...components import R
@ -37,11 +37,13 @@ class FrontmatterDeleteStep(BaseStep):
if err or target is None:
payload: dict = {"path": path, "error": err or "invalid path"}
else:
original_target = target
target, is_md = gate_md(target)
lock = await get_path_lock(target)
async with lock:
if not target.is_file():
payload = {"path": path, "error": "not found"}
elif target.suffix != ".md":
payload = {"path": path, "error": f"{display_path(workspace_dir, target)} not found"}
elif not is_md:
payload = {"path": path, "error": "not markdown"}
elif not keys:
payload = {"path": path, "error": "keys is empty"}
@ -63,6 +65,8 @@ class FrontmatterDeleteStep(BaseStep):
"missing": missing,
"frontmatter": dict(post.metadata),
}
if target != original_target:
payload["resolved_path"] = display_path(workspace_dir, target)
if "error" in payload:
self.context.response.success = False

View file

@ -13,7 +13,7 @@ from pathlib import Path
import frontmatter
import yaml
from ._path import resolve_path
from ._path import display_path, gate_md, resolve_path
from ..base_step import BaseStep
from ...components import R
@ -35,13 +35,19 @@ class FrontmatterReadStep(BaseStep):
self.context.response.metadata.update({"path": path, "exists": False, "error": err or "invalid path"})
self.logger.info(f"[{self.name}] path={path} error={err!r}")
return
original_target = target
target, is_md = gate_md(target)
resolved: dict = {}
if target != original_target:
resolved["resolved_path"] = display_path(workspace_dir, target)
probed = resolved.get("resolved_path", path)
if not target.is_file():
self.context.response.success = False
self.context.response.answer = f"Error: {path} not found"
self.context.response.metadata.update({"path": path, "exists": False})
self.context.response.answer = f"Error: {probed} not found"
self.context.response.metadata.update({"path": path, "exists": False, **resolved})
self.logger.info(f"[{self.name}] path={path} exists=False")
return
if target.suffix != ".md":
if not is_md:
self.context.response.success = False
self.context.response.answer = "Error: not markdown"
self.context.response.metadata.update({"path": path, "error": "not markdown"})
@ -52,11 +58,11 @@ class FrontmatterReadStep(BaseStep):
meta = dict(frontmatter.loads(target.read_text(encoding="utf-8")).metadata)
except yaml.YAMLError as exc:
self.context.response.success = False
self.context.response.answer = f"Error: failed to parse frontmatter in {path}: {exc}"
self.context.response.metadata.update({"path": path, "exists": True, "error": str(exc)})
self.context.response.answer = f"Error: failed to parse frontmatter in {probed}: {exc}"
self.context.response.metadata.update({"path": path, "exists": True, "error": str(exc), **resolved})
self.logger.info(f"[{self.name}] path={path} parse_error={exc!r}")
return
self.context.response.success = True
self.context.response.answer = f"Read frontmatter from {path} ({len(meta)} key(s))"
self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta})
self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta, **resolved})
self.logger.info(f"[{self.name}] path={path} keys={len(meta)}")

View file

@ -17,7 +17,7 @@ from pathlib import Path
import frontmatter
from ._file_io import get_path_lock
from ._path import _check_path_permission, resolve_path
from ._path import _check_path_permission, display_path, gate_md, resolve_path
from ..base_step import BaseStep
from ...components import R
@ -43,22 +43,27 @@ class FrontmatterUpdateStep(BaseStep):
target, err = resolve_path(workspace_dir, path)
if err or target is None:
payload: dict = {"path": path, "error": err or "invalid path"}
elif not _check_path_permission(workspace_dir, target, self.context.get("_allowed_paths")):
payload = {"path": path, "error": "no permission to update this file"}
else:
lock = await get_path_lock(target)
async with lock:
if not target.is_file():
payload = {"path": path, "error": "not found"}
elif target.suffix != ".md":
payload = {"path": path, "error": "not markdown"}
elif not metadata:
payload = {"path": path, "error": "no fields to update"}
else:
post = frontmatter.loads(target.read_text(encoding="utf-8"))
post.metadata.update(metadata)
target.write_text(frontmatter.dumps(post), encoding="utf-8")
payload = {"path": path, "updated": metadata}
original_target = target
target, is_md = gate_md(target)
if not _check_path_permission(workspace_dir, target, self.context.get("_allowed_paths")):
payload = {"path": path, "error": "no permission to update this file"}
else:
lock = await get_path_lock(target)
async with lock:
if not target.is_file():
payload = {"path": path, "error": f"{display_path(workspace_dir, target)} not found"}
elif not is_md:
payload = {"path": path, "error": "not markdown"}
elif not metadata:
payload = {"path": path, "error": "no fields to update"}
else:
post = frontmatter.loads(target.read_text(encoding="utf-8"))
post.metadata.update(metadata)
target.write_text(frontmatter.dumps(post), encoding="utf-8")
payload = {"path": path, "updated": metadata}
if target != original_target:
payload["resolved_path"] = display_path(workspace_dir, target)
if "error" in payload:
self.context.response.success = False

View file

@ -24,7 +24,7 @@ from pathlib import Path
import frontmatter
from ._path import resolve_path
from ._path import display_path, gate_md, resolve_path
from ..base_step import BaseStep
from ...components import R
@ -46,10 +46,24 @@ class StatStep(BaseStep):
self.context.response.metadata.update({"path": path, "exists": False, "error": err or "invalid path"})
self.logger.info(f"[{self.name}] path={path} error={err!r}")
return
# Auto-append .md for suffix-less paths (consistent with edit). An
# existing directory wins top priority: the daily layout keeps
# <daily_dir>/<date>/ and its <daily_dir>/<date>.md index side by
# side, and directory probing is a supported use of this step.
# Otherwise fall back to the original when the .md candidate is
# missing but the original exists (e.g., an extension-less file).
original_target = target
target, _is_md = gate_md(target)
if original_target.is_dir() or (not target.exists() and original_target.exists()):
target = original_target
if not target.exists():
probed = display_path(workspace_dir, target)
self.context.response.success = False
self.context.response.answer = f"stat: {path} not found"
self.context.response.metadata.update({"path": path, "exists": False})
self.context.response.answer = f"stat: {probed} not found"
not_found: dict = {"path": path, "exists": False}
if target != original_target:
not_found["resolved_path"] = probed
self.context.response.metadata.update(not_found)
self.logger.info(f"[{self.name}] path={path} exists=False")
return
@ -62,6 +76,8 @@ class StatStep(BaseStep):
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
"ctime": datetime.fromtimestamp(st.st_ctime).isoformat(),
}
if target != original_target:
payload["resolved_path"] = display_path(workspace_dir, target)
if target.is_file():
payload["size"] = st.st_size
is_md = target.suffix.lower() == ".md"

View file

@ -150,6 +150,107 @@ def test_stat_directory_fallback():
asyncio.run(run())
def test_stat_directory_wins_over_same_name_index():
"""stat prefers an existing directory over its same-name ``.md`` index.
The daily layout keeps ``<daily_dir>/<date>/`` and its
``<daily_dir>/<date>.md`` index side by side; probing the bare date
must report the directory, while the explicit ``.md`` still hits the file.
"""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store(
{
"daily/2026-08-10/note.md": "---\nname: n\n---\nbody",
"daily/2026-08-10.md": "---\nname: index\n---\nindex body",
},
)
step = crud_stat.StatStep(file_store=store)
await step(path="daily/2026-08-10")
payload = _metadata(step)
assert payload["exists"] is True
assert payload["type"] == "dir"
assert payload["absolute_path"].endswith("2026-08-10")
assert "resolved_path" not in payload
step = crud_stat.StatStep(file_store=store)
await step(path="daily/2026-08-10.md")
payload = _metadata(step)
assert payload["exists"] is True
assert payload["type"] == "file"
assert payload["frontmatter"] == {"name": "index"}
await store.close()
print("✓ test_stat_directory_wins_over_same_name_index passed")
asyncio.run(run())
def test_stat_shadowed_extensionless_reports_resolved_path():
"""When both ``LICENSE`` and ``LICENSE.md`` exist, ``stat path=LICENSE``
resolves to the ``.md`` but discloses the substitution via ``resolved_path``.
"""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
(Path(tmp) / "LICENSE").write_text("x" * 15, encoding="utf-8")
(Path(tmp) / "LICENSE.md").write_text("y" * 12, encoding="utf-8")
store = await _make_store()
step = crud_stat.StatStep(file_store=store)
await step(path="LICENSE")
payload = _metadata(step)
assert payload["exists"] is True
assert payload["type"] == "file"
assert payload["path"] == "LICENSE"
assert payload["size"] == 12
assert payload["mime"] == "text/markdown"
assert payload["absolute_path"].endswith("LICENSE.md")
assert payload["resolved_path"] == "LICENSE.md"
await store.close()
print("✓ test_stat_shadowed_extensionless_reports_resolved_path passed")
asyncio.run(run())
def test_stat_extensionless_file_without_md_sibling():
"""An extension-less file with no ``.md`` sibling is reported as-is."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
(Path(tmp) / "LICENSE").write_text("x" * 15, encoding="utf-8")
store = await _make_store()
step = crud_stat.StatStep(file_store=store)
await step(path="LICENSE")
payload = _metadata(step)
assert payload["exists"] is True
assert payload["type"] == "file"
assert payload["size"] == 15
assert payload["absolute_path"].endswith("LICENSE")
assert "resolved_path" not in payload
await store.close()
print("✓ test_stat_extensionless_file_without_md_sibling passed")
asyncio.run(run())
def test_stat_missing_reports_probed_md_path():
"""stat on a missing suffix-less path reports the probed ``.md`` candidate."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
step = crud_stat.StatStep(file_store=store)
await step(path="digest/missing")
payload = _metadata(step)
assert payload["exists"] is False
assert payload["resolved_path"] == "digest/missing.md"
assert "digest/missing.md" in str(step.context.response.answer)
await store.close()
print("✓ test_stat_missing_reports_probed_md_path passed")
asyncio.run(run())
# -- list ----------------------------------------------------------------
@ -1320,6 +1421,10 @@ if __name__ == "__main__":
# stat / list / move / delete
test_stat_indexed_file()
test_stat_directory_fallback()
test_stat_directory_wins_over_same_name_index()
test_stat_shadowed_extensionless_reports_resolved_path()
test_stat_extensionless_file_without_md_sibling()
test_stat_missing_reports_probed_md_path()
test_list_lists_files()
test_list_respects_limit_and_non_recursive()
test_move_relocates_within_workspace()

View file

@ -0,0 +1,194 @@
"""Tests for the frontmatter-only CRUD steps (``frontmatter_read`` /
``frontmatter_update`` / ``frontmatter_delete``).
Covers the suffix-less path contract shared with ``read`` / ``write`` /
``edit``: a bare path with no suffix auto-appends ``.md``. All three
siblings must agree on path handling, disclose the substitution via
``resolved_path`` in the response metadata, and report the actually
probed path in not-found errors instead of the caller's raw input.
"""
# pylint: disable=protected-access
import os
import tempfile
from pathlib import Path
import pytest
from reme.components.file_store import LocalFileStore
from reme.steps.file_io.frontmatter_delete import FrontmatterDeleteStep
from reme.steps.file_io.frontmatter_read import FrontmatterReadStep
from reme.steps.file_io.frontmatter_update import FrontmatterUpdateStep
class temp_chdir:
"""Context manager to temporarily chdir into a path and restore on exit."""
def __init__(self, path):
self.path = path
self.old = None
def __enter__(self):
self.old = os.getcwd()
os.chdir(self.path)
return self
def __exit__(self, *exc):
os.chdir(self.old)
def _seed(workspace: Path, rel: str, body: str) -> Path:
target = workspace / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body, encoding="utf-8")
return target
async def _make_store() -> LocalFileStore:
store = LocalFileStore(name="t_fm", embedding_store="")
await store.start()
return store
async def _run(step_cls, store: LocalFileStore, **kwargs):
step = step_cls(file_store=store)
await step(**kwargs)
return step.context.response
NOTE = "notes/post.md"
BODY = "---\nname: n\ntags:\n- a\n---\nbody\n"
@pytest.mark.asyncio
async def test_read_no_suffix_autoappends_md():
"""frontmatter_read on a suffix-less path resolves to the ``.md`` file."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
_seed(Path(tmp), NOTE, BODY)
store = await _make_store()
resp = await _run(FrontmatterReadStep, store, path="notes/post")
assert resp.success is True
assert resp.metadata["frontmatter"] == {"name": "n", "tags": ["a"]}
assert resp.metadata["path"] == "notes/post"
assert resp.metadata["resolved_path"] == "notes/post.md"
await store.close()
@pytest.mark.asyncio
async def test_update_no_suffix_autoappends_md():
"""frontmatter_update on a suffix-less path resolves to the ``.md`` file."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
note = _seed(Path(tmp), NOTE, BODY)
store = await _make_store()
resp = await _run(FrontmatterUpdateStep, store, path="notes/post", metadata={"name": "renamed"})
assert resp.success is True
assert resp.metadata["updated"] == {"name": "renamed"}
assert resp.metadata["resolved_path"] == "notes/post.md"
assert "name: renamed" in note.read_text(encoding="utf-8")
await store.close()
@pytest.mark.asyncio
async def test_delete_no_suffix_autoappends_md():
"""frontmatter_delete on a suffix-less path resolves to the ``.md`` file."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
note = _seed(Path(tmp), NOTE, BODY)
store = await _make_store()
resp = await _run(FrontmatterDeleteStep, store, path="notes/post", keys=["tags"])
assert resp.success is True
assert resp.metadata["deleted"] == ["tags"]
assert resp.metadata["missing"] == []
assert resp.metadata["resolved_path"] == "notes/post.md"
assert "tags" not in note.read_text(encoding="utf-8")
await store.close()
@pytest.mark.asyncio
async def test_family_consistent_on_suffixless_path():
"""read / update / delete all succeed on the same suffix-less path."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
_seed(Path(tmp), NOTE, BODY)
store = await _make_store()
resp = await _run(FrontmatterReadStep, store, path="notes/post")
assert resp.success is True
resp = await _run(FrontmatterUpdateStep, store, path="notes/post", metadata={"status": "done"})
assert resp.success is True
resp = await _run(FrontmatterDeleteStep, store, path="notes/post", keys=["status"])
assert resp.success is True
assert resp.metadata["deleted"] == ["status"]
await store.close()
@pytest.mark.asyncio
async def test_read_missing_reports_probed_path():
"""not-found errors surface the probed ``.md`` path, not the raw input."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
resp = await _run(FrontmatterReadStep, store, path="digest/missing")
assert resp.success is False
assert resp.metadata["exists"] is False
assert resp.metadata["path"] == "digest/missing"
assert resp.metadata["resolved_path"] == "digest/missing.md"
assert "digest/missing.md" in str(resp.answer)
await store.close()
@pytest.mark.asyncio
async def test_update_missing_reports_probed_path():
"""frontmatter_update not-found errors mention the probed ``.md`` path."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
resp = await _run(FrontmatterUpdateStep, store, path="digest/missing", metadata={"x": 1})
assert resp.success is False
assert resp.metadata["error"] == "digest/missing.md not found"
assert resp.metadata["resolved_path"] == "digest/missing.md"
assert "digest/missing.md" in str(resp.answer)
await store.close()
@pytest.mark.asyncio
async def test_delete_missing_reports_probed_path():
"""frontmatter_delete not-found errors mention the probed ``.md`` path."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
resp = await _run(FrontmatterDeleteStep, store, path="digest/missing", keys=["x"])
assert resp.success is False
assert resp.metadata["error"] == "digest/missing.md not found"
assert resp.metadata["resolved_path"] == "digest/missing.md"
assert "digest/missing.md" in str(resp.answer)
await store.close()
@pytest.mark.asyncio
async def test_non_md_rejected_without_resolved_path():
"""Non-markdown targets are rejected by all three siblings; no substitution."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
_seed(Path(tmp), "notes/data.txt", "plain\n")
store = await _make_store()
for step_cls, kwargs in (
(FrontmatterReadStep, {}),
(FrontmatterUpdateStep, {"metadata": {"x": 1}}),
(FrontmatterDeleteStep, {"keys": ["x"]}),
):
resp = await _run(step_cls, store, path="notes/data.txt", **kwargs)
assert resp.success is False
assert "not markdown" in str(resp.answer).lower()
assert "resolved_path" not in resp.metadata
await store.close()
@pytest.mark.asyncio
async def test_explicit_md_path_has_no_resolved_path():
"""Explicit ``.md`` paths need no substitution, so no ``resolved_path``."""
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
_seed(Path(tmp), NOTE, BODY)
store = await _make_store()
resp = await _run(FrontmatterReadStep, store, path=NOTE)
assert resp.success is True
assert "resolved_path" not in resp.metadata
await store.close()