Skip to content

Commit 27cb157

Browse files
RyanRanaclaude
andcommitted
fix(moss-cli): keep newlines in quoted csv fields on doc import
csv.DictReader was fed content.splitlines(), so newlines inside quoted fields were dropped and words on either side were joined. parse from a stream instead, validate the header, strip a utf-8 bom, and report real file line numbers in errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4027809 commit 27cb157

2 files changed

Lines changed: 106 additions & 25 deletions

File tree

packages/moss-cli/src/moss_cli/documents.py

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from __future__ import annotations
44

55
import csv
6+
import io
67
import json
78
import sys
89
from pathlib import Path
9-
from typing import Any, List
10+
from typing import Any, List, Optional
1011

1112
import typer
1213
from moss import DocumentInfo
@@ -23,7 +24,7 @@ def load_documents(file_path: str) -> List[DocumentInfo]:
2324
raise typer.BadParameter(f"File not found: {file_path}")
2425

2526
suffix = path.suffix.lower()
26-
content = path.read_text()
27+
content = path.read_text(encoding="utf-8-sig")
2728

2829
if suffix == ".csv":
2930
return _parse_csv_docs(content)
@@ -67,42 +68,70 @@ def _parse_jsonl_docs(raw: str, source: str = "input") -> List[DocumentInfo]:
6768

6869

6970
def _parse_csv_docs(content: str) -> List[DocumentInfo]:
70-
reader = csv.DictReader(content.splitlines())
71+
reader = csv.DictReader(io.StringIO(content, newline=""))
72+
if reader.fieldnames is None:
73+
raise typer.BadParameter("CSV is empty: expected a header row")
74+
75+
reader.fieldnames = [(name or "").strip() for name in reader.fieldnames]
76+
77+
seen = set()
78+
dupes = set()
79+
for name in reader.fieldnames:
80+
if not name:
81+
continue
82+
if name in seen:
83+
dupes.add(name)
84+
else:
85+
seen.add(name)
86+
if dupes:
87+
raise typer.BadParameter(
88+
f"CSV header has duplicate column name(s): {', '.join(sorted(dupes))}"
89+
)
90+
91+
missing = [name for name in ("id", "text") if name not in reader.fieldnames]
92+
if missing:
93+
raise typer.BadParameter(
94+
f"CSV header is missing required column(s): {', '.join(missing)}"
95+
)
96+
7197
docs = []
72-
for i, row in enumerate(reader):
73-
if "id" not in row or "text" not in row:
98+
for row in reader:
99+
line_no = reader.line_num
100+
doc_id = row.get("id")
101+
text = row.get("text")
102+
if not doc_id or text is None:
74103
raise typer.BadParameter(
75-
f"CSV row {i + 1}: missing required 'id' or 'text' column"
104+
f"CSV line {line_no}: empty value in required 'id' or 'text' column"
76105
)
77-
metadata = None
78-
if "metadata" in row and row["metadata"]:
79-
try:
80-
metadata = json.loads(row["metadata"])
81-
except json.JSONDecodeError:
82-
raise typer.BadParameter(
83-
f"CSV row {i + 1}: invalid JSON in 'metadata' column"
84-
)
85-
86-
embedding = None
87-
if "embedding" in row and row["embedding"]:
88-
try:
89-
embedding = json.loads(row["embedding"])
90-
except json.JSONDecodeError:
91-
raise typer.BadParameter(
92-
f"CSV row {i + 1}: invalid JSON in 'embedding' column"
93-
)
106+
107+
metadata = _parse_csv_json(row.get("metadata"), "metadata", line_no)
108+
embedding = _parse_csv_json(row.get("embedding"), "embedding", line_no)
94109

95110
docs.append(
96111
DocumentInfo(
97-
id=row["id"],
98-
text=row["text"],
112+
id=doc_id,
113+
text=text,
99114
metadata=metadata,
100115
embedding=embedding,
101116
)
102117
)
103118
return docs
104119

105120

121+
def _parse_csv_json(value: Optional[str], column: str, line_no: int) -> Any:
122+
if value is None:
123+
return None
124+
value = value.strip()
125+
if not value:
126+
return None
127+
try:
128+
return json.loads(value)
129+
except json.JSONDecodeError:
130+
raise typer.BadParameter(
131+
f"CSV line {line_no}: invalid JSON in '{column}' column"
132+
)
133+
134+
106135
def _dict_to_doc(d: Any, index: int) -> DocumentInfo:
107136
if not isinstance(d, dict):
108137
raise typer.BadParameter(f"Document at index {index}: expected object, got {type(d).__name__}")
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import pytest
2+
import typer
3+
4+
from moss_cli.documents import _parse_csv_docs
5+
6+
7+
def test_csv_keeps_newlines_inside_quoted_text() -> None:
8+
docs = _parse_csv_docs('id,text\ndoc1,"line one\nline two"\ndoc2,plain\n')
9+
assert len(docs) == 2
10+
assert docs[0].text == "line one\nline two"
11+
assert docs[1].text == "plain"
12+
13+
14+
def test_csv_parses_metadata_and_embedding_columns() -> None:
15+
docs = _parse_csv_docs(
16+
"id,text,metadata,embedding\n"
17+
'doc1,hello,"{""topic"": ""ml""}","[0.1, 0.2]"\n'
18+
"doc2,world,,\n"
19+
)
20+
assert docs[0].metadata == {"topic": "ml"}
21+
assert docs[0].embedding == pytest.approx([0.1, 0.2])
22+
assert docs[1].metadata is None
23+
assert docs[1].embedding is None
24+
25+
26+
def test_csv_header_is_trimmed() -> None:
27+
docs = _parse_csv_docs("id , text \ndoc1,hello\n")
28+
assert docs[0].id == "doc1"
29+
assert docs[0].text == "hello"
30+
31+
32+
def test_csv_missing_column_reports_header_error() -> None:
33+
with pytest.raises(typer.BadParameter) as exc:
34+
_parse_csv_docs("id,body\ndoc1,hello\n")
35+
assert "text" in str(exc.value)
36+
37+
38+
def test_csv_empty_content_is_rejected() -> None:
39+
with pytest.raises(typer.BadParameter):
40+
_parse_csv_docs("")
41+
42+
43+
def test_csv_short_row_is_rejected() -> None:
44+
with pytest.raises(typer.BadParameter) as exc:
45+
_parse_csv_docs("id,text\ndoc1\n")
46+
assert "line 2" in str(exc.value)
47+
48+
49+
def test_csv_invalid_metadata_reports_file_line_number() -> None:
50+
with pytest.raises(typer.BadParameter) as exc:
51+
_parse_csv_docs('id,text,metadata\ndoc1,"a\nb",not-json\n')
52+
assert "line 3" in str(exc.value)

0 commit comments

Comments
 (0)