mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fixing markdown parsing tests
This commit is contained in:
@@ -63,7 +63,7 @@ class MarkdownParser(ABC, Generic[T]):
|
||||
try:
|
||||
# Split into frontmatter and content
|
||||
frontmatter, markdown = await parse_frontmatter(content)
|
||||
|
||||
|
||||
# Extract metadata from frontmatter if present
|
||||
metadata = frontmatter.pop("metadata", None)
|
||||
|
||||
@@ -83,9 +83,7 @@ class MarkdownParser(ABC, Generic[T]):
|
||||
|
||||
# Create final document
|
||||
return await self.create_document(
|
||||
frontmatter=parsed_frontmatter,
|
||||
content=parsed_content,
|
||||
metadata=parsed_metadata
|
||||
frontmatter=parsed_frontmatter, content=parsed_content, metadata=parsed_metadata
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -106,7 +104,7 @@ class MarkdownParser(ABC, Generic[T]):
|
||||
"""
|
||||
# Initialize state
|
||||
title = None
|
||||
sections: Dict[str, List[str]] = {}
|
||||
sections: Dict[str, str] = {}
|
||||
current_section = None
|
||||
current_lines: List[str] = []
|
||||
|
||||
@@ -122,7 +120,7 @@ class MarkdownParser(ABC, Generic[T]):
|
||||
if current_section and current_lines:
|
||||
sections[current_section] = "\n".join(current_lines).strip()
|
||||
current_lines = []
|
||||
|
||||
|
||||
# Start new section
|
||||
current_section = line[3:].strip().lower()
|
||||
continue
|
||||
@@ -149,7 +147,7 @@ class MarkdownParser(ABC, Generic[T]):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def parse_content(self, title: str, sections: Dict[str, List[str]]) -> Any:
|
||||
async def parse_content(self, title: str, sections: Dict[str, str]) -> Any:
|
||||
"""Parse content sections."""
|
||||
pass
|
||||
|
||||
@@ -161,4 +159,4 @@ class MarkdownParser(ABC, Generic[T]):
|
||||
@abstractmethod
|
||||
async def create_document(self, frontmatter: Any, content: Any, metadata: Optional[Any]) -> T:
|
||||
"""Create document from parsed sections."""
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Parser for Basic Memory entity markdown files."""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -11,7 +11,7 @@ from basic_memory.markdown.schemas import (
|
||||
EntityContent,
|
||||
EntityMetadata,
|
||||
Observation,
|
||||
Relation
|
||||
Relation,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,19 +44,19 @@ class EntityParser(MarkdownParser[Entity]):
|
||||
try:
|
||||
# Preprocess fields for schema validation
|
||||
processed = frontmatter.copy()
|
||||
|
||||
|
||||
# Ensure id is string
|
||||
if 'id' in processed:
|
||||
processed['id'] = str(processed['id'])
|
||||
if "id" in processed:
|
||||
processed["id"] = str(processed["id"])
|
||||
|
||||
# Handle tags field
|
||||
if 'tags' in processed:
|
||||
if isinstance(processed['tags'], str):
|
||||
if "tags" in processed:
|
||||
if isinstance(processed["tags"], str):
|
||||
# Split comma-separated tags and strip whitespace
|
||||
processed['tags'] = [tag.strip() for tag in processed['tags'].split(',')]
|
||||
processed["tags"] = [tag.strip() for tag in processed["tags"].split(",")]
|
||||
|
||||
return EntityFrontmatter(**processed)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid entity frontmatter: {e}")
|
||||
raise ParseError(f"Invalid entity frontmatter: {str(e)}") from e
|
||||
@@ -85,10 +85,10 @@ class EntityParser(MarkdownParser[Entity]):
|
||||
observations = []
|
||||
if "observations" not in sections:
|
||||
raise ParseError("Missing required observations section")
|
||||
|
||||
|
||||
for line in sections["observations"].splitlines():
|
||||
if line and not line.isspace():
|
||||
observation = await self._parse_observation(line)
|
||||
observation = await self.parse_observation(line)
|
||||
if observation:
|
||||
observations.append(observation)
|
||||
|
||||
@@ -97,15 +97,12 @@ class EntityParser(MarkdownParser[Entity]):
|
||||
if "relations" in sections:
|
||||
for line in sections["relations"].splitlines():
|
||||
if line and not line.isspace():
|
||||
relation = await self._parse_relation(line)
|
||||
relation = await self.parse_relation(line)
|
||||
if relation:
|
||||
relations.append(relation)
|
||||
|
||||
return EntityContent(
|
||||
title=title,
|
||||
description=description,
|
||||
observations=observations,
|
||||
relations=relations
|
||||
title=title, description=description, observations=observations, relations=relations
|
||||
)
|
||||
|
||||
except ParseError:
|
||||
@@ -114,7 +111,7 @@ class EntityParser(MarkdownParser[Entity]):
|
||||
logger.error(f"Invalid entity content: {e}")
|
||||
raise ParseError(f"Invalid entity content: {str(e)}") from e
|
||||
|
||||
async def _parse_observation(self, line: str) -> Optional[Observation]:
|
||||
async def parse_observation(self, line: str) -> Optional[Observation]:
|
||||
"""
|
||||
Parse a single observation line.
|
||||
|
||||
@@ -127,47 +124,44 @@ class EntityParser(MarkdownParser[Entity]):
|
||||
# Extract category if present [category]
|
||||
category = None
|
||||
content = line
|
||||
if line.startswith('['):
|
||||
end_bracket = line.find(']')
|
||||
if line.startswith("["):
|
||||
end_bracket = line.find("]")
|
||||
if end_bracket != -1:
|
||||
category = line[1:end_bracket].strip()
|
||||
content = line[end_bracket + 1:].strip()
|
||||
content = line[end_bracket + 1 :].strip()
|
||||
|
||||
# Extract context if present (context)
|
||||
context = None
|
||||
if content.endswith(')'):
|
||||
context_start = content.rfind('(')
|
||||
if content.endswith(")"):
|
||||
context_start = content.rfind("(")
|
||||
if context_start != -1:
|
||||
context = content[context_start + 1:-1].strip()
|
||||
context = content[context_start + 1 : -1].strip()
|
||||
content = content[:context_start].strip()
|
||||
|
||||
# Extract tags #tag1 #tag2
|
||||
tags = []
|
||||
content_parts = []
|
||||
for part in content.split():
|
||||
if part.startswith('#'):
|
||||
if part.startswith("#"):
|
||||
tags.append(part[1:]) # Remove # prefix
|
||||
else:
|
||||
content_parts.append(part)
|
||||
|
||||
content = ' '.join(content_parts).strip()
|
||||
|
||||
|
||||
content = " ".join(content_parts).strip()
|
||||
|
||||
if not content:
|
||||
logger.warning(f"Skipping observation with no content: {line}")
|
||||
return None
|
||||
|
||||
return Observation(
|
||||
category=category,
|
||||
content=content,
|
||||
context=context,
|
||||
tags=tags if tags else None
|
||||
category=category, content=content, context=context, tags=tags if tags else None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse observation '{line}': {e}")
|
||||
return None
|
||||
|
||||
async def _parse_relation(self, line: str) -> Optional[Relation]:
|
||||
async def parse_relation(self, line: str) -> Optional[Relation]:
|
||||
"""
|
||||
Parse a single relation line.
|
||||
|
||||
@@ -180,34 +174,30 @@ class EntityParser(MarkdownParser[Entity]):
|
||||
# Extract context if present (context)
|
||||
context = None
|
||||
main_part = line
|
||||
if line.endswith(')'):
|
||||
context_start = line.rfind('(')
|
||||
if line.endswith(")"):
|
||||
context_start = line.rfind("(")
|
||||
if context_start != -1:
|
||||
context = line[context_start + 1:-1].strip()
|
||||
context = line[context_start + 1 : -1].strip()
|
||||
main_part = line[:context_start].strip()
|
||||
|
||||
# Extract relation type and target [[Entity]]
|
||||
if '[[' not in main_part or ']]' not in main_part:
|
||||
if "[[" not in main_part or "]]" not in main_part:
|
||||
logger.warning(f"Invalid relation format (missing [[]]): {line}")
|
||||
return None
|
||||
|
||||
# Split into relation type and target
|
||||
relation_parts = main_part.split('[[', 1)
|
||||
relation_parts = main_part.split("[[", 1)
|
||||
relation_type = relation_parts[0].strip()
|
||||
if not relation_type:
|
||||
logger.warning(f"Missing relation type: {line}")
|
||||
return None
|
||||
|
||||
target = relation_parts[1].split(']]')[0].strip()
|
||||
target = relation_parts[1].split("]]")[0].strip()
|
||||
if not target:
|
||||
logger.warning(f"Missing target entity: {line}")
|
||||
return None
|
||||
|
||||
return Relation(
|
||||
type=relation_type,
|
||||
target=target,
|
||||
context=context
|
||||
)
|
||||
return Relation(type=relation_type, target=target, context=context)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse relation '{line}': {e}")
|
||||
@@ -235,10 +225,7 @@ class EntityParser(MarkdownParser[Entity]):
|
||||
raise ParseError(f"Invalid entity metadata: {str(e)}") from e
|
||||
|
||||
async def create_document(
|
||||
self,
|
||||
frontmatter: EntityFrontmatter,
|
||||
content: EntityContent,
|
||||
metadata: EntityMetadata
|
||||
self, frontmatter: EntityFrontmatter, content: EntityContent, metadata: EntityMetadata
|
||||
) -> Entity:
|
||||
"""
|
||||
Create entity from parsed sections.
|
||||
@@ -251,8 +238,4 @@ class EntityParser(MarkdownParser[Entity]):
|
||||
Returns:
|
||||
Complete entity
|
||||
"""
|
||||
return Entity(
|
||||
frontmatter=frontmatter,
|
||||
content=content,
|
||||
metadata=metadata
|
||||
)
|
||||
return Entity(frontmatter=frontmatter, content=content, metadata=metadata)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
from basic_memory.markdown import EntityMetadata
|
||||
import pytest
|
||||
|
||||
from basic_memory.utils.file_utils import parse_frontmatter
|
||||
|
||||
|
||||
def test_parse_metadata():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata():
|
||||
"""Test parsing basic metadata."""
|
||||
text = dedent("""
|
||||
owner: team-auth
|
||||
@@ -13,22 +16,24 @@ def test_parse_metadata():
|
||||
status: active
|
||||
""")
|
||||
|
||||
result = EntityMetadata.from_text(text)
|
||||
result, remaining = await parse_frontmatter(text)
|
||||
|
||||
assert result.metadata["owner"] == "team-auth"
|
||||
assert result.metadata["priority"] == "high"
|
||||
assert result.metadata["status"] == "active"
|
||||
assert result["owner"] == "team-auth"
|
||||
assert result["priority"] == "high"
|
||||
assert result["status"] == "active"
|
||||
|
||||
|
||||
def test_parse_metadata_empty():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_empty():
|
||||
"""Test parsing empty metadata."""
|
||||
text = ""
|
||||
|
||||
result = EntityMetadata.from_text(text)
|
||||
assert result.metadata == {}
|
||||
result, remaining = await parse_frontmatter(text)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_parse_metadata_whitespace():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_whitespace():
|
||||
"""Test handling of various whitespace in metadata."""
|
||||
text = dedent("""
|
||||
owner: team-auth
|
||||
@@ -36,14 +41,15 @@ def test_parse_metadata_whitespace():
|
||||
status: active
|
||||
""")
|
||||
|
||||
result = EntityMetadata.from_text(text)
|
||||
result, remaining = await parse_frontmatter(text)
|
||||
|
||||
assert result.metadata["owner"] == "team-auth"
|
||||
assert result.metadata["priority"] == "high"
|
||||
assert result.metadata["status"] == "active"
|
||||
assert result["owner"] == "team-auth"
|
||||
assert result["priority"] == "high"
|
||||
assert result["status"] == "active"
|
||||
|
||||
|
||||
def test_parse_metadata_multiline_values():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_multiline_values():
|
||||
"""Test handling of multiline metadata values."""
|
||||
text = dedent("""
|
||||
owner: team-auth
|
||||
@@ -53,21 +59,22 @@ def test_parse_metadata_multiline_values():
|
||||
status: active
|
||||
""")
|
||||
|
||||
result = EntityMetadata.from_text(text)
|
||||
result, _ = await parse_frontmatter(text)
|
||||
|
||||
assert result.metadata["owner"] == "team-auth"
|
||||
assert result.metadata["status"] == "active"
|
||||
assert len(result.metadata["description"].splitlines()) == 3
|
||||
assert result["owner"] == "team-auth"
|
||||
assert result["status"] == "active"
|
||||
assert len(result["description"].splitlines()) == 3
|
||||
|
||||
|
||||
def test_parse_metadata_invalid():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_invalid():
|
||||
"""Test handling of invalid metadata format."""
|
||||
text = dedent("""
|
||||
owner team-auth
|
||||
priority: high
|
||||
""")
|
||||
|
||||
result = EntityMetadata.from_text(text)
|
||||
result, _ = await parse_frontmatter(text)
|
||||
|
||||
assert "priority" in result.metadata
|
||||
assert "owner" not in result.metadata
|
||||
assert "priority" in result
|
||||
assert "owner" not in result
|
||||
|
||||
@@ -2,87 +2,103 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.markdown import ParseError
|
||||
from basic_memory.markdown.schemas import Observation
|
||||
from basic_memory.markdown import ParseError, EntityParser
|
||||
|
||||
|
||||
def test_observation_empty_input():
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_empty_input():
|
||||
"""Test handling of empty input."""
|
||||
assert Observation.from_line("") is None
|
||||
assert Observation.from_line(" ") is None
|
||||
assert Observation.from_line("\n") is None
|
||||
parser = EntityParser()
|
||||
assert await parser.parse_observation("") is None
|
||||
assert await parser.parse_observation(" ") is None
|
||||
assert await parser.parse_observation("\n") is None
|
||||
|
||||
|
||||
def test_observation_unicode():
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_unicode():
|
||||
"""Test handling of Unicode content."""
|
||||
# Invalid UTF-8 sequences
|
||||
assert Observation.from_line("- [test] Bad UTF \xff") is None
|
||||
assert Observation.from_line("- [test] Bad UTF \xfe") is None
|
||||
parser = EntityParser()
|
||||
assert await parser.parse_observation("- [test] Bad UTF \xff") is None
|
||||
assert await parser.parse_observation("- [test] Bad UTF \xfe") is None
|
||||
|
||||
# Control characters
|
||||
assert Observation.from_line("- [test] With \x00 null") is None
|
||||
assert Observation.from_line("- [test] With \x01 ctrl-a") is None
|
||||
assert Observation.from_line("- [test] With \x1b escape") is None
|
||||
assert Observation.from_line("- [test] With \x7f delete") is None
|
||||
assert Observation.from_line("- [test] With " + chr(0x1F) + " unit sep") is None
|
||||
assert await parser.parse_observation("- [test] With \x00 null") is None
|
||||
assert await parser.parse_observation("- [test] With \x01 ctrl-a") is None
|
||||
assert await parser.parse_observation("- [test] With \x1b escape") is None
|
||||
assert await parser.parse_observation("- [test] With \x7f delete") is None
|
||||
assert await parser.parse_observation("- [test] With " + chr(0x1F) + " unit sep") is None
|
||||
|
||||
# Valid UTF-8
|
||||
obs = Observation.from_line("- [测试] Unicode content #标签")
|
||||
obs = await parser.parse_observation("- [测试] Unicode content #标签")
|
||||
assert obs is not None
|
||||
assert obs.category == "测试"
|
||||
assert "标签" in obs.tags
|
||||
assert "标签" in obs.tags # pyright: ignore [reportOperatorIssue]
|
||||
|
||||
|
||||
def test_observation_invalid_context():
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_invalid_context():
|
||||
"""Test handling of invalid context format."""
|
||||
obs = Observation.from_line("- [test] Content (unclosed")
|
||||
parser = EntityParser()
|
||||
obs = await parser.parse_observation("- [test] Content (unclosed")
|
||||
assert obs is not None
|
||||
assert obs.content == "Content (unclosed"
|
||||
assert obs.context is None
|
||||
|
||||
obs = Observation.from_line("- [test] Content (with) extra) parens)")
|
||||
obs = await parser.parse_observation("- [test] Content (with) extra) parens)")
|
||||
assert obs is not None
|
||||
assert obs.content == "Content"
|
||||
assert obs.context == "with) extra) parens"
|
||||
|
||||
|
||||
def test_observation_complex_format():
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_complex_format():
|
||||
"""Test parsing complex observation formats."""
|
||||
# Test multiple nested tags and spaces
|
||||
obs = Observation.from_line("- [complex test] This is #tag1#tag2 with #tag3 content")
|
||||
parser = EntityParser()
|
||||
obs = await parser.parse_observation("- [complex test] This is #tag1#tag2 with #tag3 content")
|
||||
assert obs is not None
|
||||
assert obs.category == "complex test"
|
||||
assert set(obs.tags) == {"tag1", "tag2", "tag3"}
|
||||
assert set(obs.tags) == {"tag1", "tag2", "tag3"} # pyright: ignore [reportArgumentType]
|
||||
assert obs.content == "This is with content"
|
||||
|
||||
|
||||
def test_observation_exception_handling():
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_exception_handling():
|
||||
"""Test general error handling in observation parsing."""
|
||||
# Test with a problematic regex pattern that could cause catastrophic backtracking
|
||||
long_input = "[test] " + "a" * 1000000 # Very long input
|
||||
assert Observation.from_line(long_input) is None
|
||||
parser = EntityParser()
|
||||
|
||||
assert await parser.parse_observation(long_input) is None
|
||||
|
||||
# Test with invalid types
|
||||
assert Observation.from_line(None) is None # type: ignore
|
||||
assert Observation.from_line(123) is None # type: ignore
|
||||
assert Observation.from_line(object()) is None # type: ignore
|
||||
assert await parser.parse_observation(None) is None # type: ignore
|
||||
assert await parser.parse_observation(123) is None # type: ignore
|
||||
assert await parser.parse_observation(object()) is None # type: ignore
|
||||
|
||||
|
||||
def test_observation_malformed_category():
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_malformed_category():
|
||||
"""Test handling of malformed category brackets."""
|
||||
|
||||
parser = EntityParser()
|
||||
with pytest.raises(ParseError, match="unclosed category"):
|
||||
Observation.from_line("- [test Content")
|
||||
await parser.parse_observation("- [test Content")
|
||||
|
||||
with pytest.raises(ParseError, match="missing category"):
|
||||
Observation.from_line("- test] Content")
|
||||
await parser.parse_observation("- test] Content")
|
||||
|
||||
assert Observation.from_line("- [] Empty category") is None
|
||||
assert await parser.parse_observation("- [] Empty category") is None
|
||||
|
||||
|
||||
def test_observation_whitespace():
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_whitespace():
|
||||
"""Test handling of whitespace."""
|
||||
# Valid whitespace cases
|
||||
obs = Observation.from_line("- [test] Content")
|
||||
|
||||
parser = EntityParser()
|
||||
obs = await parser.parse_observation("- [test] Content")
|
||||
assert obs is not None
|
||||
assert obs.content == "Content"
|
||||
|
||||
@@ -96,6 +112,6 @@ def test_observation_whitespace():
|
||||
|
||||
for char, name in test_chars.items():
|
||||
content = f"- [test] Content{char}with{char}{name}"
|
||||
obs = Observation.from_line(content)
|
||||
obs = await parser.parse_observation(content)
|
||||
assert obs is not None
|
||||
assert obs.content == f"Content with {name}"
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.markdown.parser import EntityParser
|
||||
|
||||
|
||||
def test_parse_complete_file(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_complete_file(tmp_path):
|
||||
"""Test parsing a complete entity file."""
|
||||
content = dedent("""
|
||||
---
|
||||
type: component
|
||||
id: component/auth_service
|
||||
id: 123
|
||||
created: 2024-12-21T14:00:00Z
|
||||
modified: 2024-12-21T14:00:00Z
|
||||
tags: authentication, security, core
|
||||
@@ -42,11 +45,11 @@ def test_parse_complete_file(tmp_path):
|
||||
test_file.write_text(content)
|
||||
|
||||
parser = EntityParser()
|
||||
entity = parser.parse_file(test_file)
|
||||
entity = await parser.parse_file(test_file)
|
||||
|
||||
# Check frontmatter
|
||||
assert entity.frontmatter.type == "component"
|
||||
assert entity.frontmatter.id == "component/auth_service"
|
||||
assert entity.frontmatter.id == "123"
|
||||
assert "authentication" in entity.frontmatter.tags
|
||||
|
||||
# Check content
|
||||
@@ -57,7 +60,7 @@ def test_parse_complete_file(tmp_path):
|
||||
# Check specific observation
|
||||
obs = entity.content.observations[0]
|
||||
assert obs.category == "design"
|
||||
assert "security" in obs.tags
|
||||
assert "security" in obs.tags # pyright: ignore [reportOperatorIssue]
|
||||
assert obs.context == "JWT based"
|
||||
|
||||
# Check specific relation
|
||||
@@ -71,15 +74,16 @@ def test_parse_complete_file(tmp_path):
|
||||
assert entity.metadata.metadata["priority"] == "high"
|
||||
|
||||
|
||||
def test_parse_minimal_file(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_minimal_file(tmp_path):
|
||||
"""Test parsing a minimal valid entity file."""
|
||||
content = dedent("""
|
||||
---
|
||||
type: component
|
||||
id: minimal
|
||||
id: 0
|
||||
created: 2024-12-21T14:00:00Z
|
||||
modified: 2024-12-21T14:00:00Z
|
||||
tags: []
|
||||
tags:
|
||||
---
|
||||
|
||||
# Minimal Entity
|
||||
@@ -95,16 +99,17 @@ def test_parse_minimal_file(tmp_path):
|
||||
test_file.write_text(content)
|
||||
|
||||
parser = EntityParser()
|
||||
entity = parser.parse_file(test_file)
|
||||
entity = await parser.parse_file(test_file)
|
||||
|
||||
assert entity.frontmatter.type == "component"
|
||||
assert entity.frontmatter.id == "minimal"
|
||||
assert entity.frontmatter.id == "0"
|
||||
assert len(entity.content.observations) == 1
|
||||
assert len(entity.content.relations) == 1
|
||||
assert not entity.metadata.metadata # Empty metadata
|
||||
|
||||
|
||||
def test_file_with_metadata_only(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_with_metadata_only(tmp_path):
|
||||
"""Test parsing a file that has metadata but no content."""
|
||||
content = dedent("""
|
||||
---
|
||||
@@ -127,7 +132,7 @@ def test_file_with_metadata_only(tmp_path):
|
||||
test_file.write_text(content)
|
||||
|
||||
parser = EntityParser()
|
||||
entity = parser.parse_file(test_file)
|
||||
entity = await parser.parse_file(test_file)
|
||||
|
||||
assert entity.metadata.metadata["owner"] == "test-team"
|
||||
assert entity.metadata.metadata["status"] == "active"
|
||||
|
||||
@@ -2,73 +2,76 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.markdown import Observation
|
||||
from basic_memory.markdown.parser import EntityParser, ParseError
|
||||
|
||||
|
||||
def test_parse_observation_basic():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_observation_basic():
|
||||
"""Test basic observation parsing with category and tags."""
|
||||
parser = EntityParser()
|
||||
|
||||
obs = Observation.from_line("- [design] Core feature #important #mvp")
|
||||
obs = await parser.parse_observation("- [design] Core feature #important #mvp")
|
||||
|
||||
assert obs is not None
|
||||
assert obs.category == "design"
|
||||
assert obs.content == "Core feature"
|
||||
assert set(obs.tags) == {"important", "mvp"}
|
||||
assert set(obs.tags) == {"important", "mvp"} # pyright: ignore [reportArgumentType]
|
||||
assert obs.context is None
|
||||
|
||||
|
||||
def test_parse_observation_with_context():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_observation_with_context():
|
||||
"""Test observation parsing with context in parentheses."""
|
||||
parser = EntityParser()
|
||||
|
||||
obs = Observation.from_line(
|
||||
obs = await parser.parse_observation(
|
||||
"- [feature] Authentication system #security #auth (Required for MVP)"
|
||||
)
|
||||
assert obs is not None
|
||||
assert obs.category == "feature"
|
||||
assert obs.content == "Authentication system"
|
||||
assert set(obs.tags) == {"security", "auth"}
|
||||
assert set(obs.tags) == {"security", "auth"} # pyright: ignore [reportArgumentType]
|
||||
assert obs.context == "Required for MVP"
|
||||
|
||||
|
||||
def test_parse_observation_edge_cases():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_observation_edge_cases():
|
||||
"""Test observation parsing edge cases."""
|
||||
parser = EntityParser()
|
||||
|
||||
# Multiple word tags
|
||||
obs = Observation.from_line("- [tech] Database #high-priority #needs-review")
|
||||
obs = await parser.parse_observation("- [tech] Database #high-priority #needs-review")
|
||||
|
||||
assert obs is not None
|
||||
|
||||
assert set(obs.tags) == {"high-priority", "needs-review"}
|
||||
assert set(obs.tags) == {"high-priority", "needs-review"} # pyright: ignore [reportArgumentType]
|
||||
|
||||
# Multiple word category
|
||||
obs = Observation.from_line("- [user experience] Design #ux")
|
||||
obs = await parser.parse_observation("- [user experience] Design #ux")
|
||||
assert obs is not None
|
||||
assert obs.category == "user experience"
|
||||
|
||||
# Parentheses in content shouldn't be treated as context
|
||||
obs = Observation.from_line("- [code] Function (x) returns y #function")
|
||||
obs = await parser.parse_observation("- [code] Function (x) returns y #function")
|
||||
assert obs is not None
|
||||
assert obs.content == "Function (x) returns y"
|
||||
assert obs.context is None
|
||||
|
||||
# Multiple hashtags together
|
||||
obs = Observation.from_line("- [test] Feature #important#urgent#now")
|
||||
obs = await parser.parse_observation("- [test] Feature #important#urgent#now")
|
||||
assert obs is not None
|
||||
assert set(obs.tags) == {"important", "urgent", "now"}
|
||||
assert set(obs.tags) == {"important", "urgent", "now"} # pyright: ignore [reportArgumentType]
|
||||
|
||||
|
||||
def test_parse_observation_errors():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_observation_errors():
|
||||
"""Test error handling in observation parsing."""
|
||||
parser = EntityParser()
|
||||
|
||||
# Missing category brackets
|
||||
with pytest.raises(ParseError, match="missing category"):
|
||||
Observation.from_line("- Design without brackets #test")
|
||||
await parser.parse_observation("- Design without brackets #test")
|
||||
|
||||
# Unclosed category
|
||||
with pytest.raises(ParseError, match="unclosed category"):
|
||||
Observation.from_line("- [design Core feature #test")
|
||||
await parser.parse_observation("- [design Core feature #test")
|
||||
|
||||
@@ -2,61 +2,64 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.markdown import Relation
|
||||
from basic_memory.markdown.parser import EntityParser, ParseError
|
||||
|
||||
|
||||
def test_parse_relation_basic():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_relation_basic():
|
||||
"""Test basic relation parsing."""
|
||||
parser = EntityParser()
|
||||
|
||||
rel = Relation.from_line("- implements [[Auth Service]]")
|
||||
rel = await parser.parse_relation("- implements [[Auth Service]]")
|
||||
assert rel is not None
|
||||
assert rel.type == "implements"
|
||||
assert rel.target == "Auth Service"
|
||||
assert rel.context is None
|
||||
|
||||
|
||||
def test_parse_relation_with_context():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_relation_with_context():
|
||||
"""Test relation parsing with context."""
|
||||
parser = EntityParser()
|
||||
|
||||
rel = Relation.from_line("- depends_on [[Database]] (Required for persistence)")
|
||||
rel = await parser.parse_relation("- depends_on [[Database]] (Required for persistence)")
|
||||
assert rel is not None
|
||||
assert rel.type == "depends_on"
|
||||
assert rel.target == "Database"
|
||||
assert rel.context == "Required for persistence"
|
||||
|
||||
|
||||
def test_parse_relation_edge_cases():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_relation_edge_cases():
|
||||
"""Test relation parsing edge cases."""
|
||||
parser = EntityParser()
|
||||
|
||||
# Multiple word type
|
||||
rel = Relation.from_line("- is used by [[Client App]] (Primary consumer)")
|
||||
rel = await parser.parse_relation("- is used by [[Client App]] (Primary consumer)")
|
||||
assert rel is not None
|
||||
assert rel.type == "is used by"
|
||||
|
||||
# Brackets in context
|
||||
rel = Relation.from_line("- implements [[API]] (Follows [OpenAPI] spec)")
|
||||
rel = await parser.parse_relation("- implements [[API]] (Follows [OpenAPI] spec)")
|
||||
assert rel is not None
|
||||
assert rel.context == "Follows [OpenAPI] spec"
|
||||
|
||||
# Extra spaces
|
||||
rel = Relation.from_line("- specifies [[Format]] (Documentation)")
|
||||
rel = await parser.parse_relation("- specifies [[Format]] (Documentation)")
|
||||
assert rel is not None
|
||||
assert rel.type == "specifies"
|
||||
assert rel.target == "Format"
|
||||
|
||||
|
||||
def test_parse_relation_errors():
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_relation_errors():
|
||||
"""Test error handling in relation parsing."""
|
||||
parser = EntityParser()
|
||||
|
||||
# Missing target brackets
|
||||
with pytest.raises(ParseError, match="missing \\[\\["):
|
||||
Relation.from_line("- implements Auth Service")
|
||||
await parser.parse_relation("- implements Auth Service")
|
||||
|
||||
# Unclosed target
|
||||
with pytest.raises(ParseError, match="missing ]]"):
|
||||
Relation.from_line("- implements [[Auth Service")
|
||||
await parser.parse_relation("- implements [[Auth Service")
|
||||
|
||||
@@ -16,15 +16,13 @@ from basic_memory.utils.file_utils import ParseError, FileError
|
||||
@pytest.fixture
|
||||
def sample_entity_content():
|
||||
"""Sample entity file content."""
|
||||
return """---
|
||||
type: test
|
||||
return """
|
||||
---
|
||||
id: 123
|
||||
type: test
|
||||
created: 2024-12-22T10:00:00Z
|
||||
modified: 2024-12-22T10:00:00Z
|
||||
tags: entity, test
|
||||
metadata:
|
||||
checksum: abc123
|
||||
doc_id: 1
|
||||
---
|
||||
|
||||
# Test Entity
|
||||
@@ -37,6 +35,13 @@ A test entity for testing purposes.
|
||||
|
||||
## Relations
|
||||
- depends_on [[Other Entity]] (Testing the relation parsing)
|
||||
|
||||
---
|
||||
metadata:
|
||||
checksum: abc123
|
||||
doc_id: 1
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -54,7 +59,7 @@ async def test_parse_valid_file(tmp_path: Path, sample_entity_content):
|
||||
# Verify frontmatter
|
||||
assert isinstance(entity.frontmatter, EntityFrontmatter)
|
||||
assert entity.frontmatter.type == "test"
|
||||
assert entity.frontmatter.id == "test/test_entity"
|
||||
assert entity.frontmatter.id == "123"
|
||||
|
||||
# Verify content
|
||||
assert isinstance(entity.content, EntityContent)
|
||||
@@ -132,7 +137,7 @@ async def test_parse_content_str(sample_entity_content):
|
||||
|
||||
assert isinstance(entity, Entity)
|
||||
assert entity.frontmatter.type == "test"
|
||||
assert entity.frontmatter.id == "test/test_entity"
|
||||
assert entity.frontmatter.id == "123"
|
||||
assert entity.content.title == "Test Entity"
|
||||
|
||||
# Verify observations parsed correctly
|
||||
|
||||
Reference in New Issue
Block a user