diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0626a11..2c48e8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,13 +10,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20, 22] + python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - node-version: ${{ matrix.node-version }} - cache: npm - - run: npm ci - - run: npm test + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install package and test deps + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run tests + run: pytest diff --git a/.gitignore b/.gitignore index 4f776ec..3848ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,19 @@ -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.DS_Store -*.tgz -coverage/ -.nyc_output/ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.venv/ +venv/ +.env +.DS_Store .idea/ .vscode/ +*.egg diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f5eef7..56ca14a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,20 +2,20 @@ ## Setup -- Use Node 20 or newer -- Install dependencies with the package manager -- Run the test script from package.json +- Use Python 3.11 or newer +- Install with `pip install -e ".[dev]"` +- Run tests with `pytest` ## Guidelines -- Keep the CLI ESM-only (type module). +- Keep the public CLI entry as the `data-transform` console script. - Prefer small, focused changes with tests for round-trips and edge cases. - Do not add company names or personal contacts in committed files. -- Match existing style: errors on stderr, exit code 1 on failure, UTF-8 I/O. +- Match existing style: errors on stderr as `error: …`, exit code 1 on failure, UTF-8 I/O. ## Pull requests 1. Fork and branch from main. -2. Add or update tests under test/. -3. Ensure the test script passes. +2. Add or update tests under `tests/`. +3. Ensure `pytest` passes. 4. Open a PR with a short summary of the change and why. diff --git a/README.md b/README.md index 52cbedc..4e9fc8f 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,19 @@ # data-transform-cli -Node.js ESM CLI for **YAML ↔ JSON ↔ CSV** transforms. Built for shell pipelines and local scripts. +Python CLI for **YAML ↔ JSON ↔ CSV** transforms. Built for shell pipelines and local scripts. Streaming-friendly where practical (stdin/stdout; full documents buffered for parse safety). -Requires **Node.js 20+**. +Requires **Python 3.11+**. ## Install ```bash -npm install -g . -# or run without installing: -npx --yes . to-json ./data.yaml +pip install -e . +# or with test extras: +pip install -e ".[dev]" ``` -From a clone of this repo: - -```bash -npm install -npm link # optional: exposes data-transform on your PATH -``` +After install, the `data-transform` console script is on your `PATH`. ## Commands @@ -63,46 +58,26 @@ printf "a: 1\n" | data-transform to-json -f yaml ### Library use -```js -import { convert, parse, serialize } from "data-transform-cli"; +```python +from data_transform import convert, parse, serialize -const yaml = convert("{\"a\":1}", "json", "yaml"); -const data = parse("id,name\n1,x\n", "csv"); -const json = serialize(data, "json", { pretty: true }); +yaml_text = convert('{"a":1}', "json", "yaml") +data = parse("id,name\n1,x\n", "csv") +json_text = serialize(data, "json", pretty=True) ``` ## CSV notes -CSV support is intentionally small and robust (RFC 4180-ish): quoted fields, escaped quotes (`""`), and newlines inside quotes. Object arrays use a header row; all cell values are strings when parsed from CSV. +CSV support is intentionally small and robust (RFC 4180-ish) via the Python standard library `csv` module: quoted fields, escaped quotes (`""`), and newlines inside quotes. Object arrays use a header row; all cell values are strings when parsed from CSV. ## Development ```bash -npm install -npm test +pip install -e ".[dev]" +pytest ``` -Tests use Node built-in `node:test` runner with fixtures under `test/fixtures/` for JSON/YAML/CSV round-trips and CLI smoke checks. - -## Extending with coding agents - -### GitHub Copilot - -- Open this repo in VS Code / JetBrains with Copilot enabled. -- Prefer asking Copilot to follow existing modules (`src/convert.js`, `src/csv.js`) and to add `node:test` cases beside fixtures. -- Optional: add workspace guidance under `.github/copilot-instructions.md`. - -### Claude Code - -- From the repo root, open the folder in Claude Code (or its CLI). -- Point it at `CONTRIBUTING.md` and ask for a failing test first, then implementation. -- Keep changes ESM-compatible and avoid adding heavy CSV libraries unless needed. - -### OpenAI Codex - -- Open the project in Codex with this directory as the workspace. -- Ask for command-level changes (`to-json`, `to-csv`, ...) and matching CLI tests in `test/cli.test.js`. -- Remind the agent: no company names, no personal contacts, no generator authorship comments in committed files. +Tests use **pytest** with fixtures under `tests/fixtures/` for JSON/YAML/CSV round-trips and CLI smoke checks. ## License diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index d6b8270..0000000 --- a/package-lock.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "data-transform-cli", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "data-transform-cli", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "commander": "^12.1.0", - "js-yaml": "^4.1.0" - }, - "bin": { - "data-transform": "src/cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "engines": { - "node": ">=18" - } - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 63f0a06..0000000 --- a/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "data-transform-cli", - "version": "0.1.0", - "description": "Streaming-friendly CLI for YAML ↔ JSON ↔ CSV transforms for pipelines and shell use", - "type": "module", - "bin": { - "data-transform": "./src/cli.js" - }, - "main": "./src/index.js", - "exports": { - ".": "./src/index.js" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "scripts": { - "test": "node --test test/*.test.js", - "start": "node src/cli.js" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "cli", - "yaml", - "json", - "csv", - "transform", - "pipeline" - ], - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/rmkr-dev/data-transform-cli.git" - }, - "bugs": { - "url": "https://github.com/rmkr-dev/data-transform-cli/issues" - }, - "homepage": "https://github.com/rmkr-dev/data-transform-cli#readme", - "dependencies": { - "commander": "^12.1.0", - "js-yaml": "^4.1.0" - } -} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42fff3f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "data-transform-cli" +version = "0.1.0" +description = "CLI for YAML ↔ JSON ↔ CSV transforms for pipelines and shell use" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +authors = [{ name = "rmkr-dev" }] +keywords = ["cli", "yaml", "json", "csv", "transform", "pipeline"] +dependencies = [ + "click>=8.1", + "PyYAML>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[project.scripts] +data-transform = "data_transform.cli:main" + +[project.urls] +Homepage = "https://github.com/rmkr-dev/data-transform-cli" +Repository = "https://github.com/rmkr-dev/data-transform-cli" +Issues = "https://github.com/rmkr-dev/data-transform-cli/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/cli.js b/src/cli.js deleted file mode 100644 index 77abf58..0000000 --- a/src/cli.js +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env node -import { readFileSync, writeFileSync } from 'node:fs'; -import { stdin as stdinStream } from 'node:process'; -import { Command } from 'commander'; -import { - convert, - detectFormat, - inferFormat, - parse, - serialize, -} from './convert.js'; - -const program = new Command(); - -program - .name('data-transform') - .description('YAML ↔ JSON ↔ CSV transforms for pipelines and shell use') - .version('0.1.0'); - -/** - * Read all of stdin as UTF-8 text. - * @returns {Promise} - */ -function readStdin() { - return new Promise((resolve, reject) => { - const chunks = []; - stdinStream.setEncoding('utf8'); - stdinStream.on('data', (c) => chunks.push(c)); - stdinStream.on('end', () => resolve(chunks.join(''))); - stdinStream.on('error', reject); - }); -} - -/** - * Load input from file path or stdin when path is "-" / omitted. - * @param {string | undefined} file - * @returns {Promise<{ text: string, name?: string }>} - */ -async function loadInput(file) { - if (!file || file === '-') { - if (stdinStream.isTTY) { - throw new Error('No input file and stdin is a TTY. Pass a file or pipe data.'); - } - return { text: await readStdin(), name: undefined }; - } - return { text: readFileSync(file, 'utf8'), name: file }; -} - -/** - * Resolve source format from flag, filename, or content. - * @param {string} text - * @param {string | undefined} name - * @param {string | undefined} fromFlag - */ -function resolveFrom(text, name, fromFlag) { - if (fromFlag) return fromFlag; - return detectFormat(name) ?? inferFormat(text); -} - -/** - * Write output to file or stdout. - * @param {string} out - * @param {string | undefined} outputPath - */ -function writeOutput(out, outputPath) { - if (outputPath && outputPath !== '-') { - writeFileSync(outputPath, out, 'utf8'); - } else { - process.stdout.write(out); - } -} - -/** - * Shared options for transform commands. - * @param {Command} cmd - */ -function addIOOptions(cmd) { - return cmd - .argument('[file]', 'input file (default: stdin)') - .option('-o, --output ', 'write to file instead of stdout') - .option('-f, --from ', 'input format: json | yaml | csv'); -} - -function makeTransformCommand(name, description, toFormat) { - const cmd = program.command(name).description(description); - addIOOptions(cmd); - if (toFormat === 'json') { - cmd.option('--minify', 'emit compact JSON', false); - } - cmd.action(async (file, opts) => { - try { - const { text, name } = await loadInput(file); - const from = resolveFrom(text, name, opts.from); - const out = convert(text, from, toFormat, { - pretty: !opts.minify, - minify: Boolean(opts.minify), - }); - writeOutput(out, opts.output); - } catch (err) { - console.error(`error: ${err.message}`); - process.exitCode = 1; - } - }); -} - -makeTransformCommand('to-json', 'Convert input to JSON', 'json'); -makeTransformCommand('to-yaml', 'Convert input to YAML', 'yaml'); -makeTransformCommand('to-csv', 'Convert input to CSV (array of objects/rows)', 'csv'); - -program - .command('pretty') - .description('Pretty-print JSON (or convert to pretty JSON)') - .argument('[file]', 'input file (default: stdin)') - .option('-o, --output ', 'write to file instead of stdout') - .option('-f, --from ', 'input format: json | yaml | csv') - .action(async (file, opts) => { - try { - const { text, name } = await loadInput(file); - const from = resolveFrom(text, name, opts.from); - const data = parse(text, from); - writeOutput(serialize(data, 'json', { pretty: true }), opts.output); - } catch (err) { - console.error(`error: ${err.message}`); - process.exitCode = 1; - } - }); - -program - .command('minify') - .description('Minify JSON (or convert to compact JSON)') - .argument('[file]', 'input file (default: stdin)') - .option('-o, --output ', 'write to file instead of stdout') - .option('-f, --from ', 'input format: json | yaml | csv') - .action(async (file, opts) => { - try { - const { text, name } = await loadInput(file); - const from = resolveFrom(text, name, opts.from); - const data = parse(text, from); - writeOutput(serialize(data, 'json', { minify: true }), opts.output); - } catch (err) { - console.error(`error: ${err.message}`); - process.exitCode = 1; - } - }); - -program.parseAsync(process.argv); diff --git a/src/convert.js b/src/convert.js deleted file mode 100644 index e5a2c4e..0000000 --- a/src/convert.js +++ /dev/null @@ -1,98 +0,0 @@ -import yaml from 'js-yaml'; -import { parseCsv, stringifyCsv } from './csv.js'; - -/** - * Detect format from filename extension. - * @param {string} [filename] - * @returns {'json' | 'yaml' | 'csv' | null} - */ -export function detectFormat(filename) { - if (!filename) return null; - const lower = filename.toLowerCase(); - if (lower.endsWith('.json')) return 'json'; - if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'; - if (lower.endsWith('.csv')) return 'csv'; - return null; -} - -/** - * Parse input text as the given format. - * @param {string} text - * @param {'json' | 'yaml' | 'csv'} format - * @returns {unknown} - */ -export function parse(text, format) { - switch (format) { - case 'json': - return JSON.parse(text); - case 'yaml': - return yaml.load(text); - case 'csv': - return parseCsv(text); - default: - throw new Error(`Unsupported parse format: ${format}`); - } -} - -/** - * Serialize data to the given format. - * @param {unknown} data - * @param {'json' | 'yaml' | 'csv'} format - * @param {{ pretty?: boolean, minify?: boolean }} [opts] - * @returns {string} - */ -export function serialize(data, format, opts = {}) { - switch (format) { - case 'json': { - if (opts.minify) return JSON.stringify(data); - const space = opts.pretty === false ? 0 : 2; - return JSON.stringify(data, null, space) + (space ? '\n' : ''); - } - case 'yaml': - return yaml.dump(data, { lineWidth: -1, noRefs: true }); - case 'csv': { - if (!Array.isArray(data)) { - throw new Error('CSV output requires an array (of objects or rows)'); - } - return stringifyCsv(data); - } - default: - throw new Error(`Unsupported serialize format: ${format}`); - } -} - -/** - * Convert between formats. - * @param {string} text - * @param {'json' | 'yaml' | 'csv'} from - * @param {'json' | 'yaml' | 'csv'} to - * @param {{ pretty?: boolean, minify?: boolean }} [opts] - * @returns {string} - */ -export function convert(text, from, to, opts = {}) { - const data = parse(text, from); - return serialize(data, to, opts); -} - -/** - * Infer source format from content heuristics when extension is unknown. - * @param {string} text - * @returns {'json' | 'yaml' | 'csv'} - */ -export function inferFormat(text) { - const trimmed = text.trimStart(); - if (!trimmed) return 'json'; - if (trimmed[0] === '{' || trimmed[0] === '[') { - try { - JSON.parse(text); - return 'json'; - } catch { - // fall through - } - } - const firstLine = trimmed.split(/\r?\n/, 1)[0] ?? ''; - if (firstLine.includes(',') && !trimmed.startsWith('---') && !/:\s/.test(firstLine)) { - return 'csv'; - } - return 'yaml'; -} diff --git a/src/csv.js b/src/csv.js deleted file mode 100644 index 538af58..0000000 --- a/src/csv.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Simple, robust CSV parse/stringify (RFC 4180-ish). - * Supports quoted fields, escaped quotes, and newlines inside quotes. - */ - -/** - * Parse a CSV string into an array of row objects (header row required) - * or an array of arrays if noHeader is true. - * @param {string} text - * @param {{ noHeader?: boolean }} [opts] - * @returns {object[] | string[][]} - */ -export function parseCsv(text, opts = {}) { - const rows = parseCsvRows(text); - if (rows.length === 0) return []; - - if (opts.noHeader) return rows; - - const headers = rows[0].map((h) => h.trim()); - return rows.slice(1).map((cells) => { - const obj = {}; - for (let i = 0; i < headers.length; i++) { - obj[headers[i]] = cells[i] ?? ''; - } - return obj; - }); -} - -/** - * Parse CSV into raw rows (arrays of strings). - * @param {string} text - * @returns {string[][]} - */ -export function parseCsvRows(text) { - const rows = []; - let row = []; - let field = ''; - let i = 0; - let inQuotes = false; - const s = String(text).replace(/^\uFEFF/, ''); - - while (i < s.length) { - const c = s[i]; - - if (inQuotes) { - if (c === '"') { - if (s[i + 1] === '"') { - field += '"'; - i += 2; - continue; - } - inQuotes = false; - i += 1; - continue; - } - field += c; - i += 1; - continue; - } - - if (c === '"') { - inQuotes = true; - i += 1; - continue; - } - - if (c === ',') { - row.push(field); - field = ''; - i += 1; - continue; - } - - if (c === '\r') { - i += 1; - continue; - } - - if (c === '\n') { - row.push(field); - rows.push(row); - row = []; - field = ''; - i += 1; - continue; - } - - field += c; - i += 1; - } - - // trailing field / row (including empty trailing newline handled above) - if (field.length > 0 || row.length > 0 || (s.length > 0 && !s.endsWith('\n') && !s.endsWith('\r'))) { - row.push(field); - rows.push(row); - } else if (row.length > 0) { - row.push(field); - rows.push(row); - } - - // Drop a single trailing empty row produced by a final newline - if (rows.length > 0) { - const last = rows[rows.length - 1]; - if (last.length === 1 && last[0] === '' && text.match(/\r?\n$/)) { - rows.pop(); - } - } - - return rows; -} - -/** - * Escape a single CSV field. - * @param {unknown} value - * @returns {string} - */ -export function escapeCsvField(value) { - if (value === null || value === undefined) return ''; - const str = typeof value === 'object' ? JSON.stringify(value) : String(value); - if (/[",\r\n]/.test(str)) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; -} - -/** - * Stringify data to CSV. - * Accepts array of objects (uses union of keys as header) or array of arrays. - * @param {object[] | unknown[][]} data - * @param {{ headers?: string[] }} [opts] - * @returns {string} - */ -export function stringifyCsv(data, opts = {}) { - if (!Array.isArray(data) || data.length === 0) { - if (opts.headers?.length) { - return opts.headers.map(escapeCsvField).join(',') + '\n'; - } - return ''; - } - - const first = data[0]; - const isObjects = first !== null && typeof first === 'object' && !Array.isArray(first); - - if (isObjects) { - const headers = - opts.headers ?? - Array.from( - data.reduce((set, row) => { - Object.keys(row ?? {}).forEach((k) => set.add(k)); - return set; - }, new Set()) - ); - const lines = [headers.map(escapeCsvField).join(',')]; - for (const row of data) { - lines.push(headers.map((h) => escapeCsvField(row?.[h])).join(',')); - } - return lines.join('\n') + '\n'; - } - - // array of arrays - const lines = data.map((row) => - (Array.isArray(row) ? row : [row]).map(escapeCsvField).join(',') - ); - return lines.join('\n') + '\n'; -} diff --git a/src/data_transform/__init__.py b/src/data_transform/__init__.py new file mode 100644 index 0000000..3e789e2 --- /dev/null +++ b/src/data_transform/__init__.py @@ -0,0 +1,18 @@ +"""YAML ↔ JSON ↔ CSV transforms for pipelines and shell use.""" + +from .convert import convert, detect_format, infer_format, parse, serialize +from .csv_io import escape_csv_field, parse_csv, parse_csv_rows, stringify_csv + +__all__ = [ + "convert", + "detect_format", + "infer_format", + "parse", + "serialize", + "parse_csv", + "parse_csv_rows", + "stringify_csv", + "escape_csv_field", +] + +__version__ = "0.1.0" diff --git a/src/data_transform/__main__.py b/src/data_transform/__main__.py new file mode 100644 index 0000000..9ae637f --- /dev/null +++ b/src/data_transform/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() diff --git a/src/data_transform/cli.py b/src/data_transform/cli.py new file mode 100644 index 0000000..1af1716 --- /dev/null +++ b/src/data_transform/cli.py @@ -0,0 +1,184 @@ +"""Click CLI entry point for data-transform.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Optional + +import click + +from . import __version__ +from .convert import convert, detect_format, infer_format, parse, serialize + + +def load_input(file: Optional[str]) -> tuple[str, Optional[str]]: + """Load input from file path or stdin when path is '-' / omitted.""" + if not file or file == "-": + if sys.stdin.isatty(): + raise ValueError( + "No input file and stdin is a TTY. Pass a file or pipe data." + ) + return sys.stdin.read(), None + return Path(file).read_text(encoding="utf-8"), file + + +def resolve_from(text: str, name: Optional[str], from_flag: Optional[str]) -> str: + """Resolve source format from flag, filename, or content.""" + if from_flag: + return from_flag.lower() + detected = detect_format(name) + if detected: + return detected + return infer_format(text) + + +def write_output(out: str, output_path: Optional[str]) -> None: + """Write output to file or stdout.""" + if output_path and output_path != "-": + Path(output_path).write_text(out, encoding="utf-8") + else: + sys.stdout.write(out) + + +def _run_transform( + file: Optional[str], + output: Optional[str], + from_format: Optional[str], + to_format: str, + *, + pretty: bool | None = None, + minify: bool = False, +) -> None: + try: + text, name = load_input(file) + src = resolve_from(text, name, from_format) + out = convert(text, src, to_format, pretty=pretty, minify=minify) # type: ignore[arg-type] + write_output(out, output) + except Exception as err: # noqa: BLE001 — CLI surface + click.echo(f"error: {err}", err=True) + sys.exit(1) + + +def _run_json_view( + file: Optional[str], + output: Optional[str], + from_format: Optional[str], + *, + pretty: bool = False, + minify: bool = False, +) -> None: + try: + text, name = load_input(file) + src = resolve_from(text, name, from_format) + data = parse(text, src) # type: ignore[arg-type] + write_output(serialize(data, "json", pretty=pretty, minify=minify), output) + except Exception as err: # noqa: BLE001 + click.echo(f"error: {err}", err=True) + sys.exit(1) + + +@click.group() +@click.version_option(__version__, prog_name="data-transform") +def main() -> None: + """YAML ↔ JSON ↔ CSV transforms for pipelines and shell use.""" + + +@main.command("to-json") +@click.argument("file", required=False) +@click.option("-o", "--output", default=None, help="write to file instead of stdout") +@click.option( + "-f", + "--from", + "from_format", + type=click.Choice(["json", "yaml", "csv"], case_sensitive=False), + default=None, + help="input format: json | yaml | csv", +) +@click.option("--minify", is_flag=True, default=False, help="emit compact JSON") +def to_json( + file: Optional[str], + output: Optional[str], + from_format: Optional[str], + minify: bool, +) -> None: + """Convert input to JSON.""" + _run_transform( + file, output, from_format, "json", pretty=not minify, minify=minify + ) + + +@main.command("to-yaml") +@click.argument("file", required=False) +@click.option("-o", "--output", default=None, help="write to file instead of stdout") +@click.option( + "-f", + "--from", + "from_format", + type=click.Choice(["json", "yaml", "csv"], case_sensitive=False), + default=None, + help="input format: json | yaml | csv", +) +def to_yaml( + file: Optional[str], output: Optional[str], from_format: Optional[str] +) -> None: + """Convert input to YAML.""" + _run_transform(file, output, from_format, "yaml") + + +@main.command("to-csv") +@click.argument("file", required=False) +@click.option("-o", "--output", default=None, help="write to file instead of stdout") +@click.option( + "-f", + "--from", + "from_format", + type=click.Choice(["json", "yaml", "csv"], case_sensitive=False), + default=None, + help="input format: json | yaml | csv", +) +def to_csv( + file: Optional[str], output: Optional[str], from_format: Optional[str] +) -> None: + """Convert input to CSV (array of objects/rows).""" + _run_transform(file, output, from_format, "csv") + + +@main.command("pretty") +@click.argument("file", required=False) +@click.option("-o", "--output", default=None, help="write to file instead of stdout") +@click.option( + "-f", + "--from", + "from_format", + type=click.Choice(["json", "yaml", "csv"], case_sensitive=False), + default=None, + help="input format: json | yaml | csv", +) +def pretty_cmd( + file: Optional[str], output: Optional[str], from_format: Optional[str] +) -> None: + """Pretty-print JSON (or convert to pretty JSON).""" + _run_json_view(file, output, from_format, pretty=True) + + +@main.command("minify") +@click.argument("file", required=False) +@click.option("-o", "--output", default=None, help="write to file instead of stdout") +@click.option( + "-f", + "--from", + "from_format", + type=click.Choice(["json", "yaml", "csv"], case_sensitive=False), + default=None, + help="input format: json | yaml | csv", +) +def minify_cmd( + file: Optional[str], output: Optional[str], from_format: Optional[str] +) -> None: + """Minify JSON (or convert to compact JSON).""" + _run_json_view(file, output, from_format, minify=True) + + +if __name__ == "__main__": + main() diff --git a/src/data_transform/convert.py b/src/data_transform/convert.py new file mode 100644 index 0000000..4fe82e0 --- /dev/null +++ b/src/data_transform/convert.py @@ -0,0 +1,106 @@ +"""Format detection, parse, serialize, and convert helpers.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Literal + +import yaml + +from .csv_io import parse_csv, stringify_csv + +FormatName = Literal["json", "yaml", "csv"] + + +def detect_format(filename: str | None) -> FormatName | None: + """Detect format from filename extension.""" + if not filename: + return None + lower = str(filename).lower() + # Support bare names and paths. + name = Path(lower).name + if name.endswith(".json"): + return "json" + if name.endswith(".yaml") or name.endswith(".yml"): + return "yaml" + if name.endswith(".csv"): + return "csv" + return None + + +def parse(text: str, format: FormatName) -> Any: + """Parse input text as the given format.""" + if format == "json": + return json.loads(text) + if format == "yaml": + return yaml.safe_load(text) + if format == "csv": + return parse_csv(text) + raise ValueError(f"Unsupported parse format: {format}") + + +def serialize( + data: Any, + format: FormatName, + *, + pretty: bool | None = None, + minify: bool = False, +) -> str: + """Serialize data to the given format.""" + if format == "json": + if minify: + return json.dumps(data, separators=(",", ":"), ensure_ascii=False) + space = 0 if pretty is False else 2 + body = json.dumps(data, indent=space or None, ensure_ascii=False) + if space: + return body + "\n" + return body + if format == "yaml": + return yaml.safe_dump( + data, + sort_keys=False, + allow_unicode=True, + default_flow_style=False, + width=10**9, + ) + if format == "csv": + if not isinstance(data, list): + raise ValueError("CSV output requires an array (of objects or rows)") + return stringify_csv(data) + raise ValueError(f"Unsupported serialize format: {format}") + + +def convert( + text: str, + from_format: FormatName, + to_format: FormatName, + *, + pretty: bool | None = None, + minify: bool = False, +) -> str: + """Convert between formats.""" + data = parse(text, from_format) + return serialize(data, to_format, pretty=pretty, minify=minify) + + +def infer_format(text: str) -> FormatName: + """Infer source format from content heuristics when extension is unknown.""" + trimmed = text.lstrip() + if not trimmed: + return "json" + if trimmed[0] in "{[": + try: + json.loads(text) + return "json" + except json.JSONDecodeError: + pass + first_line = trimmed.splitlines()[0] if trimmed else "" + if ( + "," in first_line + and not trimmed.startswith("---") + and not re.search(r":\s", first_line) + ): + return "csv" + return "yaml" diff --git a/src/data_transform/csv_io.py b/src/data_transform/csv_io.py new file mode 100644 index 0000000..c120c71 --- /dev/null +++ b/src/data_transform/csv_io.py @@ -0,0 +1,116 @@ +"""CSV parse/stringify helpers built on the stdlib csv module (RFC 4180-ish).""" + +from __future__ import annotations + +import csv +import io +import json +from typing import Any, Mapping, Sequence + + +def escape_csv_field(value: Any) -> str: + """Escape a single CSV field (mirrors Node escapeCsvField behavior).""" + if value is None: + return "" + if isinstance(value, (dict, list)): + text = json.dumps(value, separators=(",", ":"), ensure_ascii=False) + else: + text = str(value) + if any(ch in text for ch in '",\r\n'): + return '"' + text.replace('"', '""') + '"' + return text + + +def parse_csv_rows(text: str) -> list[list[str]]: + """Parse CSV into raw rows (lists of strings).""" + raw = str(text).lstrip("\ufeff") + if raw == "": + return [] + reader = csv.reader(io.StringIO(raw), lineterminator="\n") + rows = [list(row) for row in reader] + # Drop a single trailing empty row produced by a final newline (parity with Node). + if rows and len(rows[-1]) == 1 and rows[-1][0] == "" and ( + text.endswith("\n") or text.endswith("\r") + ): + # csv.reader usually does not produce this; keep defensive parity. + rows.pop() + return rows + + +def parse_csv(text: str, *, no_header: bool = False) -> list[dict[str, str]] | list[list[str]]: + """ + Parse a CSV string into an array of row objects (header row required) + or an array of arrays if no_header is True. + """ + rows = parse_csv_rows(text) + if not rows: + return [] + if no_header: + return rows + headers = [h.strip() for h in rows[0]] + result: list[dict[str, str]] = [] + for cells in rows[1:]: + obj: dict[str, str] = {} + for i, header in enumerate(headers): + obj[header] = cells[i] if i < len(cells) else "" + result.append(obj) + return result + + +def stringify_csv( + data: Sequence[Any], + *, + headers: Sequence[str] | None = None, +) -> str: + """ + Stringify data to CSV. + Accepts a sequence of mappings (uses union of keys as header) or a sequence of sequences. + """ + if not isinstance(data, Sequence) or isinstance(data, (str, bytes)): + raise TypeError("CSV output requires an array (of objects or rows)") + + if len(data) == 0: + if headers: + return ",".join(escape_csv_field(h) for h in headers) + "\n" + return "" + + first = data[0] + is_objects = isinstance(first, Mapping) + + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n", quoting=csv.QUOTE_MINIMAL) + + if is_objects: + if headers is None: + key_order: dict[str, None] = {} + for row in data: + if row is None: + continue + for key in row.keys(): + key_order.setdefault(str(key), None) + hdrs = list(key_order.keys()) + else: + hdrs = list(headers) + writer.writerow(hdrs) + for row in data: + mapping = row if isinstance(row, Mapping) else {} + writer.writerow( + [ + _cell_for_writer(mapping.get(h) if mapping is not None else None) + for h in hdrs + ] + ) + return buf.getvalue() + + for row in data: + cells = list(row) if isinstance(row, Sequence) and not isinstance(row, (str, bytes)) else [row] + writer.writerow([_cell_for_writer(c) for c in cells]) + return buf.getvalue() + + +def _cell_for_writer(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, separators=(",", ":"), ensure_ascii=False) + return str(value) diff --git a/src/index.js b/src/index.js deleted file mode 100644 index b601fa6..0000000 --- a/src/index.js +++ /dev/null @@ -1,14 +0,0 @@ -export { - detectFormat, - parse, - serialize, - convert, - inferFormat, -} from './convert.js'; - -export { - parseCsv, - parseCsvRows, - stringifyCsv, - escapeCsvField, -} from './csv.js'; diff --git a/test/cli.test.js b/test/cli.test.js deleted file mode 100644 index 21e014e..0000000 --- a/test/cli.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = join(__dirname, '..'); -const cli = join(root, 'src', 'cli.js'); -const fixture = (name) => join(__dirname, 'fixtures', name); - -/** - * Run the CLI with args and optional stdin. - * @param {string[]} args - * @param {string} [stdinText] - * @returns {Promise<{ code: number|null, stdout: string, stderr: string }>} - */ -function runCli(args, stdinText) { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [cli, ...args], { - cwd: root, - env: process.env, - }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (d) => (stdout += d)); - child.stderr.on('data', (d) => (stderr += d)); - child.on('error', reject); - child.on('close', (code) => resolve({ code, stdout, stderr })); - if (stdinText !== undefined) { - child.stdin.end(stdinText); - } else { - child.stdin.end(); - } - }); -} - -describe('cli', () => { - it('to-json from yaml file', async () => { - const { code, stdout, stderr } = await runCli(['to-json', fixture('sample.yaml')]); - assert.equal(code, 0, stderr); - const data = JSON.parse(stdout); - assert.equal(data[0].name, 'alpha'); - }); - - it('to-yaml from json file', async () => { - const { code, stdout, stderr } = await runCli(['to-yaml', fixture('sample.json')]); - assert.equal(code, 0, stderr); - assert.match(stdout, /name:\s*alpha/); - }); - - it('to-csv from json via stdin', async () => { - const input = readFileSync(fixture('sample.json'), 'utf8'); - const { code, stdout, stderr } = await runCli(['to-csv', '-f', 'json'], input); - assert.equal(code, 0, stderr); - assert.match(stdout, /^id,name,note/m); - assert.match(stdout, /hello, world/); - }); - - it('pretty formats compact json from stdin', async () => { - const { code, stdout, stderr } = await runCli(['pretty', '-f', 'json'], '{"a":1}'); - assert.equal(code, 0, stderr); - assert.equal(stdout, '{\n "a": 1\n}\n'); - }); - - it('minify collapses json', async () => { - const { code, stdout, stderr } = await runCli( - ['minify', '-f', 'json'], - '{\n "a": 1\n}\n' - ); - assert.equal(code, 0, stderr); - assert.equal(stdout, '{"a":1}'); - }); - - it('to-json --minify emits compact output', async () => { - const { code, stdout, stderr } = await runCli([ - 'to-json', - '--minify', - fixture('sample.yaml'), - ]); - assert.equal(code, 0, stderr); - assert.ok(!stdout.includes('\n ')); - assert.ok(JSON.parse(stdout)); - }); -}); diff --git a/test/convert.test.js b/test/convert.test.js deleted file mode 100644 index bdc5257..0000000 --- a/test/convert.test.js +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - convert, - detectFormat, - inferFormat, - parse, - serialize, -} from '../src/convert.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const fixture = (name) => readFileSync(join(__dirname, 'fixtures', name), 'utf8'); - -describe('detectFormat / inferFormat', () => { - it('detects by extension', () => { - assert.equal(detectFormat('a.json'), 'json'); - assert.equal(detectFormat('a.YAML'), 'yaml'); - assert.equal(detectFormat('a.yml'), 'yaml'); - assert.equal(detectFormat('a.csv'), 'csv'); - assert.equal(detectFormat('a.txt'), null); - }); - - it('infers from content', () => { - assert.equal(inferFormat('{"a":1}'), 'json'); - assert.equal(inferFormat('[1,2]'), 'json'); - assert.equal(inferFormat('a: 1\nb: 2\n'), 'yaml'); - assert.equal(inferFormat('id,name\n1,a\n'), 'csv'); - }); -}); - -describe('convert round-trips', () => { - it('json → yaml → json preserves data', () => { - const jsonText = fixture('sample.json'); - const data = parse(jsonText, 'json'); - const yamlText = serialize(data, 'yaml'); - const back = parse(yamlText, 'yaml'); - assert.deepEqual(back, data); - }); - - it('yaml → json → yaml preserves data', () => { - const yamlText = fixture('sample.yaml'); - const data = parse(yamlText, 'yaml'); - const jsonText = serialize(data, 'json', { pretty: true }); - const back = parse(jsonText, 'json'); - assert.deepEqual(back, data); - }); - - it('json → csv → json preserves stringified fields', () => { - // CSV is stringly-typed; compare via CSV object shape - const data = parse(fixture('sample.json'), 'json'); - const asCsvObjects = data.map((row) => ({ - id: String(row.id), - name: row.name, - note: row.note, - })); - const csv = serialize(asCsvObjects, 'csv'); - const back = parse(csv, 'csv'); - assert.deepEqual(back, asCsvObjects); - }); - - it('csv fixture parses and re-serializes', () => { - const csvText = fixture('sample.csv'); - const data = parse(csvText, 'csv'); - const again = serialize(data, 'csv'); - assert.deepEqual(parse(again, 'csv'), data); - }); - - it('convert() helper json to yaml', () => { - const out = convert('{"x":1}', 'json', 'yaml'); - assert.match(out, /x:\s*1/); - }); - - it('pretty and minify json', () => { - const data = { a: 1, b: [2] }; - const pretty = serialize(data, 'json', { pretty: true }); - const mini = serialize(data, 'json', { minify: true }); - assert.ok(pretty.includes('\n')); - assert.equal(mini, '{"a":1,"b":[2]}'); - assert.deepEqual(JSON.parse(pretty), data); - assert.deepEqual(JSON.parse(mini), data); - }); - - it('csv serialize rejects non-arrays', () => { - assert.throws(() => serialize({ a: 1 }, 'csv'), /array/i); - }); -}); diff --git a/test/csv.test.js b/test/csv.test.js deleted file mode 100644 index 667d4af..0000000 --- a/test/csv.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parseCsv, parseCsvRows, stringifyCsv, escapeCsvField } from '../src/csv.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const fixture = (name) => readFileSync(join(__dirname, 'fixtures', name), 'utf8'); - -describe('csv', () => { - it('escapeCsvField quotes special characters', () => { - assert.equal(escapeCsvField('plain'), 'plain'); - assert.equal(escapeCsvField('a,b'), '"a,b"'); - assert.equal(escapeCsvField('say "hi"'), '"say ""hi"""'); - assert.equal(escapeCsvField('a\nb'), '"a\nb"'); - assert.equal(escapeCsvField(null), ''); - }); - - it('parses quoted commas and newlines', () => { - const rows = parseCsv(fixture('sample.csv')); - assert.equal(rows.length, 2); - assert.equal(rows[0].name, 'alpha'); - assert.equal(rows[0].note, 'hello, world'); - assert.equal(rows[1].note, 'line1\nline2'); - }); - - it('round-trips object rows', () => { - const data = [ - { id: '1', name: 'alpha', note: 'hello, world' }, - { id: '2', name: 'beta', note: 'line1\nline2' }, - ]; - const csv = stringifyCsv(data); - const parsed = parseCsv(csv); - assert.deepEqual(parsed, data); - }); - - it('parses raw rows without header', () => { - const rows = parseCsvRows('a,b\n1,2\n'); - assert.deepEqual(rows, [ - ['a', 'b'], - ['1', '2'], - ]); - }); - - it('handles empty input', () => { - assert.deepEqual(parseCsv(''), []); - assert.equal(stringifyCsv([]), ''); - }); -}); diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..88a0df6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import pytest + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def fixtures_dir() -> Path: + return FIXTURES + + +@pytest.fixture +def fixture_text(fixtures_dir: Path): + def _load(name: str) -> str: + return (fixtures_dir / name).read_text(encoding="utf-8") + + return _load diff --git a/test/fixtures/sample.csv b/tests/fixtures/sample.csv similarity index 100% rename from test/fixtures/sample.csv rename to tests/fixtures/sample.csv diff --git a/test/fixtures/sample.json b/tests/fixtures/sample.json similarity index 100% rename from test/fixtures/sample.json rename to tests/fixtures/sample.json diff --git a/test/fixtures/sample.yaml b/tests/fixtures/sample.yaml similarity index 100% rename from test/fixtures/sample.yaml rename to tests/fixtures/sample.yaml diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..956dec2 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,66 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = Path(__file__).parent / "fixtures" + + +def run_cli(args: list[str], stdin_text: str | None = None): + env = os.environ.copy() + # Prefer installed console script; fall back to python -m + cmd = [sys.executable, "-m", "data_transform.cli", *args] + # Also try data-transform if on PATH after editable install + completed = subprocess.run( + cmd, + input=stdin_text, + text=True, + capture_output=True, + cwd=ROOT, + env=env, + ) + return completed.returncode, completed.stdout, completed.stderr + + +def test_to_json_from_yaml_file(): + code, stdout, stderr = run_cli(["to-json", str(FIXTURE / "sample.yaml")]) + assert code == 0, stderr + data = json.loads(stdout) + assert data[0]["name"] == "alpha" + + +def test_to_yaml_from_json_file(): + code, stdout, stderr = run_cli(["to-yaml", str(FIXTURE / "sample.json")]) + assert code == 0, stderr + assert "name:" in stdout and "alpha" in stdout + + +def test_to_csv_from_json_via_stdin(): + input_text = (FIXTURE / "sample.json").read_text(encoding="utf-8") + code, stdout, stderr = run_cli(["to-csv", "-f", "json"], input_text) + assert code == 0, stderr + assert "id,name,note" in stdout.splitlines()[0] + assert "hello, world" in stdout + + +def test_pretty_formats_compact_json_from_stdin(): + code, stdout, stderr = run_cli(["pretty", "-f", "json"], '{"a":1}') + assert code == 0, stderr + assert stdout == '{\n "a": 1\n}\n' + + +def test_minify_collapses_json(): + code, stdout, stderr = run_cli(["minify", "-f", "json"], '{\n "a": 1\n}\n') + assert code == 0, stderr + assert stdout == '{"a":1}' + + +def test_to_json_minify_emits_compact_output(): + code, stdout, stderr = run_cli( + ["to-json", "--minify", str(FIXTURE / "sample.yaml")] + ) + assert code == 0, stderr + assert "\n " not in stdout + assert json.loads(stdout) diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 0000000..ad56702 --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,80 @@ +import json +import re + +import pytest + +from data_transform.convert import ( + convert, + detect_format, + infer_format, + parse, + serialize, +) + + +def test_detects_by_extension(): + assert detect_format("a.json") == "json" + assert detect_format("a.YAML") == "yaml" + assert detect_format("a.yml") == "yaml" + assert detect_format("a.csv") == "csv" + assert detect_format("a.txt") is None + + +def test_infers_from_content(): + assert infer_format('{"a":1}') == "json" + assert infer_format("[1,2]") == "json" + assert infer_format("a: 1\nb: 2\n") == "yaml" + assert infer_format("id,name\n1,a\n") == "csv" + + +def test_json_yaml_json_preserves_data(fixture_text): + json_text = fixture_text("sample.json") + data = parse(json_text, "json") + yaml_text = serialize(data, "yaml") + back = parse(yaml_text, "yaml") + assert back == data + + +def test_yaml_json_yaml_preserves_data(fixture_text): + yaml_text = fixture_text("sample.yaml") + data = parse(yaml_text, "yaml") + json_text = serialize(data, "json", pretty=True) + back = parse(json_text, "json") + assert back == data + + +def test_json_csv_json_preserves_stringified_fields(fixture_text): + data = parse(fixture_text("sample.json"), "json") + as_csv_objects = [ + {"id": str(row["id"]), "name": row["name"], "note": row["note"]} for row in data + ] + csv_text = serialize(as_csv_objects, "csv") + back = parse(csv_text, "csv") + assert back == as_csv_objects + + +def test_csv_fixture_parses_and_re_serializes(fixture_text): + csv_text = fixture_text("sample.csv") + data = parse(csv_text, "csv") + again = serialize(data, "csv") + assert parse(again, "csv") == data + + +def test_convert_helper_json_to_yaml(): + out = convert('{"x":1}', "json", "yaml") + assert re.search(r"x:\s*1", out) + + +def test_pretty_and_minify_json(): + data = {"a": 1, "b": [2]} + pretty = serialize(data, "json", pretty=True) + mini = serialize(data, "json", minify=True) + assert "\n" in pretty + assert mini == '{"a":1,"b":[2]}' + assert json.loads(pretty) == data + assert json.loads(mini) == data + + +def test_csv_serialize_rejects_non_arrays(): + with pytest.raises(ValueError, match="array"): + serialize({"a": 1}, "csv") diff --git a/tests/test_csv.py b/tests/test_csv.py new file mode 100644 index 0000000..9311d99 --- /dev/null +++ b/tests/test_csv.py @@ -0,0 +1,40 @@ +from data_transform.csv_io import escape_csv_field, parse_csv, parse_csv_rows, stringify_csv + + +def test_escape_csv_field_quotes_special_characters(): + assert escape_csv_field("plain") == "plain" + assert escape_csv_field("a,b") == '"a,b"' + assert escape_csv_field('say "hi"') == '"say ""hi"""' + assert escape_csv_field("a\nb") == '"a\nb"' + assert escape_csv_field(None) == "" + + +def test_parses_quoted_commas_and_newlines(fixture_text): + rows = parse_csv(fixture_text("sample.csv")) + assert len(rows) == 2 + assert rows[0]["name"] == "alpha" + assert rows[0]["note"] == "hello, world" + assert rows[1]["note"] == "line1\nline2" + + +def test_round_trips_object_rows(): + data = [ + {"id": "1", "name": "alpha", "note": "hello, world"}, + {"id": "2", "name": "beta", "note": "line1\nline2"}, + ] + csv_text = stringify_csv(data) + parsed = parse_csv(csv_text) + assert parsed == data + + +def test_parses_raw_rows_without_header(): + rows = parse_csv_rows("a,b\n1,2\n") + assert rows == [ + ["a", "b"], + ["1", "2"], + ] + + +def test_handles_empty_input(): + assert parse_csv("") == [] + assert stringify_csv([]) == ""