ReMe/reme4/utils/common_utils.py
Sen Huang bb354cc580
refactor(steps): reorganize step modules and remove demo steps (#255)
* feat(config): add comprehensive job definitions for vault operations

- Add utility jobs like version, search, traverse, list, read, stat
- Include file operations like move, delete, upload, download
- Add daily workspace management jobs: daily_list, daily_resolve, daily_reindex
- Update descriptions to reflect vault-based operations instead of working_dir
- Add proper section headers and documentation for each job category

refactor(steps): reorganize step modules and remove demo steps

- Move steps into categorized packages: common, crud, frontmatter, daily, jobs
- Remove demo steps (DemoEchoStep1, DemoEchoStep2, StreamDemoStep1, StreamDemoStep2)
- Add new steps: InitStep for vault initialization, TraverseStep for graph traversal
- Update __init__.py to auto-import all step modules
- Organize imports by functionality (common, CRUD operations, frontmatter, daily)

feat(vault): implement vault-centric file operations and configuration

- Change default config to use vault_dir instead of working_dir
- Add environment variable support for embedding configuration
- Implement file watcher with lite backend for daily/digest directories
- Update search step to use 'name' instead of 'title' from frontmatter
- Create ResourceEntry schema for tracking uploaded assets

docs(steps): add comprehensive documentation for all step categories

- Document file-I/O split by blast radius (crud vs frontmatter packages)
- Add detailed descriptions for each step category and functionality
- Explain the purpose and usage patterns for different types of file operations
- Provide clear parameter documentation for all new job configurations

* fix(config): correct vault directory path and remove unused job configurations

- Fix vault_dir from 'vaultd' to 'vault' in default configuration
- Remove deprecated traverse and list job configurations
- Remove unused tag tooling configurations
- Remove background watch_file job configuration

refactor(steps): remove unused jobs module import

- Comment out jobs module import in steps/__init__.py
- This removes unused synchronizer and digester step registrations

refactor(tests): update import path and add pylint directive

- Update ResourceEntry import from reme4.schema to reme4.schema.resource_meta
- Add pylint disable directive for unused argument in test datetime mocks

* efactor(steps): remove unused modules from __all__

- Remove "background" module from __all__ list
- Remove "jobs" module from __all__ list
- These modules were no longer being used in the steps package

* feat(config): update vault directory structure and remove file watcher

- Change vault_dir reference from ./vault to ./vault in CLI example
- Add daily_dir, digest_dir, and resource_dir configuration options
- Remove file_watcher component configuration as it's no longer needed
- Update comment to reflect correct module name (reme4vault)

refactor(steps): add background step and remove deprecated init step

- Import and register background step module
- Remove deprecated InitStep from common steps
- Update __all__ export list to include background step

refactor(reindex): improve reindex step to scan vault directly

- Update docstring to reflect vault scanning instead of watcher sync
- Replace file watcher stop/start logic with direct vault path walking
- Add support for suffix filtering during reindex operation
- Use index_changes job to process found files

refactor(wikilink_utils): enhance inbound source lookup with link scope

- Import LinkScopeEnum for proper type handling
- Update get_inlinks call to use ALL scope for virtual targets
- Improve documentation for reverse-index lookup behavior

test(refactor): clean up test suite removing deprecated functionality

- Remove test_init_job and test_demo_job unit tests
- Update help job assertion to check for literal command format
- Change test directory from .reme to vault in CRUD tests
- Remove init and demo job calls from integration test

BREAKING CHANGE: Removes file_watcher component and init step

* style(steps): fix import formatting in __init__.py

Add proper spacing in the background module import statement
to maintain consistent code style and readability.

* refactor(config): change default vault directory from vault to .reme

Default dev config now points vault_dir at ./.reme so `python -m
reme4 start` can be run from the repo root and exercise the full
atomic-tool surface against the seeded test data.

BREAKING CHANGE: The default vault directory has been changed from
'vault' to '.reme' in the configuration.

* docs(reme4_report): fix markdown formatting and remove extra content

* refactor(file_parser): delegate wikilink extraction to WikilinkHandler

* fix(search): handle empty query case gracefully

- Replace assertion with conditional check for empty query
- Set response success to false when query is empty
- Return error message instead of throwing assertion error
- Maintain existing validation for other parameters
2026-05-25 17:52:51 +08:00

249 lines
8.7 KiB
Python

"""Common utilities: hashing, async stream task execution, HTTP helpers."""
import asyncio
import hashlib
import json
import socket
import subprocess
import sys
import time
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from typing import Any, Literal
from .logger_utils import get_logger
from ..constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
from ..enumeration import ChunkEnum
from ..schema import StreamChunk
def hash_text(text: str, encoding: str = "utf-8") -> str:
"""Return SHA-256 hex digest of text."""
return hashlib.sha256(text.encode(encoding)).hexdigest()
def _format_chunk(
chunk: StreamChunk,
output_format: Literal["str", "bytes", "chunk"],
) -> str | bytes | StreamChunk:
"""Render a StreamChunk in the requested transport format."""
if output_format == "chunk":
return chunk
data = "data:[DONE]\n\n" if chunk.done else f"data:{chunk.model_dump_json()}\n\n"
return data.encode() if output_format == "bytes" else data
async def execute_stream_task(
stream_queue: asyncio.Queue[StreamChunk],
task: asyncio.Task[Any],
task_name: str | None = None,
output_format: Literal["str", "bytes", "chunk"] = "str",
) -> AsyncGenerator[str | bytes | StreamChunk, None]:
"""Yield chunks from stream_queue while monitoring task; cancels task on exit.
output_format: "str"/"bytes" emit SSE frames, "chunk" emits raw StreamChunk.
"""
logger = get_logger()
consumer: asyncio.Task[StreamChunk] | None = None
try:
while True:
consumer = get_chunk = asyncio.create_task(stream_queue.get())
done, _pending = await asyncio.wait({get_chunk, task}, return_when=asyncio.FIRST_COMPLETED)
# Producer still running — relay the next chunk and continue.
if task not in done:
chunk = get_chunk.result()
yield _format_chunk(chunk, output_format)
if chunk.done:
return
continue
# Producer finished. Capture any pending chunk, then stop the consumer wait
# so we can inspect task state safely.
pending_chunk: StreamChunk | None = None
if get_chunk in done:
pending_chunk = get_chunk.result()
else:
get_chunk.cancel()
try:
await get_chunk
except asyncio.CancelledError:
pass
# Surface task failure first — an exception trumps trailing data.
if task.cancelled():
msg = f"Task cancelled: {task_name}" if task_name else "Task cancelled"
raise asyncio.CancelledError(msg)
exc = task.exception()
if exc is not None:
log_msg = f"Task error in {task_name}: {exc}" if task_name else f"Task error: {exc}"
logger.error(log_msg, exc_info=exc)
raise exc
# Producer ended cleanly — flush pending + drain queue so no chunk is lost,
# then emit the terminal sentinel.
if pending_chunk is not None:
yield _format_chunk(pending_chunk, output_format)
if pending_chunk.done:
return
while not stream_queue.empty():
chunk = stream_queue.get_nowait()
yield _format_chunk(chunk, output_format)
if chunk.done:
return
yield _format_chunk(StreamChunk(chunk_type=ChunkEnum.DONE, chunk="", done=True), output_format)
return
finally:
# Cancel consumer wait if still pending (e.g. on consumer aclose).
if consumer is not None and not consumer.done():
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
# Cancel producer task if still running to avoid resource leaks.
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def _pick_free_port(host: str = REME_DEFAULT_HOST) -> int:
"""Bind to port 0 and return the OS-assigned free port."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((host, 0))
return s.getsockname()[1]
async def _wait_reme_ready(host: str, port: int, timeout: float) -> None:
"""Poll find_reme until it reports 'reme' or timeout elapses."""
from .service_utils import find_reme
deadline = time.time() + timeout
while time.time() < deadline:
status = await find_reme(host, port)
if status == "reme":
return
await asyncio.sleep(0.2)
raise TimeoutError(f"ReMe service did not become ready at {host}:{port} within {timeout}s")
@asynccontextmanager
async def mock_reme_server(
host: str = REME_DEFAULT_HOST,
port: int | None = None,
config: str | None = None,
extra_args: list[str] | None = None,
startup_timeout: float = 30.0,
shutdown_timeout: float = 10.0,
log_to_file: bool = False,
enable_logo: bool = False,
):
"""Spawn `reme4 start` as a subprocess and yield (host, port) once ready.
Auto-picks a free port when port is None. Subprocess is terminated on exit.
"""
logger = get_logger()
if port is None:
port = _pick_free_port(host)
cmd: list[str] = [
sys.executable,
"-m",
"reme4.reme",
"start",
f"service.host={host}",
f"service.port={port}",
f"log_to_file={'true' if log_to_file else 'false'}",
f"enable_logo={'true' if enable_logo else 'false'}",
]
if config:
cmd.append(f"config={config}")
if extra_args:
cmd.extend(extra_args)
logger.info(f"Launching mock reme server: {' '.join(cmd)}")
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
await _wait_reme_ready(host, port, startup_timeout)
yield host, port
except Exception:
# Capture early-exit output for diagnostics.
if proc.poll() is not None and proc.stdout is not None:
tail = proc.stdout.read()
logger.error(f"reme server exited early. output:\n{tail}")
raise
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=shutdown_timeout)
except subprocess.TimeoutExpired:
logger.warning("reme server did not terminate gracefully, killing")
proc.kill()
proc.wait(timeout=shutdown_timeout)
if proc.stdout is not None:
try:
proc.stdout.close()
except Exception:
pass
async def call_action(
action: str,
host: str = REME_DEFAULT_HOST,
port: int = REME_DEFAULT_PORT,
timeout: float = 30.0,
**kwargs,
) -> dict | str:
"""POST to /{action}; return parsed JSON (dict) for JSON endpoints, raw text for SSE."""
from ..components.client.http_client import HttpClient
pieces: list[str] = []
async with HttpClient(host=host, port=port, timeout=timeout) as client:
async for chunk in client.stream_chunks(action, **kwargs):
payload = chunk.chunk
pieces.append(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
raw = "".join(pieces)
try:
return json.loads(raw)
except (ValueError, json.JSONDecodeError):
return raw
async def call_and_check(
action: str,
host: str = REME_DEFAULT_HOST,
port: int = REME_DEFAULT_PORT,
validator: Callable[[Any], bool] | None = None,
expected: Any = None,
timeout: float = 30.0,
**kwargs,
) -> Any:
"""Call action and verify response. Raises AssertionError on mismatch.
- validator(result) -> bool: custom predicate.
- expected: deep-equality target (compared to result, or to result[key] when expected is dict).
"""
result = await call_action(action, host=host, port=port, timeout=timeout, **kwargs)
if validator is not None and not validator(result):
raise AssertionError(f"validator rejected response for action={action!r}: {result!r}")
if expected is not None:
if isinstance(expected, dict) and isinstance(result, dict):
for k, v in expected.items():
if result.get(k) != v:
raise AssertionError(
f"action={action!r} expected {k}={v!r}, got {result.get(k)!r} (full: {result!r})",
)
elif result != expected:
raise AssertionError(f"action={action!r} expected {expected!r}, got {result!r}")
return result