|
| 1 | +"""Tests for the first-party ``ndjson`` source adapter (RFC 002). |
| 2 | +
|
| 3 | +A pipeline that pre-extracts records writes a JSON-Lines spool and runs |
| 4 | +``mempalace mine --source ndjson <spool>``; this adapter reads it back into |
| 5 | +verbatim drawers. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import json |
| 11 | +import os |
| 12 | +from argparse import Namespace |
| 13 | + |
| 14 | +import pytest |
| 15 | + |
| 16 | +from mempalace.sources import available_adapters |
| 17 | +from mempalace.sources.base import SourceAdapterError, SourceNotFoundError, SourceRef |
| 18 | +from mempalace.sources.ndjson import NdjsonSourceAdapter |
| 19 | + |
| 20 | + |
| 21 | +def _write_spool(tmp_dir, records) -> str: |
| 22 | + path = os.path.join(tmp_dir, "batch-0.ndjson") |
| 23 | + with open(path, "w", encoding="utf-8") as fh: |
| 24 | + fh.write("\n".join(json.dumps(r) for r in records) + "\n") |
| 25 | + return path |
| 26 | + |
| 27 | + |
| 28 | +def test_ndjson_is_registered_first_party(): |
| 29 | + # Registered on import of mempalace.sources — no install step needed. |
| 30 | + assert "ndjson" in available_adapters() |
| 31 | + |
| 32 | + |
| 33 | +def test_ingest_reads_spool_verbatim(tmp_dir): |
| 34 | + spool = _write_spool( |
| 35 | + tmp_dir, |
| 36 | + [ |
| 37 | + { |
| 38 | + "content": "the record stored exactly as written", |
| 39 | + "source_file": "feed://abc/item/1", |
| 40 | + "metadata": {"opaque_field": "kept", "wing": "wing_a"}, |
| 41 | + } |
| 42 | + ], |
| 43 | + ) |
| 44 | + adapter = NdjsonSourceAdapter() |
| 45 | + out = list(adapter.ingest(source=SourceRef(local_path=spool), palace=None)) |
| 46 | + |
| 47 | + assert len(out) == 1 |
| 48 | + drawer = out[0] |
| 49 | + assert drawer.content == "the record stored exactly as written" # verbatim |
| 50 | + assert drawer.source_file == "feed://abc/item/1" |
| 51 | + assert drawer.chunk_index == 0 |
| 52 | + assert drawer.metadata["opaque_field"] == "kept" # producer metadata flows through |
| 53 | + assert drawer.metadata["wing"] == "wing_a" |
| 54 | + |
| 55 | + |
| 56 | +def test_absent_chunk_index_gets_per_source_ordinal(tmp_dir): |
| 57 | + # Two records sharing a source_file with no chunk_index must NOT collide on |
| 58 | + # the deterministic drawer id (sha256(source_file)_chunk). |
| 59 | + spool = _write_spool( |
| 60 | + tmp_dir, |
| 61 | + [ |
| 62 | + {"content": "first", "source_file": "feed://x"}, |
| 63 | + {"content": "second", "source_file": "feed://x"}, |
| 64 | + {"content": "other", "source_file": "feed://y"}, |
| 65 | + ], |
| 66 | + ) |
| 67 | + out = list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=spool), palace=None)) |
| 68 | + |
| 69 | + triples = [(d.source_file, d.chunk_index, d.content) for d in out] |
| 70 | + assert triples == [ |
| 71 | + ("feed://x", 0, "first"), |
| 72 | + ("feed://x", 1, "second"), |
| 73 | + ("feed://y", 0, "other"), |
| 74 | + ] |
| 75 | + |
| 76 | + |
| 77 | +def test_provided_chunk_index_passes_through(tmp_dir): |
| 78 | + spool = _write_spool( |
| 79 | + tmp_dir, |
| 80 | + [{"content": "c", "source_file": "feed://x", "chunk_index": 7}], |
| 81 | + ) |
| 82 | + out = list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=spool), palace=None)) |
| 83 | + assert out[0].chunk_index == 7 |
| 84 | + |
| 85 | + |
| 86 | +def test_wing_option_fills_only_when_absent(tmp_dir): |
| 87 | + spool = _write_spool( |
| 88 | + tmp_dir, |
| 89 | + [ |
| 90 | + {"content": "a", "source_file": "s://1"}, |
| 91 | + {"content": "b", "source_file": "s://2", "metadata": {"wing": "wing_own"}}, |
| 92 | + ], |
| 93 | + ) |
| 94 | + out = list( |
| 95 | + NdjsonSourceAdapter().ingest( |
| 96 | + source=SourceRef(local_path=spool, options={"wing": "wing_flag"}), palace=None |
| 97 | + ) |
| 98 | + ) |
| 99 | + assert out[0].metadata["wing"] == "wing_flag" # filled from the flag |
| 100 | + assert out[1].metadata["wing"] == "wing_own" # the record's own wing wins |
| 101 | + |
| 102 | + |
| 103 | +def test_blank_lines_skipped(tmp_dir): |
| 104 | + path = os.path.join(tmp_dir, "batch.ndjson") |
| 105 | + with open(path, "w", encoding="utf-8") as fh: |
| 106 | + fh.write('\n{"content": "c", "source_file": "s"}\n\n') |
| 107 | + out = list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=path), palace=None)) |
| 108 | + assert len(out) == 1 |
| 109 | + |
| 110 | + |
| 111 | +def test_malformed_json_raises(tmp_dir): |
| 112 | + path = os.path.join(tmp_dir, "bad.ndjson") |
| 113 | + with open(path, "w", encoding="utf-8") as fh: |
| 114 | + fh.write("{not json}\n") |
| 115 | + with pytest.raises(SourceAdapterError): |
| 116 | + list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=path), palace=None)) |
| 117 | + |
| 118 | + |
| 119 | +def test_missing_required_fields_raises(tmp_dir): |
| 120 | + spool = _write_spool(tmp_dir, [{"content": "no source_file"}]) |
| 121 | + with pytest.raises(SourceAdapterError): |
| 122 | + list(NdjsonSourceAdapter().ingest(source=SourceRef(local_path=spool), palace=None)) |
| 123 | + |
| 124 | + |
| 125 | +def test_missing_spool_raises_not_found(tmp_dir): |
| 126 | + with pytest.raises(SourceNotFoundError): |
| 127 | + list( |
| 128 | + NdjsonSourceAdapter().ingest( |
| 129 | + source=SourceRef(local_path=os.path.join(tmp_dir, "nope.ndjson")), palace=None |
| 130 | + ) |
| 131 | + ) |
| 132 | + |
| 133 | + |
| 134 | +def test_end_to_end_mine_source_ndjson_files_drawers(tmp_dir, palace_path): |
| 135 | + from mempalace.cli import _mine_via_source_adapter |
| 136 | + from mempalace.palace import get_collection |
| 137 | + |
| 138 | + spool = _write_spool( |
| 139 | + tmp_dir, |
| 140 | + [ |
| 141 | + { |
| 142 | + "content": "pre-extracted record line", |
| 143 | + "source_file": "feed://e2e/1", |
| 144 | + "metadata": {"opaque_field": "kept"}, |
| 145 | + } |
| 146 | + ], |
| 147 | + ) |
| 148 | + args = Namespace(source="ndjson", dir=spool, wing=None, dry_run=False) |
| 149 | + _mine_via_source_adapter(args, palace_path) |
| 150 | + |
| 151 | + col = get_collection(palace_path, create=True) |
| 152 | + got = col.get(where={"source_file": "feed://e2e/1"}) |
| 153 | + assert got["documents"] == ["pre-extracted record line"] |
| 154 | + meta = got["metadatas"][0] |
| 155 | + assert meta["opaque_field"] == "kept" |
| 156 | + assert meta["adapter_name"] == "ndjson" # stamped by PalaceContext, not by the adapter |
| 157 | + assert meta["adapter_version"] == "0.1.0" |
| 158 | + |
| 159 | + |
| 160 | +def test_end_to_end_dry_run_files_nothing(tmp_dir, palace_path): |
| 161 | + from mempalace.cli import _mine_via_source_adapter |
| 162 | + from mempalace.palace import get_collection |
| 163 | + |
| 164 | + spool = _write_spool(tmp_dir, [{"content": "c", "source_file": "feed://dry/1"}]) |
| 165 | + args = Namespace(source="ndjson", dir=spool, wing=None, dry_run=True) |
| 166 | + _mine_via_source_adapter(args, palace_path) |
| 167 | + |
| 168 | + col = get_collection(palace_path, create=True) |
| 169 | + assert col.get(where={"source_file": "feed://dry/1"})["ids"] == [] |
0 commit comments