Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/get-body-from-current-file.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **`pyrite get` reads the current body from its Markdown file (#753).** The JSON body and SHA-256 now reflect edits made since the index was built, so echoing the body through `pyrite update --body-file` no longer discards a newer section.
7 changes: 6 additions & 1 deletion pyrite/cli/entry_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,12 @@ def get_entry(
):
"""Get a specific entry by ID."""
with cli_context() as (config, db, svc):
result = svc.get_entry(entry_id, kb_name=kb_name, readable_kbs=UNSCOPED)
result = svc.get_entry(
entry_id,
kb_name=kb_name,
readable_kbs=UNSCOPED,
read_current_body=True,
)

if not result:
_cli_error(f"Entry '{entry_id}' not found", output_format, "NOT_FOUND")
Expand Down
72 changes: 72 additions & 0 deletions pyrite/services/kb_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import copy
import dataclasses
import hashlib
import logging
import os
from collections.abc import Callable
Expand Down Expand Up @@ -533,10 +534,14 @@ def get_entry(
kb_name: str | None = None,
*,
readable_kbs: set[str] | None,
read_current_body: bool = False,
) -> dict[str, Any] | None:
"""
Get entry by ID, with its outlinks and backlinks.

CLI callers may request the exact current file body for a lossless
get/update round trip; other read surfaces keep their normal projection.

If kb_name not specified, searches all KBs in config order.

``readable_kbs`` is the caller's ``ReadScope`` set (``None``:
Expand All @@ -553,6 +558,8 @@ def get_entry(
return None
result = self.db.get_entry(entry_id, kb_name)
if result:
if read_current_body:
result = self._refresh_entry_body(result, entry_id, kb_name)
self._attach_links(result, entry_id, kb_name, readable_kbs)
return result

Expand All @@ -562,10 +569,75 @@ def get_entry(
continue
result = self.db.get_entry(entry_id, kb.name)
if result:
if read_current_body:
result = self._refresh_entry_body(result, entry_id, kb.name)
self._attach_links(result, entry_id, kb.name, readable_kbs)
return result
return None

@staticmethod
def _body_from_entry_file(content: bytes) -> str | None:
"""Return the body's exact text after the YAML frontmatter.

Consume the closing fence's line ending and the one blank separator
emitted by the entry serializer. Preserve body whitespace and its
original line endings so get can be echoed to update losslessly.
"""
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
return None
if text.startswith("\ufeff"):
text = text[1:]
if not text.startswith(("---\n", "---\r\n")):
return None

cursor = text.find("\n") + 1
while cursor > 0 and cursor <= len(text):
line_end = text.find("\n", cursor)
if line_end < 0:
line_end = len(text)
line = text[cursor:line_end].removesuffix("\r")
if line.strip(" \t") == "---":
body_start = line_end + 1 if line_end < len(text) else line_end
if text.startswith("\r\n", body_start):
body_start += 2
elif text.startswith("\n", body_start):
body_start += 1
return text[body_start:]
if line_end == len(text):
break
cursor = line_end + 1
return None

def _refresh_entry_body(
self, result: dict[str, Any], entry_id: str, kb_name: str
) -> dict[str, Any]:
"""Use the current file for a single-entry body's content and hash.

The index is a search/listing cache and can lag a hand edit. A caller
that gets an entry and writes its body back must see the file's current
content, even before the next index sync.
"""
kb_config = self.get_kb(kb_name)
if kb_config is None:
return result

entry = KBRepository(kb_config).load(entry_id)
if entry is None or entry.file_path is None:
return result
try:
content = Path(entry.file_path).read_bytes()
except OSError:
logger.warning("Could not read current entry file %s", entry.file_path, exc_info=True)
return result

body = self._body_from_entry_file(content)
result["body"] = entry.body if body is None else body
result["file_path"] = str(entry.file_path)
result["content_hash"] = hashlib.sha256(content).hexdigest()
return result

def _attach_links(
self,
result: dict[str, Any],
Expand Down
73 changes: 73 additions & 0 deletions tests/test_cli_json_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
Verifies that commands with --format json return valid JSON with expected keys.
"""

import hashlib
import json
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -433,6 +436,76 @@ def test_search_error_logged_at_debug(cli_env, caplog):
)


@pytest.mark.cli
@pytest.mark.core
def test_get_reads_current_file_body_and_body_round_trip_is_clean(cli_env):
"""A stale index must not hide a section present in the file from get."""
kb = cli_env["config"].knowledge_bases[0]
repo = KBRepository(kb)
entry, entry_path = next(repo.list_entries())
entry_id = entry.id
model_body = (
" Body text. \n\n### Conductor QC (2026-10-05)\n\nThe existing section remains intact. "
)
expected_body = model_body.replace("\n", os.linesep) + os.linesep
entry.body = model_body
entry_path.write_text(entry.to_markdown(), encoding="utf-8")
committed_content = entry_path.read_bytes()

# The fixture's index still has the old body; commit the current file as the
# baseline so the get/update round trip must leave it unchanged.
db = PyriteDB(cli_env["config"].settings.index_path)
try:
indexed = db.get_entry(entry_id, kb.name)
finally:
db.close()
assert indexed is not None
assert indexed["body"] == "Body text."

for args in (
["git", "init", "--quiet"],
["git", "config", "user.name", "Pyrite test"],
["git", "config", "user.email", "pyrite-test@example.invalid"],
["git", "add", "--all"],
["git", "commit", "--quiet", "-m", "baseline"],
):
subprocess.run(args, cwd=kb.path, check=True, capture_output=True)

with _patch_config("pyrite.cli.entry_commands.load_config", cli_env):
result = runner.invoke(app, ["get", entry_id, "--kb", kb.name, "--format", "json"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["body"] == expected_body
assert data["content_hash"] == hashlib.sha256(committed_content).hexdigest()

body_file = cli_env["tmpdir"] / "round-trip-body.md"
body_file.write_text(data["body"], encoding="utf-8", newline="")
with _patch_config("pyrite.cli.entry_commands.load_config", cli_env):
updated = runner.invoke(
app,
[
"update",
entry_id,
"--kb",
kb.name,
"--body-file",
str(body_file),
"--format",
"json",
],
)
assert updated.exit_code == 0, updated.output
assert entry_path.read_bytes() == committed_content
diff = subprocess.run(
["git", "diff", "--exit-code"],
cwd=kb.path,
check=False,
capture_output=True,
text=True,
)
assert diff.returncode == 0, diff.stdout + diff.stderr


@pytest.mark.cli
def test_get_not_found_json_is_parseable(cli_env):
"""get -f json for a missing entry emits a valid JSON error object on
Expand Down
1 change: 1 addition & 0 deletions tests/test_test_affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ def test_selector_parses_the_repository(self):
"tests/test_cli_commands.py::TestTyperGetCommand",
"tests/test_cli_commands.py::TestTyperCreateCommand",
"tests/test_cli_commands.py::TestTopLevelHelpAdvertisesOrient",
"tests/test_cli_json_output.py::test_get_reads_current_file_body_and_body_round_trip_is_clean",
}

def test_core_set_is_exactly_the_named_surfaces(self):
Expand Down
Loading