diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c48e8a..2ca5a76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,31 @@ jobs: pip install -e ".[dev]" - name: Run tests run: pytest + + package: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build + python -m build + - name: Install wheel in a clean environment + run: | + python -m venv /tmp/wheel-env + /tmp/wheel-env/bin/pip install dist/*.whl + - name: Smoke-test the installed command + run: | + /tmp/wheel-env/bin/data-transform --version + echo '[{"id":1},{"id":2}]' | /tmp/wheel-env/bin/data-transform validate -r id -u id -f json -q + printf 'id\n1\n1\n' | /tmp/wheel-env/bin/data-transform validate -u id -f csv -q && exit 1 || test $? -eq 1 + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b25a584..bd42acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog -All notable changes to this project are listed here. Versions follow [Semantic Versioning](https://semver.org/); until 1.0.0, minor versions may change CLI behaviour. +All notable changes to this project are listed here. Versions follow [Semantic Versioning](https://semver.org/). From 1.0.0, documented commands, options, exit codes, and output shapes only break in a new major version. + +## [1.0.0] - 2026-10-16 + +### Added + +- `validate [file]` checks a list of records (or one object) and exits `0` when valid, `1` when not, and `2` when the check can't run. + - `-r, --require PATH`, `--type PATH=TYPE[|TYPE...]`, and `-u, --unique PATH`, all repeatable and using dotted paths. + - `-s, --schema FILE` reads `required`, `types`, and `unique` from a JSON or YAML file; command-line flags are added on top. + - `--coerce` lets numeric, `true` / `false`, and empty strings satisfy `integer`, `number`, `boolean`, and `null`, for CSV input. + - `--max-errors N` and `-q, --quiet`. `-t csv` / `-t ndjson` writes only the error rows. +- `validate_rows` and `load_schema` in the library API. +- CI now builds the sdist and wheel, installs the wheel in a clean virtual environment, and smoke-tests the `data-transform` command. +- A Stability section in the README describing what 1.0.0 commits to. ## [0.8.0] - 2026-10-14 diff --git a/README.md b/README.md index f599b7e..0dd7528 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ After install, the `data-transform` console script is on your `PATH`. | `count` | Count items, per KEY value, or per group | | `stats` | count / sum / min / max / avg of a numeric FIELD | | `join` | Join two arrays of objects on a top-level KEY (`--with` file) | +| `validate` | Check records for required paths, types, and unique keys (exit code 0 / 1 / 2) | ### Options (most commands) @@ -51,6 +52,7 @@ After install, the `data-transform` console script is on your `PATH`. - `--desc` — reverse sort order (`sort` only) - `--sep ` — separator between nested keys (`flatten` and `unflatten`, default `.`) - `-w, --with `, `--how inner|left`, `--right-from ` — right-hand file, join type, and right file format (`join` only) +- `-r, --require `, `--type =`, `-u, --unique `, `-s, --schema `, `--coerce`, `--max-errors `, `-q, --quiet` — checks and report options (`validate` only). See [Validate](#validate) Format is taken from `--from`, else the file extension (`.ndjson` / `.jsonl` → ndjson), else simple content heuristics. @@ -311,6 +313,44 @@ cat users.json | data-transform join id -w teams.csv -f json \ # → {"core":1,"web":1,"ops":1} ``` +### Validate + +`validate` checks data before it moves further down a pipeline. It works on a list of objects (each item is a record) or on a single object. Paths are dotted, like `select`, so `user.name` and `items.0.sku` work. + +- `-r, --require PATH` — the path must be present in every record. A `null` value counts as present; add a type check to rule it out. +- `--type PATH=TYPE` — the value must be one of the listed types: `string`, `integer`, `number`, `boolean`, `object`, `array`, `null`. Join several with `|`, for example `email=string|null`. Records without the path are skipped, so pair it with `--require` when the path is mandatory. Booleans are never numbers, and `integer` also satisfies `number`. +- `-u, --unique PATH` — the value must not repeat across records. Values compare like `group-by` labels, so `1` and `"1"` count as the same. Records without the path are skipped. +- `-s, --schema FILE` — a JSON or YAML file with any of `required` (list), `types` (path → type, or a list of types), and `unique` (list). Flags on the command line are added on top. +- `--coerce` — let strings that look like integers, numbers, `true` / `false`, or an empty string satisfy `integer`, `number`, `boolean`, or `null`. Use it for CSV, where every cell is a string. +- `--max-errors N` — list at most `N` errors. `error_count` is always the full count. +- `-q, --quiet` — print nothing and rely on the exit code. + +The report is `{"valid", "records", "error_count", "errors"}`, where each error has `record` (the list index, or `null` for an object root), `path`, and `message`. `-t yaml` writes the report as YAML; `-t csv` or `-t ndjson` writes just the errors, one row per error, which is handy as a CI artifact. + +Exit codes: `0` when the data is valid, `1` when it is not, and `2` when the check could not run (unreadable input, a bad schema, an unknown type, or no checks given). + +```bash +echo '[{"id":1,"email":"a@x.io"},{"id":"2"},{"id":1,"email":null}]' \ + | data-transform validate -r id -r email --type id=integer --type 'email=string|null' -u id -f json --minify +# → {"valid":false,"records":3,"error_count":3,"errors":[ +# {"record":1,"path":"email","message":"missing required value"}, +# {"record":1,"path":"id","message":"expected integer, got string"}, +# {"record":2,"path":"id","message":"duplicate value '1' (first seen in record 0)"}]} + +# Data contract kept next to the pipeline +cat > users.schema.yaml <<'YAML' +required: [id, email] +types: + id: integer + email: [string, "null"] +unique: [id] +YAML +data-transform validate -s users.schema.yaml --coerce users.csv -q || echo "users.csv failed checks" + +# Fail a CI step and keep an error table +data-transform validate -s users.schema.yaml --coerce users.csv -t csv -o validation-errors.csv +``` + ### Library use ```python @@ -333,6 +373,7 @@ from data_transform import ( count_rows, stats_rows, join_rows, + validate_rows, ) yaml_text = convert('{"a":1}', "json", "yaml") @@ -353,6 +394,7 @@ total = count_rows([{"id": 1}, {"id": 2}]) per_team = count_rows([{"team": "a"}, {"team": "a"}], "team") summary = stats_rows([{"ms": 120}, {"ms": 80}], "ms") joined = join_rows([{"id": 1, "name": "Ada"}], [{"id": "1", "team": "core"}], "id") +report = validate_rows([{"id": 1}, {"id": "x"}], required=["id"], types={"id": ["integer"]}) ``` ## NDJSON notes @@ -363,6 +405,10 @@ NDJSON (also known as JSON Lines / `.jsonl`) is one JSON value per non-empty lin 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. +## Stability + +From 1.0.0 the command names, options, exit codes, and output shapes documented here follow [Semantic Versioning](https://semver.org/): they only change in a breaking way in a new major version. New commands and options may arrive in minor versions. The functions exported from `data_transform` follow the same rule; anything starting with `_` is internal. + ## Changelog See [CHANGELOG.md](./CHANGELOG.md). diff --git a/pyproject.toml b/pyproject.toml index c824479..7a0225d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "data-transform-cli" -version = "0.8.0" +version = "1.0.0" description = "CLI for YAML ↔ JSON ↔ CSV ↔ NDJSON 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", "ndjson", "transform", "pipeline"] +keywords = ["cli", "yaml", "json", "csv", "ndjson", "transform", "pipeline", "validation"] dependencies = [ "click>=8.1", "PyYAML>=6.0", diff --git a/src/data_transform/__init__.py b/src/data_transform/__init__.py index e9319db..0eb5edc 100644 --- a/src/data_transform/__init__.py +++ b/src/data_transform/__init__.py @@ -19,6 +19,7 @@ unflatten_object, unique_rows, ) +from .validate import load_schema, validate_rows __all__ = [ "convert", @@ -45,6 +46,8 @@ "count_rows", "stats_rows", "join_rows", + "validate_rows", + "load_schema", ] -__version__ = "0.8.0" +__version__ = "1.0.0" diff --git a/src/data_transform/cli.py b/src/data_transform/cli.py index 6ff5c01..7ef1c47 100644 --- a/src/data_transform/cli.py +++ b/src/data_transform/cli.py @@ -27,6 +27,7 @@ unflatten_object, unique_rows, ) +from .validate import load_schema, parse_type_option, validate_rows FORMAT_CHOICES = ["json", "yaml", "csv", "ndjson"] @@ -879,5 +880,127 @@ def join_cmd( sys.exit(1) +@main.command("validate") +@click.argument("file", required=False) +@click.option( + "-r", + "--require", + "required", + multiple=True, + metavar="PATH", + help="dotted path every record must have (repeatable)", +) +@click.option( + "--type", + "type_specs", + multiple=True, + metavar="PATH=TYPE", + help="allowed type(s) for PATH, e.g. id=integer or email=string|null (repeatable)", +) +@click.option( + "-u", + "--unique", + multiple=True, + metavar="PATH", + help="dotted path whose values must not repeat across records (repeatable)", +) +@click.option( + "-s", + "--schema", + "schema_file", + default=None, + metavar="FILE", + help="JSON or YAML file with required / types / unique (merged with flags)", +) +@click.option( + "--coerce", + is_flag=True, + default=False, + help="let numeric / true / false / empty strings satisfy integer, number, " + "boolean, null (useful for CSV)", +) +@click.option( + "--max-errors", + type=click.IntRange(min=0), + default=None, + help="list at most N errors in the report (error_count stays exact)", +) +@click.option( + "-q", "--quiet", is_flag=True, default=False, help="print nothing; use the exit code" +) +@shaping_options +def validate_cmd( + file: Optional[str], + required: tuple[str, ...], + type_specs: tuple[str, ...], + unique: tuple[str, ...], + schema_file: Optional[str], + coerce: bool, + max_errors: Optional[int], + quiet: bool, + output: Optional[str], + from_format: Optional[str], + to_format: Optional[str], + minify: bool, +) -> None: + """Check records against required paths, types, and unique keys. + + Prints a report with valid, records, error_count, and errors. With + --to csv or --to ndjson, prints just the errors, one row per error. + Types: string, integer, number, boolean, object, array, null. + + Exit codes: 0 valid, 1 invalid, 2 the check could not run (bad input, + schema, or options). + + Usage: data-transform validate [--require PATH] [--type PATH=TYPE] + [--unique PATH] [--schema FILE] [file] + """ + try: + req: list[str] = [] + types: dict[str, list[str]] = {} + uniq: list[str] = [] + if schema_file: + schema_path = Path(schema_file) + if not schema_path.is_file(): + raise ValueError(f"schema file not found: {schema_file}") + schema_text = schema_path.read_text(encoding="utf-8") + schema_src = resolve_from(schema_text, schema_file, None) + req, types, uniq = load_schema(parse(schema_text, schema_src)) # type: ignore[arg-type] + req += [p for p in required if p not in req] + for spec in type_specs: + path, names = parse_type_option(spec) + types[path] = names + uniq += [p for p in unique if p not in uniq] + if not (req or types or uniq): + raise ValueError( + "nothing to check; pass --require, --type, --unique, or --schema" + ) + + text, name = load_input(file) + src = resolve_from(text, name, from_format) + data = parse(text, src) # type: ignore[arg-type] + report = validate_rows( + data, + required=req, + types=types, + unique=uniq, + coerce=coerce, + max_errors=max_errors, + ) + except Exception as err: # noqa: BLE001 + click.echo(f"error: {err}", err=True) + sys.exit(2) + + if not quiet: + try: + fmt = resolve_to(to_format, output) + result = report["errors"] if fmt in ("csv", "ndjson") else report + _emit(result, output, fmt, minify) + except Exception as err: # noqa: BLE001 + click.echo(f"error: {err}", err=True) + sys.exit(2) + sys.exit(0 if report["valid"] else 1) + + if __name__ == "__main__": main() diff --git a/src/data_transform/validate.py b/src/data_transform/validate.py new file mode 100644 index 0000000..fda1782 --- /dev/null +++ b/src/data_transform/validate.py @@ -0,0 +1,225 @@ +"""Record validation: required paths, value types, and unique keys.""" + +from __future__ import annotations + +import re +from typing import Any, Iterable, Mapping, Sequence + +from .shape import _group_label, _parse_path + +TYPE_NAMES = ("string", "integer", "number", "boolean", "object", "array", "null") + +_INT_RE = re.compile(r"^[+-]?\d+$") +_NUM_RE = re.compile(r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$") + +_MISSING = object() + + +def _resolve(data: Any, path: str) -> Any: + """Resolve a dotted path, returning _MISSING (not None) when absent.""" + current: Any = data + for seg in _parse_path(path): + if isinstance(seg, int): + if not isinstance(current, list) or seg >= len(current): + return _MISSING + current = current[seg] + else: + if not isinstance(current, Mapping) or seg not in current: + return _MISSING + current = current[seg] + return current + + +def parse_type_spec(spec: str) -> list[str]: + """Split a type spec like ``string|null`` into known type names.""" + names = [part.strip().lower() for part in spec.split("|")] + if not names or any(name == "" for name in names): + raise ValueError(f"Invalid type spec: {spec!r}") + unknown = [name for name in names if name not in TYPE_NAMES] + if unknown: + raise ValueError( + f"Unknown type {unknown[0]!r}; expected one of: {', '.join(TYPE_NAMES)}" + ) + return names + + +def parse_type_option(option: str) -> tuple[str, list[str]]: + """Parse a ``PATH=TYPE[|TYPE...]`` CLI option.""" + path, sep, spec = option.partition("=") + if not sep or not path: + raise ValueError(f"--type expects PATH=TYPE, got {option!r}") + _parse_path(path) + return path, parse_type_spec(spec) + + +def type_of(value: Any, *, coerce: bool = False) -> set[str]: + """Return every type name a value satisfies. + + With ``coerce``, strings that look like integers, numbers, or booleans + (``true`` / ``false``, any case) also satisfy those types, and an empty + string satisfies ``null``. This suits CSV, where every cell is a string. + """ + if value is None: + return {"null"} + if isinstance(value, bool): + return {"boolean"} + if isinstance(value, int): + return {"integer", "number"} + if isinstance(value, float): + return {"number"} + if isinstance(value, Mapping): + return {"object"} + if isinstance(value, list): + return {"array"} + if isinstance(value, str): + found = {"string"} + if coerce: + text = value.strip() + if text == "": + found.add("null") + elif _INT_RE.match(text): + found.update({"integer", "number"}) + elif _NUM_RE.match(text): + found.add("number") + elif text.lower() in ("true", "false"): + found.add("boolean") + return found + return {type(value).__name__} + + +def _describe(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int): + return "integer" + if isinstance(value, float): + return "number" + if isinstance(value, Mapping): + return "object" + if isinstance(value, list): + return "array" + if isinstance(value, str): + return "string" + return type(value).__name__ + + +def load_schema(schema: Any) -> tuple[list[str], dict[str, list[str]], list[str]]: + """Read ``required``, ``types``, and ``unique`` from a parsed schema document.""" + if not isinstance(schema, Mapping): + raise ValueError("schema must be an object with required / types / unique") + unknown = set(schema) - {"required", "types", "unique"} + if unknown: + raise ValueError(f"schema: unknown key {sorted(unknown)[0]!r}") + + required = schema.get("required", []) or [] + unique = schema.get("unique", []) or [] + types = schema.get("types", {}) or {} + for name, value in (("required", required), ("unique", unique)): + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise ValueError(f"schema: {name} must be a list of paths") + if not isinstance(types, Mapping): + raise ValueError("schema: types must map PATH to TYPE") + + parsed_types: dict[str, list[str]] = {} + for path, spec in types.items(): + if isinstance(spec, list): + spec = "|".join(str(item) for item in spec) + if not isinstance(path, str) or not isinstance(spec, str): + raise ValueError("schema: types must map PATH to TYPE") + _parse_path(path) + parsed_types[path] = parse_type_spec(spec) + for path in [*required, *unique]: + _parse_path(path) + return list(required), parsed_types, list(unique) + + +def validate_rows( + data: Any, + *, + required: Sequence[str] = (), + types: Mapping[str, Sequence[str]] | None = None, + unique: Sequence[str] = (), + coerce: bool = False, + max_errors: int | None = None, +) -> dict[str, Any]: + """Validate a list of records (or a single object) and return a report. + + - ``required`` paths must be present (``null`` counts as present). + - ``types`` maps a path to allowed type names; absent paths are skipped, + so combine with ``required`` to demand both. + - ``unique`` paths must not repeat across records (absent values + skipped). Values compare like ``group-by`` labels, so ``1`` and + ``"1"`` count as the same value. + + The report is ``{"valid", "records", "error_count", "errors"}``; each + error has ``record`` (list index, or ``None`` for an object root), + ``path``, and ``message``. ``max_errors`` caps the listed errors, not + ``error_count``. + """ + type_map = {path: [t.lower() for t in names] for path, names in (types or {}).items()} + for names in type_map.values(): + for name in names: + if name not in TYPE_NAMES: + raise ValueError(f"Unknown type {name!r}") + for path in [*required, *type_map, *unique]: + _parse_path(path) + if max_errors is not None and max_errors < 0: + raise ValueError("max_errors must be a non-negative integer") + + if isinstance(data, list): + records: Iterable[tuple[int | None, Any]] = enumerate(data) + total = len(data) + elif isinstance(data, Mapping): + records = [(None, data)] + total = 1 + else: + raise ValueError("validate: root must be a list of objects or an object") + + errors: list[dict[str, Any]] = [] + error_count = 0 + + def add(record: int | None, path: str | None, message: str) -> None: + nonlocal error_count + error_count += 1 + if max_errors is None or len(errors) < max_errors: + errors.append({"record": record, "path": path, "message": message}) + + seen: dict[str, dict[str, int | None]] = {path: {} for path in unique} + + for index, item in records: + if not isinstance(item, Mapping): + add(index, None, f"record is {_describe(item)}, not an object") + continue + for path in required: + if _resolve(item, path) is _MISSING: + add(index, path, "missing required value") + for path, allowed in type_map.items(): + value = _resolve(item, path) + if value is _MISSING: + continue + if not type_of(value, coerce=coerce) & set(allowed): + add( + index, + path, + f"expected {'|'.join(allowed)}, got {_describe(value)}", + ) + for path in unique: + value = _resolve(item, path) + if value is _MISSING: + continue + label = _group_label(value) + first = seen[path].get(label, _MISSING) + if first is _MISSING: + seen[path][label] = index + else: + where = "an earlier record" if first is None else f"record {first}" + add(index, path, f"duplicate value {label!r} (first seen in {where})") + + return { + "valid": error_count == 0, + "records": total, + "error_count": error_count, + "errors": errors, + } diff --git a/tests/test_shape.py b/tests/test_shape.py index 73fd408..920e259 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -166,7 +166,7 @@ def test_unique_requires_list(): def test_public_exports_include_reshape_helpers(): - assert data_transform.__version__ == "0.8.0" + assert data_transform.__version__ == "1.0.0" for name in ( "rename_keys", "flatten_object", diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000..49968e1 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,158 @@ +import json + +import pytest + +from data_transform import load_schema, validate_rows +from data_transform.validate import parse_type_option, type_of +from test_cli import run_cli + +ROWS = [ + {"id": 1, "email": "a@x.io", "user": {"name": "Ada"}}, + {"id": "2", "email": None, "user": {}}, + {"id": 1, "user": {"name": "Cy"}}, +] + + +def test_required_paths_and_null_counts_as_present(): + report = validate_rows(ROWS, required=["id", "email", "user.name"]) + assert report["valid"] is False + assert report["records"] == 3 + assert report["errors"] == [ + {"record": 1, "path": "user.name", "message": "missing required value"}, + {"record": 2, "path": "email", "message": "missing required value"}, + ] + + +def test_types_with_union_and_absent_paths_skipped(): + report = validate_rows(ROWS, types={"id": ["integer"], "email": ["string", "null"]}) + assert report["error_count"] == 1 + assert report["errors"][0] == { + "record": 1, "path": "id", "message": "expected integer, got string", + } + + +def test_coerce_lets_csv_strings_match(): + rows = [{"id": "7", "ms": "1.5", "ok": "TRUE", "note": ""}] + types = {"id": ["integer"], "ms": ["number"], "ok": ["boolean"], "note": ["null"]} + assert validate_rows(rows, types=types)["error_count"] == 4 + assert validate_rows(rows, types=types, coerce=True)["valid"] is True + + +def test_type_of_basics(): + assert type_of(True) == {"boolean"} + assert type_of(3) == {"integer", "number"} + assert type_of(2.5) == {"number"} + assert type_of([1]) == {"array"} + assert type_of({}) == {"object"} + assert type_of("1e3", coerce=True) == {"string", "number"} + assert type_of("abc", coerce=True) == {"string"} + + +def test_unique_reports_first_seen_record(): + report = validate_rows(ROWS, unique=["id"]) + assert report["errors"] == [ + {"record": 2, "path": "id", "message": "duplicate value '1' (first seen in record 0)"} + ] + # Same labels as group-by / join: 1 and "1" collide, null is a value + assert validate_rows([{"id": 1}, {"id": "1"}], unique=["id"])["valid"] is False + assert validate_rows([{"id": 1}, {}, {}], unique=["id"])["valid"] is True + + +def test_object_root_and_non_object_records(): + assert validate_rows({"id": 1}, required=["id"]) == { + "valid": True, "records": 1, "error_count": 0, "errors": [], + } + report = validate_rows([{"id": 1}, 5, None], required=["id"]) + assert [e["message"] for e in report["errors"]] == [ + "record is integer, not an object", + "record is null, not an object", + ] + + +def test_max_errors_caps_list_not_count(): + rows = [{} for _ in range(5)] + report = validate_rows(rows, required=["id"], max_errors=2) + assert report["error_count"] == 5 + assert len(report["errors"]) == 2 + + +def test_bad_root_and_bad_specs(): + with pytest.raises(ValueError, match="root must be"): + validate_rows(5, required=["id"]) + with pytest.raises(ValueError, match="Unknown type"): + parse_type_option("id=int") + with pytest.raises(ValueError, match="PATH=TYPE"): + parse_type_option("id") + with pytest.raises(ValueError, match="Invalid path"): + validate_rows([], required=["a..b"]) + + +def test_load_schema_shapes(): + req, types, uniq = load_schema( + {"required": ["id"], "types": {"id": "integer", "tags": ["array", "null"]}, "unique": ["id"]} + ) + assert req == ["id"] and uniq == ["id"] + assert types == {"id": ["integer"], "tags": ["array", "null"]} + with pytest.raises(ValueError, match="unknown key"): + load_schema({"require": ["id"]}) + with pytest.raises(ValueError, match="list of paths"): + load_schema({"required": "id"}) + + +def test_cli_valid_exit_zero(): + code, stdout, _ = run_cli( + ["validate", "-r", "id", "--type", "id=integer", "-f", "json", "--minify"], + '[{"id":1},{"id":2}]', + ) + assert code == 0 + assert json.loads(stdout) == {"valid": True, "records": 2, "error_count": 0, "errors": []} + + +def test_cli_invalid_exit_one_and_quiet(): + args = ["validate", "-r", "id", "-u", "id", "-f", "json"] + code, stdout, _ = run_cli(args, '[{"id":1},{"id":1},{}]') + assert code == 1 + assert json.loads(stdout)["error_count"] == 2 + code, stdout, _ = run_cli([*args, "-q"], '[{"id":1},{"id":1}]') + assert code == 1 and stdout == "" + + +def test_cli_csv_coerce_and_errors_as_csv(): + data = "id,age\n1,30\nx,\n" + code, _, _ = run_cli(["validate", "--type", "id=integer", "--coerce", "-f", "csv"], "id\n1\n2\n") + assert code == 0 + code, stdout, _ = run_cli( + ["validate", "--type", "id=integer", "--type", "age=integer|null", "--coerce", + "-f", "csv", "-t", "csv"], + data, + ) + assert code == 1 + assert stdout.splitlines() == [ + "record,path,message", + "1,id,\"expected integer, got string\"", + ] + + +def test_cli_schema_file_merges_with_flags(tmp_path): + schema = tmp_path / "users.schema.yaml" + schema.write_text("required: [id]\ntypes:\n id: integer\n", encoding="utf-8") + data = tmp_path / "users.json" + data.write_text('[{"id":1,"name":"Ada"},{"id":2}]', encoding="utf-8") + code, _, _ = run_cli(["validate", "-s", str(schema), str(data), "-q"]) + assert code == 0 + code, stdout, _ = run_cli(["validate", "-s", str(schema), "-r", "name", str(data), "--minify"]) + assert code == 1 + assert json.loads(stdout)["errors"] == [ + {"record": 1, "path": "name", "message": "missing required value"} + ] + + +def test_cli_usage_errors_exit_two(tmp_path): + code, _, stderr = run_cli(["validate", "-f", "json"], "[]") + assert code == 2 and "nothing to check" in stderr + code, _, stderr = run_cli(["validate", "--type", "id=int", "-f", "json"], "[]") + assert code == 2 and "Unknown type" in stderr + code, _, stderr = run_cli(["validate", "-r", "id", "-f", "json"], "5") + assert code == 2 and "root must be" in stderr + code, _, stderr = run_cli(["validate", "-s", str(tmp_path / "nope.yaml"), "-f", "json"], "[]") + assert code == 2 and "schema file not found" in stderr