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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/dynavec/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import datetime
import hashlib
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
Expand All @@ -30,6 +31,24 @@
Metadata = dict[str, Any]


def _normalize_front_matter(value: Any) -> Any:
"""Recursively normalize parsed YAML front-matter for storage.

``yaml.safe_load()`` converts unquoted dates (``date: 2026-09-24``) into
``datetime.date`` / ``datetime.datetime`` objects, which DynamoDB's
``TypeSerializer`` rejects. Convert those to ISO 8601 strings, recurse
through mappings and sequences, and leave strings, ints, floats, and
booleans untouched.
"""
if isinstance(value, (datetime.datetime, datetime.date)):
return value.isoformat()
if isinstance(value, dict):
return {k: _normalize_front_matter(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize_front_matter(v) for v in value]
return value


@dataclass
class Record:
"""One source document before chunking."""
Expand Down Expand Up @@ -360,7 +379,9 @@ def _front_matter(text: str, path: Path) -> tuple[str, Metadata]:
metadata = {}
if not isinstance(metadata, dict) or any(not isinstance(k, str) for k in metadata):
raise ValueError(f"Front matter in {path} must be a mapping with string keys")
return "".join(lines[end + 1 :]), metadata
# yaml.safe_load() turns unquoted dates into datetime.date/datetime
# objects, which storage backends (e.g. DynamoDB) cannot serialize.
return "".join(lines[end + 1 :]), _normalize_front_matter(metadata)

def __iter__(self) -> Iterator[Record]:
for path in sorted(self.root.glob(self.glob)):
Expand Down
73 changes: 73 additions & 0 deletions tests/test_markdown_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,79 @@ def test_root_must_be_an_existing_directory(tmp_path):
def test_empty_directory_yields_no_records(tmp_path):
assert list(MarkdownSource(tmp_path)) == []

def test_front_matter_dates_normalize_to_iso_strings(tmp_path):
(tmp_path / "notes.md").write_text(
"---\ntitle: Research Notes\ndate: 2026-09-24\n---\nBody\n",
encoding="utf-8",
)

record = next(iter(MarkdownSource(tmp_path)))

assert record.metadata["date"] == "2026-09-24"
assert isinstance(record.metadata["date"], str)


def test_front_matter_timestamps_normalize_to_iso_strings(tmp_path):
(tmp_path / "notes.md").write_text(
"---\ntitle: Notes\ncreated: 2026-09-24 10:00:00\n---\nBody\n",
encoding="utf-8",
)

record = next(iter(MarkdownSource(tmp_path)))

assert record.metadata["created"] == "2026-09-24T10:00:00"
assert isinstance(record.metadata["created"], str)


def test_front_matter_nested_dates_normalize(tmp_path):
(tmp_path / "notes.md").write_text(
"---\ntitle: Notes\n"
"schedule:\n start: 2026-09-24\n milestones: [2026-09-25, 2026-09-26]\n---\nBody\n",
encoding="utf-8",
)

record = next(iter(MarkdownSource(tmp_path)))

assert record.metadata["schedule"] == {
"start": "2026-09-24",
"milestones": ["2026-09-25", "2026-09-26"],
}


def test_front_matter_quoted_dates_and_scalars_untouched(tmp_path):
(tmp_path / "notes.md").write_text(
"---\ntitle: Notes\ndate: '2026-09-24'\nyear: 2026\npublished: true\n"
"score: 4.5\n---\nBody\n",
encoding="utf-8",
)

record = next(iter(MarkdownSource(tmp_path)))

assert record.metadata["date"] == "2026-09-24"
assert record.metadata["year"] == 2026
assert record.metadata["published"] is True
assert record.metadata["score"] == 4.5


def test_front_matter_dates_survive_dynamodb_serialization(tmp_path):
"""End-to-end: ingest a dated Markdown file and prove the stored item
passes DynamoDB's TypeSerializer (issue #246)."""
from boto3.dynamodb.types import TypeSerializer

from dynavec.stores.dynamodb import _to_dynamo

(tmp_path / "notes.md").write_text(
"---\ntitle: Research Notes\ndate: 2026-09-24\n---\nBody\n",
encoding="utf-8",
)
record = next(iter(MarkdownSource(tmp_path)))

serializer = TypeSerializer()
serialized = serializer.serialize(_to_dynamo(record.metadata))

assert serialized["M"]["date"] == {"S": "2026-09-24"}



def test_markdown_source_flows_through_ingest(tmp_path):
(tmp_path / "guide.md").write_text("---\ntopic: aws\n---\nabcdefghij", encoding="utf-8")
Expand Down