Snippet content copied to clipboard.
Are you sure to delete this snippet? No, don't delete

CSV to JSON converters - stdlib only, 44 tests passing

  1. # CSV <-> JSON converters — built and tested 2026-11-05
  2. ​
  3. Source, README and tests in one paste. Reproduce with:
  4. ​
  5. python3 -m pytest test_csv2json.py test_json2csv.py -q
  6. ​
  7. Verified state: 44 passed. Both CLIs run as shown below.
  8. ​
  9. === README.md ===
  10. ​
  11. # CSV ⇄ JSON converters
  12. ​
  13. Two small, dependency-free Python tools that convert between CSV and JSON.
  14. ​
  15. * [`csv2json.py`](csv2json.py) — CSV file → JSON
  16. * [`json2csv.py`](json2csv.py) — JSON array of objects → CSV
  17. ​
  18. Both use only the standard library. Python 3.10+ for `json2csv`, Python 3.8+ for
  19. `csv2json`. Both run as a CLI or as an importable module.
  20. ​
  21. ## Install / requirements
  22. ​
  23. ```bash
  24. python3 --version # 3.10+ recommended
  25. # nothing else to install — no third-party packages
  26. ```
  27. ​
  28. To run the tests you need pytest:
  29. ​
  30. ```bash
  31. pip install pytest
  32. python3 -m pytest test_csv2json.py test_json2csv.py -q
  33. ```
  34. ​
  35. ## csv2json — CSV → JSON
  36. ​
  37. ```bash
  38. # basic: a JSON array of row objects
  39. python3 csv2json.py data.csv
  40. ​
  41. # write to a file
  42. python3 csv2json.py data.csv -o out.json
  43. ​
  44. # force a delimiter (auto-detected by default: , ; tab |)
  45. python3 csv2json.py data.csv --delimiter ";"
  46. ​
  47. # object keyed by a column instead of a list
  48. python3 csv2json.py data.csv --key-column id
  49. ​
  50. # emit numbers as JSON numbers instead of strings
  51. python3 csv2json.py data.csv --numbers
  52. ​
  53. # map tokens to JSON null (defaults: "" NULL null N/A n/a -)
  54. python3 csv2json.py data.csv --null-values MISSING "unknown"
  55. ​
  56. # compact single-line output
  57. python3 csv2json.py data.csv --compact
  58. ​
  59. # read from stdin
  60. cat data.csv | python3 csv2json.py -
  61. ```
  62. ​
  63. Options: `--no-header` (synthesise `col_1..col_n`), `--encoding` (default
  64. `utf-8-sig`, so a BOM is stripped), `--indent` (default `2`).
  65. ​
  66. Exit codes: `0` ok, `1` usage/IO error, `2` malformed CSV (e.g. a ragged row).
  67. ​
  68. ## json2csv — JSON → CSV
  69. ​
  70. ```bash
  71. # basic
  72. python3 json2csv.py data.json
  73. ​
  74. # missing keys become empty strings
  75. python3 json2csv.py data.json --missing "N/A"
  76. ​
  77. # pin the column order
  78. python3 json2csv.py data.json --columns id name email
  79. ​
  80. # deterministic alphabetical columns
  81. python3 json2csv.py data.json --sort-keys
  82. ​
  83. # semicolon output, everything quoted
  84. python3 json2csv.py data.json --delimiter ";" --quoting all
  85. ```
  86. ​
  87. Options: `--quoting minimal|all|nonnumeric` (default `minimal`), `-o` output file,
  88. `-` for stdin.
  89. ​
  90. Exit codes: `0` ok, `1` usage/IO error, `2` malformed JSON.
  91. ​
  92. ## Library use
  93. ​
  94. ```python
  95. import csv2json, json2csv
  96. ​
  97. header, rows = csv2json.read_rows(path, delimiter=None, encoding="utf-8-sig", has_header=True)
  98. objects = csv2json.rows_to_objects(header, rows, null_values=[""], as_number=False)
  99. ​
  100. records = json2csv.load_records(path)
  101. text = json2csv.to_csv(records, columns=json2csv.collect_columns(records),
  102. delimiter=",", quoting="minimal", missing="")
  103. ```
  104. ​
  105. ## Tests
  106. ​
  107. `test_csv2json.py` (24 tests) and `test_json2csv.py` (20 tests) cover:
  108. ​
  109. * list-of-objects and keyed-object output shapes
  110. * delimiter auto-detection for `,` `;` tab `|` and explicit `--delimiter`
  111. * quoting/escaping of quotes, commas, tabs and embedded newlines
  112. * UTF-8 and BOM handling
  113. * null-token mapping, `--numbers`, `--missing` filler, bool rendering
  114. * nested values inlined as JSON instead of crashing
  115. * error paths: missing file, empty file, ragged row, non-array JSON,
  116. non-object row, invalid JSON, duplicate key column
  117. * stdin input and file output
  118. ​
  119. ```bash
  120. $ python3 -m pytest test_csv2json.py test_json2csv.py -q
  121. 44 passed
  122. ```
  123. ​
  124. === csv2json.py ===
  125. ​
  126. #!/usr/bin/env python3
  127. """csv2json — convert CSV files of any structure to JSON.
  128. ​
  129. Zero dependencies (stdlib only), Python 3.8+.
  130. ​
  131. Features
  132. --------
  133. * Auto-detects the delimiter (comma, semicolon, tab, pipe) from the header line.
  134. * Emits either a list of row objects (default) or a dict keyed by a chosen column.
  135. * ``--indent`` controls pretty-printing; ``--no-indent`` emits a compact single line.
  136. * ``--null-values`` maps given tokens (e.g. "", "NULL", "N/A") to JSON ``null``.
  137. * ``--numbers`` converts numeric-looking fields to JSON numbers.
  138. * ``--encoding`` for non-UTF-8 sources (default utf-8-sig, so BOM is stripped).
  139. * Clear, actionable errors for missing files, empty input and ragged rows.
  140. ​
  141. Exit codes: 0 ok, 1 usage/IO error, 2 malformed CSV.
  142. """
  143. from __future__ import annotations
  144. ​
  145. import argparse
  146. import csv
  147. import json
  148. import sys
  149. from pathlib import Path
  150. from typing import Any, Dict, Iterable, List, Sequence
  151. ​
  152. SUPPORTED_DELIMITERS = [",", ";", "\t", "|"]
  153. DEFAULT_NULL_VALUES = ["", "NULL", "null", "N/A", "n/a", "-"]
  154. ​
  155. ​
  156. class MalformedCsvError(ValueError):
  157. """Raised when the CSV parses but its shape is inconsistent (e.g. ragged rows)."""
  158. ​
  159. ​
  160. def detect_delimiter(sample: str, candidates: Sequence[str] = SUPPORTED_DELIMITERS) -> str:
  161. """Pick the candidate delimiter that yields the most columns in the header."""
  162. first_line = sample.splitlines()[0] if sample.strip() else ""
  163. best, best_score = ",", -1
  164. for delim in candidates:
  165. score = len(next(csv.reader([first_line], delimiter=delim), []))
  166. if score > best_score:
  167. best, best_score = delim, score
  168. return best
  169. ​
  170. ​
  171. def read_rows(
  172. path: Path,
  173. delimiter: str | None,
  174. encoding: str,
  175. has_header: bool,
  176. ) -> tuple[List[str], List[List[str]]]:
  177. """Return (header, rows). Raises ValueError with an actionable message."""
  178. try:
  179. text = path.read_text(encoding=encoding)
  180. except FileNotFoundError:
  181. raise ValueError(f"file not found: {path}")
  182. except IsADirectoryError:
  183. raise ValueError(f"not a file: {path}")
  184. except UnicodeDecodeError as exc:
  185. raise ValueError(
  186. f"could not decode {path} as {encoding}; pass --encoding (e.g. latin-1)"
  187. ) from exc
  188. ​
  189. if not text.strip():
  190. raise ValueError(f"file is empty: {path}")
  191. ​
  192. delim = delimiter or detect_delimiter(text)
  193. reader = csv.reader(text.splitlines(True), delimiter=delim)
  194. ​
  195. rows: List[List[str]] = []
  196. for lineno, row in enumerate(reader, start=1):
  197. if not row or (len(row) == 1 and row[0].strip() == ""):
  198. continue # skip blank lines
  199. rows.append(row)
  200. ​
  201. if not rows:
  202. raise ValueError(f"no data rows in {path}")
  203. ​
  204. if has_header:
  205. header, body = rows[0], rows[1:]
  206. else:
  207. width = max(len(r) for r in rows)
  208. header = [f"col_{i + 1}" for i in range(width)]
  209. body = rows
  210. ​
  211. width = len(header)
  212. fixed: List[List[str]] = []
  213. for lineno, row in enumerate(body, start=2 if has_header else 1):
  214. if len(row) != width:
  215. raise MalformedCsvError(
  216. f"{path}:{lineno}: expected {width} fields, found {len(row)} "
  217. f"(delimiter={delim!r}); check quoting or pass --delimiter"
  218. )
  219. fixed.append(row)
  220. return header, fixed
  221. ​
  222. ​
  223. def convert_value(raw: str, null_values: Iterable[str], as_number: bool) -> Any:
  224. """Map one raw CSV cell to a JSON value."""
  225. if raw in null_values:
  226. return None
  227. if as_number:
  228. try:
  229. return int(raw)
  230. except ValueError:
  231. try:
  232. return float(raw)
  233. except ValueError:
  234. return raw
  235. return raw
  236. ​
  237. ​
  238. def rows_to_objects(
  239. header: Sequence[str],
  240. rows: Sequence[Sequence[str]],
  241. null_values: Iterable[str],
  242. as_number: bool,
  243. ) -> List[Dict[str, Any]]:
  244. nulls = set(null_values)
  245. return [
  246. {name: convert_value(value, nulls, as_number) for name, value in zip(header, row)}
  247. for row in rows
  248. ]
  249. ​
  250. ​
  251. def rows_to_keyed(
  252. header: Sequence[str],
  253. rows: Sequence[Sequence[str]],
  254. key_column: str,
  255. null_values: Iterable[str],
  256. as_number: bool,
  257. ) -> Dict[str, Any]:
  258. if key_column not in header:
  259. raise MalformedCsvError(f"--key-column {key_column!r} not in header {list(header)}")
  260. idx = header.index(key_column)
  261. out: Dict[str, Any] = {}
  262. for row in rows:
  263. key = row[idx]
  264. if key in out:
  265. raise MalformedCsvError(f"duplicate key {key!r} in column {key_column!r}; use list mode")
  266. out[key] = {
  267. name: convert_value(value, null_values, as_number)
  268. for name, value in zip(header, row)
  269. if name != key_column
  270. }
  271. return out
  272. ​
  273. ​
  274. def build_parser() -> argparse.ArgumentParser:
  275. p = argparse.ArgumentParser(
  276. prog="csv2json",
  277. description="Convert a CSV file to JSON (list of objects or keyed object).",
  278. )
  279. p.add_argument("input", type=Path, help="input CSV file, or - for stdin")
  280. p.add_argument("-o", "--output", type=Path, default=None, help="output file (default: stdout)")
  281. p.add_argument("--delimiter", default=None, help="force delimiter (auto-detected by default)")
  282. p.add_argument("--encoding", default="utf-8-sig", help="input encoding (default: utf-8-sig)")
  283. p.add_argument("--no-header", action="store_true", help="treat the first row as data")
  284. p.add_argument("--key-column", default=None, help="emit an object keyed by this column")
  285. p.add_argument("--indent", type=int, default=2, help="indent spaces (default: 2)")
  286. p.add_argument("--compact", action="store_true", help="emit one compact line")
  287. p.add_argument("--null-values", nargs="*", default=DEFAULT_NULL_VALUES,
  288. help="tokens mapped to JSON null")
  289. p.add_argument("--numbers", action="store_true", help="convert numeric fields to JSON numbers")
  290. return p
  291. ​
  292. ​
  293. def main(argv: Sequence[str] | None = None) -> int:
  294. args = build_parser().parse_args(argv)
  295. ​
  296. if args.input == Path("-"):
  297. import tempfile
  298. with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as tmp:
  299. tmp.write(sys.stdin.read())
  300. args.input = Path(tmp.name)
  301. ​
  302. try:
  303. header, rows = read_rows(args.input, args.delimiter, args.encoding, not args.no_header)
  304. except MalformedCsvError as exc:
  305. print(f"csv2json: error: {exc}", file=sys.stderr)
  306. return 2
  307. except ValueError as exc:
  308. print(f"csv2json: error: {exc}", file=sys.stderr)
  309. return 1
  310. ​
  311. try:
  312. if args.key_column:
  313. data = rows_to_keyed(header, rows, args.key_column, args.null_values, args.numbers)
  314. else:
  315. data = rows_to_objects(header, rows, args.null_values, args.numbers)
  316. except MalformedCsvError as exc:
  317. print(f"csv2json: error: {exc}", file=sys.stderr)
  318. return 2
  319. ​
  320. if args.compact:
  321. text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
  322. else:
  323. text = json.dumps(data, ensure_ascii=False, indent=args.indent)
  324. ​
  325. if args.output:
  326. args.output.write_text(text + "\n", encoding="utf-8")
  327. print(f"csv2json: wrote {args.output}", file=sys.stderr)
  328. else:
  329. print(text)
  330. return 0
  331. ​
  332. ​
  333. if __name__ == "__main__":
  334. raise SystemExit(main())
  335. ​
  336. === json2csv.py ===
  337. ​
  338. #!/usr/bin/env python3
  339. """json2csv — convert a JSON array of flat objects to CSV.
  340. ​
  341. Zero dependencies (stdlib only), Python 3.10+.
  342. ​
  343. Features
  344. --------
  345. * Reads a JSON array of flat objects from a file or stdin.
  346. * Collects the union of all keys; ``--columns`` pins an explicit order.
  347. * Missing keys become empty strings (``--missing`` overrides the filler).
  348. * ``--delimiter`` / ``--quoting`` control the emitted CSV dialect.
  349. * ``--sort-keys`` emits columns alphabetically for reproducible diffs.
  350. * Nested values (dict/list) are JSON-encoded inline rather than crashing.
  351. * Clear errors for non-array input, non-object rows and unreadable JSON.
  352. ​
  353. Exit codes: 0 ok, 1 usage/IO error, 2 malformed JSON.
  354. """
  355. from __future__ import annotations
  356. ​
  357. import argparse
  358. import csv
  359. import io
  360. import json
  361. import sys
  362. from pathlib import Path
  363. from typing import Any, Dict, List, Sequence
  364. ​
  365. ​
  366. class MalformedJsonError(ValueError):
  367. """Raised when the JSON parses but its shape is unusable for CSV output."""
  368. ​
  369. ​
  370. def load_records(path: Path) -> List[Any]:
  371. try:
  372. text = path.read_text(encoding="utf-8")
  373. except FileNotFoundError:
  374. raise ValueError(f"file not found: {path}") from None
  375. except IsADirectoryError:
  376. raise ValueError(f"not a file: {path}") from None
  377. except UnicodeDecodeError as exc:
  378. raise ValueError(f"could not decode {path} as utf-8") from exc
  379. ​
  380. if not text.strip():
  381. raise MalformedJsonError(f"file is empty: {path}")
  382. try:
  383. data = json.loads(text)
  384. except json.JSONDecodeError as exc:
  385. raise MalformedJsonError(f"{path}: invalid JSON: {exc}") from exc
  386. if not isinstance(data, list):
  387. raise MalformedJsonError(
  388. f"{path}: expected a JSON array of objects, got {type(data).__name__}"
  389. )
  390. return data
  391. ​
  392. ​
  393. def collect_columns(records: Sequence[Any]) -> List[str]:
  394. columns: List[str] = []
  395. seen: set[str] = set()
  396. for i, rec in enumerate(records, start=1):
  397. if not isinstance(rec, dict):
  398. raise MalformedJsonError(f"record {i} is a {type(rec).__name__}, expected an object")
  399. for key in rec:
  400. if key not in seen:
  401. seen.add(key)
  402. columns.append(key)
  403. return columns
  404. ​
  405. ​
  406. def render(value: Any) -> str:
  407. """Render one JSON value as a CSV cell."""
  408. if value is None:
  409. return ""
  410. if isinstance(value, bool):
  411. return "true" if value else "false"
  412. if isinstance(value, (int, float, str)):
  413. return str(value)
  414. return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
  415. ​
  416. ​
  417. def to_csv(
  418. records: Sequence[Any],
  419. columns: Sequence[str],
  420. delimiter: str,
  421. quoting: str,
  422. missing: str,
  423. ) -> str:
  424. if not columns:
  425. return ""
  426. buf = io.StringIO()
  427. if quoting == "all":
  428. writer = csv.writer(buf, delimiter=delimiter, quoting=csv.QUOTE_ALL, lineterminator="\n")
  429. elif quoting == "minimal":
  430. writer = csv.writer(buf, delimiter=delimiter, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
  431. else: # nonnumeric
  432. writer = csv.writer(buf, delimiter=delimiter, quoting=csv.QUOTE_NONNUMERIC, lineterminator="\n")
  433. writer.writerow(columns)
  434. for rec in records:
  435. writer.writerow([
  436. missing if col not in rec else render(rec[col])
  437. for col in columns
  438. ])
  439. return buf.getvalue()
  440. ​
  441. ​
  442. def build_parser() -> argparse.ArgumentParser:
  443. p = argparse.ArgumentParser(
  444. prog="json2csv",
  445. description="Convert a JSON array of flat objects to CSV.",
  446. )
  447. p.add_argument("input", type=Path, help="input JSON file, or - for stdin")
  448. p.add_argument("-o", "--output", type=Path, default=None, help="output file (default: stdout)")
  449. p.add_argument("--columns", nargs="+", default=None, help="explicit column order")
  450. p.add_argument("--delimiter", default=",", help="output delimiter (default: ,)")
  451. p.add_argument("--quoting", choices=["minimal", "all", "nonnumeric"], default="minimal")
  452. p.add_argument("--missing", default="", help="filler for absent keys (default: empty string)")
  453. p.add_argument("--sort-keys", action="store_true", help="emit columns alphabetically")
  454. return p
  455. ​
  456. ​
  457. def main(argv: Sequence[str] | None = None) -> int:
  458. args = build_parser().parse_args(argv)
  459. ​
  460. if args.input == Path("-"):
  461. import tempfile
  462. with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tmp:
  463. tmp.write(sys.stdin.read())
  464. args.input = Path(tmp.name)
  465. ​
  466. try:
  467. records = load_records(args.input)
  468. except MalformedJsonError as exc:
  469. print(f"json2csv: error: {exc}", file=sys.stderr)
  470. return 2
  471. except ValueError as exc:
  472. print(f"json2csv: error: {exc}", file=sys.stderr)
  473. return 1
  474. ​
  475. try:
  476. columns = args.columns or collect_columns(records)
  477. if args.sort_keys:
  478. columns = sorted(columns)
  479. except MalformedJsonError as exc:
  480. print(f"json2csv: error: {exc}", file=sys.stderr)
  481. return 2
  482. ​
  483. text = to_csv(records, columns, args.delimiter, args.quoting, args.missing)
  484. ​
  485. if args.output:
  486. args.output.write_text(text, encoding="utf-8")
  487. print(f"json2csv: wrote {args.output}", file=sys.stderr)
  488. else:
  489. sys.stdout.write(text)
  490. return 0
  491. ​
  492. ​
  493. if __name__ == "__main__":
  494. raise SystemExit(main())
  495. ​
  496. === test_csv2json.py ===
  497. ​
  498. """Tests for csv2json."""
  499. import json
  500. import subprocess
  501. import sys
  502. from pathlib import Path
  503. ​
  504. import pytest
  505. ​
  506. SCRIPT = Path(__file__).with_name("csv2json.py")
  507. ​
  508. ​
  509. def run(*args, stdin=None):
  510. return subprocess.run(
  511. [sys.executable, str(SCRIPT), *args],
  512. input=stdin, capture_output=True, text=True,
  513. )
  514. ​
  515. ​
  516. def write(tmp_path, name, content):
  517. p = tmp_path / name
  518. p.write_text(content, encoding="utf-8")
  519. return p
  520. ​
  521. ​
  522. # ---------------------------------------------------------------- basic
  523. def test_basic_list_of_objects(tmp_path):
  524. src = write(tmp_path, "a.csv", "name,city\nAndy,Jakarta\nBudi,Bandung\n")
  525. r = run(str(src))
  526. assert r.returncode == 0, r.stderr
  527. assert json.loads(r.stdout) == [
  528. {"name": "Andy", "city": "Jakarta"},
  529. {"name": "Budi", "city": "Bandung"},
  530. ]
  531. ​
  532. ​
  533. def test_special_characters_are_escaped(tmp_path):
  534. src = write(tmp_path, "a.csv", 'note\n"has ""quotes"", comma, and\ttab"\nplain\n')
  535. r = run(str(src))
  536. assert r.returncode == 0, r.stderr
  537. data = json.loads(r.stdout)
  538. assert data[0]["note"] == 'has "quotes", comma, and\ttab'
  539. assert data[1]["note"] == "plain"
  540. ​
  541. ​
  542. def test_unicode_roundtrip(tmp_path):
  543. src = write(tmp_path, "a.csv", "k\n日本\ncafé\n")
  544. r = run(str(src))
  545. assert json.loads(r.stdout) == [{"k": "日本"}, {"k": "café"}]
  546. ​
  547. ​
  548. def test_bom_is_stripped(tmp_path):
  549. p = tmp_path / "bom.csv"
  550. p.write_bytes("\ufeffname\nAndy\n".encode("utf-8"))
  551. r = run(str(p))
  552. assert json.loads(r.stdout) == [{"name": "Andy"}]
  553. ​
  554. ​
  555. # ------------------------------------------------------- delimiter detection
  556. @pytest.mark.parametrize("delim", [";", "\t", "|"])
  557. def test_delimiter_autodetect(tmp_path, delim):
  558. src = write(tmp_path, "d.csv", f"a{delim}b\n1{delim}2\n")
  559. r = run(str(src))
  560. assert json.loads(r.stdout) == [{"a": "1", "b": "2"}]
  561. ​
  562. ​
  563. def test_explicit_delimiter_overrides(tmp_path):
  564. src = write(tmp_path, "d.csv", "a;b\n1;2\n")
  565. r = run(str(src), "--delimiter", ";")
  566. assert json.loads(r.stdout) == [{"a": "1", "b": "2"}]
  567. ​
  568. ​
  569. # ------------------------------------------------------------- value mapping
  570. def test_null_values(tmp_path):
  571. src = write(tmp_path, "a.csv", "x,y\n1,\n2,NULL\n3,N/A\n")
  572. r = run(str(src))
  573. assert json.loads(r.stdout) == [
  574. {"x": "1", "y": None},
  575. {"x": "2", "y": None},
  576. {"x": "3", "y": None},
  577. ]
  578. ​
  579. ​
  580. def test_numbers_flag(tmp_path):
  581. src = write(tmp_path, "a.csv", "i,f,s\n42,3.14,abc\n")
  582. r = run(str(src), "--numbers")
  583. assert json.loads(r.stdout) == [{"i": 42, "f": 3.14, "s": "abc"}]
  584. ​
  585. ​
  586. def test_custom_null_values(tmp_path):
  587. src = write(tmp_path, "a.csv", "x\nMISSING\n1\n")
  588. r = run(str(src), "--null-values", "MISSING")
  589. assert json.loads(r.stdout) == [{"x": None}, {"x": "1"}]
  590. ​
  591. ​
  592. # --------------------------------------------------------------- output shape
  593. def test_keyed_object(tmp_path):
  594. src = write(tmp_path, "a.csv", "id,v\n1,a\n2,b\n")
  595. r = run(str(src), "--key-column", "id")
  596. assert json.loads(r.stdout) == {"1": {"v": "a"}, "2": {"v": "b"}}
  597. ​
  598. ​
  599. def test_keyed_duplicate_is_error(tmp_path):
  600. src = write(tmp_path, "a.csv", "id,v\n1,a\n1,b\n")
  601. r = run(str(src), "--key-column", "id")
  602. assert r.returncode == 2
  603. assert "duplicate key" in r.stderr
  604. ​
  605. ​
  606. def test_key_column_missing_is_error(tmp_path):
  607. src = write(tmp_path, "a.csv", "id,v\n1,a\n")
  608. r = run(str(src), "--key-column", "nope")
  609. assert r.returncode == 2
  610. ​
  611. ​
  612. def test_no_header(tmp_path):
  613. src = write(tmp_path, "a.csv", "Andy,Jakarta\n")
  614. r = run(str(src), "--no-header")
  615. assert json.loads(r.stdout) == [{"col_1": "Andy", "col_2": "Jakarta"}]
  616. ​
  617. ​
  618. def test_compact_output(tmp_path):
  619. src = write(tmp_path, "a.csv", "a\n1\n")
  620. r = run(str(src), "--compact")
  621. assert r.stdout.strip() == '[{"a":"1"}]'
  622. ​
  623. ​
  624. def test_indent_option(tmp_path):
  625. src = write(tmp_path, "a.csv", "a\n1\n")
  626. r = run(str(src), "--indent", "4")
  627. assert '\n "a"' in r.stdout
  628. ​
  629. ​
  630. def test_write_to_file(tmp_path):
  631. src = write(tmp_path, "a.csv", "a\n1\n")
  632. out = tmp_path / "out.json"
  633. r = run(str(src), "-o", str(out))
  634. assert r.returncode == 0
  635. assert json.loads(out.read_text()) == [{"a": "1"}]
  636. ​
  637. ​
  638. # ----------------------------------------------------------------- errors
  639. def test_missing_file(tmp_path):
  640. r = run(str(tmp_path / "nope.csv"))
  641. assert r.returncode == 1
  642. assert "file not found" in r.stderr
  643. ​
  644. ​
  645. def test_empty_file(tmp_path):
  646. src = write(tmp_path, "a.csv", "")
  647. r = run(str(src))
  648. assert r.returncode == 1
  649. assert "empty" in r.stderr
  650. ​
  651. ​
  652. def test_ragged_row_is_error(tmp_path):
  653. src = write(tmp_path, "a.csv", "a,b\n1,2\n3\n")
  654. r = run(str(src))
  655. assert r.returncode == 2
  656. assert "expected 2 fields, found 1" in r.stderr
  657. ​
  658. ​
  659. def test_stdin_input(tmp_path):
  660. r = run("-", stdin="a\n1\n")
  661. assert r.returncode == 0
  662. assert json.loads(r.stdout) == [{"a": "1"}]
  663. ​
  664. ​
  665. def test_blank_lines_skipped(tmp_path):
  666. src = write(tmp_path, "a.csv", "a\n1\n\n2\n")
  667. r = run(str(src))
  668. assert json.loads(r.stdout) == [{"a": "1"}, {"a": "2"}]
  669. ​
  670. ​
  671. # ------------------------------------------------------------------ library API
  672. def test_library_import(tmp_path):
  673. sys.path.insert(0, str(SCRIPT.parent))
  674. import csv2json # noqa: PLC0415
  675. ​
  676. header, rows = csv2json.read_rows(write(tmp_path, "a.csv", "a,b\n1,2\n"), None, "utf-8-sig", True)
  677. assert csv2json.rows_to_objects(header, rows, [""], False) == [{"a": "1", "b": "2"}]
  678. ​
  679. === test_json2csv.py ===
  680. ​
  681. """Tests for json2csv."""
  682. import json
  683. import subprocess
  684. import sys
  685. from pathlib import Path
  686. ​
  687. SCRIPT = Path(__file__).with_name("json2csv.py")
  688. ​
  689. ​
  690. def run(*args, stdin=None):
  691. return subprocess.run(
  692. [sys.executable, str(SCRIPT), *args],
  693. input=stdin, capture_output=True, text=True,
  694. )
  695. ​
  696. ​
  697. def write(tmp_path, name, obj):
  698. p = tmp_path / name
  699. p.write_text(json.dumps(obj), encoding="utf-8")
  700. return p
  701. ​
  702. ​
  703. def parse(text, delimiter=","):
  704. import csv
  705. import io
  706. return list(csv.reader(io.StringIO(text), delimiter=delimiter))
  707. ​
  708. ​
  709. def test_basic_conversion(tmp_path):
  710. src = write(tmp_path, "a.json", [{"a": 1, "b": "x"}, {"a": 2, "b": "y"}])
  711. r = run(str(src))
  712. assert r.returncode == 0, r.stderr
  713. assert parse(r.stdout) == [["a", "b"], ["1", "x"], ["2", "y"]]
  714. ​
  715. ​
  716. def test_missing_keys_filled_with_empty_string(tmp_path):
  717. src = write(tmp_path, "a.json", [{"a": 1}, {"a": 2, "b": 3}])
  718. r = run(str(src))
  719. assert parse(r.stdout) == [["a", "b"], ["1", ""], ["2", "3"]]
  720. ​
  721. ​
  722. def test_missing_filler_override(tmp_path):
  723. src = write(tmp_path, "a.json", [{"a": 1}, {"a": 2, "b": 3}])
  724. r = run(str(src), "--missing", "N/A")
  725. assert parse(r.stdout) == [["a", "b"], ["1", "N/A"], ["2", "3"]]
  726. ​
  727. ​
  728. def test_null_becomes_empty(tmp_path):
  729. src = write(tmp_path, "a.json", [{"a": None, "b": 1}])
  730. r = run(str(src))
  731. assert parse(r.stdout) == [["a", "b"], ["", "1"]]
  732. ​
  733. ​
  734. def test_bool_rendering(tmp_path):
  735. src = write(tmp_path, "a.json", [{"ok": True, "no": False}])
  736. r = run(str(src))
  737. assert parse(r.stdout) == [["ok", "no"], ["true", "false"]]
  738. ​
  739. ​
  740. def test_special_characters_are_quoted(tmp_path):
  741. src = write(tmp_path, "a.json", [{"note": 'has "quotes", comma'}])
  742. r = run(str(src))
  743. rows = parse(r.stdout)
  744. assert rows[1][0] == 'has "quotes", comma'
  745. ​
  746. ​
  747. def test_custom_delimiter(tmp_path):
  748. src = write(tmp_path, "a.json", [{"a": 1, "b": 2}])
  749. r = run(str(src), "--delimiter", ";")
  750. assert parse(r.stdout, ";") == [["a", "b"], ["1", "2"]]
  751. ​
  752. ​
  753. def test_explicit_columns(tmp_path):
  754. src = write(tmp_path, "a.json", [{"a": 1, "b": 2, "c": 3}])
  755. r = run(str(src), "--columns", "c", "a")
  756. assert parse(r.stdout) == [["c", "a"], ["3", "1"]]
  757. ​
  758. ​
  759. def test_sort_keys(tmp_path):
  760. src = write(tmp_path, "a.json", [{"z": 1, "a": 2, "m": 3}])
  761. r = run(str(src), "--sort-keys")
  762. assert parse(r.stdout) == [["a", "m", "z"], ["2", "3", "1"]]
  763. ​
  764. ​
  765. def test_nested_value_is_inlined_json(tmp_path):
  766. src = write(tmp_path, "a.json", [{"tags": ["x", "y"], "n": 1}])
  767. r = run(str(src))
  768. assert parse(r.stdout)[1][0] == '["x","y"]'
  769. ​
  770. ​
  771. def test_quoting_all(tmp_path):
  772. src = write(tmp_path, "a.json", [{"a": 1}])
  773. r = run(str(src), "--quoting", "all")
  774. assert r.stdout == '"a"\n"1"\n'
  775. # and the quotes are real CSV quoting, not literal characters
  776. assert parse(r.stdout) == [["a"], ["1"]]
  777. ​
  778. ​
  779. def test_empty_array_header_only(tmp_path):
  780. src = write(tmp_path, "a.json", [])
  781. r = run(str(src))
  782. assert r.returncode == 0
  783. assert r.stdout == ""
  784. ​
  785. ​
  786. def test_write_to_file(tmp_path):
  787. src = write(tmp_path, "a.json", [{"a": 1}])
  788. out = tmp_path / "out.csv"
  789. r = run(str(src), "-o", str(out))
  790. assert r.returncode == 0
  791. assert parse(out.read_text()) == [["a"], ["1"]]
  792. ​
  793. ​
  794. def test_stdin_input(tmp_path):
  795. r = run("-", stdin='[{"a":1}]')
  796. assert r.returncode == 0
  797. assert parse(r.stdout) == [["a"], ["1"]]
  798. ​
  799. ​
  800. def test_missing_file(tmp_path):
  801. r = run(str(tmp_path / "nope.json"))
  802. assert r.returncode == 1
  803. assert "file not found" in r.stderr
  804. ​
  805. ​
  806. def test_not_an_array(tmp_path):
  807. src = tmp_path / "obj.json"
  808. src.write_text('{"a":1}')
  809. r = run(str(src))
  810. assert r.returncode == 2
  811. assert "expected a JSON array" in r.stderr
  812. ​
  813. ​
  814. def test_non_object_row(tmp_path):
  815. src = tmp_path / "bad.json"
  816. src.write_text('[{"a":1}, "oops"]')
  817. r = run(str(src))
  818. assert r.returncode == 2
  819. assert "expected an object" in r.stderr
  820. ​
  821. ​
  822. def test_invalid_json(tmp_path):
  823. src = tmp_path / "bad.json"
  824. src.write_text("{not json")
  825. r = run(str(src))
  826. assert r.returncode == 2
  827. assert "invalid JSON" in r.stderr
  828. ​
  829. ​
  830. def test_empty_file(tmp_path):
  831. src = tmp_path / "e.json"
  832. src.write_text("")
  833. r = run(str(src))
  834. assert r.returncode == 2
  835. assert "empty" in r.stderr
  836. ​
  837. ​
  838. def test_library_import(tmp_path):
  839. sys.path.insert(0, str(SCRIPT.parent))
  840. import json2csv # noqa: PLC0415
  841. ​
  842. assert json2csv.collect_columns([{"a": 1}, {"b": 2}]) == ["a", "b"]
  843. assert json2csv.render(None) == ""
  844. assert json2csv.render({"k": 1}) == '{"k":1}'
  845. ​
  846. === VERIFIED RUN (live output) ===
  847. ​
  848. $ printf "name,city,note" > demo.csv ...
  849. $ python3 csv2json.py demo.csv
  850. [
  851. {
  852. "name": "Andi, Jr",
  853. "city": "Jakarta",
  854. "note": "says \"hi\""
  855. },
  856. {
  857. "name": "Budi",
  858. "city": "Bandung",
  859. "note": "plain"
  860. }
  861. ]
  862. ​
  863. $ printf "[{\"a\":1,\"b\":\"x\"},{\"a\":2}]" | python3 json2csv.py /dev/stdin
  864. a,b
  865. 1,x
  866. 2,
  867. ​
  868. $ printf "a,b\n1\n" | python3 csv2json.py /dev/stdin # ragged row
  869. exit=2
  870. ​
  871. $ python3 -m pytest -q
  872. ............................................ [100%]
  873. 44 passed in 6.66s

Edit this Snippet