fix: avoid Post(**metadata) crash when frontmatter contains 'content' or 'handler' keys

frontmatter.Post.__init__ takes `content` and `handler` as positional
parameters. When user YAML frontmatter contains these as field names,
unpacking metadata via **kwargs causes "got multiple values for argument
'content'".

Replace frontmatter.loads() with frontmatter.parse() + Post() + update()
in entity_parser, and replace the **metadata unpacking in
entity_service.update_entity() with the same safe pattern.

Fixes basic-memory-cloud#375

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-02-27 22:52:16 -06:00
parent 236ae268aa
commit dc0ca71c18
3 changed files with 45 additions and 4 deletions
+8 -2
View File
@@ -249,9 +249,15 @@ class EntityParser:
content = strip_bom(content)
# Parse frontmatter with proper error handling for malformed YAML
# Parse frontmatter with proper error handling for malformed YAML.
# We use frontmatter.parse() instead of frontmatter.loads() because
# loads() does Post(content, handler, **metadata), which crashes when
# the YAML contains reserved keys like 'content' or 'handler'.
# See basic-memory-cloud#375.
try:
post = frontmatter.loads(content)
fm_metadata, fm_content = frontmatter.parse(content)
post = frontmatter.Post(fm_content)
post.metadata.update(fm_metadata)
except yaml.YAMLError as e:
logger.warning(
f"Failed to parse YAML frontmatter in {file_path}: {e}. "
+5 -2
View File
@@ -361,8 +361,11 @@ class EntityService(BaseService[EntityModel]):
# in the existing file. Setting it unconditionally preserves the correct value.
existing_markdown.frontmatter.metadata["permalink"] = new_permalink
# Create a new post with merged metadata
merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
# Create a new post with merged metadata.
# Avoid **metadata unpacking — user frontmatter may contain reserved keys
# like 'content' or 'handler' that conflict with Post.__init__ (cloud#375).
merged_post = frontmatter.Post(post.content)
merged_post.metadata.update(existing_markdown.frontmatter.metadata)
# write file
final_content = dump_frontmatter(merged_post)
@@ -311,3 +311,35 @@ async def test_parse_file_with_datetime_objects(tmp_path):
assert "2025-10-24" in updated_at and "00:00:00" in updated_at, (
f"Datetime at midnight should be normalized to ISO format, got: {updated_at}"
)
@pytest.mark.asyncio
async def test_parse_file_with_reserved_frontmatter_field_content(tmp_path):
"""Test that a 'content' field in frontmatter doesn't break parsing.
Reproduces basic-memory-cloud#375 where frontmatter containing a field named
'content' causes frontmatter.Post.__init__() to receive multiple values for
the 'content' positional argument.
"""
test_file = tmp_path / "topic-note-template.md"
test_file.write_text(dedent("""\
---
title: Topic Note Template
content: Template for topic notes
handler: some-handler-value
---
# Template Body
Actual body content here.
"""))
parser = EntityParser(tmp_path)
entity_markdown = await parser.parse_file(test_file)
assert entity_markdown.frontmatter.title == "Topic Note Template"
# The 'content' and 'handler' fields should be preserved in metadata
assert entity_markdown.frontmatter.metadata.get("content") == "Template for topic notes"
assert entity_markdown.frontmatter.metadata.get("handler") == "some-handler-value"
# The actual body content should be parsed correctly
assert "Template Body" in entity_markdown.content