feat: upgrade fastmcp 2.12.3 to 3.0.1 with tool annotations (#598)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2026-02-21 12:28:30 -06:00
committed by GitHub
parent b86dd6fb53
commit 9515130b2a
53 changed files with 917 additions and 777 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ dependencies = [
"alembic>=1.14.1",
"pillow>=11.1.0",
"pybars3>=0.9.7",
"fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
"fastmcp>=3.0.1,<4",
"pyjwt>=2.10.1",
"python-dotenv>=1.1.0",
"pytest-aio>=1.9.0",
+33 -29
View File
@@ -104,7 +104,7 @@ async def _write_note_json(
) -> dict:
"""Write a note and return structured JSON metadata."""
# Use the MCP tool to create/update the entity (handles create-or-update logic)
await mcp_write_note.fn(
await mcp_write_note(
title=title,
content=content,
directory=folder,
@@ -155,7 +155,7 @@ async def _read_note_json(
if entity_id is None:
from basic_memory.mcp.tools.search import search_notes as mcp_search_tool
title_results = await mcp_search_tool.fn(
title_results = await mcp_search_tool(
query=identifier,
search_type="title",
project=project_name,
@@ -387,7 +387,7 @@ def write_note(
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
else:
note = run_with_cleanup(
mcp_write_note.fn(
mcp_write_note(
title=title,
content=content,
directory=folder,
@@ -468,13 +468,15 @@ def read_note(
result["content"] = stripped_content
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
else:
note = run_with_cleanup(
mcp_read_note.fn(
identifier=identifier,
project=project_name,
workspace=workspace,
page=page,
page_size=page_size,
note = str(
run_with_cleanup(
mcp_read_note(
identifier=identifier,
project=project_name,
workspace=workspace,
page=page,
page_size=page_size,
)
)
)
if strip_frontmatter:
@@ -560,16 +562,18 @@ def edit_note(
)
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
else:
result = run_with_cleanup(
mcp_edit_note.fn(
identifier=identifier,
operation=operation,
content=content,
project=project_name,
workspace=workspace,
section=section,
find_text=find_text,
expected_replacements=expected_replacements,
result = str(
run_with_cleanup(
mcp_edit_note(
identifier=identifier,
operation=operation,
content=content,
project=project_name,
workspace=workspace,
section=section,
find_text=find_text,
expected_replacements=expected_replacements,
)
)
)
rprint(result)
@@ -629,7 +633,7 @@ def build_context(
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_build_context.fn(
mcp_build_context(
project=project_name,
workspace=workspace,
url=url,
@@ -712,10 +716,10 @@ def recent_activity(
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
else:
result = run_with_cleanup(
mcp_recent_activity.fn(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
mcp_recent_activity(
type=type, # pyright: ignore[reportArgumentType]
depth=depth if depth is not None else 1,
timeframe=timeframe if timeframe is not None else "7d",
project=project_name,
workspace=workspace,
)
@@ -862,11 +866,12 @@ def search_notes(
with force_routing(local=local, cloud=cloud):
results = run_with_cleanup(
mcp_search.fn(
mcp_search(
query=query or "",
project=project_name,
workspace=workspace,
search_type=search_type,
output_format="json",
page=page,
after_date=after_date,
page_size=page_size,
@@ -881,8 +886,7 @@ def search_notes(
print(results)
raise typer.Exit(1)
results_dict = results.model_dump(exclude_none=True)
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
print(json.dumps(results, indent=2, ensure_ascii=True, default=str))
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@@ -916,7 +920,7 @@ def continue_conversation(
with force_routing(local=local, cloud=cloud):
# Prompt functions return formatted strings directly
session = run_with_cleanup(
mcp_continue_conversation.fn(topic=topic, timeframe=timeframe) # type: ignore[arg-type]
mcp_continue_conversation(topic=topic, timeframe=timeframe) # type: ignore[arg-type]
)
rprint(session)
except ValueError as e:
+22 -19
View File
@@ -99,11 +99,9 @@ def _workspace_choices(workspaces: list[WorkspaceInfo]) -> str:
async def get_available_workspaces(context: Optional[Context] = None) -> list[WorkspaceInfo]:
"""Load available cloud workspaces for the current authenticated user."""
if context:
cached_workspaces = context.get_state("available_workspaces")
if isinstance(cached_workspaces, list) and all(
isinstance(item, WorkspaceInfo) for item in cached_workspaces
):
return cached_workspaces
cached_raw = await context.get_state("available_workspaces")
if isinstance(cached_raw, list):
return [WorkspaceInfo.model_validate(item) for item in cached_raw]
from basic_memory.mcp.async_client import get_cloud_control_plane_client
from basic_memory.mcp.tools.utils import call_get
@@ -113,7 +111,10 @@ async def get_available_workspaces(context: Optional[Context] = None) -> list[Wo
workspace_list = WorkspaceListResponse.model_validate(response.json())
if context:
context.set_state("available_workspaces", workspace_list.workspaces)
await context.set_state(
"available_workspaces",
[ws.model_dump() for ws in workspace_list.workspaces],
)
return workspace_list.workspaces
@@ -124,12 +125,12 @@ async def resolve_workspace_parameter(
) -> WorkspaceInfo:
"""Resolve workspace using explicit input, session cache, and cloud discovery."""
if context:
cached_workspace = context.get_state("active_workspace")
if isinstance(cached_workspace, WorkspaceInfo) and (
workspace is None or _workspace_matches_identifier(cached_workspace, workspace)
):
logger.debug(f"Using cached workspace from context: {cached_workspace.tenant_id}")
return cached_workspace
cached_raw = await context.get_state("active_workspace")
if isinstance(cached_raw, dict):
cached_workspace = WorkspaceInfo.model_validate(cached_raw)
if workspace is None or _workspace_matches_identifier(cached_workspace, workspace):
logger.debug(f"Using cached workspace from context: {cached_workspace.tenant_id}")
return cached_workspace
workspaces = await get_available_workspaces(context=context)
if not workspaces:
@@ -164,7 +165,7 @@ async def resolve_workspace_parameter(
)
if context:
context.set_state("active_workspace", selected_workspace)
await context.set_state("active_workspace", selected_workspace.model_dump())
logger.debug(f"Cached workspace in context: {selected_workspace.tenant_id}")
return selected_workspace
@@ -206,10 +207,12 @@ async def get_active_project(
# Check if already cached in context
if context:
cached_project = context.get_state("active_project")
if cached_project and cached_project.name == project:
logger.debug(f"Using cached project from context: {project}")
return cached_project
cached_raw = await context.get_state("active_project")
if isinstance(cached_raw, dict):
cached_project = ProjectItem.model_validate(cached_raw)
if cached_project.name == project:
logger.debug(f"Using cached project from context: {project}")
return cached_project
# Validate project exists by calling API
logger.debug(f"Validating project: {project}")
@@ -230,7 +233,7 @@ async def get_active_project(
# Cache in context if available
if context:
context.set_state("active_project", active_project)
await context.set_state("active_project", active_project.model_dump())
logger.debug(f"Cached project in context: {project}")
logger.debug(f"Validated project: {active_project.name}")
@@ -307,7 +310,7 @@ async def resolve_project_and_path(
is_default=resolved.is_default,
)
if context:
context.set_state("active_project", active_project)
await context.set_state("active_project", active_project.model_dump())
resolved_path = f"{resolved.permalink}/{remainder}" if include_project else remainder
return active_project, resolved_path, True
@@ -46,7 +46,7 @@ async def recent_activity_prompt(
logger.info(f"Getting recent activity, timeframe: {timeframe}, project: {project}")
# Call the tool function - it returns a well-formatted string
activity_summary = await recent_activity.fn(project=project, timeframe=timeframe)
activity_summary = await recent_activity(project=project, timeframe=timeframe)
# Build the prompt response
# The tool already returns formatted markdown, so we use it directly
@@ -192,6 +192,7 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
- "json" (default): Slimmed JSON with redundant fields removed
- "text": Compact markdown text for LLM consumption
""",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def build_context(
url: MemoryUrl,
+1
View File
@@ -16,6 +16,7 @@ from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
@mcp.tool(
description="Create an Obsidian canvas file to visualize concepts and connections.",
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
)
async def canvas(
nodes: List[Dict[str, Any]],
+18 -10
View File
@@ -92,7 +92,10 @@ def _format_document_for_chatgpt(
}
@mcp.tool(description="Search for content across the knowledge base")
@mcp.tool(
description="Search for content across the knowledge base",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search(
query: str,
context: Context | None = None,
@@ -115,7 +118,7 @@ async def search(
default_project = config.default_project
# Call underlying search_notes with sensible defaults for ChatGPT
results = await search_notes.fn(
results = await search_notes(
query=query,
project=default_project, # Use default project for ChatGPT
page=1,
@@ -156,7 +159,10 @@ async def search(
return [{"type": "text", "text": json.dumps(error_results, ensure_ascii=False)}]
@mcp.tool(description="Fetch the full contents of a search result document")
@mcp.tool(
description="Fetch the full contents of a search result document",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def fetch(
id: str,
context: Context | None = None,
@@ -178,13 +184,15 @@ async def fetch(
config = ConfigManager().config
default_project = config.default_project
# Call underlying read_note function
content = await read_note.fn(
identifier=id,
project=default_project, # Use default project for ChatGPT
page=1,
page_size=10, # Default pagination
context=context,
# Call underlying read_note function (default output_format="text" returns str)
content = str(
await read_note(
identifier=id,
project=default_project, # Use default project for ChatGPT
page=1,
page_size=10, # Default pagination
context=context,
)
)
# Format the document for ChatGPT
+4 -1
View File
@@ -5,7 +5,10 @@ from pathlib import Path
from basic_memory.mcp.server import mcp
@mcp.tool("cloud_info")
@mcp.tool(
"cloud_info",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
def cloud_info() -> str:
"""Return optional Basic Memory Cloud information and setup guidance."""
content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md"
+4 -1
View File
@@ -146,7 +146,10 @@ delete_note("{project}", "correct-identifier-from-search")
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
@mcp.tool(description="Delete a note or directory by title, permalink, or path")
@mcp.tool(
description="Delete a note or directory by title, permalink, or path",
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_note(
identifier: str,
is_directory: bool = False,
+1
View File
@@ -125,6 +125,7 @@ Error editing note '{identifier}': {error_message}
@mcp.tool(
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def edit_note(
identifier: str,
@@ -11,6 +11,7 @@ from basic_memory.mcp.server import mcp
@mcp.tool(
description="List directory contents with filtering and depth control.",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_directory(
dir_name: str = "/",
+1
View File
@@ -343,6 +343,7 @@ delete_note("{identifier}")
@mcp.tool(
description="Move a note or directory to a new location, updating database and maintaining links.",
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def move_note(
identifier: str,
@@ -14,7 +14,10 @@ from basic_memory.schemas.project_info import ProjectInfoRequest
from basic_memory.utils import generate_permalink
@mcp.tool("list_memory_projects")
@mcp.tool(
"list_memory_projects",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_memory_projects(
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
@@ -77,7 +80,10 @@ async def list_memory_projects(
return result
@mcp.tool("create_memory_project")
@mcp.tool(
"create_memory_project",
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def create_memory_project(
project_name: str,
project_path: str,
@@ -193,7 +199,9 @@ async def create_memory_project(
return result
@mcp.tool()
@mcp.tool(
annotations={"destructiveHint": True, "openWorldHint": False},
)
async def delete_project(project_name: str, context: Context | None = None) -> str:
"""Delete a Basic Memory project.
+4 -1
View File
@@ -148,7 +148,10 @@ def optimize_image(img, content_length, max_output_bytes=350000):
return buf.getvalue()
@mcp.tool(description="Read a file's raw content by path or permalink")
@mcp.tool(
description="Read a file's raw content by path or permalink",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def read_content(
path: str,
project: Optional[str] = None,
+3 -2
View File
@@ -59,6 +59,7 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]:
description="Read a markdown note by title or permalink.",
# TODO: re-enable once MCP client rendering is working
# meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def read_note(
identifier: str,
@@ -228,7 +229,7 @@ async def read_note(
# Fallback 1: Try title search via API
logger.info(f"Search title for: {identifier}")
title_results = await search_notes.fn(
title_results = await search_notes(
query=identifier,
search_type="title",
project=active_project.name,
@@ -280,7 +281,7 @@ async def read_note(
# Fallback 2: Text search as a last resort
logger.info(f"Title search failed, trying text search for: {identifier}")
text_results = await search_notes.fn(
text_results = await search_notes(
query=identifier,
search_type="text",
project=active_project.name,
@@ -35,6 +35,7 @@ from basic_memory.schemas.search import SearchItemType
- "3 weeks ago"
Or standard formats like "7d"
""",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def recent_activity(
type: Union[str, List[str]] = "",
+4 -1
View File
@@ -5,7 +5,10 @@ from pathlib import Path
from basic_memory.mcp.server import mcp
@mcp.tool("release_notes")
@mcp.tool(
"release_notes",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
def release_notes() -> str:
"""Return the latest product release notes for optional user review."""
content_path = Path(__file__).parent.parent / "resources" / "release_notes.md"
+3
View File
@@ -71,6 +71,7 @@ def _no_schema_guidance(note_type: str, tool_name: str) -> str:
@mcp.tool(
description="Validate notes against their Picoschema definitions.",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_validate(
note_type: Optional[str] = None,
@@ -166,6 +167,7 @@ async def schema_validate(
@mcp.tool(
description="Analyze existing notes and suggest a Picoschema definition.",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_infer(
note_type: str,
@@ -267,6 +269,7 @@ async def schema_infer(
@mcp.tool(
description="Detect drift between a schema definition and actual note usage.",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def schema_diff(
note_type: str,
+2
View File
@@ -250,6 +250,7 @@ Error searching for '{query}': {error_message}
description="Search across all content in the knowledge base with advanced syntax support.",
# TODO: re-enable once MCP client rendering is working
# meta={"ui/resourceUri": "ui://basic-memory/search-results"},
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search_notes(
query: str,
@@ -514,6 +515,7 @@ async def search_notes(
@mcp.tool(
description="Search entities by structured frontmatter metadata.",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search_by_metadata(
filters: Dict[str, Any],
+4 -2
View File
@@ -20,6 +20,7 @@ def _text_block(message: str) -> List[ContentBlock]:
@mcp.tool(
description="Search notes and return an embedded MCP-UI resource (raw HTML).",
output_schema=None,
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def search_notes_ui(
query: str,
@@ -36,7 +37,7 @@ async def search_notes_ui(
context: Context | None = None,
) -> List[ContentBlock]:
"""Return a search results UI as an embedded MCP-UI resource."""
result = await search_notes.fn(
result = await search_notes(
query=query,
project=project,
page=page,
@@ -82,6 +83,7 @@ async def search_notes_ui(
@mcp.tool(
description="Read a note and return an embedded MCP-UI resource (raw HTML).",
output_schema=None,
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def read_note_ui(
identifier: str,
@@ -91,7 +93,7 @@ async def read_note_ui(
context: Context | None = None,
) -> List[ContentBlock]:
"""Return a note preview UI as an embedded MCP-UI resource."""
content = await read_note.fn(
content = await read_note(
identifier=identifier,
project=project,
page=page,
+11 -8
View File
@@ -12,6 +12,7 @@ from basic_memory.mcp.tools.read_note import read_note
@mcp.tool(
description="View a note as a formatted artifact for better readability.",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def view_note(
identifier: str,
@@ -57,14 +58,16 @@ async def view_note(
"""
logger.info(f"Viewing note: {identifier} in project: {project}")
# Call the existing read_note logic
content = await read_note.fn(
identifier=identifier,
project=project,
workspace=workspace,
page=page,
page_size=page_size,
context=context,
# Call the existing read_note logic (default output_format="text" returns str)
content = str(
await read_note(
identifier=identifier,
project=project,
workspace=workspace,
page=page,
page_size=page_size,
context=context,
)
)
# Check if this is an error message (note not found)
+4 -1
View File
@@ -6,7 +6,10 @@ from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.mcp.server import mcp
@mcp.tool(description="List available cloud workspaces (tenant_id, type, role, and name).")
@mcp.tool(
description="List available cloud workspaces (tenant_id, type, role, and name).",
annotations={"readOnlyHint": True, "openWorldHint": False},
)
async def list_workspaces(context: Context | None = None) -> str:
"""List workspaces available to the current cloud user."""
workspaces = await get_available_workspaces(context=context)
+1
View File
@@ -16,6 +16,7 @@ TagType = Union[List[str], str, None]
@mcp.tool(
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
)
async def write_note(
title: str,
+43 -47
View File
@@ -96,14 +96,13 @@ def test_write_note_json_output(mock_write_json, mock_config_cls):
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_write_note",
new_callable=AsyncMock,
return_value="Created note: Test Note",
)
def test_write_note_text_output(mock_mcp_write, mock_config_cls):
"""write-note with default text format uses the MCP tool path."""
mock_config_cls.return_value = _mock_config_manager()
# MCP tool .fn returns a formatted string
mock_mcp_write.fn = AsyncMock(return_value="Created note: Test Note")
result = runner.invoke(
cli_app,
[
@@ -120,7 +119,7 @@ def test_write_note_text_output(mock_mcp_write, mock_config_cls):
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "Created note: Test Note" in result.output
mock_mcp_write.fn.assert_called_once()
mock_mcp_write.assert_called_once()
# --- read-note --format json ---
@@ -156,15 +155,13 @@ def test_read_note_json_output(mock_read_json, mock_config_cls):
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value="---\ntitle: Test Note\n---\n# Test Note\n\nhello world",
)
def test_read_note_text_output(mock_mcp_read, mock_config_cls):
"""read-note with default text format uses the MCP tool path."""
mock_config_cls.return_value = _mock_config_manager()
mock_mcp_read.fn = AsyncMock(
return_value="---\ntitle: Test Note\n---\n# Test Note\n\nhello world"
)
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note"],
@@ -172,15 +169,18 @@ def test_read_note_text_output(mock_mcp_read, mock_config_cls):
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "---" in result.output
mock_mcp_read.fn.assert_called_once()
mock_mcp_read.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch("basic_memory.cli.commands.tool.mcp_read_note")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value="# Test Note",
)
def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls):
"""read-note --workspace passes workspace through to the MCP tool call."""
mock_config_cls.return_value = _mock_config_manager()
mock_mcp_read.fn = AsyncMock(return_value="# Test Note")
result = runner.invoke(
cli_app,
@@ -188,8 +188,8 @@ def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls):
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
mock_mcp_read.fn.assert_called_once()
assert mock_mcp_read.fn.call_args.kwargs["workspace"] == "tenant-123"
mock_mcp_read.assert_called_once()
assert mock_mcp_read.call_args.kwargs["workspace"] == "tenant-123"
@patch("basic_memory.cli.commands.tool.ConfigManager")
@@ -220,15 +220,13 @@ def test_read_note_json_strip_frontmatter(mock_read_json, mock_config_cls):
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value="---\ntitle: Test Note\n---\n# Test Note\n\nhello world",
)
def test_read_note_text_strip_frontmatter(mock_mcp_read, mock_config_cls):
"""read-note --strip-frontmatter strips opening frontmatter in text mode."""
mock_config_cls.return_value = _mock_config_manager()
mock_mcp_read.fn = AsyncMock(
return_value="---\ntitle: Test Note\n---\n# Test Note\n\nhello world"
)
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--strip-frontmatter"],
@@ -237,19 +235,19 @@ def test_read_note_text_strip_frontmatter(mock_mcp_read, mock_config_cls):
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "---" not in result.output
assert "# Test Note" in result.output
mock_mcp_read.fn.assert_called_once()
mock_mcp_read.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
new_callable=AsyncMock,
return_value="# Test Note\n\nhello world",
)
def test_read_note_text_strip_frontmatter_no_frontmatter(mock_mcp_read, mock_config_cls):
"""read-note --strip-frontmatter keeps notes unchanged when no frontmatter exists."""
mock_config_cls.return_value = _mock_config_manager()
mock_mcp_read.fn = AsyncMock(return_value="# Test Note\n\nhello world")
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--strip-frontmatter"],
@@ -257,7 +255,7 @@ def test_read_note_text_strip_frontmatter_no_frontmatter(mock_mcp_read, mock_con
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert result.output.strip() == "# Test Note\n\nhello world"
mock_mcp_read.fn.assert_called_once()
mock_mcp_read.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@@ -343,11 +341,11 @@ def test_recent_activity_json_output(mock_recent_json):
@patch(
"basic_memory.cli.commands.tool.mcp_recent_activity",
new_callable=AsyncMock,
return_value="Recent activity:\n- Note A\n- Note B",
)
def test_recent_activity_text_output(mock_mcp_recent):
"""recent-activity with default text format uses the MCP tool path."""
mock_mcp_recent.fn = AsyncMock(return_value="Recent activity:\n- Note A\n- Note B")
result = runner.invoke(
cli_app,
["tool", "recent-activity"],
@@ -355,7 +353,7 @@ def test_recent_activity_text_output(mock_mcp_recent):
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "Recent activity:" in result.output
mock_mcp_recent.fn.assert_called_once()
mock_mcp_recent.assert_called_once()
# --- read-note title fallback ---
@@ -413,21 +411,20 @@ def test_recent_activity_json_pagination(mock_recent_json):
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch("basic_memory.cli.commands.tool.mcp_build_context")
@patch(
"basic_memory.cli.commands.tool.mcp_build_context",
new_callable=AsyncMock,
return_value={
"results": [],
"metadata": {"uri": "test/topic", "depth": 1},
"page": 1,
"page_size": 10,
},
)
def test_build_context_format_json(mock_build_ctx, mock_config_cls):
"""build-context --format json outputs valid JSON."""
mock_config_cls.return_value = _mock_config_manager()
# build_context now returns a slimmed dict directly
mock_build_ctx.fn = AsyncMock(
return_value={
"results": [],
"metadata": {"uri": "test/topic", "depth": 1},
"page": 1,
"page_size": 10,
}
)
result = runner.invoke(
cli_app,
["tool", "build-context", "memory://test/topic", "--format", "json"],
@@ -436,25 +433,24 @@ def test_build_context_format_json(mock_build_ctx, mock_config_cls):
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert "results" in data
mock_build_ctx.fn.assert_called_once()
mock_build_ctx.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch("basic_memory.cli.commands.tool.mcp_build_context")
@patch(
"basic_memory.cli.commands.tool.mcp_build_context",
new_callable=AsyncMock,
return_value={
"results": [],
"metadata": {"uri": "test/topic", "depth": 1},
"page": 1,
"page_size": 10,
},
)
def test_build_context_default_format_is_json(mock_build_ctx, mock_config_cls):
"""build-context defaults to JSON output (backward compatible)."""
mock_config_cls.return_value = _mock_config_manager()
# build_context now returns a slimmed dict directly
mock_build_ctx.fn = AsyncMock(
return_value={
"results": [],
"metadata": {"uri": "test/topic", "depth": 1},
"page": 1,
"page_size": 10,
}
)
result = runner.invoke(
cli_app,
["tool", "build-context", "memory://test/topic"],
+8 -8
View File
@@ -9,7 +9,7 @@ from basic_memory.mcp.tools import write_note
async def test_write_note_tags_yaml_format(app, project_config, test_project):
"""Test that write_note creates files with proper YAML list format for tags."""
# Create a note with tags using write_note
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="YAML Format Test",
directory="test",
@@ -41,7 +41,7 @@ async def test_write_note_tags_yaml_format(app, project_config, test_project):
async def test_write_note_stringified_json_tags(app, project_config, test_project):
"""Test that stringified JSON arrays are handled correctly."""
# This simulates the issue where AI assistants pass tags as stringified JSON
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Stringified JSON Test",
directory="test",
@@ -71,7 +71,7 @@ async def test_write_note_stringified_json_tags(app, project_config, test_projec
@pytest.mark.asyncio
async def test_write_note_single_tag_yaml_format(app, project_config, test_project):
"""Test that single tags are still formatted as YAML lists."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Single Tag Test",
directory="test",
@@ -90,7 +90,7 @@ async def test_write_note_single_tag_yaml_format(app, project_config, test_proje
@pytest.mark.asyncio
async def test_write_note_no_tags(app, project_config, test_project):
"""Test that notes without tags work normally."""
await write_note.fn(
await write_note(
project=test_project.name,
title="No Tags Test",
directory="test",
@@ -109,7 +109,7 @@ async def test_write_note_no_tags(app, project_config, test_project):
@pytest.mark.asyncio
async def test_write_note_empty_tags_list(app, project_config, test_project):
"""Test that empty tag lists are handled properly."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Empty Tags Test",
directory="test",
@@ -128,7 +128,7 @@ async def test_write_note_empty_tags_list(app, project_config, test_project):
async def test_write_note_update_preserves_yaml_format(app, project_config, test_project):
"""Test that updating a note preserves the YAML list format."""
# First, create the note
await write_note.fn(
await write_note(
project=test_project.name,
title="Update Format Test",
directory="test",
@@ -137,7 +137,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
)
# Then update it with new tags
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Update Format Test",
directory="test",
@@ -170,7 +170,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
@pytest.mark.asyncio
async def test_complex_tags_yaml_format(app, project_config, test_project):
"""Test that complex tags with special characters format correctly."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Complex Tags Test",
directory="test",
@@ -62,7 +62,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
- Data loss occurs without user warning
"""
# Step 1: Create first note "Node A"
result_a = await write_note.fn(
result_a = await write_note(
project=test_project.name,
title="Node A",
directory="edge-cases",
@@ -74,12 +74,12 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
assert f"permalink: {test_project.name}/edge-cases/node-a" in result_a
# Verify Node A content via read
content_a = await read_note.fn("edge-cases/node-a", project=test_project.name)
content_a = await read_note("edge-cases/node-a", project=test_project.name)
assert "Node A" in content_a
assert "Original content for Node A" in content_a
# Step 2: Create second note "Node B" (should be independent)
result_b = await write_note.fn(
result_b = await write_note(
project=test_project.name,
title="Node B",
directory="edge-cases",
@@ -91,7 +91,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
assert f"permalink: {test_project.name}/edge-cases/node-b" in result_b
# Step 3: Create third note "Node C" (this is where the bug occurs)
result_c = await write_note.fn(
result_c = await write_note(
project=test_project.name,
title="Node C",
directory="edge-cases",
@@ -104,7 +104,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
# CRITICAL CHECK: Verify Node A still has its original content
# This is where the bug manifests - Node A.md gets overwritten with Node C content
content_a_after = await read_note.fn("edge-cases/node-a", project=test_project.name)
content_a_after = await read_note("edge-cases/node-a", project=test_project.name)
assert "Node A" in content_a_after, "Node A title should still be 'Node A'"
assert "Original content for Node A" in content_a_after, (
"Node A file should NOT be overwritten by Node C creation"
@@ -112,7 +112,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
assert "Content for Node C" not in content_a_after, "Node A should NOT contain Node C's content"
# Verify Node C has its own content
content_c = await read_note.fn("edge-cases/node-c", project=test_project.name)
content_c = await read_note("edge-cases/node-c", project=test_project.name)
assert "Node C" in content_c
assert "Content for Node C" in content_c
assert "Original content for Node A" not in content_c, (
@@ -163,7 +163,7 @@ async def test_notes_with_similar_titles_maintain_separate_files(app, test_proje
created_permalinks = []
for title, folder in titles_and_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title=title,
directory=folder,
@@ -179,7 +179,7 @@ async def test_notes_with_similar_titles_maintain_separate_files(app, test_proje
break
# Verify each note can be read back with its own content
content = await read_note.fn(permalink, project=test_project.name)
content = await read_note(permalink, project=test_project.name)
assert f"Unique content for {title}" in content, (
f"Note with title '{title}' should maintain its unique content"
)
@@ -208,7 +208,7 @@ async def test_sequential_note_creation_preserves_all_files(app, test_project):
# Create all notes
for title, content in notes_data:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title=title,
directory="sequence-test",
@@ -220,7 +220,7 @@ async def test_sequential_note_creation_preserves_all_files(app, test_project):
for title, expected_content in notes_data:
# Normalize title to permalink format
permalink = f"sequence-test/{title.lower()}"
content = await read_note.fn(permalink, project=test_project.name)
content = await read_note(permalink, project=test_project.name)
assert title in content, f"Note '{title}' should still have its title"
assert expected_content.split("\n\n")[1] in content, (
+4 -4
View File
@@ -15,10 +15,10 @@ class _ContextState:
def __init__(self):
self._state: dict[str, object] = {}
def get_state(self, key: str):
async def get_state(self, key: str):
return self._state.get(key)
def set_state(self, key: str, value: object) -> None:
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
@@ -142,7 +142,7 @@ async def test_workspace_auto_selects_single_and_caches(monkeypatch):
resolved = await resolve_workspace_parameter(context=context)
assert resolved.tenant_id == only_workspace.tenant_id
assert context.get_state("active_workspace") == only_workspace
assert await context.get_state("active_workspace") == only_workspace.model_dump()
@pytest.mark.asyncio
@@ -251,7 +251,7 @@ async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch):
role="owner",
)
context = _ContextState()
context.set_state("active_workspace", cached_workspace)
await context.set_state("active_workspace", cached_workspace.model_dump())
async def fail_if_called(context=None): # pragma: no cover
raise AssertionError("Workspace fetch should not run when cache is available")
+10 -10
View File
@@ -15,7 +15,7 @@ async def test_continue_conversation_with_topic(client, test_graph):
# We can use the test_graph fixture which already has relevant content
# Call the function with a topic that should match existing content
result = await continue_conversation.fn(topic="Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
result = await continue_conversation(topic="Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
# Check that the result contains expected content
assert "Continuing conversation on: Root" in result # pyright: ignore [reportOperatorIssue]
@@ -27,7 +27,7 @@ async def test_continue_conversation_with_topic(client, test_graph):
async def test_continue_conversation_with_recent_activity(client, test_graph):
"""Test continue_conversation with no topic, using recent activity."""
# Call the function without a topic
result = await continue_conversation.fn(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
result = await continue_conversation(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
# Check that the result contains expected content for recent activity
assert "Continuing conversation on: Recent Activity" in result # pyright: ignore [reportOperatorIssue]
@@ -40,7 +40,7 @@ async def test_continue_conversation_with_recent_activity(client, test_graph):
async def test_continue_conversation_no_results(client):
"""Test continue_conversation when no results are found."""
# Call with a non-existent topic
result = await continue_conversation.fn(topic="NonExistentTopic", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
result = await continue_conversation(topic="NonExistentTopic", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
# Check the response indicates no results found
assert "Continuing conversation on: NonExistentTopic" in result # pyright: ignore [reportOperatorIssue]
@@ -51,7 +51,7 @@ async def test_continue_conversation_no_results(client):
async def test_continue_conversation_creates_structured_suggestions(client, test_graph):
"""Test that continue_conversation generates structured tool usage suggestions."""
# Call the function with a topic that should match existing content
result = await continue_conversation.fn(topic="Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
result = await continue_conversation(topic="Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
# Verify the response includes clear tool usage instructions
assert "start by executing one of the suggested commands" in result.lower() # pyright: ignore [reportAttributeAccessIssue]
@@ -69,7 +69,7 @@ async def test_continue_conversation_creates_structured_suggestions(client, test
async def test_search_prompt_with_results(client, test_graph):
"""Test search_prompt with a query that returns results."""
# Call the function with a query that should match existing content
result = await search_prompt.fn("Root") # pyright: ignore [reportGeneralTypeIssues]
result = await search_prompt("Root") # pyright: ignore [reportGeneralTypeIssues]
# Check the response contains expected content
assert 'Search Results for: "Root"' in result # pyright: ignore [reportOperatorIssue]
@@ -82,7 +82,7 @@ async def test_search_prompt_with_results(client, test_graph):
async def test_search_prompt_with_timeframe(client, test_graph):
"""Test search_prompt with a timeframe."""
# Call the function with a query and timeframe
result = await search_prompt.fn("Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
result = await search_prompt("Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
# Check the response includes timeframe information
assert 'Search Results for: "Root" (after 7d)' in result # pyright: ignore [reportOperatorIssue]
@@ -93,7 +93,7 @@ async def test_search_prompt_with_timeframe(client, test_graph):
async def test_search_prompt_no_results(client):
"""Test search_prompt when no results are found."""
# Call with a query that won't match anything
result = await search_prompt.fn("XYZ123NonExistentQuery") # pyright: ignore [reportGeneralTypeIssues]
result = await search_prompt("XYZ123NonExistentQuery") # pyright: ignore [reportGeneralTypeIssues]
# Check the response indicates no results found
assert 'Search Results for: "XYZ123NonExistentQuery"' in result # pyright: ignore [reportOperatorIssue]
@@ -151,7 +151,7 @@ def test_prompt_context_with_file_path_no_permalink():
async def test_recent_activity_prompt_discovery_mode(client, test_project, test_graph):
"""Test recent_activity_prompt in discovery mode (no project)."""
# Call the function in discovery mode
result = await recent_activity_prompt.fn(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
result = await recent_activity_prompt(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
# Check the response contains expected discovery mode content
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
@@ -164,7 +164,7 @@ async def test_recent_activity_prompt_discovery_mode(client, test_project, test_
async def test_recent_activity_prompt_project_specific(client, test_project, test_graph):
"""Test recent_activity_prompt in project-specific mode."""
# Call the function with a specific project
result = await recent_activity_prompt.fn(timeframe="1w", project=test_project.name) # pyright: ignore [reportGeneralTypeIssues]
result = await recent_activity_prompt(timeframe="1w", project=test_project.name) # pyright: ignore [reportGeneralTypeIssues]
# Check the response contains expected project-specific content
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
@@ -177,7 +177,7 @@ async def test_recent_activity_prompt_project_specific(client, test_project, tes
async def test_recent_activity_prompt_with_custom_timeframe(client, test_project, test_graph):
"""Test recent_activity_prompt with custom timeframe."""
# Call the function with a custom timeframe in discovery mode
result = await recent_activity_prompt.fn(timeframe="1d") # pyright: ignore [reportGeneralTypeIssues]
result = await recent_activity_prompt(timeframe="1d") # pyright: ignore [reportGeneralTypeIssues]
# Check the response includes the custom timeframe
assert "1d" in result # pyright: ignore [reportOperatorIssue]
@@ -28,9 +28,9 @@ async def test_recent_activity_prompt_discovery_mode(monkeypatch):
async def fake_fn(**_kwargs):
return tool_output
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity", fake_fn)
out = await recent_activity_prompt.fn(timeframe="7d", project=None) # pyright: ignore[reportGeneralTypeIssues]
out = await recent_activity_prompt(timeframe="7d", project=None) # pyright: ignore[reportGeneralTypeIssues]
# Should contain the tool output
assert "Recent Activity Summary (7d)" in out
@@ -59,9 +59,9 @@ async def test_recent_activity_prompt_project_mode(monkeypatch):
async def fake_fn(**_kwargs):
return tool_output
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity", fake_fn)
out = await recent_activity_prompt.fn(timeframe="1d", project="my-project") # pyright: ignore[reportGeneralTypeIssues]
out = await recent_activity_prompt(timeframe="1d", project="my-project") # pyright: ignore[reportGeneralTypeIssues]
# Should contain the tool output
assert "Recent Activity: my-project" in out
@@ -82,9 +82,9 @@ async def test_recent_activity_prompt_passes_correct_params(monkeypatch):
captured_kwargs.update(kwargs)
return "## Recent Activity"
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity", fake_fn)
await recent_activity_prompt.fn(timeframe="2d", project="test-proj") # pyright: ignore[reportGeneralTypeIssues]
await recent_activity_prompt(timeframe="2d", project="test-proj") # pyright: ignore[reportGeneralTypeIssues]
assert captured_kwargs["timeframe"] == "2d"
assert captured_kwargs["project"] == "test-proj"
@@ -100,9 +100,9 @@ async def test_recent_activity_prompt_defaults_timeframe(monkeypatch):
captured_kwargs.update(kwargs)
return "## Recent Activity"
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity", fake_fn)
await recent_activity_prompt.fn(timeframe=None, project=None) # pyright: ignore[reportGeneralTypeIssues]
await recent_activity_prompt(timeframe=None, project=None) # pyright: ignore[reportGeneralTypeIssues]
assert captured_kwargs["timeframe"] == "7d"
assert captured_kwargs["project"] is None
+1 -1
View File
@@ -8,7 +8,7 @@ import pytest
async def test_ai_assistant_guide_exists(app):
"""Test that the canvas spec resource exists and returns content."""
# Call the resource function
guide = ai_assistant_guide.fn()
guide = ai_assistant_guide()
# Verify basic characteristics of the content
assert guide is not None
+12 -12
View File
@@ -10,7 +10,7 @@ from basic_memory.mcp.tools import build_context
@pytest.mark.asyncio
async def test_get_basic_discussion_context(client, test_graph, test_project):
"""Test getting basic discussion context returns slimmed JSON dict."""
result = await build_context.fn(project=test_project.name, url="memory://test/root")
result = await build_context(project=test_project.name, url="memory://test/root")
assert isinstance(result, dict)
assert len(result["results"]) == 1
@@ -43,7 +43,7 @@ async def test_get_basic_discussion_context(client, test_graph, test_project):
@pytest.mark.asyncio
async def test_get_discussion_context_pattern(client, test_graph, test_project):
"""Test getting context with pattern matching."""
result = await build_context.fn(project=test_project.name, url="memory://test/*", depth=1)
result = await build_context(project=test_project.name, url="memory://test/*", depth=1)
assert isinstance(result, dict)
assert len(result["results"]) > 1 # Should match multiple test/* paths
@@ -58,14 +58,14 @@ async def test_get_discussion_context_pattern(client, test_graph, test_project):
async def test_get_discussion_context_timeframe(client, test_graph, test_project):
"""Test timeframe parameter filtering."""
# Get recent context
recent = await build_context.fn(
recent = await build_context(
project=test_project.name,
url="memory://test/root",
timeframe="1d",
)
# Get older context
older = await build_context.fn(
older = await build_context(
project=test_project.name,
url="memory://test/root",
timeframe="30d",
@@ -85,7 +85,7 @@ async def test_get_discussion_context_timeframe(client, test_graph, test_project
@pytest.mark.asyncio
async def test_get_discussion_context_not_found(client, test_project):
"""Test handling of non-existent URIs."""
result = await build_context.fn(project=test_project.name, url="memory://test/does-not-exist")
result = await build_context(project=test_project.name, url="memory://test/does-not-exist")
assert isinstance(result, dict)
assert len(result["results"]) == 0
@@ -114,7 +114,7 @@ async def test_build_context_timeframe_formats(client, test_graph, test_project)
# Test each valid timeframe
for timeframe in valid_timeframes:
try:
result = await build_context.fn(
result = await build_context(
project=test_project.name,
url=test_url,
timeframe=timeframe,
@@ -129,7 +129,7 @@ async def test_build_context_timeframe_formats(client, test_graph, test_project)
# Test invalid timeframes should raise ValidationError
for timeframe in invalid_timeframes:
with pytest.raises(ToolError):
await build_context.fn(project=test_project.name, url=test_url, timeframe=timeframe)
await build_context(project=test_project.name, url=test_url, timeframe=timeframe)
@pytest.mark.asyncio
@@ -139,7 +139,7 @@ async def test_build_context_string_depth_parameter(client, test_graph, test_pro
# Test valid string depth parameter — should convert to int
try:
result = await build_context.fn(url=test_url, depth="2", project=test_project.name)
result = await build_context(url=test_url, depth="2", project=test_project.name)
assert isinstance(result["metadata"]["depth"], int)
assert result["metadata"]["depth"] == 2
except ToolError:
@@ -148,13 +148,13 @@ async def test_build_context_string_depth_parameter(client, test_graph, test_pro
# Test invalid string depth parameter - should raise ToolError
with pytest.raises(ToolError):
await build_context.fn(test_url, depth="invalid", project=test_project.name)
await build_context(test_url, depth="invalid", project=test_project.name)
@pytest.mark.asyncio
async def test_build_context_text_format(client, test_graph, test_project):
"""Test that output_format='text' returns compact text."""
result = await build_context.fn(
result = await build_context(
project=test_project.name,
url="memory://test/root",
output_format="text",
@@ -173,7 +173,7 @@ async def test_build_context_text_format(client, test_graph, test_project):
@pytest.mark.asyncio
async def test_build_context_markdown_pattern(client, test_graph, test_project):
"""Test markdown format with pattern matching (multiple results)."""
result = await build_context.fn(
result = await build_context(
project=test_project.name,
url="memory://test/*",
output_format="text",
@@ -190,7 +190,7 @@ async def test_build_context_markdown_pattern(client, test_graph, test_project):
@pytest.mark.asyncio
async def test_build_context_markdown_not_found(client, test_project):
"""Test markdown format for non-existent URIs."""
result = await build_context.fn(
result = await build_context(
project=test_project.name,
url="memory://test/does-not-exist",
output_format="text",
+6 -8
View File
@@ -34,7 +34,7 @@ async def test_create_canvas(app, project_config, test_project):
folder = "visualizations"
# Execute
result = await canvas.fn(
result = await canvas(
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
@@ -73,7 +73,7 @@ async def test_create_canvas_with_extension(app, project_config, test_project):
folder = "visualizations"
# Execute
result = await canvas.fn(
result = await canvas(
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
@@ -109,9 +109,7 @@ async def test_update_existing_canvas(app, project_config, test_project):
folder = "visualizations"
# Create initial canvas
await canvas.fn(
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
await canvas(project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder)
# Verify file exists
file_path = Path(project_config.home) / folder / f"{title}.canvas"
@@ -134,7 +132,7 @@ async def test_update_existing_canvas(app, project_config, test_project):
]
# Execute update
result = await canvas.fn(
result = await canvas(
project=test_project.name,
nodes=updated_nodes,
edges=updated_edges,
@@ -171,7 +169,7 @@ async def test_create_canvas_with_nested_folders(app, project_config, test_proje
folder = "visualizations/nested/folders" # Deep path
# Execute
result = await canvas.fn(
result = await canvas(
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
@@ -256,7 +254,7 @@ async def test_create_canvas_complex_content(app, project_config, test_project):
test_file_path.write_text("# Test File\nThis is referenced by the canvas")
# Execute
result = await canvas.fn(
result = await canvas(
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
)
+2 -2
View File
@@ -4,7 +4,7 @@ from basic_memory.mcp.tools import cloud_info, release_notes
def test_cloud_info_tool_returns_expected_copy():
result = cloud_info.fn()
result = cloud_info()
assert "# Basic Memory Cloud (optional)" in result
assert "{{OSS_DISCOUNT_CODE}}" in result
@@ -12,7 +12,7 @@ def test_cloud_info_tool_returns_expected_copy():
def test_release_notes_tool_returns_expected_copy():
result = release_notes.fn()
result = release_notes()
assert "# Release Notes" in result
assert "2026-02-06" in result
+1 -2
View File
@@ -134,9 +134,8 @@ TOOL_FUNCTIONS: dict[str, object] = {
def _signature_params(tool_obj: object) -> list[str]:
fn = tool_obj.fn
params = []
for param in inspect.signature(fn).parameters.values():
for param in inspect.signature(tool_obj).parameters.values():
if param.name == "context":
continue
params.append(param.name)
+34 -34
View File
@@ -10,7 +10,7 @@ from basic_memory.mcp.tools.write_note import write_note
async def test_edit_note_append_operation(client, test_project):
"""Test appending content to an existing note."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -18,7 +18,7 @@ async def test_edit_note_append_operation(client, test_project):
)
# Append content
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="append",
@@ -38,7 +38,7 @@ async def test_edit_note_append_operation(client, test_project):
async def test_edit_note_prepend_operation(client, test_project):
"""Test prepending content to an existing note."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Meeting Notes",
directory="meetings",
@@ -46,7 +46,7 @@ async def test_edit_note_prepend_operation(client, test_project):
)
# Prepend content
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="meetings/meeting-notes",
operation="prepend",
@@ -66,7 +66,7 @@ async def test_edit_note_prepend_operation(client, test_project):
async def test_edit_note_find_replace_operation(client, test_project):
"""Test find and replace operation."""
# Create initial note with version info
await write_note.fn(
await write_note(
project=test_project.name,
title="Config Document",
directory="config",
@@ -74,7 +74,7 @@ async def test_edit_note_find_replace_operation(client, test_project):
)
# Replace version - expecting 2 replacements
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="config/config-document",
operation="find_replace",
@@ -95,7 +95,7 @@ async def test_edit_note_find_replace_operation(client, test_project):
async def test_edit_note_replace_section_operation(client, test_project):
"""Test replacing content under a specific section."""
# Create initial note with sections
await write_note.fn(
await write_note(
project=test_project.name,
title="API Specification",
directory="specs",
@@ -103,7 +103,7 @@ async def test_edit_note_replace_section_operation(client, test_project):
)
# Replace implementation section
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="specs/api-specification",
operation="replace_section",
@@ -122,7 +122,7 @@ async def test_edit_note_replace_section_operation(client, test_project):
@pytest.mark.asyncio
async def test_edit_note_nonexistent_note(client, test_project):
"""Test editing a note that doesn't exist - should return helpful guidance."""
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="nonexistent/note",
operation="append",
@@ -139,7 +139,7 @@ async def test_edit_note_nonexistent_note(client, test_project):
async def test_edit_note_invalid_operation(client, test_project):
"""Test using an invalid operation."""
# Create a note first
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -147,7 +147,7 @@ async def test_edit_note_invalid_operation(client, test_project):
)
with pytest.raises(ValueError) as exc_info:
await edit_note.fn(
await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="invalid_op",
@@ -161,7 +161,7 @@ async def test_edit_note_invalid_operation(client, test_project):
async def test_edit_note_find_replace_missing_find_text(client, test_project):
"""Test find_replace operation without find_text parameter."""
# Create a note first
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -169,7 +169,7 @@ async def test_edit_note_find_replace_missing_find_text(client, test_project):
)
with pytest.raises(ValueError) as exc_info:
await edit_note.fn(
await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="find_replace",
@@ -183,7 +183,7 @@ async def test_edit_note_find_replace_missing_find_text(client, test_project):
async def test_edit_note_replace_section_missing_section(client, test_project):
"""Test replace_section operation without section parameter."""
# Create a note first
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -191,7 +191,7 @@ async def test_edit_note_replace_section_missing_section(client, test_project):
)
with pytest.raises(ValueError) as exc_info:
await edit_note.fn(
await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="replace_section",
@@ -205,7 +205,7 @@ async def test_edit_note_replace_section_missing_section(client, test_project):
async def test_edit_note_replace_section_nonexistent_section(client, test_project):
"""Test replacing a section that doesn't exist - should append it."""
# Create initial note without the target section
await write_note.fn(
await write_note(
project=test_project.name,
title="Document",
directory="docs",
@@ -213,7 +213,7 @@ async def test_edit_note_replace_section_nonexistent_section(client, test_projec
)
# Try to replace non-existent section
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="docs/document",
operation="replace_section",
@@ -233,7 +233,7 @@ async def test_edit_note_replace_section_nonexistent_section(client, test_projec
async def test_edit_note_with_observations_and_relations(client, test_project):
"""Test editing a note that contains observations and relations."""
# Create note with semantic content
await write_note.fn(
await write_note(
project=test_project.name,
title="Feature Spec",
directory="features",
@@ -241,7 +241,7 @@ async def test_edit_note_with_observations_and_relations(client, test_project):
)
# Append more semantic content
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="features/feature-spec",
operation="append",
@@ -258,7 +258,7 @@ async def test_edit_note_with_observations_and_relations(client, test_project):
async def test_edit_note_identifier_variations(client, test_project):
"""Test that various identifier formats work."""
# Create a note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Document",
directory="docs",
@@ -273,7 +273,7 @@ async def test_edit_note_identifier_variations(client, test_project):
]
for identifier in identifiers_to_test:
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier=identifier,
operation="append",
@@ -290,7 +290,7 @@ async def test_edit_note_identifier_variations(client, test_project):
async def test_edit_note_find_replace_no_matches(client, test_project):
"""Test find_replace when the find_text doesn't exist - should return error."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -298,7 +298,7 @@ async def test_edit_note_find_replace_no_matches(client, test_project):
)
# Try to replace text that doesn't exist - should fail with default expected_replacements=1
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="find_replace",
@@ -316,7 +316,7 @@ async def test_edit_note_find_replace_no_matches(client, test_project):
async def test_edit_note_empty_content_operations(client, test_project):
"""Test operations with empty content."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -324,7 +324,7 @@ async def test_edit_note_empty_content_operations(client, test_project):
)
# Test append with empty content
result = await edit_note.fn(
result = await edit_note(
project=test_project.name, identifier="test/test-note", operation="append", content=""
)
@@ -337,7 +337,7 @@ async def test_edit_note_empty_content_operations(client, test_project):
async def test_edit_note_find_replace_wrong_count(client, test_project):
"""Test find_replace when replacement count doesn't match expected."""
# Create initial note with version info
await write_note.fn(
await write_note(
project=test_project.name,
title="Config Document",
directory="config",
@@ -345,7 +345,7 @@ async def test_edit_note_find_replace_wrong_count(client, test_project):
)
# Try to replace expecting 1 occurrence, but there are actually 2
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="config/config-document",
operation="find_replace",
@@ -366,7 +366,7 @@ async def test_edit_note_find_replace_wrong_count(client, test_project):
async def test_edit_note_replace_section_multiple_sections(client, test_project):
"""Test replace_section with multiple sections having same header - should return helpful error."""
# Create note with duplicate section headers
await write_note.fn(
await write_note(
project=test_project.name,
title="Sample Note",
directory="docs",
@@ -374,7 +374,7 @@ async def test_edit_note_replace_section_multiple_sections(client, test_project)
)
# Try to replace section when multiple exist
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="docs/sample-note",
operation="replace_section",
@@ -393,7 +393,7 @@ async def test_edit_note_replace_section_multiple_sections(client, test_project)
async def test_edit_note_find_replace_empty_find_text(client, test_project):
"""Test find_replace with empty/whitespace find_text - should return helpful error."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -401,7 +401,7 @@ async def test_edit_note_find_replace_empty_find_text(client, test_project):
)
# Try with whitespace-only find_text - this should be caught by service validation
result = await edit_note.fn(
result = await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="find_replace",
@@ -423,7 +423,7 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
in its frontmatter.
"""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -431,7 +431,7 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
)
# Verify the note was created with a permalink
first_result = await edit_note.fn(
first_result = await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="append",
@@ -443,7 +443,7 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
# Perform another edit - this should preserve the permalink even if the
# file doesn't have a permalink in its frontmatter
second_result = await edit_note.fn(
second_result = await edit_note(
project=test_project.name,
identifier="test/test-note",
operation="append",
+33 -33
View File
@@ -22,7 +22,7 @@ from basic_memory.mcp.tools import (
@pytest.mark.asyncio
async def test_write_note_text_and_json_modes(app, test_project):
text_result = await write_note.fn(
text_result = await write_note(
project=test_project.name,
title="Mode Write Note",
directory="mode-tests",
@@ -32,7 +32,7 @@ async def test_write_note_text_and_json_modes(app, test_project):
assert isinstance(text_result, str)
assert "note" in text_result.lower()
json_result = await write_note.fn(
json_result = await write_note(
project=test_project.name,
title="Mode Write Note",
directory="mode-tests",
@@ -49,14 +49,14 @@ async def test_write_note_text_and_json_modes(app, test_project):
@pytest.mark.asyncio
async def test_read_note_text_and_json_modes(app, test_project):
await write_note.fn(
await write_note(
project=test_project.name,
title="Mode Read Note",
directory="mode-tests",
content="# Mode Read Note\n\nbody",
)
text_result = await read_note.fn(
text_result = await read_note(
identifier="mode-tests/mode-read-note",
project=test_project.name,
output_format="text",
@@ -64,7 +64,7 @@ async def test_read_note_text_and_json_modes(app, test_project):
assert isinstance(text_result, str)
assert "Mode Read Note" in text_result
json_result = await read_note.fn(
json_result = await read_note(
identifier="mode-tests/mode-read-note",
project=test_project.name,
output_format="json",
@@ -76,7 +76,7 @@ async def test_read_note_text_and_json_modes(app, test_project):
assert isinstance(json_result["content"], str)
assert "frontmatter" in json_result
missing_json = await read_note.fn(
missing_json = await read_note(
identifier="mode-tests/missing-note",
project=test_project.name,
output_format="json",
@@ -89,14 +89,14 @@ async def test_read_note_text_and_json_modes(app, test_project):
@pytest.mark.asyncio
async def test_edit_note_text_and_json_modes(app, test_project):
await write_note.fn(
await write_note(
project=test_project.name,
title="Mode Edit Note",
directory="mode-tests",
content="# Mode Edit Note\n\nstart",
)
text_result = await edit_note.fn(
text_result = await edit_note(
identifier="mode-tests/mode-edit-note",
operation="append",
content="\n\ntext-append",
@@ -106,7 +106,7 @@ async def test_edit_note_text_and_json_modes(app, test_project):
assert isinstance(text_result, str)
assert "Edited note" in text_result
json_result = await edit_note.fn(
json_result = await edit_note(
identifier="mode-tests/mode-edit-note",
operation="append",
content="\n\njson-append",
@@ -123,14 +123,14 @@ async def test_edit_note_text_and_json_modes(app, test_project):
@pytest.mark.asyncio
async def test_recent_activity_text_and_json_modes(app, test_project):
await write_note.fn(
await write_note(
project=test_project.name,
title="Mode Activity Note",
directory="mode-tests",
content="# Mode Activity Note\n\nactivity",
)
text_result = await recent_activity.fn(
text_result = await recent_activity(
project=test_project.name,
timeframe="7d",
output_format="text",
@@ -138,7 +138,7 @@ async def test_recent_activity_text_and_json_modes(app, test_project):
assert isinstance(text_result, str)
assert "Recent Activity" in text_result
json_result = await recent_activity.fn(
json_result = await recent_activity(
project=test_project.name,
timeframe="7d",
output_format="json",
@@ -151,7 +151,7 @@ async def test_recent_activity_text_and_json_modes(app, test_project):
@pytest.mark.asyncio
async def test_recent_activity_json_preserves_relation_and_observation_types(app, test_project):
await write_note.fn(
await write_note(
project=test_project.name,
title="Activity Type Source",
directory="mode-tests",
@@ -161,14 +161,14 @@ async def test_recent_activity_json_preserves_relation_and_observation_types(app
"- links_to [[Activity Type Target]]"
),
)
await write_note.fn(
await write_note(
project=test_project.name,
title="Activity Type Target",
directory="mode-tests",
content="# Activity Type Target",
)
relation_json = await recent_activity.fn(
relation_json = await recent_activity(
project=test_project.name,
type="relation",
timeframe="7d",
@@ -180,7 +180,7 @@ async def test_recent_activity_json_preserves_relation_and_observation_types(app
for item in relation_json:
assert set(["type", "title", "permalink", "file_path", "created_at"]).issubset(item.keys())
observation_json = await recent_activity.fn(
observation_json = await recent_activity(
project=test_project.name,
type="observation",
timeframe="7d",
@@ -195,11 +195,11 @@ async def test_recent_activity_json_preserves_relation_and_observation_types(app
@pytest.mark.asyncio
async def test_list_and_create_project_text_and_json_modes(app, test_project, tmp_path):
list_text = await list_memory_projects.fn(output_format="text")
list_text = await list_memory_projects(output_format="text")
assert isinstance(list_text, str)
assert test_project.name in list_text
list_json = await list_memory_projects.fn(output_format="json")
list_json = await list_memory_projects(output_format="json")
assert isinstance(list_json, dict)
assert "projects" in list_json
assert any(project["name"] == test_project.name for project in list_json["projects"])
@@ -207,7 +207,7 @@ async def test_list_and_create_project_text_and_json_modes(app, test_project, tm
project_name = "mode-create-project"
project_path = str(tmp_path.parent / (tmp_path.name + "-projects") / "mode-create-project")
create_text = await create_memory_project.fn(
create_text = await create_memory_project(
project_name=project_name,
project_path=project_path,
output_format="text",
@@ -215,7 +215,7 @@ async def test_list_and_create_project_text_and_json_modes(app, test_project, tm
assert isinstance(create_text, str)
assert "mode-create-project" in create_text
create_json_again = await create_memory_project.fn(
create_json_again = await create_memory_project(
project_name=project_name,
project_path=project_path,
output_format="json",
@@ -231,14 +231,14 @@ async def test_list_and_create_project_text_and_json_modes(app, test_project, tm
default_project_path = str(
tmp_path.parent / (tmp_path.name + "-projects") / "mode-default-project"
)
await create_memory_project.fn(
await create_memory_project(
project_name=default_project_name,
project_path=default_project_path,
set_default=True,
output_format="text",
)
default_text_again = await create_memory_project.fn(
default_text_again = await create_memory_project(
project_name=default_project_name,
project_path=default_project_path,
output_format="text",
@@ -249,28 +249,28 @@ async def test_list_and_create_project_text_and_json_modes(app, test_project, tm
@pytest.mark.asyncio
async def test_delete_note_text_and_json_modes(app, test_project):
await write_note.fn(
await write_note(
project=test_project.name,
title="Mode Delete Text",
directory="mode-tests",
content="# Mode Delete Text",
)
text_delete = await delete_note.fn(
text_delete = await delete_note(
identifier="mode-tests/mode-delete-text",
project=test_project.name,
output_format="text",
)
assert text_delete is True
await write_note.fn(
await write_note(
project=test_project.name,
title="Mode Delete Json",
directory="mode-tests",
content="# Mode Delete Json",
)
json_delete = await delete_note.fn(
json_delete = await delete_note(
identifier="mode-tests/mode-delete-json",
project=test_project.name,
output_format="json",
@@ -291,7 +291,7 @@ async def test_delete_directory_json_mode_returns_structured_error_on_failure(
monkeypatch.setattr(KnowledgeClient, "delete_directory", mock_delete_directory)
json_delete = await delete_note.fn(
json_delete = await delete_note(
identifier="mode-tests",
is_directory=True,
project=test_project.name,
@@ -306,14 +306,14 @@ async def test_delete_directory_json_mode_returns_structured_error_on_failure(
@pytest.mark.asyncio
async def test_move_note_text_and_json_modes(app, test_project):
await write_note.fn(
await write_note(
project=test_project.name,
title="Mode Move Text",
directory="mode-tests",
content="# Mode Move Text",
)
text_move = await move_note.fn(
text_move = await move_note(
identifier="mode-tests/mode-move-text",
destination_path="mode-tests/moved/mode-move-text.md",
project=test_project.name,
@@ -322,14 +322,14 @@ async def test_move_note_text_and_json_modes(app, test_project):
assert isinstance(text_move, str)
assert "moved" in text_move.lower()
await write_note.fn(
await write_note(
project=test_project.name,
title="Mode Move Json",
directory="mode-tests",
content="# Mode Move Json",
)
json_move = await move_note.fn(
json_move = await move_note(
identifier="mode-tests/mode-move-json",
destination_path="mode-tests/moved/mode-move-json.md",
project=test_project.name,
@@ -346,14 +346,14 @@ async def test_move_note_text_and_json_modes(app, test_project):
@pytest.mark.asyncio
async def test_build_context_json_default_and_text_mode(client, test_graph, test_project):
json_result = await build_context.fn(
json_result = await build_context(
project=test_project.name,
url="memory://test/root",
)
assert isinstance(json_result, dict)
assert "results" in json_result
text_result = await build_context.fn(
text_result = await build_context(
project=test_project.name,
url="memory://test/root",
output_format="text",
+17 -17
View File
@@ -9,7 +9,7 @@ from basic_memory.mcp.tools.write_note import write_note
@pytest.mark.asyncio
async def test_list_directory_empty(client, test_project):
"""Test listing directory when no entities exist."""
result = await list_directory.fn(project=test_project.name)
result = await list_directory(project=test_project.name)
assert isinstance(result, str)
assert "No files found in directory '/'" in result
@@ -26,7 +26,7 @@ async def test_list_directory_with_test_graph(client, test_graph, test_project):
# /test/Root.md
# List root directory
result = await list_directory.fn(project=test_project.name)
result = await list_directory(project=test_project.name)
assert isinstance(result, str)
assert "Contents of '/' (depth 1):" in result
@@ -38,7 +38,7 @@ async def test_list_directory_with_test_graph(client, test_graph, test_project):
async def test_list_directory_specific_path(client, test_graph, test_project):
"""Test listing specific directory path."""
# List the test directory
result = await list_directory.fn(project=test_project.name, dir_name="/test")
result = await list_directory(project=test_project.name, dir_name="/test")
assert isinstance(result, str)
assert "Contents of '/test' (depth 1):" in result
@@ -54,7 +54,7 @@ async def test_list_directory_specific_path(client, test_graph, test_project):
async def test_list_directory_with_glob_filter(client, test_graph, test_project):
"""Test listing directory with glob filtering."""
# Filter for files containing "Connected"
result = await list_directory.fn(
result = await list_directory(
project=test_project.name, dir_name="/test", file_name_glob="*Connected*"
)
@@ -72,7 +72,7 @@ async def test_list_directory_with_glob_filter(client, test_graph, test_project)
@pytest.mark.asyncio
async def test_list_directory_with_markdown_filter(client, test_graph, test_project):
"""Test listing directory with markdown file filter."""
result = await list_directory.fn(
result = await list_directory(
project=test_project.name, dir_name="/test", file_name_glob="*.md"
)
@@ -91,7 +91,7 @@ async def test_list_directory_with_markdown_filter(client, test_graph, test_proj
async def test_list_directory_with_depth_control(client, test_graph, test_project):
"""Test listing directory with depth control."""
# Depth 1: should return only the test directory
result_depth_1 = await list_directory.fn(project=test_project.name, dir_name="/", depth=1)
result_depth_1 = await list_directory(project=test_project.name, dir_name="/", depth=1)
assert isinstance(result_depth_1, str)
assert "Contents of '/' (depth 1):" in result_depth_1
@@ -99,7 +99,7 @@ async def test_list_directory_with_depth_control(client, test_graph, test_projec
assert "Total: 1 items (1 directory)" in result_depth_1
# Depth 2: should return directory + its files
result_depth_2 = await list_directory.fn(project=test_project.name, dir_name="/", depth=2)
result_depth_2 = await list_directory(project=test_project.name, dir_name="/", depth=2)
assert isinstance(result_depth_2, str)
assert "Contents of '/' (depth 2):" in result_depth_2
@@ -115,7 +115,7 @@ async def test_list_directory_with_depth_control(client, test_graph, test_projec
@pytest.mark.asyncio
async def test_list_directory_nonexistent_path(client, test_graph, test_project):
"""Test listing nonexistent directory."""
result = await list_directory.fn(project=test_project.name, dir_name="/nonexistent")
result = await list_directory(project=test_project.name, dir_name="/nonexistent")
assert isinstance(result, str)
assert "No files found in directory '/nonexistent'" in result
@@ -124,7 +124,7 @@ async def test_list_directory_nonexistent_path(client, test_graph, test_project)
@pytest.mark.asyncio
async def test_list_directory_glob_no_matches(client, test_graph, test_project):
"""Test listing directory with glob that matches nothing."""
result = await list_directory.fn(
result = await list_directory(
project=test_project.name, dir_name="/test", file_name_glob="*.xyz"
)
@@ -136,7 +136,7 @@ async def test_list_directory_glob_no_matches(client, test_graph, test_project):
async def test_list_directory_with_created_notes(client, test_project):
"""Test listing directory with dynamically created notes."""
# Create some test notes
await write_note.fn(
await write_note(
project=test_project.name,
title="Project Planning",
directory="projects",
@@ -144,7 +144,7 @@ async def test_list_directory_with_created_notes(client, test_project):
tags=["planning", "project"],
)
await write_note.fn(
await write_note(
project=test_project.name,
title="Meeting Notes",
directory="projects",
@@ -152,7 +152,7 @@ async def test_list_directory_with_created_notes(client, test_project):
tags=["meeting", "notes"],
)
await write_note.fn(
await write_note(
project=test_project.name,
title="Research Document",
directory="research",
@@ -161,7 +161,7 @@ async def test_list_directory_with_created_notes(client, test_project):
)
# List root directory
result_root = await list_directory.fn(project=test_project.name)
result_root = await list_directory(project=test_project.name)
assert isinstance(result_root, str)
assert "Contents of '/' (depth 1):" in result_root
@@ -170,7 +170,7 @@ async def test_list_directory_with_created_notes(client, test_project):
assert "Total: 2 items (2 directories)" in result_root
# List projects directory
result_projects = await list_directory.fn(project=test_project.name, dir_name="/projects")
result_projects = await list_directory(project=test_project.name, dir_name="/projects")
assert isinstance(result_projects, str)
assert "Contents of '/projects' (depth 1):" in result_projects
@@ -179,7 +179,7 @@ async def test_list_directory_with_created_notes(client, test_project):
assert "Total: 2 items (2 files)" in result_projects
# Test glob filter for "Meeting"
result_meeting = await list_directory.fn(
result_meeting = await list_directory(
project=test_project.name, dir_name="/projects", file_name_glob="*Meeting*"
)
@@ -197,7 +197,7 @@ async def test_list_directory_path_normalization(client, test_graph, test_projec
paths_to_test = ["/test", "test", "/test/", "test/"]
for path in paths_to_test:
result = await list_directory.fn(project=test_project.name, dir_name=path)
result = await list_directory(project=test_project.name, dir_name=path)
# All should return the same number of items
assert "Total: 5 items (5 files)" in result
assert "📄 Connected Entity 1.md" in result
@@ -206,7 +206,7 @@ async def test_list_directory_path_normalization(client, test_graph, test_projec
@pytest.mark.asyncio
async def test_list_directory_shows_file_metadata(client, test_graph, test_project):
"""Test that file metadata is displayed correctly."""
result = await list_directory.fn(project=test_project.name, dir_name="/test")
result = await list_directory(project=test_project.name, dir_name="/test")
assert isinstance(result, str)
# Should show file names
+93 -97
View File
@@ -39,7 +39,7 @@ async def test_detect_cross_project_move_attempt_is_defensive_on_api_error(monke
async def test_move_note_success(app, client, test_project):
"""Test successfully moving a note to a new location."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -47,7 +47,7 @@ async def test_move_note_success(app, client, test_project):
)
# Move note
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path="target/MovedNote.md",
@@ -58,13 +58,13 @@ async def test_move_note_success(app, client, test_project):
# Verify original location no longer exists
try:
await read_note.fn(test_project.name, "source/test-note")
await read_note(test_project.name, "source/test-note")
assert False, "Original note should not exist after move"
except Exception:
pass # Expected - note should not exist at original location
# Verify note exists at new location with same content
content = await read_note.fn("target/moved-note", project=test_project.name)
content = await read_note("target/moved-note", project=test_project.name)
assert "# Test Note" in content
assert "Original content here" in content
assert f"permalink: {test_project.name}/target/moved-note" in content
@@ -74,7 +74,7 @@ async def test_move_note_success(app, client, test_project):
async def test_move_note_with_folder_creation(client, test_project):
"""Test moving note creates necessary folders."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Deep Note",
directory="",
@@ -82,7 +82,7 @@ async def test_move_note_with_folder_creation(client, test_project):
)
# Move to deeply nested path
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="deep-note",
destination_path="deeply/nested/folder/DeepNote.md",
@@ -92,7 +92,7 @@ async def test_move_note_with_folder_creation(client, test_project):
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note.fn("deeply/nested/folder/deep-note", project=test_project.name)
content = await read_note("deeply/nested/folder/deep-note", project=test_project.name)
assert "# Deep Note" in content
assert "Content in root folder" in content
@@ -101,7 +101,7 @@ async def test_move_note_with_folder_creation(client, test_project):
async def test_move_note_with_observations_and_relations(app, client, test_project):
"""Test moving note preserves observations and relations."""
# Create note with complex semantic content
await write_note.fn(
await write_note(
project=test_project.name,
title="Complex Entity",
directory="source",
@@ -120,7 +120,7 @@ Some additional content.
)
# Move note
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/complex-entity",
destination_path="target/MovedComplex.md",
@@ -130,7 +130,7 @@ Some additional content.
assert "✅ Note moved successfully" in result
# Verify moved note preserves all content
content = await read_note.fn("target/moved-complex", project=test_project.name)
content = await read_note("target/moved-complex", project=test_project.name)
assert "Important observation #tag1" in content
assert "Key feature #feature" in content
assert "[[SomeOtherEntity]]" in content
@@ -142,7 +142,7 @@ Some additional content.
async def test_move_note_by_title(client, test_project):
"""Test moving note using title as identifier."""
# Create note with unique title
await write_note.fn(
await write_note(
project=test_project.name,
title="UniqueTestTitle",
directory="source",
@@ -150,7 +150,7 @@ async def test_move_note_by_title(client, test_project):
)
# Move using title as identifier
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="UniqueTestTitle",
destination_path="target/MovedByTitle.md",
@@ -160,7 +160,7 @@ async def test_move_note_by_title(client, test_project):
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note.fn("target/moved-by-title", project=test_project.name)
content = await read_note("target/moved-by-title", project=test_project.name)
assert "# UniqueTestTitle" in content
assert "Test content" in content
@@ -169,7 +169,7 @@ async def test_move_note_by_title(client, test_project):
async def test_move_note_by_file_path(client, test_project):
"""Test moving note using file path as identifier."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="PathTest",
directory="source",
@@ -177,7 +177,7 @@ async def test_move_note_by_file_path(client, test_project):
)
# Move using file path as identifier
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/PathTest.md",
destination_path="target/MovedByPath.md",
@@ -187,7 +187,7 @@ async def test_move_note_by_file_path(client, test_project):
assert "✅ Note moved successfully" in result
# Verify note exists at new location
content = await read_note.fn("target/moved-by-path", project=test_project.name)
content = await read_note("target/moved-by-path", project=test_project.name)
assert "# PathTest" in content
assert "Content for path test" in content
@@ -195,7 +195,7 @@ async def test_move_note_by_file_path(client, test_project):
@pytest.mark.asyncio
async def test_move_note_nonexistent_note(client, test_project):
"""Test moving a note that doesn't exist."""
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="nonexistent/note",
destination_path="target/SomeFile.md",
@@ -212,7 +212,7 @@ async def test_move_note_nonexistent_note(client, test_project):
async def test_move_note_invalid_destination_path(client, test_project):
"""Test moving note with invalid destination path."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="TestNote",
directory="source",
@@ -220,7 +220,7 @@ async def test_move_note_invalid_destination_path(client, test_project):
)
# Test absolute path (should be rejected by validation)
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path="/absolute/path.md",
@@ -236,7 +236,7 @@ async def test_move_note_invalid_destination_path(client, test_project):
async def test_move_note_missing_file_extension(client, test_project):
"""Test moving note without file extension in destination path."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="ExtensionTest",
directory="source",
@@ -244,7 +244,7 @@ async def test_move_note_missing_file_extension(client, test_project):
)
# Test path without extension
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/extension-test",
destination_path="target/renamed-note",
@@ -258,7 +258,7 @@ async def test_move_note_missing_file_extension(client, test_project):
assert "renamed-note.md" in result # Should suggest adding .md
# Test path with empty extension (edge case)
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/extension-test",
destination_path="target/renamed-note.",
@@ -269,7 +269,7 @@ async def test_move_note_missing_file_extension(client, test_project):
assert "must include a file extension" in result
# Test that note still exists at original location
content = await read_note.fn("source/extension-test", project=test_project.name)
content = await read_note("source/extension-test", project=test_project.name)
assert "# Extension Test" in content
assert "Testing extension validation" in content
@@ -278,7 +278,7 @@ async def test_move_note_missing_file_extension(client, test_project):
async def test_move_note_file_extension_mismatch(client, test_project):
"""Test that moving note with different extension is blocked."""
# Create initial note with .md extension
await write_note.fn(
await write_note(
project=test_project.name,
title="MarkdownNote",
directory="source",
@@ -286,7 +286,7 @@ async def test_move_note_file_extension_mismatch(client, test_project):
)
# Try to move with .txt extension
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/markdown-note",
destination_path="target/renamed-note.txt",
@@ -301,7 +301,7 @@ async def test_move_note_file_extension_mismatch(client, test_project):
assert "renamed-note.md" in result # Should suggest correct extension
# Test that note still exists at original location with original extension
content = await read_note.fn("source/markdown-note", project=test_project.name)
content = await read_note("source/markdown-note", project=test_project.name)
assert "# Markdown Note" in content
assert "This is a markdown file" in content
@@ -310,7 +310,7 @@ async def test_move_note_file_extension_mismatch(client, test_project):
async def test_move_note_preserves_file_extension(client, test_project):
"""Test that moving note with matching extension succeeds."""
# Create initial note with .md extension
await write_note.fn(
await write_note(
project=test_project.name,
title="PreserveExtension",
directory="source",
@@ -318,7 +318,7 @@ async def test_move_note_preserves_file_extension(client, test_project):
)
# Move with same .md extension
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/preserve-extension",
destination_path="target/preserved-note.md",
@@ -329,13 +329,13 @@ async def test_move_note_preserves_file_extension(client, test_project):
assert "✅ Note moved successfully" in result
# Verify note exists at new location with same extension
content = await read_note.fn("target/preserved-note", project=test_project.name)
content = await read_note("target/preserved-note", project=test_project.name)
assert "# Preserve Extension" in content
assert "Testing that extension is preserved" in content
# Verify old location no longer exists
try:
await read_note.fn("source/preserve-extension")
await read_note("source/preserve-extension")
assert False, "Original note should not exist after move"
except Exception:
pass # Expected
@@ -345,7 +345,7 @@ async def test_move_note_preserves_file_extension(client, test_project):
async def test_move_note_destination_exists(client, test_project):
"""Test moving note to existing destination."""
# Create source note
await write_note.fn(
await write_note(
project=test_project.name,
title="SourceNote",
directory="source",
@@ -353,7 +353,7 @@ async def test_move_note_destination_exists(client, test_project):
)
# Create destination note
await write_note.fn(
await write_note(
project=test_project.name,
title="DestinationNote",
directory="target",
@@ -361,7 +361,7 @@ async def test_move_note_destination_exists(client, test_project):
)
# Try to move source to existing destination
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/source-note",
destination_path="target/DestinationNote.md",
@@ -377,7 +377,7 @@ async def test_move_note_destination_exists(client, test_project):
async def test_move_note_same_location(client, test_project):
"""Test moving note to the same location."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="SameLocationTest",
directory="test",
@@ -385,7 +385,7 @@ async def test_move_note_same_location(client, test_project):
)
# Try to move to same location
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="test/same-location-test",
destination_path="test/SameLocationTest.md",
@@ -401,7 +401,7 @@ async def test_move_note_same_location(client, test_project):
async def test_move_note_rename_only(client, test_project):
"""Test moving note within same folder (rename operation)."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="OriginalName",
directory="test",
@@ -409,7 +409,7 @@ async def test_move_note_rename_only(client, test_project):
)
# Rename within same folder
await move_note.fn(
await move_note(
project=test_project.name,
identifier="test/original-name",
destination_path="test/NewName.md",
@@ -417,13 +417,13 @@ async def test_move_note_rename_only(client, test_project):
# Verify original is gone
try:
await read_note.fn("test/original-name", project=test_project.name)
await read_note("test/original-name", project=test_project.name)
assert False, "Original note should not exist after rename"
except Exception:
pass # Expected
# Verify new name exists with same content
content = await read_note.fn("test/new-name", project=test_project.name)
content = await read_note("test/new-name", project=test_project.name)
assert "# OriginalName" in content # Title in content remains same
assert "Content to rename" in content
assert f"permalink: {test_project.name}/test/new-name" in content
@@ -433,7 +433,7 @@ async def test_move_note_rename_only(client, test_project):
async def test_move_note_complex_filename(client, test_project):
"""Test moving note with spaces in filename."""
# Create note with spaces in name
await write_note.fn(
await write_note(
project=test_project.name,
title="Meeting Notes 2025",
directory="meetings",
@@ -441,7 +441,7 @@ async def test_move_note_complex_filename(client, test_project):
)
# Move to new location
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="meetings/meeting-notes-2025",
destination_path="archive/2025/meetings/Meeting Notes 2025.md",
@@ -451,9 +451,7 @@ async def test_move_note_complex_filename(client, test_project):
assert "✅ Note moved successfully" in result
# Verify note exists at new location with correct content
content = await read_note.fn(
"archive/2025/meetings/meeting-notes-2025", project=test_project.name
)
content = await read_note("archive/2025/meetings/meeting-notes-2025", project=test_project.name)
assert "# Meeting Notes 2025" in content
assert "Meeting content with dates" in content
@@ -462,7 +460,7 @@ async def test_move_note_complex_filename(client, test_project):
async def test_move_note_with_tags(app, client, test_project):
"""Test moving note with tags preserves tags."""
# Create note with tags
await write_note.fn(
await write_note(
project=test_project.name,
title="Tagged Note",
directory="source",
@@ -471,7 +469,7 @@ async def test_move_note_with_tags(app, client, test_project):
)
# Move note
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/tagged-note",
destination_path="target/MovedTaggedNote.md",
@@ -481,7 +479,7 @@ async def test_move_note_with_tags(app, client, test_project):
assert "✅ Note moved successfully" in result
# Verify tags are preserved in correct YAML format
content = await read_note.fn("target/moved-tagged-note", project=test_project.name)
content = await read_note("target/moved-tagged-note", project=test_project.name)
assert "- important" in content
assert "- work" in content
assert "- project" in content
@@ -491,7 +489,7 @@ async def test_move_note_with_tags(app, client, test_project):
async def test_move_note_empty_string_destination(client, test_project):
"""Test moving note with empty destination path."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="TestNote",
directory="source",
@@ -499,7 +497,7 @@ async def test_move_note_empty_string_destination(client, test_project):
)
# Test empty destination path
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path="",
@@ -515,7 +513,7 @@ async def test_move_note_empty_string_destination(client, test_project):
async def test_move_note_parent_directory_path(client, test_project):
"""Test moving note with parent directory in destination path."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="TestNote",
directory="source",
@@ -523,7 +521,7 @@ async def test_move_note_parent_directory_path(client, test_project):
)
# Test parent directory path
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path="../parent/file.md",
@@ -539,7 +537,7 @@ async def test_move_note_parent_directory_path(client, test_project):
async def test_move_note_identifier_variations(client, test_project):
"""Test that various identifier formats work for moving."""
# Create a note to test different identifier formats
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Document",
directory="docs",
@@ -547,7 +545,7 @@ async def test_move_note_identifier_variations(client, test_project):
)
# Test with permalink identifier
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="docs/test-document",
destination_path="moved/TestDocument.md",
@@ -557,7 +555,7 @@ async def test_move_note_identifier_variations(client, test_project):
assert "✅ Note moved successfully" in result
# Verify it moved correctly
content = await read_note.fn("moved/test-document", project=test_project.name)
content = await read_note("moved/test-document", project=test_project.name)
assert "# Test Document" in content
assert "Content for testing identifiers" in content
@@ -566,7 +564,7 @@ async def test_move_note_identifier_variations(client, test_project):
async def test_move_note_preserves_frontmatter(app, client, test_project):
"""Test that moving preserves custom frontmatter."""
# Create note with custom frontmatter by first creating it normally
await write_note.fn(
await write_note(
project=test_project.name,
title="Custom Frontmatter Note",
directory="source",
@@ -574,7 +572,7 @@ async def test_move_note_preserves_frontmatter(app, client, test_project):
)
# Move the note
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/custom-frontmatter-note",
destination_path="target/MovedCustomNote.md",
@@ -584,7 +582,7 @@ async def test_move_note_preserves_frontmatter(app, client, test_project):
assert "✅ Note moved successfully" in result
# Verify the moved note has proper frontmatter structure
content = await read_note.fn("target/moved-custom-note", project=test_project.name)
content = await read_note("target/moved-custom-note", project=test_project.name)
assert "title: Custom Frontmatter Note" in content
assert "type: note" in content
assert f"permalink: {test_project.name}/target/moved-custom-note" in content
@@ -638,7 +636,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_blocks_path_traversal_unix(self, client, test_project):
"""Test that Unix-style path traversal attacks are blocked."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -656,7 +654,7 @@ class TestMoveNoteSecurityValidation:
]
for attack_path in attack_paths:
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path=attack_path,
@@ -672,7 +670,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_blocks_path_traversal_windows(self, client, test_project):
"""Test that Windows-style path traversal attacks are blocked."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -690,7 +688,7 @@ class TestMoveNoteSecurityValidation:
]
for attack_path in attack_paths:
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path=attack_path,
@@ -705,7 +703,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_blocks_absolute_paths(self, client, test_project):
"""Test that absolute paths are blocked."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -725,7 +723,7 @@ class TestMoveNoteSecurityValidation:
]
for attack_path in attack_paths:
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path=attack_path,
@@ -740,7 +738,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_blocks_home_directory_access(self, client, test_project):
"""Test that home directory access patterns are blocked."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -758,7 +756,7 @@ class TestMoveNoteSecurityValidation:
]
for attack_path in attack_paths:
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path=attack_path,
@@ -773,7 +771,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_blocks_mixed_attack_patterns(self, client, test_project):
"""Test that mixed legitimate/attack patterns are blocked."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -790,7 +788,7 @@ class TestMoveNoteSecurityValidation:
]
for attack_path in attack_paths:
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path=attack_path,
@@ -804,7 +802,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_allows_safe_paths(self, client, test_project):
"""Test that legitimate paths are still allowed."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -822,7 +820,7 @@ class TestMoveNoteSecurityValidation:
]
for safe_path in safe_paths:
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path=safe_path,
@@ -841,7 +839,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_security_logging(self, client, test_project, caplog):
"""Test that security violations are properly logged."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -849,7 +847,7 @@ class TestMoveNoteSecurityValidation:
)
# Attempt path traversal attack
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path="../../../etc/passwd",
@@ -865,7 +863,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_empty_path_security(self, client, test_project):
"""Test that empty destination path is handled securely."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -873,7 +871,7 @@ class TestMoveNoteSecurityValidation:
)
# Test empty destination path (should be allowed as it resolves to project root)
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path="",
@@ -887,7 +885,7 @@ class TestMoveNoteSecurityValidation:
async def test_move_note_current_directory_references_security(self, client, test_project):
"""Test that current directory references are handled securely."""
# Create initial note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="source",
@@ -902,7 +900,7 @@ class TestMoveNoteSecurityValidation:
]
for safe_path in safe_paths:
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/test-note",
destination_path=safe_path,
@@ -919,14 +917,14 @@ class TestMoveNoteDestinationFolder:
@pytest.mark.asyncio
async def test_move_note_with_destination_folder(self, client, test_project):
"""Test moving a note using destination_folder preserves the original filename."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Folder Move Test",
directory="source",
content="# Folder Move Test\nContent for folder move.",
)
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/folder-move-test",
destination_folder="archive",
@@ -936,21 +934,21 @@ class TestMoveNoteDestinationFolder:
assert "✅ Note moved successfully" in result
# Verify note exists at archive/Folder Move Test.md (original filename preserved)
content = await read_note.fn("archive/folder-move-test", project=test_project.name)
content = await read_note("archive/folder-move-test", project=test_project.name)
assert "# Folder Move Test" in content
assert "Content for folder move" in content
@pytest.mark.asyncio
async def test_move_note_with_nested_destination_folder(self, client, test_project):
"""Test moving a note into a nested folder structure."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Nested Folder Test",
directory="source",
content="# Nested Folder Test\nNested folder content.",
)
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/nested-folder-test",
destination_folder="archive/2025/q1",
@@ -959,22 +957,20 @@ class TestMoveNoteDestinationFolder:
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
content = await read_note.fn(
"archive/2025/q1/nested-folder-test", project=test_project.name
)
content = await read_note("archive/2025/q1/nested-folder-test", project=test_project.name)
assert "# Nested Folder Test" in content
@pytest.mark.asyncio
async def test_move_note_destination_folder_strips_slashes(self, client, test_project):
"""Test that leading/trailing slashes are stripped from destination_folder."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Slash Strip Test",
directory="source",
content="# Slash Strip Test\nSlash stripping content.",
)
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/slash-strip-test",
destination_folder="/archive/notes/",
@@ -983,13 +979,13 @@ class TestMoveNoteDestinationFolder:
assert isinstance(result, str)
assert "✅ Note moved successfully" in result
content = await read_note.fn("archive/notes/slash-strip-test", project=test_project.name)
content = await read_note("archive/notes/slash-strip-test", project=test_project.name)
assert "# Slash Strip Test" in content
@pytest.mark.asyncio
async def test_move_note_both_params_error(self, client, test_project):
"""Test that providing both destination_path and destination_folder is an error."""
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="some-note",
destination_path="target/note.md",
@@ -1003,7 +999,7 @@ class TestMoveNoteDestinationFolder:
@pytest.mark.asyncio
async def test_move_note_both_params_error_json(self, client, test_project):
"""Test JSON output when both destination_path and destination_folder are provided."""
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="some-note",
destination_path="target/note.md",
@@ -1018,7 +1014,7 @@ class TestMoveNoteDestinationFolder:
@pytest.mark.asyncio
async def test_move_note_neither_param_error(self, client, test_project):
"""Test that providing neither destination_path nor destination_folder is an error."""
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="some-note",
)
@@ -1030,7 +1026,7 @@ class TestMoveNoteDestinationFolder:
@pytest.mark.asyncio
async def test_move_note_neither_param_error_json(self, client, test_project):
"""Test JSON output when neither param is provided."""
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="some-note",
output_format="json",
@@ -1043,7 +1039,7 @@ class TestMoveNoteDestinationFolder:
@pytest.mark.asyncio
async def test_move_note_destination_folder_with_is_directory_error(self, client, test_project):
"""Test that destination_folder is rejected for directory moves."""
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="some-dir",
destination_folder="archive",
@@ -1059,7 +1055,7 @@ class TestMoveNoteDestinationFolder:
self, client, test_project
):
"""Test JSON output when destination_folder is used with is_directory."""
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="some-dir",
destination_folder="archive",
@@ -1074,7 +1070,7 @@ class TestMoveNoteDestinationFolder:
@pytest.mark.asyncio
async def test_move_note_destination_folder_nonexistent_note(self, client, test_project):
"""Test destination_folder with a note that doesn't exist."""
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="nonexistent/note",
destination_folder="archive",
@@ -1086,14 +1082,14 @@ class TestMoveNoteDestinationFolder:
@pytest.mark.asyncio
async def test_move_note_destination_folder_json_output(self, client, test_project):
"""Test JSON output for successful destination_folder move."""
await write_note.fn(
await write_note(
project=test_project.name,
title="JSON Folder Test",
directory="source",
content="# JSON Folder Test\nJSON folder content.",
)
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/json-folder-test",
destination_folder="archive",
@@ -1109,7 +1105,7 @@ class TestMoveNoteDestinationFolder:
@pytest.mark.asyncio
async def test_move_note_destination_folder_path_traversal(self, client, test_project):
"""Test that path traversal via destination_folder is blocked."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Traversal Test",
directory="source",
@@ -1123,7 +1119,7 @@ class TestMoveNoteDestinationFolder:
]
for attack_folder in attack_folders:
result = await move_note.fn(
result = await move_note(
project=test_project.name,
identifier="source/traversal-test",
destination_folder=attack_folder,
+6 -6
View File
@@ -10,7 +10,7 @@ from basic_memory.models.project import Project
@pytest.mark.asyncio
async def test_list_memory_projects_unconstrained(app, test_project):
result = await list_memory_projects.fn()
result = await list_memory_projects()
assert "Available projects:" in result
assert f"{test_project.name}" in result
@@ -49,7 +49,7 @@ async def test_list_memory_projects_shows_display_name(app, client, test_project
new_callable=AsyncMock,
return_value=mock_list,
):
result = await list_memory_projects.fn()
result = await list_memory_projects()
# Regular project shows just the name
assert "• main\n" in result
@@ -77,7 +77,7 @@ async def test_list_memory_projects_no_display_name_shows_name_only(app, client,
new_callable=AsyncMock,
return_value=mock_list,
):
result = await list_memory_projects.fn()
result = await list_memory_projects()
assert "• my-project\n" in result
# Should NOT have parenthetical format
@@ -87,7 +87,7 @@ async def test_list_memory_projects_no_display_name_shows_name_only(app, client,
@pytest.mark.asyncio
async def test_list_memory_projects_constrained_env(monkeypatch, app, test_project):
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)
result = await list_memory_projects.fn()
result = await list_memory_projects()
assert f"Project: {test_project.name}" in result
assert "constrained to a single project" in result
@@ -98,7 +98,7 @@ async def test_create_and_delete_project_and_name_match_branch(
):
# Create a project through the tool (exercises POST + response formatting).
project_root = tmp_path_factory.mktemp("extra-project-home")
result = await create_memory_project.fn(
result = await create_memory_project(
project_name="My Project",
project_path=str(project_root),
set_default=False,
@@ -114,5 +114,5 @@ async def test_create_and_delete_project_and_name_match_branch(
project.permalink = "custom-permalink"
await session.commit()
delete_result = await delete_project.fn("My Project")
delete_result = await delete_project("My Project")
assert delete_result.startswith("")
+11 -11
View File
@@ -25,7 +25,7 @@ async def test_read_content_blocks_path_traversal_unix(client, test_project):
]
for attack_path in attack_paths:
result = await read_content.fn(project=test_project.name, path=attack_path)
result = await read_content(project=test_project.name, path=attack_path)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
assert attack_path in result["error"]
@@ -44,7 +44,7 @@ async def test_read_content_blocks_path_traversal_windows(client, test_project):
]
for attack_path in attack_paths:
result = await read_content.fn(project=test_project.name, path=attack_path)
result = await read_content(project=test_project.name, path=attack_path)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
assert attack_path in result["error"]
@@ -65,7 +65,7 @@ async def test_read_content_blocks_absolute_paths(client, test_project):
]
for attack_path in attack_paths:
result = await read_content.fn(project=test_project.name, path=attack_path)
result = await read_content(project=test_project.name, path=attack_path)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
assert attack_path in result["error"]
@@ -85,7 +85,7 @@ async def test_read_content_blocks_home_directory_access(client, test_project):
]
for attack_path in attack_paths:
result = await read_content.fn(project=test_project.name, path=attack_path)
result = await read_content(project=test_project.name, path=attack_path)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
assert attack_path in result["error"]
@@ -101,7 +101,7 @@ async def test_read_content_blocks_memory_url_attacks(client, test_project):
]
for attack_path in attack_paths:
result = await read_content.fn(project=test_project.name, path=attack_path)
result = await read_content(project=test_project.name, path=attack_path)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
@@ -115,7 +115,7 @@ async def test_read_content_unicode_path_attacks(client, test_project):
]
for attack_path in unicode_attacks:
result = await read_content.fn(project=test_project.name, path=attack_path)
result = await read_content(project=test_project.name, path=attack_path)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
@@ -123,7 +123,7 @@ async def test_read_content_unicode_path_attacks(client, test_project):
@pytest.mark.asyncio
async def test_read_content_very_long_attack_path(client, test_project):
long_attack = "../" * 1000 + "etc/passwd"
result = await read_content.fn(project=test_project.name, path=long_attack)
result = await read_content(project=test_project.name, path=long_attack)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
@@ -138,21 +138,21 @@ async def test_read_content_case_variations_attacks(client, test_project):
]
for attack_path in case_attacks:
result = await read_content.fn(project=test_project.name, path=attack_path)
result = await read_content(project=test_project.name, path=attack_path)
assert result["type"] == "error"
assert "paths must stay within project boundaries" in result["error"]
@pytest.mark.asyncio
async def test_read_content_allows_safe_path_integration(client, test_project):
await write_note.fn(
await write_note(
project=test_project.name,
title="Meeting",
directory="notes",
content="This is a safe note for read_content()",
)
result = await read_content.fn(project=test_project.name, path="notes/meeting")
result = await read_content(project=test_project.name, path="notes/meeting")
assert result["type"] == "text"
assert "safe note" in result["text"]
@@ -160,7 +160,7 @@ async def test_read_content_allows_safe_path_integration(client, test_project):
@pytest.mark.asyncio
async def test_read_content_empty_path_does_not_trigger_security_error(client, test_project):
try:
result = await read_content.fn(project=test_project.name, path="")
result = await read_content(project=test_project.name, path="")
if isinstance(result, dict) and result.get("type") == "error":
assert "paths must stay within project boundaries" not in result.get("error", "")
except ToolError:
+37 -37
View File
@@ -12,7 +12,7 @@ from basic_memory.utils import normalize_newlines
async def test_read_note_by_title(app, test_project):
"""Test reading a note by its title."""
# First create a note
await write_note.fn(
await write_note(
project=test_project.name,
title="Special Note",
directory="test",
@@ -20,14 +20,14 @@ async def test_read_note_by_title(app, test_project):
)
# Should be able to read it by title
content = await read_note.fn("Special Note", project=test_project.name)
content = await read_note("Special Note", project=test_project.name)
assert "Note content here" in content
@pytest.mark.asyncio
async def test_read_note_title_search_fallback_fetches_by_permalink(monkeypatch, app, test_project):
"""Force direct resolve to fail so we exercise the title-search + fetch fallback path."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Fallback Title Note",
directory="test",
@@ -50,7 +50,7 @@ async def test_read_note_title_search_fallback_fetches_by_permalink(monkeypatch,
monkeypatch.setattr(clients_mod, "KnowledgeClient", SelectiveKnowledgeClient)
content = await read_note.fn("Fallback Title Note", project=test_project.name)
content = await read_note("Fallback Title Note", project=test_project.name)
assert "fallback content" in content
@@ -98,9 +98,9 @@ async def test_read_note_returns_related_results_when_text_search_finds_matches(
raise RuntimeError("force fallback")
monkeypatch.setattr(clients_mod, "KnowledgeClient", FailingKnowledgeClient)
monkeypatch.setattr(read_note_module.search_notes, "fn", fake_search_notes_fn)
monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes_fn)
result = await read_note.fn("missing-note", project=test_project.name)
result = await read_note("missing-note", project=test_project.name)
assert "I couldn't find an exact match" in result
assert "## 1. Related One" in result
assert "## 2. Related Two" in result
@@ -109,7 +109,7 @@ async def test_read_note_returns_related_results_when_text_search_finds_matches(
@pytest.mark.asyncio
async def test_read_note_title_fallback_requires_exact_title_match(monkeypatch, app, test_project):
"""Do not fetch note content when title-search returns only fuzzy matches."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Existing Note",
directory="test",
@@ -147,9 +147,9 @@ async def test_read_note_title_fallback_requires_exact_title_match(monkeypatch,
return {"results": [], "current_page": 1, "page_size": 10}
monkeypatch.setattr(clients_mod, "KnowledgeClient", StrictFailingKnowledgeClient)
monkeypatch.setattr(read_note_module.search_notes, "fn", fake_search_notes_fn)
monkeypatch.setattr(read_note_module, "search_notes", fake_search_notes_fn)
result = await read_note.fn("Missing Exact Title", project=test_project.name)
result = await read_note("Missing Exact Title", project=test_project.name)
assert "Note Not Found" in result
assert "Missing Exact Title" in result
assert "existing note content" not in result
@@ -159,7 +159,7 @@ async def test_read_note_title_fallback_requires_exact_title_match(monkeypatch,
async def test_note_unicode_content(app, test_project):
"""Test handling of unicode content in"""
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
result = await write_note.fn(
result = await write_note(
project=test_project.name, title="Unicode Test", directory="test", content=content
)
@@ -171,7 +171,7 @@ async def test_note_unicode_content(app, test_project):
assert "checksum:" in result # Checksum exists but may be "unknown"
# Read back should preserve unicode
result = await read_note.fn("test/unicode-test", project=test_project.name)
result = await read_note("test/unicode-test", project=test_project.name)
assert normalize_newlines(content) in result
@@ -186,13 +186,13 @@ async def test_multiple_notes(app, test_project):
]
for _, title, folder, content, tags in notes_data:
await write_note.fn(
await write_note(
project=test_project.name, title=title, directory=folder, content=content, tags=tags
)
# Should be able to read each one individually
for permalink, title, folder, content, _ in notes_data:
note = await read_note.fn(permalink, project=test_project.name)
note = await read_note(permalink, project=test_project.name)
assert content in note
# Note: v2 API does not support glob patterns in read_note
@@ -211,14 +211,14 @@ async def test_multiple_notes_pagination(app, test_project):
]
for _, title, folder, content, tags in notes_data:
await write_note.fn(
await write_note(
project=test_project.name, title=title, directory=folder, content=content, tags=tags
)
# Should be able to read each one individually with pagination
# Note: pagination now applies to single note content, not multiple notes
for permalink, title, folder, content, _ in notes_data:
note = await read_note.fn(permalink, page=1, page_size=10, project=test_project.name)
note = await read_note(permalink, page=1, page_size=10, project=test_project.name)
assert content in note
# Note: v2 API does not support glob patterns in read_note
@@ -235,7 +235,7 @@ async def test_read_note_memory_url(app, test_project):
- Return the note content
"""
# First create a note
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Memory URL Test",
directory="test",
@@ -245,14 +245,14 @@ async def test_read_note_memory_url(app, test_project):
# Should be able to read it with a memory:// URL
memory_url = "memory://test/memory-url-test"
content = await read_note.fn(memory_url, project=test_project.name)
content = await read_note(memory_url, project=test_project.name)
assert "Testing memory:// URL handling" in content
@pytest.mark.asyncio
async def test_read_note_memory_url_with_project_prefix(app, test_project):
"""Test reading a note using a memory:// URL with explicit project prefix."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Project Prefixed Memory URL Test",
directory="test",
@@ -260,7 +260,7 @@ async def test_read_note_memory_url_with_project_prefix(app, test_project):
)
memory_url = f"memory://{test_project.name}/test/project-prefixed-memory-url-test"
content = await read_note.fn(memory_url)
content = await read_note(memory_url)
assert "Testing memory:// URL handling with project prefix" in content
@@ -282,7 +282,7 @@ class TestReadNoteSecurityValidation:
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(attack_identifier, project=test_project.name)
result = await read_note(attack_identifier, project=test_project.name)
assert isinstance(result, str)
assert "# Error" in result
@@ -304,7 +304,7 @@ class TestReadNoteSecurityValidation:
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(attack_identifier, project=test_project.name)
result = await read_note(attack_identifier, project=test_project.name)
assert isinstance(result, str)
assert "# Error" in result
@@ -328,7 +328,7 @@ class TestReadNoteSecurityValidation:
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(project=test_project.name, identifier=attack_identifier)
result = await read_note(project=test_project.name, identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
@@ -351,7 +351,7 @@ class TestReadNoteSecurityValidation:
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(project=test_project.name, identifier=attack_identifier)
result = await read_note(project=test_project.name, identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
@@ -372,7 +372,7 @@ class TestReadNoteSecurityValidation:
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(project=test_project.name, identifier=attack_identifier)
result = await read_note(project=test_project.name, identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
@@ -392,7 +392,7 @@ class TestReadNoteSecurityValidation:
]
for attack_identifier in attack_identifiers:
result = await read_note.fn(project=test_project.name, identifier=attack_identifier)
result = await read_note(project=test_project.name, identifier=attack_identifier)
assert isinstance(result, str)
assert "# Error" in result
@@ -414,7 +414,7 @@ class TestReadNoteSecurityValidation:
]
for safe_identifier in safe_identifiers:
result = await read_note.fn(project=test_project.name, identifier=safe_identifier)
result = await read_note(project=test_project.name, identifier=safe_identifier)
assert isinstance(result, str)
# Should not contain security error message
@@ -428,7 +428,7 @@ class TestReadNoteSecurityValidation:
async def test_read_note_allows_legitimate_titles(self, app, test_project):
"""Test that legitimate note titles work normally."""
# Create a test note first
await write_note.fn(
await write_note(
project=test_project.name,
title="Security Test Note",
directory="security-tests",
@@ -436,7 +436,7 @@ class TestReadNoteSecurityValidation:
)
# Test reading by title (should work)
result = await read_note.fn("Security Test Note", project=test_project.name)
result = await read_note("Security Test Note", project=test_project.name)
assert isinstance(result, str)
# Should not be a security error
@@ -447,7 +447,7 @@ class TestReadNoteSecurityValidation:
async def test_read_note_empty_identifier_security(self, app, test_project):
"""Test that empty identifier is handled securely."""
# Empty identifier should be allowed (may return search results or error, but not security error)
result = await read_note.fn(identifier="", project=test_project.name)
result = await read_note(identifier="", project=test_project.name)
assert isinstance(result, str)
# Empty identifier should not trigger security error
@@ -457,7 +457,7 @@ class TestReadNoteSecurityValidation:
async def test_read_note_security_with_all_parameters(self, app, test_project):
"""Test security validation works with all read_note parameters."""
# Test that security validation is applied even when all other parameters are provided
result = await read_note.fn(
result = await read_note(
project=test_project.name,
identifier="../../../etc/malicious",
page=1,
@@ -473,7 +473,7 @@ class TestReadNoteSecurityValidation:
async def test_read_note_security_logging(self, app, caplog, test_project):
"""Test that security violations are properly logged."""
# Attempt path traversal attack
result = await read_note.fn(identifier="../../../etc/passwd", project=test_project.name)
result = await read_note(identifier="../../../etc/passwd", project=test_project.name)
assert "# Error" in result
assert "paths must stay within project boundaries" in result
@@ -486,7 +486,7 @@ class TestReadNoteSecurityValidation:
async def test_read_note_preserves_functionality_with_security(self, app, test_project):
"""Test that security validation doesn't break normal note reading functionality."""
# Create a note with complex content to ensure security validation doesn't interfere
await write_note.fn(
await write_note(
project=test_project.name,
title="Full Feature Security Test Note",
directory="security-tests",
@@ -510,7 +510,7 @@ class TestReadNoteSecurityValidation:
)
# Test reading by permalink
result = await read_note.fn(
result = await read_note(
"security-tests/full-feature-security-test-note", project=test_project.name
)
@@ -534,7 +534,7 @@ class TestReadNoteSecurityEdgeCases:
]
for attack_identifier in unicode_attack_identifiers:
result = await read_note.fn(attack_identifier, project=test_project.name)
result = await read_note(attack_identifier, project=test_project.name)
assert isinstance(result, str)
assert "# Error" in result
@@ -546,7 +546,7 @@ class TestReadNoteSecurityEdgeCases:
# Create a very long path traversal attack
long_attack_identifier = "../" * 1000 + "etc/malicious"
result = await read_note.fn(long_attack_identifier, project=test_project.name)
result = await read_note(long_attack_identifier, project=test_project.name)
assert isinstance(result, str)
assert "# Error" in result
@@ -564,7 +564,7 @@ class TestReadNoteSecurityEdgeCases:
]
for attack_identifier in case_attack_identifiers:
result = await read_note.fn(attack_identifier, project=test_project.name)
result = await read_note(attack_identifier, project=test_project.name)
assert isinstance(result, str)
assert "# Error" in result
@@ -582,7 +582,7 @@ class TestReadNoteSecurityEdgeCases:
]
for attack_identifier in whitespace_attack_identifiers:
result = await read_note.fn(attack_identifier, project=test_project.name)
result = await read_note(attack_identifier, project=test_project.name)
assert isinstance(result, str)
# The attack should still be blocked even with whitespace
+26 -26
View File
@@ -37,7 +37,7 @@ async def test_recent_activity_timeframe_formats(client, test_project, test_grap
# Test each valid timeframe with project-specific mode
for timeframe in valid_timeframes:
try:
result = await recent_activity.fn(
result = await recent_activity(
project=test_project.name,
type=["entity"],
timeframe=timeframe,
@@ -52,7 +52,7 @@ async def test_recent_activity_timeframe_formats(client, test_project, test_grap
# Test invalid timeframes should raise ValidationError
for timeframe in invalid_timeframes:
with pytest.raises(ToolError):
await recent_activity.fn(project=test_project.name, timeframe=timeframe)
await recent_activity(project=test_project.name, timeframe=timeframe)
@pytest.mark.asyncio
@@ -60,28 +60,28 @@ async def test_recent_activity_type_filters(client, test_project, test_graph):
"""Test that recent_activity correctly filters by types."""
# Test single string type
result = await recent_activity.fn(project=test_project.name, type=SearchItemType.ENTITY)
result = await recent_activity(project=test_project.name, type=SearchItemType.ENTITY)
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
assert "Recent Notes & Documents" in result
# Test single string type
result = await recent_activity.fn(project=test_project.name, type="entity")
result = await recent_activity(project=test_project.name, type="entity")
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
assert "Recent Notes & Documents" in result
# Test single type
result = await recent_activity.fn(project=test_project.name, type=["entity"])
result = await recent_activity(project=test_project.name, type=["entity"])
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
assert "Recent Notes & Documents" in result
# Test multiple types
result = await recent_activity.fn(project=test_project.name, type=["entity", "observation"])
result = await recent_activity(project=test_project.name, type=["entity", "observation"])
assert result is not None
assert isinstance(result, str)
assert "Recent Activity:" in result
@@ -89,7 +89,7 @@ async def test_recent_activity_type_filters(client, test_project, test_graph):
assert "Recent Notes & Documents" in result or "Recent Observations" in result
# Test multiple types
result = await recent_activity.fn(
result = await recent_activity(
project=test_project.name, type=[SearchItemType.ENTITY, SearchItemType.OBSERVATION]
)
assert result is not None
@@ -99,7 +99,7 @@ async def test_recent_activity_type_filters(client, test_project, test_graph):
assert "Recent Notes & Documents" in result or "Recent Observations" in result
# Test all types
result = await recent_activity.fn(
result = await recent_activity(
project=test_project.name, type=["entity", "observation", "relation"]
)
assert result is not None
@@ -114,14 +114,14 @@ async def test_recent_activity_type_invalid(client, test_project, test_graph):
# Test single invalid string type
with pytest.raises(ValueError) as e:
await recent_activity.fn(project=test_project.name, type="note")
await recent_activity(project=test_project.name, type="note")
assert (
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
)
# Test invalid string array type
with pytest.raises(ValueError) as e:
await recent_activity.fn(project=test_project.name, type=["note"])
await recent_activity(project=test_project.name, type=["note"])
assert (
str(e.value) == "Invalid type: note. Valid types are: ['entity', 'observation', 'relation']"
)
@@ -136,7 +136,7 @@ async def test_recent_activity_discovery_mode(client, test_project, test_graph,
config_manager.save_config(cfg)
# Test discovery mode (no project parameter)
result = await recent_activity.fn()
result = await recent_activity()
assert result is not None
assert isinstance(result, str)
@@ -159,7 +159,7 @@ async def test_recent_activity_discovery_mode_no_activity(client, test_project,
cfg.default_project = None
config_manager.save_config(cfg)
result = await recent_activity.fn()
result = await recent_activity()
assert "Recent Activity Summary" in result
assert "No recent activity found in any project." in result
@@ -178,17 +178,17 @@ async def test_recent_activity_discovery_mode_multiple_active_projects(
second_root = tmp_path_factory.mktemp("second-project-home")
result = await create_memory_project.fn(
result = await create_memory_project(
project_name="second-project",
project_path=str(second_root),
set_default=False,
)
assert result.startswith("")
await write_note.fn(project=test_project.name, title="One", directory="notes", content="one")
await write_note.fn(project="second-project", title="Two", directory="notes", content="two")
await write_note(project=test_project.name, title="One", directory="notes", content="one")
await write_note(project="second-project", title="Two", directory="notes", content="two")
out = await recent_activity.fn()
out = await recent_activity()
assert "Recent Activity Summary" in out
assert "or would you prefer a different project" in out
@@ -438,7 +438,7 @@ async def test_recent_activity_entity_only_default(client, test_project, test_gr
test_graph creates entities with observations and relations, so if all types
were returned we'd see observation/relation rows in the JSON output.
"""
json_result = await recent_activity.fn(project=test_project.name, output_format="json")
json_result = await recent_activity(project=test_project.name, output_format="json")
assert isinstance(json_result, list)
assert len(json_result) > 0
# Every item should be an entity — no observations or relations
@@ -452,7 +452,7 @@ async def test_recent_activity_explicit_types_returns_requested_types(
):
"""Explicitly requesting observation/relation types should return those types."""
# Request observations only
obs_result = await recent_activity.fn(
obs_result = await recent_activity(
project=test_project.name, type=["observation"], output_format="json"
)
assert isinstance(obs_result, list)
@@ -460,7 +460,7 @@ async def test_recent_activity_explicit_types_returns_requested_types(
assert item["type"] == "observation", f"Expected observation type, got type={item['type']}"
# Request relations only
rel_result = await recent_activity.fn(
rel_result = await recent_activity(
project=test_project.name, type=["relation"], output_format="json"
)
assert isinstance(rel_result, list)
@@ -468,7 +468,7 @@ async def test_recent_activity_explicit_types_returns_requested_types(
assert item["type"] == "relation", f"Expected relation type, got type={item['type']}"
# Request all types explicitly
all_result = await recent_activity.fn(
all_result = await recent_activity(
project=test_project.name,
type=["entity", "observation", "relation"],
output_format="json",
@@ -483,7 +483,7 @@ async def test_recent_activity_explicit_types_returns_requested_types(
@pytest.mark.asyncio
async def test_recent_activity_pagination_params(client, test_project, test_graph):
"""Test that page and page_size params are forwarded correctly."""
result = await recent_activity.fn(
result = await recent_activity(
project=test_project.name,
type=["entity"],
page=1,
@@ -497,19 +497,19 @@ async def test_recent_activity_pagination_params(client, test_project, test_grap
async def test_recent_activity_pagination_validation():
"""Invalid page/page_size values should raise clear ValueError messages."""
with pytest.raises(ValueError, match="page must be >= 1, got 0"):
await recent_activity.fn(page=0)
await recent_activity(page=0)
with pytest.raises(ValueError, match="page must be >= 1, got -1"):
await recent_activity.fn(page=-1)
await recent_activity(page=-1)
with pytest.raises(ValueError, match="page_size must be >= 1, got 0"):
await recent_activity.fn(page_size=0)
await recent_activity(page_size=0)
with pytest.raises(ValueError, match="page_size must be >= 1, got -5"):
await recent_activity.fn(page_size=-5)
await recent_activity(page_size=-5)
with pytest.raises(ValueError, match="page_size must be <= 100, got 999"):
await recent_activity.fn(page_size=999)
await recent_activity(page_size=999)
def test_format_project_output_has_more_pagination_guidance():
+12 -12
View File
@@ -25,7 +25,7 @@ async def test_read_file_text_file(app, synced_files, test_project):
- Include correct metadata
"""
# First create a text file via notes
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Text Resource",
directory="test",
@@ -35,7 +35,7 @@ async def test_read_file_text_file(app, synced_files, test_project):
assert result is not None
# Now read it as a resource
response = await read_content.fn("test/text-resource", project=test_project.name)
response = await read_content("test/text-resource", project=test_project.name)
assert response["type"] == "text"
assert "This is a test text resource" in response["text"]
@@ -53,7 +53,7 @@ async def test_read_content_file_path(app, synced_files, test_project):
- Include correct metadata
"""
# First create a text file via notes
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Text Resource",
directory="test",
@@ -63,7 +63,7 @@ async def test_read_content_file_path(app, synced_files, test_project):
assert result is not None
# Now read it as a resource
response = await read_content.fn("test/Text Resource.md", project=test_project.name)
response = await read_content("test/Text Resource.md", project=test_project.name)
assert response["type"] == "text"
assert "This is a test text resource" in response["text"]
@@ -84,7 +84,7 @@ async def test_read_file_image_file(app, synced_files, test_project):
image_path = synced_files["image"].name
# Read it as a resource
response = await read_content.fn(image_path, project=test_project.name)
response = await read_content(image_path, project=test_project.name)
assert response["type"] == "image"
assert response["source"]["type"] == "base64"
@@ -112,7 +112,7 @@ async def test_read_file_pdf_file(app, synced_files, test_project):
pdf_path = synced_files["pdf"].name
# Read it as a resource
response = await read_content.fn(pdf_path, project=test_project.name)
response = await read_content(pdf_path, project=test_project.name)
assert response["type"] == "document"
assert response["source"]["type"] == "base64"
@@ -128,14 +128,14 @@ async def test_read_file_pdf_file(app, synced_files, test_project):
async def test_read_file_not_found(app, test_project):
"""Test trying to read a non-existent"""
with pytest.raises(ToolError, match="Resource not found"):
await read_content.fn("does-not-exist", project=test_project.name)
await read_content("does-not-exist", project=test_project.name)
@pytest.mark.asyncio
async def test_read_file_memory_url(app, synced_files, test_project):
"""Test reading a resource using a memory:// URL."""
# Create a text file via notes
await write_note.fn(
await write_note(
project=test_project.name,
title="Memory URL Test",
directory="test",
@@ -144,7 +144,7 @@ async def test_read_file_memory_url(app, synced_files, test_project):
# Read it with a memory:// URL
memory_url = "memory://test/memory-url-test"
response = await read_content.fn(memory_url, project=test_project.name)
response = await read_content(memory_url, project=test_project.name)
assert response["type"] == "text"
assert "Testing memory:// URL handling for resources" in response["text"]
@@ -153,7 +153,7 @@ async def test_read_file_memory_url(app, synced_files, test_project):
@pytest.mark.asyncio
async def test_read_file_memory_url_with_project_prefix(app, synced_files, test_project):
"""Test reading a resource using a memory:// URL with explicit project prefix."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Project Prefixed Resource URL Test",
directory="test",
@@ -161,7 +161,7 @@ async def test_read_file_memory_url_with_project_prefix(app, synced_files, test_
)
memory_url = f"memory://{test_project.name}/test/project-prefixed-resource-url-test"
response = await read_content.fn(memory_url)
response = await read_content(memory_url)
assert response["type"] == "text"
assert "Testing memory:// URL handling for resources with project prefix" in response["text"]
@@ -225,7 +225,7 @@ async def test_image_conversion(app, synced_files, test_project):
image_path = synced_files["image"].name
# Test reading the resource
response = await read_content.fn(image_path, project=test_project.name)
response = await read_content(image_path, project=test_project.name)
assert response["type"] == "image"
assert response["source"]["media_type"] == "image/jpeg"
+14 -14
View File
@@ -77,7 +77,7 @@ async def test_schema_validate_by_type(app, test_project, sync_service):
# Sync so the database picks up the files
await sync_service.sync(project_path)
result = await schema_validate.fn(
result = await schema_validate(
note_type="person",
project=test_project.name,
)
@@ -100,7 +100,7 @@ async def test_schema_validate_by_identifier(app, test_project, sync_service):
await sync_service.sync(project_path)
result = await schema_validate.fn(
result = await schema_validate(
identifier="people/alice",
project=test_project.name,
)
@@ -123,7 +123,7 @@ async def test_schema_infer(app, test_project, sync_service):
await sync_service.sync(project_path)
result = await schema_infer.fn(
result = await schema_infer(
note_type="person",
project=test_project.name,
)
@@ -162,7 +162,7 @@ permalink: people/dave
await sync_service.sync(project_path)
result = await schema_diff.fn(
result = await schema_diff(
note_type="person",
project=test_project.name,
)
@@ -191,7 +191,7 @@ async def test_write_note_metadata_creates_schema_note(app, test_project, sync_s
)
# 2. Create the schema note via write_note with metadata
await write_note.fn(
await write_note(
title="Person",
directory="schemas",
note_type="schema",
@@ -209,7 +209,7 @@ async def test_write_note_metadata_creates_schema_note(app, test_project, sync_s
await sync_service.sync(project_path)
# 4. Validate — schema_validate should find the schema and validate person notes
result = await schema_validate.fn(
result = await schema_validate(
note_type="person",
project=test_project.name,
)
@@ -274,7 +274,7 @@ permalink: employees/{name.lower()}
await sync_service.sync(project_path)
# Validate — must find "Employee Schema" via entity_metadata['entity'] == "employee"
result = await schema_validate.fn(
result = await schema_validate(
note_type="employee",
project=test_project.name,
)
@@ -323,7 +323,7 @@ permalink: things/{name}
await sync_service.sync(project_path)
result = await schema_infer.fn(
result = await schema_infer(
note_type="widget",
project=test_project.name,
)
@@ -341,7 +341,7 @@ permalink: things/{name}
@pytest.mark.asyncio
async def test_schema_validate_no_notes_returns_guidance(app, test_project, sync_service):
"""When no notes of the requested type exist, return guidance on creating notes."""
result = await schema_validate.fn(
result = await schema_validate(
note_type="employee",
project=test_project.name,
)
@@ -369,7 +369,7 @@ async def test_schema_validate_no_schema_returns_guidance(app, test_project, syn
await sync_service.sync(project_path)
result = await schema_validate.fn(
result = await schema_validate(
note_type="person",
project=test_project.name,
)
@@ -396,7 +396,7 @@ async def test_schema_diff_no_schema_returns_guidance(app, test_project, sync_se
await sync_service.sync(project_path)
result = await schema_diff.fn(
result = await schema_diff(
note_type="person",
project=test_project.name,
)
@@ -418,7 +418,7 @@ async def test_schema_validate_error_returns_guidance(app, test_project):
mock_validate = AsyncMock(side_effect=RuntimeError("connection lost"))
with patch("basic_memory.mcp.clients.schema.SchemaClient.validate", mock_validate):
result = await schema_validate.fn(
result = await schema_validate(
note_type="person",
project=test_project.name,
)
@@ -434,7 +434,7 @@ async def test_schema_infer_error_returns_guidance(app, test_project):
mock_infer = AsyncMock(side_effect=RuntimeError("db unavailable"))
with patch("basic_memory.mcp.clients.schema.SchemaClient.infer", mock_infer):
result = await schema_infer.fn(
result = await schema_infer(
note_type="person",
project=test_project.name,
)
@@ -450,7 +450,7 @@ async def test_schema_diff_error_returns_guidance(app, test_project):
mock_diff = AsyncMock(side_effect=RuntimeError("network error"))
with patch("basic_memory.mcp.clients.schema.SchemaClient.diff", mock_diff):
result = await schema_diff.fn(
result = await schema_diff(
note_type="person",
project=test_project.name,
)
+33 -37
View File
@@ -13,7 +13,7 @@ from basic_memory.schemas.search import SearchItemType, SearchResponse
async def test_search_text(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
@@ -23,7 +23,7 @@ async def test_search_text(client, test_project):
assert result
# Search for it
response = await search_notes.fn(project=test_project.name, query="searchable")
response = await search_notes(project=test_project.name, query="searchable")
# Verify results - handle both success and error cases
if isinstance(response, SearchResponse):
@@ -41,7 +41,7 @@ async def test_search_text(client, test_project):
async def test_search_title(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
@@ -51,7 +51,7 @@ async def test_search_title(client, test_project):
assert result
# Search for it
response = await search_notes.fn(
response = await search_notes(
project=test_project.name, query="Search Note", search_type="title"
)
@@ -71,7 +71,7 @@ async def test_search_title(client, test_project):
async def test_search_permalink(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
@@ -81,7 +81,7 @@ async def test_search_permalink(client, test_project):
assert result
# Search for it
response = await search_notes.fn(
response = await search_notes(
project=test_project.name,
query=f"{test_project.name}/test/test-search-note",
search_type="permalink",
@@ -103,7 +103,7 @@ async def test_search_permalink(client, test_project):
async def test_search_permalink_match(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
@@ -113,7 +113,7 @@ async def test_search_permalink_match(client, test_project):
assert result
# Search for it
response = await search_notes.fn(
response = await search_notes(
project=test_project.name,
query=f"{test_project.name}/test/test-search-*",
search_type="permalink",
@@ -134,7 +134,7 @@ async def test_search_permalink_match(client, test_project):
@pytest.mark.asyncio
async def test_search_memory_url_with_project_prefix(client, test_project):
"""Test searching with a memory:// URL that includes the project prefix."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Memory URL Search Note",
directory="test",
@@ -142,9 +142,7 @@ async def test_search_memory_url_with_project_prefix(client, test_project):
)
assert result
response = await search_notes.fn(
query=f"memory://{test_project.name}/test/memory-url-search-note"
)
response = await search_notes(query=f"memory://{test_project.name}/test/memory-url-search-note")
if isinstance(response, SearchResponse):
assert len(response.results) > 0
@@ -160,7 +158,7 @@ async def test_search_memory_url_with_project_prefix(client, test_project):
async def test_search_pagination(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
@@ -170,7 +168,7 @@ async def test_search_pagination(client, test_project):
assert result
# Search for it
response = await search_notes.fn(
response = await search_notes(
project=test_project.name, query="searchable", page=1, page_size=1
)
@@ -190,7 +188,7 @@ async def test_search_pagination(client, test_project):
async def test_search_with_type_filter(client, test_project):
"""Test search with entity type filter."""
# Create test content
await write_note.fn(
await write_note(
project=test_project.name,
title="Entity Type Test",
directory="test",
@@ -198,7 +196,7 @@ async def test_search_with_type_filter(client, test_project):
)
# Search with type filter
response = await search_notes.fn(project=test_project.name, query="type", types=["note"])
response = await search_notes(project=test_project.name, query="type", types=["note"])
# Verify results - handle both success and error cases
if isinstance(response, SearchResponse):
@@ -213,7 +211,7 @@ async def test_search_with_type_filter(client, test_project):
async def test_search_with_entity_type_filter(client, test_project):
"""Test search with entity type filter."""
# Create test content
await write_note.fn(
await write_note(
project=test_project.name,
title="Entity Type Test",
directory="test",
@@ -221,9 +219,7 @@ async def test_search_with_entity_type_filter(client, test_project):
)
# Search with entity type filter
response = await search_notes.fn(
project=test_project.name, query="type", entity_types=["entity"]
)
response = await search_notes(project=test_project.name, query="type", entity_types=["entity"])
# Verify results - handle both success and error cases
if isinstance(response, SearchResponse):
@@ -238,7 +234,7 @@ async def test_search_with_entity_type_filter(client, test_project):
async def test_search_with_date_filter(client, test_project):
"""Test search with date filter."""
# Create test content
await write_note.fn(
await write_note(
project=test_project.name,
title="Recent Note",
directory="test",
@@ -247,7 +243,7 @@ async def test_search_with_date_filter(client, test_project):
# Search with date filter
one_hour_ago = datetime.now() - timedelta(hours=1)
response = await search_notes.fn(
response = await search_notes(
project=test_project.name, query="recent", after_date=one_hour_ago.isoformat()
)
@@ -385,7 +381,7 @@ class TestSearchToolErrorHandling:
# Patch at the clients module level where the import happens
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes.fn(project="test-project", query="test query")
result = await search_mod.search_notes(project="test-project", query="test query")
assert isinstance(result, str)
assert "# Search Failed - Invalid Syntax" in result
@@ -423,7 +419,7 @@ class TestSearchToolErrorHandling:
# Patch at the clients module level where the import happens
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes.fn(project="test-project", query="test query")
result = await search_mod.search_notes(project="test-project", query="test query")
assert isinstance(result, str)
assert "# Search Failed - Access Error" in result
@@ -466,7 +462,7 @@ async def test_search_notes_sets_retrieval_mode_for_semantic_types(monkeypatch,
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes.fn(
result = await search_mod.search_notes(
project="test-project",
query="semantic lookup",
search_type=search_type,
@@ -517,7 +513,7 @@ async def test_search_notes_passes_metadata_filters(monkeypatch):
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes.fn(
await search_mod.search_notes(
project="test-project",
query="test",
metadata_filters={"status": "active"},
@@ -564,7 +560,7 @@ async def test_search_by_metadata_basic(monkeypatch):
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_by_metadata.fn(
result = await search_by_metadata(
filters={"status": "in-progress"},
project="test-project",
limit=10,
@@ -581,7 +577,7 @@ async def test_search_by_metadata_limit_zero():
"""search_by_metadata rejects limit <= 0 with error string."""
from basic_memory.mcp.tools.search import search_by_metadata
result = await search_by_metadata.fn(
result = await search_by_metadata(
filters={"status": "active"},
limit=0,
)
@@ -635,7 +631,7 @@ async def test_search_by_metadata_offset_within_page(monkeypatch):
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
# offset=2, limit=5 → page=1, offset_within_page=2
result = await search_by_metadata.fn(
result = await search_by_metadata(
filters={"status": "active"},
project="test-project",
limit=5,
@@ -675,7 +671,7 @@ async def test_search_by_metadata_error_handling(monkeypatch):
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_by_metadata.fn(
result = await search_by_metadata(
filters={"status": "active"},
project="test-project",
)
@@ -716,7 +712,7 @@ async def test_search_notes_invalid_search_type_returns_error(monkeypatch):
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes.fn(
result = await search_mod.search_notes(
project="test-project",
query="test query",
search_type="bogus",
@@ -763,7 +759,7 @@ async def test_search_notes_passes_min_similarity(monkeypatch):
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes.fn(
await search_mod.search_notes(
project="test-project",
query="test",
search_type="vector",
@@ -825,7 +821,7 @@ async def test_search_notes_defaults_to_hybrid_when_semantic_enabled(monkeypatch
monkeypatch.setattr(search_mod, "get_container", lambda: StubContainer())
await search_mod.search_notes.fn(
await search_mod.search_notes(
project="test-project",
query="test query",
)
@@ -886,7 +882,7 @@ async def test_search_notes_defaults_to_fts_when_semantic_disabled(monkeypatch):
monkeypatch.setattr(search_mod, "get_container", lambda: StubContainer())
await search_mod.search_notes.fn(
await search_mod.search_notes(
project="test-project",
query="test query",
)
@@ -946,7 +942,7 @@ async def test_search_notes_explicit_text_stays_fts_when_semantic_enabled(monkey
monkeypatch.setattr(search_mod, "get_container", lambda: StubContainer())
await search_mod.search_notes.fn(
await search_mod.search_notes(
project="test-project",
query="test query",
search_type="text",
@@ -1006,7 +1002,7 @@ async def test_search_notes_defaults_to_hybrid_when_container_not_initialized(mo
)(),
)
await search_mod.search_notes.fn(
await search_mod.search_notes(
project="test-project",
query="test query",
)
@@ -1067,7 +1063,7 @@ async def test_search_notes_defaults_to_fts_when_container_not_initialized_and_s
)(),
)
await search_mod.search_notes.fn(
await search_mod.search_notes(
project="test-project",
query="test query",
)
+25 -25
View File
@@ -11,7 +11,7 @@ from basic_memory.mcp.tools import write_note, view_note
async def test_view_note_basic_functionality(app, test_project):
"""Test viewing a note creates an artifact."""
# First create a note
await write_note.fn(
await write_note(
project=test_project.name,
title="Test View Note",
directory="test",
@@ -19,7 +19,7 @@ async def test_view_note_basic_functionality(app, test_project):
)
# View the note
result = await view_note.fn("Test View Note", project=test_project.name)
result = await view_note("Test View Note", project=test_project.name)
# Should contain note retrieval message
assert 'Note retrieved: "Test View Note"' in result
@@ -47,12 +47,12 @@ async def test_view_note_with_frontmatter_title(app, test_project):
Content with frontmatter title.
""").strip()
await write_note.fn(
await write_note(
project=test_project.name, title="Frontmatter Title", directory="test", content=content
)
# View the note
result = await view_note.fn("Frontmatter Title", project=test_project.name)
result = await view_note("Frontmatter Title", project=test_project.name)
# Should show title in retrieval message
assert 'Note retrieved: "Frontmatter Title"' in result
@@ -65,12 +65,12 @@ async def test_view_note_with_heading_title(app, test_project):
# Create note with heading but no frontmatter title
content = "# Heading Title\n\nContent with heading title."
await write_note.fn(
await write_note(
project=test_project.name, title="Heading Title", directory="test", content=content
)
# View the note
result = await view_note.fn("Heading Title", project=test_project.name)
result = await view_note("Heading Title", project=test_project.name)
# Should show title in retrieval message
assert 'Note retrieved: "Heading Title"' in result
@@ -82,12 +82,12 @@ async def test_view_note_unicode_content(app, test_project):
"""Test viewing a note with Unicode content."""
content = "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦"
await write_note.fn(
await write_note(
project=test_project.name, title="Unicode Test 🚀", directory="test", content=content
)
# View the note
result = await view_note.fn("Unicode Test 🚀", project=test_project.name)
result = await view_note("Unicode Test 🚀", project=test_project.name)
# Should handle Unicode properly
assert "🚀" in result
@@ -99,7 +99,7 @@ async def test_view_note_unicode_content(app, test_project):
@pytest.mark.asyncio
async def test_view_note_by_permalink(app, test_project):
"""Test viewing a note by its permalink."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Permalink Test",
directory="test",
@@ -107,7 +107,7 @@ async def test_view_note_by_permalink(app, test_project):
)
# View by permalink
result = await view_note.fn("test/permalink-test", project=test_project.name)
result = await view_note("test/permalink-test", project=test_project.name)
# Should work with permalink
assert 'Note retrieved: "test/permalink-test"' in result
@@ -118,7 +118,7 @@ async def test_view_note_by_permalink(app, test_project):
@pytest.mark.asyncio
async def test_view_note_with_memory_url(app, test_project):
"""Test viewing a note using a memory:// URL."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Memory URL Test",
directory="test",
@@ -126,7 +126,7 @@ async def test_view_note_with_memory_url(app, test_project):
)
# View with memory:// URL
result = await view_note.fn("memory://test/memory-url-test", project=test_project.name)
result = await view_note("memory://test/memory-url-test", project=test_project.name)
# Should work with memory:// URL
assert 'Note retrieved: "memory://test/memory-url-test"' in result
@@ -138,7 +138,7 @@ async def test_view_note_with_memory_url(app, test_project):
async def test_view_note_not_found(app, test_project):
"""Test viewing a non-existent note returns error without artifact."""
# Try to view non-existent note
result = await view_note.fn("NonExistent Note", project=test_project.name)
result = await view_note("NonExistent Note", project=test_project.name)
# Should return error message without artifact instructions
assert "# Note Not Found" in result
@@ -151,7 +151,7 @@ async def test_view_note_not_found(app, test_project):
@pytest.mark.asyncio
async def test_view_note_pagination(app, test_project):
"""Test viewing a note with pagination parameters."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Pagination Test",
directory="test",
@@ -159,7 +159,7 @@ async def test_view_note_pagination(app, test_project):
)
# View with pagination
result = await view_note.fn("Pagination Test", page=1, page_size=5, project=test_project.name)
result = await view_note("Pagination Test", page=1, page_size=5, project=test_project.name)
# Should work with pagination
assert 'Note retrieved: "Pagination Test"' in result
@@ -170,7 +170,7 @@ async def test_view_note_pagination(app, test_project):
@pytest.mark.asyncio
async def test_view_note_project_parameter(app, test_project):
"""Test viewing a note with project parameter."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Project Test",
directory="test",
@@ -178,7 +178,7 @@ async def test_view_note_project_parameter(app, test_project):
)
# View with explicit project
result = await view_note.fn("Project Test", project=test_project.name)
result = await view_note("Project Test", project=test_project.name)
# Should work with project parameter
assert 'Note retrieved: "Project Test"' in result
@@ -190,16 +190,16 @@ async def test_view_note_project_parameter(app, test_project):
async def test_view_note_artifact_identifier_unique(app, test_project):
"""Test that different notes are retrieved correctly with unique identifiers."""
# Create two notes
await write_note.fn(
await write_note(
project=test_project.name, title="Note One", directory="test", content="Content one"
)
await write_note.fn(
await write_note(
project=test_project.name, title="Note Two", directory="test", content="Content two"
)
# View both notes
result1 = await view_note.fn("Note One", project=test_project.name)
result2 = await view_note.fn("Note Two", project=test_project.name)
result1 = await view_note("Note One", project=test_project.name)
result2 = await view_note("Note Two", project=test_project.name)
# Should have different note identifiers in retrieval messages
assert 'Note retrieved: "Note One"' in result1
@@ -212,7 +212,7 @@ async def test_view_note_artifact_identifier_unique(app, test_project):
async def test_view_note_fallback_identifier_as_title(app, test_project):
"""Test that view_note uses identifier as title when no title is extractable."""
# Create a note with no clear title structure
await write_note.fn(
await write_note(
project=test_project.name,
title="Simple Note",
directory="test",
@@ -220,7 +220,7 @@ async def test_view_note_fallback_identifier_as_title(app, test_project):
)
# View the note
result = await view_note.fn("Simple Note", project=test_project.name)
result = await view_note("Simple Note", project=test_project.name)
# Should use identifier as title in retrieval message
assert 'Note retrieved: "Simple Note"' in result
@@ -230,7 +230,7 @@ async def test_view_note_fallback_identifier_as_title(app, test_project):
@pytest.mark.asyncio
async def test_view_note_direct_success(app, test_project):
"""Direct permalink lookup should succeed without mocks via the full integration path."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -238,6 +238,6 @@ async def test_view_note_direct_success(app, test_project):
)
# This should take the direct permalink path (no title search needed).
result = await view_note.fn("test/test-note", project=test_project.name)
result = await view_note("test/test-note", project=test_project.name)
assert 'Note retrieved: "test/test-note"' in result
assert "This is a test note." in result
+9 -9
View File
@@ -10,10 +10,10 @@ class _ContextState:
def __init__(self):
self._state: dict[str, object] = {}
def get_state(self, key: str):
async def get_state(self, key: str):
return self._state.get(key)
def set_state(self, key: str, value: object) -> None:
async def set_state(self, key: str, value: object, **kwargs) -> None:
self._state[key] = value
@@ -40,7 +40,7 @@ async def test_list_workspaces_formats_workspace_rows(monkeypatch):
fake_get_available_workspaces,
)
result = await list_workspaces.fn()
result = await list_workspaces()
assert "# Available Workspaces (2)" in result
assert "Personal (type=personal, role=owner" in result
assert "Team (type=organization, role=editor" in result
@@ -56,7 +56,7 @@ async def test_list_workspaces_handles_empty_list(monkeypatch):
fake_get_available_workspaces,
)
result = await list_workspaces.fn()
result = await list_workspaces()
assert "# No Workspaces Available" in result
@@ -71,7 +71,7 @@ async def test_list_workspaces_oauth_error_bubbles_up(monkeypatch):
)
with pytest.raises(RuntimeError, match="Workspace discovery requires OAuth login"):
await list_workspaces.fn()
await list_workspaces()
@pytest.mark.asyncio
@@ -87,11 +87,11 @@ async def test_list_workspaces_uses_context_cache_path(monkeypatch):
async def fake_get_available_workspaces(context=None):
assert context is not None
cached = context.get_state("available_workspaces")
cached = await context.get_state("available_workspaces")
if cached:
return cached
call_count["fetches"] += 1
context.set_state("available_workspaces", [workspace])
await context.set_state("available_workspaces", [workspace])
return [workspace]
monkeypatch.setattr(
@@ -99,8 +99,8 @@ async def test_list_workspaces_uses_context_cache_path(monkeypatch):
fake_get_available_workspaces,
)
first = await list_workspaces.fn(context=context)
second = await list_workspaces.fn(context=context)
first = await list_workspaces(context=context)
second = await list_workspaces(context=context)
assert "# Available Workspaces (1)" in first
assert "# Available Workspaces (1)" in second
+56 -56
View File
@@ -17,7 +17,7 @@ async def test_write_note(app, test_project):
- Handle tags correctly
- Return valid permalink
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -35,7 +35,7 @@ async def test_write_note(app, test_project):
assert f"[Session: Using project '{test_project.name}']" in result
# Try reading it back via permalink
content = await read_note.fn("test/test-note", project=test_project.name)
content = await read_note("test/test-note", project=test_project.name)
expected = normalize_newlines(
dedent("""
---
@@ -59,7 +59,7 @@ async def test_write_note(app, test_project):
@pytest.mark.asyncio
async def test_write_note_no_tags(app, test_project):
"""Test creating a note without tags."""
result = await write_note.fn(
result = await write_note(
project=test_project.name, title="Simple Note", directory="test", content="Just some text"
)
@@ -70,7 +70,7 @@ async def test_write_note_no_tags(app, test_project):
assert f"permalink: {test_project.name}/test/simple-note" in result
assert f"[Session: Using project '{test_project.name}']" in result
# Should be able to read it back
content = await read_note.fn("test/simple-note", project=test_project.name)
content = await read_note("test/simple-note", project=test_project.name)
expected = normalize_newlines(
dedent("""
---
@@ -97,7 +97,7 @@ async def test_write_note_update_existing(app, test_project):
- Handle tags correctly
- Return valid permalink
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -114,7 +114,7 @@ async def test_write_note_update_existing(app, test_project):
assert "- test, documentation" in result
assert f"[Session: Using project '{test_project.name}']" in result
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -130,7 +130,7 @@ async def test_write_note_update_existing(app, test_project):
assert f"[Session: Using project '{test_project.name}']" in result
# Try reading it back
content = await read_note.fn("test/test-note", project=test_project.name)
content = await read_note("test/test-note", project=test_project.name)
assert (
normalize_newlines(
dedent(
@@ -172,7 +172,7 @@ async def test_issue_93_write_note_respects_custom_permalink_new_note(app, test_
- [note] Testing if custom permalink is respected
""").strip()
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="My New Note",
directory="notes",
@@ -192,7 +192,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app,
"""Test that write_note respects custom permalinks when updating existing notes (Issue #93)"""
# Step 1: Create initial note (auto-generated permalink)
result1 = await write_note.fn(
result1 = await write_note(
project=test_project.name,
title="Existing Note",
directory="test",
@@ -224,7 +224,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app,
- [note] Custom permalink should be respected on update
""").strip()
result2 = await write_note.fn(
result2 = await write_note(
project=test_project.name,
title="Existing Note",
directory="test",
@@ -248,7 +248,7 @@ async def test_delete_note_existing(app, test_project):
- Return valid permalink
- Delete the note
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -259,7 +259,7 @@ async def test_delete_note_existing(app, test_project):
assert result
assert f"project: {test_project.name}" in result
deleted = await delete_note.fn("test/test-note", project=test_project.name)
deleted = await delete_note("test/test-note", project=test_project.name)
assert deleted is True
@@ -271,7 +271,7 @@ async def test_delete_note_doesnt_exist(app, test_project):
- Delete the note
- verify returns false
"""
deleted = await delete_note.fn("doesnt-exist", project=test_project.name)
deleted = await delete_note("doesnt-exist", project=test_project.name)
assert deleted is False
@@ -292,7 +292,7 @@ async def test_write_note_with_tag_array_from_bug_report(app, test_project):
}
# Try to call the function with this data directly
result = await write_note.fn(**bug_payload)
result = await write_note(**bug_payload)
assert result
assert f"project: {test_project.name}" in result
@@ -312,7 +312,7 @@ async def test_write_note_verbose(app, test_project):
- Handle tags correctly
- Return valid permalink
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -351,7 +351,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
- Verify custom frontmatter is preserved
"""
# First, create a note with custom metadata using write_note
await write_note.fn(
await write_note(
project=test_project.name,
title="Custom Metadata Note",
directory="test",
@@ -360,7 +360,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
)
# Read the note to get its permalink
content = await read_note.fn("test/custom-metadata-note", project=test_project.name)
content = await read_note("test/custom-metadata-note", project=test_project.name)
# Now directly update the file with custom frontmatter
# We need to use a direct file update to add custom frontmatter
@@ -379,7 +379,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
f.write(frontmatter.dumps(post))
# Now update the note using write_note
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Custom Metadata Note",
directory="test",
@@ -394,7 +394,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
assert f"project: {test_project.name}" in result
# Read the note back and check if custom frontmatter is preserved
content = await read_note.fn("test/custom-metadata-note", project=test_project.name)
content = await read_note("test/custom-metadata-note", project=test_project.name)
# Custom frontmatter should be preserved
assert "Status: In Progress" in content
@@ -414,7 +414,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
@pytest.mark.asyncio
async def test_write_note_preserves_content_frontmatter(app, test_project):
"""Test creating a new note."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Note",
directory="test",
@@ -435,7 +435,7 @@ async def test_write_note_preserves_content_frontmatter(app, test_project):
)
# Try reading it back via permalink
content = await read_note.fn("test/test-note", project=test_project.name)
content = await read_note("test/test-note", project=test_project.name)
assert (
normalize_newlines(
dedent(
@@ -476,7 +476,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
After the fix, it should either update the existing note or create with unique permalink.
"""
# Step 1: Create first note
result1 = await write_note.fn(
result1 = await write_note(
project=test_project.name,
title="Note 1",
directory="test",
@@ -487,7 +487,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
assert f"permalink: {test_project.name}/test/note-1" in result1
# Step 2: Create second note with different title
result2 = await write_note.fn(
result2 = await write_note(
project=test_project.name, title="Note 2", directory="test", content="Content for note 2"
)
assert "# Created note" in result2
@@ -496,7 +496,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
# Step 3: Try to create/replace first note again
# This scenario would trigger the UNIQUE constraint failure before the fix
result3 = await write_note.fn(
result3 = await write_note(
project=test_project.name,
title="Note 1", # Same title as first note
directory="test", # Same folder as first note
@@ -521,14 +521,14 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
# Verify we can read back the content
if f"permalink: {test_project.name}/test/note-1" in result3:
# Updated existing note case
content = await read_note.fn("test/note-1", project=test_project.name)
content = await read_note("test/note-1", project=test_project.name)
assert "Replacement content for note 1" in content
else:
# Created new note with unique permalink case
content = await read_note.fn(test_project.name, "test/note-1-1")
content = await read_note(test_project.name, "test/note-1-1")
assert "Replacement content for note 1" in content
# Original note should still exist
original_content = await read_note.fn(test_project.name, "test/note-1")
original_content = await read_note(test_project.name, "test/note-1")
assert "Original content for note 1" in original_content
@@ -539,7 +539,7 @@ async def test_write_note_with_custom_entity_type(app, test_project):
This test verifies the fix for Issue #144 where entity_type parameter
was hardcoded to "note" instead of allowing custom types.
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Guide",
directory="guides",
@@ -558,7 +558,7 @@ async def test_write_note_with_custom_entity_type(app, test_project):
assert f"[Session: Using project '{test_project.name}']" in result
# Verify the entity type is correctly set in the frontmatter
content = await read_note.fn("guides/test-guide", project=test_project.name)
content = await read_note("guides/test-guide", project=test_project.name)
expected = normalize_newlines(
dedent("""
---
@@ -582,7 +582,7 @@ async def test_write_note_with_custom_entity_type(app, test_project):
@pytest.mark.asyncio
async def test_write_note_with_report_entity_type(app, test_project):
"""Test creating a note with note_type="report"."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Monthly Report",
directory="reports",
@@ -599,7 +599,7 @@ async def test_write_note_with_report_entity_type(app, test_project):
assert f"[Session: Using project '{test_project.name}']" in result
# Verify the entity type is correctly set in the frontmatter
content = await read_note.fn("reports/monthly-report", project=test_project.name)
content = await read_note("reports/monthly-report", project=test_project.name)
assert "type: report" in content
assert "# Monthly Report" in content
@@ -607,7 +607,7 @@ async def test_write_note_with_report_entity_type(app, test_project):
@pytest.mark.asyncio
async def test_write_note_with_config_entity_type(app, test_project):
"""Test creating a note with note_type="config"."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="System Config",
directory="config",
@@ -623,7 +623,7 @@ async def test_write_note_with_config_entity_type(app, test_project):
assert f"[Session: Using project '{test_project.name}']" in result
# Verify the entity type is correctly set in the frontmatter
content = await read_note.fn("config/system-config", project=test_project.name)
content = await read_note("config/system-config", project=test_project.name)
assert "type: config" in content
assert "# System Configuration" in content
@@ -635,7 +635,7 @@ async def test_write_note_entity_type_default_behavior(app, test_project):
This ensures backward compatibility - existing code that doesn't specify
entity_type should continue to work as before.
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Default Type Test",
directory="test",
@@ -651,7 +651,7 @@ async def test_write_note_entity_type_default_behavior(app, test_project):
assert f"[Session: Using project '{test_project.name}']" in result
# Verify the entity type defaults to "note"
content = await read_note.fn("test/default-type-test", project=test_project.name)
content = await read_note("test/default-type-test", project=test_project.name)
assert "type: note" in content
assert "# Default Type Test" in content
@@ -660,7 +660,7 @@ async def test_write_note_entity_type_default_behavior(app, test_project):
async def test_write_note_update_existing_with_different_entity_type(app, test_project):
"""Test updating an existing note with a different entity_type."""
# Create initial note as "note" type
result1 = await write_note.fn(
result1 = await write_note(
project=test_project.name,
title="Changeable Type",
directory="test",
@@ -674,7 +674,7 @@ async def test_write_note_update_existing_with_different_entity_type(app, test_p
assert f"project: {test_project.name}" in result1
# Update the same note with a different note_type
result2 = await write_note.fn(
result2 = await write_note(
project=test_project.name,
title="Changeable Type",
directory="test",
@@ -688,7 +688,7 @@ async def test_write_note_update_existing_with_different_entity_type(app, test_p
assert f"project: {test_project.name}" in result2
# Verify the entity type was updated
content = await read_note.fn("test/changeable-type", project=test_project.name)
content = await read_note("test/changeable-type", project=test_project.name)
assert "type: guide" in content
assert "# Updated Content" in content
assert "- guide" in content
@@ -717,7 +717,7 @@ async def test_write_note_respects_frontmatter_entity_type(app, test_project):
""").strip()
# Call write_note without entity_type parameter - it should respect frontmatter type
result = await write_note.fn(
result = await write_note(
project=test_project.name, title="Test Guide", directory="guides", content=note
)
@@ -729,7 +729,7 @@ async def test_write_note_respects_frontmatter_entity_type(app, test_project):
assert f"[Session: Using project '{test_project.name}']" in result
# Verify the entity type from frontmatter is respected (should be "guide", not "note")
content = await read_note.fn("guides/test-guide", project=test_project.name)
content = await read_note("guides/test-guide", project=test_project.name)
assert "type: guide" in content
assert "# Guide Content" in content
assert "- guide" in content
@@ -756,7 +756,7 @@ class TestWriteNoteSecurityValidation:
]
for attack_folder in attack_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory=attack_folder,
@@ -785,7 +785,7 @@ class TestWriteNoteSecurityValidation:
]
for attack_folder in attack_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory=attack_folder,
@@ -814,7 +814,7 @@ class TestWriteNoteSecurityValidation:
]
for attack_folder in attack_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory=attack_folder,
@@ -842,7 +842,7 @@ class TestWriteNoteSecurityValidation:
]
for attack_folder in attack_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory=attack_folder,
@@ -868,7 +868,7 @@ class TestWriteNoteSecurityValidation:
]
for attack_folder in attack_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory=attack_folder,
@@ -895,7 +895,7 @@ class TestWriteNoteSecurityValidation:
]
for safe_folder in safe_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title=f"Test Note in {safe_folder.replace('/', '-')}",
directory=safe_folder,
@@ -915,7 +915,7 @@ class TestWriteNoteSecurityValidation:
async def test_write_note_empty_folder_security(self, app, test_project):
"""Test that empty folder parameter is handled securely."""
# Empty folder should be allowed (creates in root)
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Root Note",
directory="",
@@ -934,7 +934,7 @@ class TestWriteNoteSecurityValidation:
"""Test that default folder behavior works securely when folder is omitted."""
# The write_note function requires folder parameter, but we can test with empty string
# which effectively creates in project root
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Root Folder Note",
directory="", # Empty string instead of None since folder is required
@@ -959,7 +959,7 @@ class TestWriteNoteSecurityValidation:
]
for safe_folder in safe_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title=f"Current Dir Test {safe_folder.replace('/', '-').replace('.', 'dot')}",
directory=safe_folder,
@@ -977,7 +977,7 @@ class TestWriteNoteSecurityValidation:
async def test_write_note_security_with_all_parameters(self, app, test_project):
"""Test security validation works with all write_note parameters."""
# Test that security validation is applied even when all other parameters are provided
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Security Test with All Params",
directory="../../../etc/malicious",
@@ -995,7 +995,7 @@ class TestWriteNoteSecurityValidation:
async def test_write_note_security_logging(self, app, test_project, caplog):
"""Test that security violations are properly logged."""
# Attempt path traversal attack
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Security Logging Test",
directory="../../../etc/passwd_folder",
@@ -1013,7 +1013,7 @@ class TestWriteNoteSecurityValidation:
async def test_write_note_preserves_functionality_with_security(self, app, test_project):
"""Test that security validation doesn't break normal note creation functionality."""
# Create a note with all features to ensure security validation doesn't interfere
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Full Feature Security Test",
directory="security-tests",
@@ -1068,7 +1068,7 @@ class TestWriteNoteSecurityEdgeCases:
]
for attack_folder in unicode_attack_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Unicode Attack Test",
directory=attack_folder,
@@ -1085,7 +1085,7 @@ class TestWriteNoteSecurityEdgeCases:
# Create a very long path traversal attack
long_attack_folder = "../" * 1000 + "etc/malicious"
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Long Attack Test",
directory=long_attack_folder,
@@ -1108,7 +1108,7 @@ class TestWriteNoteSecurityEdgeCases:
]
for attack_folder in case_attack_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Case Variation Attack Test",
directory=attack_folder,
@@ -1131,7 +1131,7 @@ class TestWriteNoteSecurityEdgeCases:
]
for attack_folder in whitespace_attack_folders:
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Whitespace Attack Test",
directory=attack_folder,
@@ -29,7 +29,7 @@ async def test_write_note_spaces_to_hyphens(app, test_project, app_config):
"""Test that spaces are converted to hyphens when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="My Awesome Note",
directory="test",
@@ -45,7 +45,7 @@ async def test_write_note_underscores_to_hyphens(app, test_project, app_config):
"""Test that underscores are converted to hyphens when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="my_note_with_underscores",
directory="test",
@@ -61,7 +61,7 @@ async def test_write_note_camelcase_to_kebab(app, test_project, app_config):
"""Test that CamelCase is converted to kebab-case when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="MyAwesomeFeature",
directory="test",
@@ -77,7 +77,7 @@ async def test_write_note_mixed_case_to_lowercase(app, test_project, app_config)
"""Test that mixed case is converted to lowercase when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="MIXED_Case_Example",
directory="test",
@@ -102,7 +102,7 @@ async def test_write_note_single_period_preserved(app, test_project, app_config)
"""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test 3.0 Version",
directory="test",
@@ -118,7 +118,7 @@ async def test_write_note_multiple_periods_preserved(app, test_project, app_conf
"""Test that multiple periods in version numbers are preserved when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Version 1.2.3 Release",
directory="test",
@@ -139,7 +139,7 @@ async def test_write_note_special_chars_to_hyphens(app, test_project, app_config
"""Test that special characters are converted while preserving periods in version numbers."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test 2.0: New Feature",
directory="test",
@@ -155,7 +155,7 @@ async def test_write_note_parentheses_removed(app, test_project, app_config):
"""Test that parentheses are handled while preserving periods in version numbers."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Feature (v2.0) Update",
directory="test",
@@ -171,7 +171,7 @@ async def test_write_note_apostrophes_removed(app, test_project, app_config):
"""Test that apostrophes are removed when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="User's Guide",
directory="test",
@@ -192,7 +192,7 @@ async def test_write_note_all_transformations_combined(app, test_project, app_co
"""Test multiple transformation types combined while preserving periods in version numbers."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="MyProject_v3.0: Feature Update (DRAFT)",
directory="test",
@@ -208,7 +208,7 @@ async def test_write_note_consecutive_special_chars_collapsed(app, test_project,
"""Test that consecutive special characters collapse to single hyphen."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test___Multiple---Separators",
directory="test",
@@ -230,7 +230,7 @@ async def test_write_note_leading_trailing_hyphens_trimmed(app, test_project, ap
"""Test that leading/trailing hyphens are trimmed when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="---Test Note---",
directory="test",
@@ -246,7 +246,7 @@ async def test_write_note_all_special_chars_becomes_valid_filename(app, test_pro
"""Test that a title with mostly special characters becomes valid."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="!!!Test!!!",
directory="test",
@@ -267,7 +267,7 @@ async def test_write_note_folder_path_unaffected(app, test_project, app_config):
"""Test that folder paths are NOT affected by kebab_filenames setting."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test Note",
directory="My_Folder/Sub Folder", # Folder should remain as-is
@@ -284,7 +284,7 @@ async def test_write_note_root_folder_with_kebab(app, test_project, app_config):
"""Test kebab_filenames preserves periods in version numbers with root folder."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test 3.0 Note",
directory="", # Root folder
@@ -305,7 +305,7 @@ async def test_write_note_kebab_disabled_preserves_original(app, test_project, a
"""Test that original formatting is preserved when kebab_filenames=false."""
ConfigManager().config.kebab_filenames = False
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Test 3.0 Version",
directory="test",
@@ -323,7 +323,7 @@ async def test_write_note_kebab_disabled_preserves_underscores(app, test_project
"""Test that underscores are preserved when kebab_filenames=false."""
ConfigManager().config.kebab_filenames = False
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="my_note_example",
directory="test",
@@ -339,7 +339,7 @@ async def test_write_note_kebab_disabled_preserves_case(app, test_project, app_c
"""Test that case is preserved when kebab_filenames=false."""
ConfigManager().config.kebab_filenames = False
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="MyAwesomeNote",
directory="test",
@@ -366,7 +366,7 @@ async def test_permalinks_always_kebab_case(app, test_project, app_config):
# Test with kebab disabled
ConfigManager().config.kebab_filenames = False
result1 = await write_note.fn(
result1 = await write_note(
project=test_project.name,
title="Test Note 1",
directory="test",
@@ -380,7 +380,7 @@ async def test_permalinks_always_kebab_case(app, test_project, app_config):
# Test with kebab enabled
ConfigManager().config.kebab_filenames = True
result2 = await write_note.fn(
result2 = await write_note(
project=test_project.name,
title="Test Note 2",
directory="test",
+27 -27
View File
@@ -15,7 +15,7 @@ from basic_memory.mcp.tools import write_note, read_note
@pytest.mark.asyncio
async def test_metadata_simple_keys(app, test_project):
"""Simple key-value metadata appears as top-level YAML frontmatter."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Simple Metadata",
directory="meta-tests",
@@ -25,7 +25,7 @@ async def test_metadata_simple_keys(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/simple-metadata", project=test_project.name)
content = await read_note("meta-tests/simple-metadata", project=test_project.name)
assert "author: Alice" in content
assert "status: draft" in content
@@ -33,7 +33,7 @@ async def test_metadata_simple_keys(app, test_project):
@pytest.mark.asyncio
async def test_metadata_nested_dict(app, test_project):
"""Nested dict metadata renders as nested YAML."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Nested Metadata",
directory="meta-tests",
@@ -46,7 +46,7 @@ async def test_metadata_nested_dict(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/nested-metadata", project=test_project.name)
content = await read_note("meta-tests/nested-metadata", project=test_project.name)
assert "schema:" in content
assert "name: string" in content
assert "role?: string" in content
@@ -57,7 +57,7 @@ async def test_metadata_nested_dict(app, test_project):
@pytest.mark.asyncio
async def test_metadata_with_tags(app, test_project):
"""Metadata and tags coexist — both appear in frontmatter."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Tags And Metadata",
directory="meta-tests",
@@ -69,7 +69,7 @@ async def test_metadata_with_tags(app, test_project):
assert "# Created note" in result
assert "## Tags" in result
content = await read_note.fn("meta-tests/tags-and-metadata", project=test_project.name)
content = await read_note("meta-tests/tags-and-metadata", project=test_project.name)
assert "priority: high" in content
assert "- one" in content
assert "- two" in content
@@ -78,7 +78,7 @@ async def test_metadata_with_tags(app, test_project):
@pytest.mark.asyncio
async def test_metadata_various_value_types(app, test_project):
"""Metadata values of int, bool, and list types survive round-trip."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Typed Values",
directory="meta-tests",
@@ -88,7 +88,7 @@ async def test_metadata_various_value_types(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/typed-values", project=test_project.name)
content = await read_note("meta-tests/typed-values", project=test_project.name)
# YAML normalizes values to strings during frontmatter round-trip
assert "version:" in content
assert "active:" in content
@@ -99,7 +99,7 @@ async def test_metadata_various_value_types(app, test_project):
async def test_metadata_survives_update(app, test_project):
"""Metadata set at create time persists through an update cycle."""
# Create with metadata
await write_note.fn(
await write_note(
project=test_project.name,
title="Update Cycle",
directory="meta-tests",
@@ -108,7 +108,7 @@ async def test_metadata_survives_update(app, test_project):
)
# Update same note with new content + metadata
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Update Cycle",
directory="meta-tests",
@@ -118,7 +118,7 @@ async def test_metadata_survives_update(app, test_project):
assert "# Updated note" in result
content = await read_note.fn("meta-tests/update-cycle", project=test_project.name)
content = await read_note("meta-tests/update-cycle", project=test_project.name)
assert "# Version 2" in content
assert "author: Bob" in content
assert "version:" in content
@@ -127,7 +127,7 @@ async def test_metadata_survives_update(app, test_project):
@pytest.mark.asyncio
async def test_metadata_with_note_type(app, test_project):
"""Metadata works together with a custom note_type."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Config Entry",
directory="meta-tests",
@@ -138,7 +138,7 @@ async def test_metadata_with_note_type(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/config-entry", project=test_project.name)
content = await read_note("meta-tests/config-entry", project=test_project.name)
assert "type: config" in content
assert "env: production" in content
@@ -149,7 +149,7 @@ async def test_metadata_with_note_type(app, test_project):
@pytest.mark.asyncio
async def test_metadata_none_default(app, test_project):
"""metadata=None (default) produces the same output as before the feature existed."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="No Metadata",
directory="meta-tests",
@@ -160,7 +160,7 @@ async def test_metadata_none_default(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/no-metadata", project=test_project.name)
content = await read_note("meta-tests/no-metadata", project=test_project.name)
# Only standard keys should be present
assert "title: No Metadata" in content
assert "type: note" in content
@@ -170,7 +170,7 @@ async def test_metadata_none_default(app, test_project):
@pytest.mark.asyncio
async def test_metadata_empty_dict(app, test_project):
"""metadata={} behaves identically to metadata=None."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Empty Dict",
directory="meta-tests",
@@ -180,7 +180,7 @@ async def test_metadata_empty_dict(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/empty-dict", project=test_project.name)
content = await read_note("meta-tests/empty-dict", project=test_project.name)
assert "title: Empty Dict" in content
assert "type: note" in content
@@ -194,7 +194,7 @@ async def test_metadata_title_key_stripped(app, test_project):
schema_to_markdown pops 'title' from entity_metadata so the Entity.title wins.
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Real Title",
directory="meta-tests",
@@ -204,7 +204,7 @@ async def test_metadata_title_key_stripped(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/real-title", project=test_project.name)
content = await read_note("meta-tests/real-title", project=test_project.name)
assert "title: Real Title" in content
assert "Fake Title" not in content
@@ -215,7 +215,7 @@ async def test_metadata_type_key_stripped(app, test_project):
schema_to_markdown pops 'type' from entity_metadata so Entity.entity_type wins.
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Type Conflict",
directory="meta-tests",
@@ -226,7 +226,7 @@ async def test_metadata_type_key_stripped(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/type-conflict", project=test_project.name)
content = await read_note("meta-tests/type-conflict", project=test_project.name)
assert "type: guide" in content
assert "evil" not in content
@@ -237,7 +237,7 @@ async def test_metadata_permalink_key_stripped(app, test_project):
schema_to_markdown pops 'permalink' from entity_metadata.
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Permalink Conflict",
directory="meta-tests",
@@ -249,7 +249,7 @@ async def test_metadata_permalink_key_stripped(app, test_project):
# The canonical permalink should be based on title/directory, not the metadata value
assert "meta-tests/permalink-conflict" in result
content = await read_note.fn("meta-tests/permalink-conflict", project=test_project.name)
content = await read_note("meta-tests/permalink-conflict", project=test_project.name)
assert "hacked/path" not in content
@@ -260,7 +260,7 @@ async def test_tags_param_wins_over_metadata_tags(app, test_project):
The explicit tags parameter is applied after metadata.update(), so it takes
precedence. The summary and file contents stay consistent.
"""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Tags Override",
directory="meta-tests",
@@ -273,7 +273,7 @@ async def test_tags_param_wins_over_metadata_tags(app, test_project):
# Summary should reflect the winning tags
assert "from-param" in result
content = await read_note.fn("meta-tests/tags-override", project=test_project.name)
content = await read_note("meta-tests/tags-override", project=test_project.name)
# Explicit tags parameter wins over metadata tags key
assert "- from-param" in content
assert "from-metadata" not in content
@@ -282,7 +282,7 @@ async def test_tags_param_wins_over_metadata_tags(app, test_project):
@pytest.mark.asyncio
async def test_metadata_tags_key_works_when_no_tags_param(app, test_project):
"""When only metadata['tags'] is provided (no tags param), it is used."""
result = await write_note.fn(
result = await write_note(
project=test_project.name,
title="Metadata Tags Only",
directory="meta-tests",
@@ -292,6 +292,6 @@ async def test_metadata_tags_key_works_when_no_tags_param(app, test_project):
assert "# Created note" in result
content = await read_note.fn("meta-tests/metadata-tags-only", project=test_project.name)
content = await read_note("meta-tests/metadata-tags-only", project=test_project.name)
assert "- meta-tag-1" in content
assert "- meta-tag-2" in content
+9 -9
View File
@@ -1,7 +1,7 @@
"""Tests for MCP UI resource endpoints (resources/ui.py).
Each resource function is wrapped by @mcp.resource() into a FunctionResource.
We call the underlying .fn() to exercise the template loading logic.
We call the underlying () to exercise the template loading logic.
NOTE: UI resources are temporarily disabled (not registered with MCP server)
while MCP client rendering is being sorted out. These tests are skipped
@@ -30,14 +30,14 @@ class TestVariantResources:
def test_search_results_ui(self, monkeypatch):
"""search_results_ui loads the variant-specific template."""
monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "vanilla")
html = search_results_ui.fn()
html = search_results_ui()
assert isinstance(html, str)
assert len(html) > 0
def test_note_preview_ui(self, monkeypatch):
"""note_preview_ui loads the variant-specific template."""
monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "vanilla")
html = note_preview_ui.fn()
html = note_preview_ui()
assert isinstance(html, str)
assert len(html) > 0
@@ -46,31 +46,31 @@ class TestExplicitVariantResources:
"""Tests for variant-specific resource endpoints."""
def test_search_results_vanilla(self):
html = search_results_ui_vanilla.fn()
html = search_results_ui_vanilla()
assert isinstance(html, str)
assert len(html) > 0
def test_search_results_tool_ui(self):
html = search_results_ui_tool_ui.fn()
html = search_results_ui_tool_ui()
assert isinstance(html, str)
assert len(html) > 0
def test_search_results_mcp_ui(self):
html = search_results_ui_mcp_ui.fn()
html = search_results_ui_mcp_ui()
assert isinstance(html, str)
assert len(html) > 0
def test_note_preview_vanilla(self):
html = note_preview_ui_vanilla.fn()
html = note_preview_ui_vanilla()
assert isinstance(html, str)
assert len(html) > 0
def test_note_preview_tool_ui(self):
html = note_preview_ui_tool_ui.fn()
html = note_preview_ui_tool_ui()
assert isinstance(html, str)
assert len(html) > 0
def test_note_preview_mcp_ui(self):
html = note_preview_ui_mcp_ui.fn()
html = note_preview_ui_mcp_ui()
assert isinstance(html, str)
assert len(html) > 0
+14 -14
View File
@@ -10,13 +10,13 @@ from basic_memory.schemas.search import SearchResponse, SearchResult, SearchItem
@pytest.mark.asyncio
async def test_search_successful_results(client, test_project):
"""Test search with successful results returns proper MCP content array format."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Document 1",
directory="docs",
content="# Test Document 1\n\nThis is test content for document 1",
)
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Document 2",
directory="docs",
@@ -25,7 +25,7 @@ async def test_search_successful_results(client, test_project):
from basic_memory.mcp.tools.chatgpt_tools import search
result = await search.fn("test content")
result = await search("test content")
# Verify MCP content array format
assert isinstance(result, list)
@@ -52,9 +52,9 @@ async def test_search_with_error_response(monkeypatch, client, test_project):
async def fake_search_notes_fn(*args, **kwargs):
return error_message
monkeypatch.setattr(chatgpt_tools.search_notes, "fn", fake_search_notes_fn)
monkeypatch.setattr(chatgpt_tools, "search_notes", fake_search_notes_fn)
result = await chatgpt_tools.search.fn("invalid query")
result = await chatgpt_tools.search("invalid query")
assert isinstance(result, list)
assert len(result) == 1
@@ -77,9 +77,9 @@ async def test_search_uses_dynamic_default_search_type(monkeypatch, client, test
captured_kwargs.update(kwargs)
return {"results": []}
monkeypatch.setattr(chatgpt_tools.search_notes, "fn", fake_search_notes_fn)
monkeypatch.setattr(chatgpt_tools, "search_notes", fake_search_notes_fn)
result = await chatgpt_tools.search.fn("default search mode query")
result = await chatgpt_tools.search("default search mode query")
assert isinstance(result, list)
assert "search_type" not in captured_kwargs
@@ -88,7 +88,7 @@ async def test_search_uses_dynamic_default_search_type(monkeypatch, client, test
@pytest.mark.asyncio
async def test_fetch_successful_document(client, test_project):
"""Test fetch with successful document retrieval."""
await write_note.fn(
await write_note(
project=test_project.name,
title="Test Document",
directory="docs",
@@ -97,7 +97,7 @@ async def test_fetch_successful_document(client, test_project):
from basic_memory.mcp.tools.chatgpt_tools import fetch
result = await fetch.fn("docs/test-document")
result = await fetch("docs/test-document")
assert isinstance(result, list)
assert len(result) == 1
@@ -116,7 +116,7 @@ async def test_fetch_document_not_found(client, test_project):
"""Test fetch when document is not found."""
from basic_memory.mcp.tools.chatgpt_tools import fetch
result = await fetch.fn("nonexistent-doc")
result = await fetch("nonexistent-doc")
assert isinstance(result, list)
assert len(result) == 1
@@ -208,9 +208,9 @@ async def test_search_internal_exception_returns_error_payload(monkeypatch, clie
async def boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(chatgpt_tools.search_notes, "fn", boom)
monkeypatch.setattr(chatgpt_tools, "search_notes", boom)
result = await chatgpt_tools.search.fn("anything")
result = await chatgpt_tools.search("anything")
assert isinstance(result, list)
content = json.loads(result[0]["text"])
assert content["error"] == "Internal search error"
@@ -225,9 +225,9 @@ async def test_fetch_internal_exception_returns_error_payload(monkeypatch, clien
async def boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(chatgpt_tools.read_note, "fn", boom)
monkeypatch.setattr(chatgpt_tools, "read_note", boom)
result = await chatgpt_tools.fetch.fn("docs/test")
result = await chatgpt_tools.fetch("docs/test")
assert isinstance(result, list)
content = json.loads(result[0]["text"])
assert content["id"] == "docs/test"
Generated
+200 -101
View File
@@ -7,6 +7,18 @@ resolution-markers = [
"python_full_version < '3.13'",
]
[[package]]
name = "aiofile"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "caio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
]
[[package]]
name = "aiofiles"
version = "25.1.0"
@@ -212,7 +224,7 @@ requires-dist = [
{ name = "dateparser", specifier = ">=1.2.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" },
{ name = "fastembed", specifier = ">=0.7.4" },
{ name = "fastmcp", specifier = "==2.12.3" },
{ name = "fastmcp", specifier = ">=3.0.1,<4" },
{ name = "greenlet", specifier = ">=3.1.1" },
{ name = "httpx", specifier = ">=0.28.0" },
{ name = "loguru", specifier = ">=0.7.3" },
@@ -262,6 +274,39 @@ dev = [
{ name = "ty", specifier = ">=0.0.18" },
]
[[package]]
name = "beartype"
version = "0.22.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
[[package]]
name = "cachetools"
version = "7.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
]
[[package]]
name = "caio"
version = "0.9.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -816,24 +861,33 @@ wheels = [
[[package]]
name = "fastmcp"
version = "2.12.3"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
{ name = "cyclopts" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "jsonref" },
{ name = "jsonschema-path" },
{ name = "mcp" },
{ name = "openapi-core" },
{ name = "openapi-pydantic" },
{ name = "opentelemetry-api" },
{ name = "packaging" },
{ name = "platformdirs" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pyperclip" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "uvicorn" },
{ name = "watchfiles" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/5e/035fdfa23646de8811776cd62d93440e334e8a4557b35c63c1bff125c08c/fastmcp-2.12.3.tar.gz", hash = "sha256:541dd569d5b6c083140b04d997ba3dc47f7c10695cee700d0a733ce63b20bb65", size = 5246812, upload-time = "2025-09-12T12:28:07.136Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/39/0847a868a8681f0d9bf42ad4b6856ef675f799eb464bd10dbcfe9ae87323/fastmcp-3.0.1.tar.gz", hash = "sha256:ba463ae51e357fba2bafe513cc97f0a06c9f31220e6584990b7d8bcbf69f0516", size = 17236395, upload-time = "2026-02-21T01:35:25.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/79/0fd386e61819e205563d4eb15da76564b80dc2edd3c64b46f2706235daec/fastmcp-2.12.3-py3-none-any.whl", hash = "sha256:aee50872923a9cba731861fc0120e7dbe4642a2685ba251b2b202b82fb6c25a9", size = 314031, upload-time = "2025-09-12T12:28:05.024Z" },
{ url = "https://files.pythonhosted.org/packages/87/f9/94f2531d0c519e1e394c264941848caf2309284c5318c070f7b0e95ac496/fastmcp-3.0.1-py3-none-any.whl", hash = "sha256:71de15ffa4e54baebb78d7031c4c9a042a1ab8d1c0b44a9961b75d65809b67e8", size = 605494, upload-time = "2026-02-21T01:35:22.857Z" },
]
[[package]]
@@ -1100,6 +1154,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1110,12 +1176,45 @@ wheels = [
]
[[package]]
name = "isodate"
version = "0.7.2"
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
@@ -1198,6 +1297,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
]
[[package]]
name = "jsonref"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -1241,35 +1349,20 @@ wheels = [
]
[[package]]
name = "lazy-object-proxy"
version = "1.12.0"
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" }
dependencies = [
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" },
{ url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" },
{ url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" },
{ url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" },
{ url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" },
{ url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" },
{ url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" },
{ url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" },
{ url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" },
{ url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" },
{ url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" },
{ url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" },
{ url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" },
{ url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" },
{ url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" },
{ url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" },
{ url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" },
{ url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" },
{ url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" },
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
@@ -1685,25 +1778,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/97/284535aa75e6e84ab388248b5a323fc296b1f70530130dee37f7f4fbe856/openai-2.17.0-py3-none-any.whl", hash = "sha256:4f393fd886ca35e113aac7ff239bcd578b81d8f104f5aedc7d3693eb2af1d338", size = 1069524, upload-time = "2026-02-05T16:27:38.941Z" },
]
[[package]]
name = "openapi-core"
version = "0.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "isodate" },
{ name = "jsonschema" },
{ name = "jsonschema-path" },
{ name = "more-itertools" },
{ name = "openapi-schema-validator" },
{ name = "openapi-spec-validator" },
{ name = "typing-extensions" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/65/ee75f25b9459a02df6f713f8ffde5dacb57b8b4e45145cde4cab28b5abba/openapi_core-0.22.0.tar.gz", hash = "sha256:b30490dfa74e3aac2276105525590135212352f5dd7e5acf8f62f6a89ed6f2d0", size = 109242, upload-time = "2025-12-22T19:19:49.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/8e/a1bf9e7d1b170122aa33b6cf2c788b68824712427deb19795eb7db1b8dd5/openapi_core-0.22.0-py3-none-any.whl", hash = "sha256:8fb7c325f2db4ef6c60584b1870f90eeb3183aa47e30643715c5003b7677a149", size = 108384, upload-time = "2025-12-22T19:19:47.904Z" },
]
[[package]]
name = "openapi-pydantic"
version = "0.5.1"
@@ -1717,32 +1791,16 @@ wheels = [
]
[[package]]
name = "openapi-schema-validator"
version = "0.6.3"
name = "opentelemetry-api"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonschema" },
{ name = "jsonschema-specifications" },
{ name = "rfc3339-validator" },
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" }
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" },
]
[[package]]
name = "openapi-spec-validator"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonschema" },
{ name = "jsonschema-path" },
{ name = "lazy-object-proxy" },
{ name = "openapi-schema-validator" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" },
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]]
@@ -1829,6 +1887,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1866,6 +1933,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/f3/0b4a4c25a47c2d907afa97674287dab61bc9941c9ac3972a67100e33894d/psycopg-3.3.1-py3-none-any.whl", hash = "sha256:e44d8eae209752efe46318f36dd0fdf5863e928009338d736843bb1084f6435c", size = 212760, upload-time = "2025-12-02T21:02:36.029Z" },
]
[[package]]
name = "py-key-value-aio"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
]
[package.optional-dependencies]
filetree = [
{ name = "aiofile" },
{ name = "anyio" },
]
keyring = [
{ name = "keyring" },
]
memory = [
{ name = "cachetools" },
]
[[package]]
name = "py-rust-stemmers"
version = "0.1.5"
@@ -2244,6 +2336,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -2397,18 +2498,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rfc3339-validator"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" },
]
[[package]]
name = "rich"
version = "14.2.0"
@@ -2633,6 +2722,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" },
]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.49.0"
@@ -3104,18 +3206,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
@@ -3194,6 +3284,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]
[[package]]
name = "zope-event"
version = "6.1"