diff --git a/README.md b/README.md index dda8db2..f0673e7 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,22 @@ After install, the `data-transform` console script is on your `PATH`. | `select` | Extract dotted paths from parsed data | | `pick` | Keep only listed top-level keys | | `omit` | Drop listed top-level keys | +| `rename` | Rename top-level keys (`OLD NEW` pairs) | +| `flatten` | Flatten nested objects into joined keys | | `filter` | Keep array items matching KEY OP VALUE | | `sort` | Sort array of objects by top-level KEY | | `unique` | Deduplicate array (by KEY or whole item) | +| `head` | First N items of a list | +| `tail` | Last N items of a list | ### Options (most commands) - `[file]` — input path; omit or use `-` for stdin - `-o, --output ` — write to a file instead of stdout - `-f, --from ` — force input format: `json` | `yaml` | `csv` | `ndjson` -- `--minify` — compact JSON output (`to-json`, `select`, `pick`, `omit`, `filter`, `sort`, `unique`) +- `--minify` — compact JSON output (`to-json`, `select`, `pick`, `omit`, `rename`, `flatten`, `filter`, `sort`, `unique`, `head`, `tail`) - `--desc` — reverse sort order (`sort` only) +- `--sep ` — separator between nested keys (`flatten` only, default `.`) Format is taken from `--from`, else the file extension (`.ndjson` / `.jsonl` → ndjson), else simple content heuristics. @@ -96,6 +101,34 @@ echo '{"id":1,"secret":"x","name":"a"}' \ # → {"id":1,"name":"a"} ``` +### Rename and flatten + +`rename` takes pairs of `OLD NEW` and maps over an object or each object in an array, the same way `pick` and `omit` do. Missing old keys are left alone. A later pair wins when the same old key is given twice. Renames are one pass over the original keys: `a→b` together with `b→c` leaves the value of `a` under `b`. + +`flatten` collapses nested objects into top-level keys joined by `--sep` (default `.`). A list root is flattened item by item. Nested lists stay as values. Empty objects stay empty. A number, string, or other non-object root is returned unchanged. + +```bash +# Rename top-level keys (several pairs) +echo '{"id":1,"name":"Ada","note":"x"}' \ + | data-transform rename name title note comment -f json --minify +# → {"id":1,"title":"Ada","comment":"x"} + +# Same mapping over an array; missing keys are a no-op +echo '[{"name":"Ada"},{"id":2},1]' \ + | data-transform rename name title -f json --minify +# → [{"title":"Ada"},{"id":2},1] + +# Flatten nested objects; lists stay as values +echo '{"user":{"name":"Ada","city":"London"},"tags":["a","b"]}' \ + | data-transform flatten -f json --minify +# → {"user.name":"Ada","user.city":"London","tags":["a","b"]} + +# Custom separator +echo '{"user":{"name":"Ada"}}' \ + | data-transform flatten --sep _ -f json --minify +# → {"user_name":"Ada"} +``` + ### Filter, sort, unique These commands require a **list** root (JSON/YAML/CSV/NDJSON array). Non-object @@ -135,6 +168,23 @@ echo '[{"a":1},{"a":1},{"b":2}]' \ # → [{"a":1},{"b":2}] ``` +### Head and tail + +`head` and `tail` require a **list** root, same as `filter`, `sort`, and `unique`. `N` is a non-negative integer. A count past the end returns the items that exist; `0` returns an empty list. + +```bash +echo '[{"id":1},{"id":2},{"id":3}]' \ + | data-transform head 2 -f json --minify +# → [{"id":1},{"id":2}] + +echo '[{"id":1},{"id":2},{"id":3}]' \ + | data-transform tail 1 -f json --minify +# → [{"id":3}] + +echo '[1,2,3]' | data-transform head 0 -f json --minify +# → [] +``` + ### Library use ```python @@ -145,9 +195,13 @@ from data_transform import ( get_path, pick_keys, omit_keys, + rename_keys, + flatten_object, filter_rows, sort_rows, unique_rows, + head_rows, + tail_rows, ) yaml_text = convert('{"a":1}', "json", "yaml") @@ -155,9 +209,13 @@ data = parse("id,name\n1,x\n", "csv") json_text = serialize(data, "json", pretty=True) name = get_path({"user": {"name": "Ada"}}, "user.name") slim = pick_keys({"id": 1, "name": "a", "note": "x"}, ["id", "name"]) +renamed = rename_keys({"id": 1, "name": "a"}, {"name": "title"}) +flat = flatten_object({"user": {"name": "Ada"}, "tags": ["x"]}) adults = filter_rows([{"age": 36}, {"age": 22}], "age", "ge", "30") ordered = sort_rows([{"id": 2}, {"id": 1}], "id") deduped = unique_rows([{"id": 1}, {"id": 1}], "id") +first = head_rows([{"id": 1}, {"id": 2}, {"id": 3}], 2) +last = tail_rows([{"id": 1}, {"id": 2}, {"id": 3}], 1) ``` ## NDJSON notes diff --git a/pyproject.toml b/pyproject.toml index c4b6609..3541611 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "data-transform-cli" -version = "0.3.0" +version = "0.4.0" description = "CLI for YAML ↔ JSON ↔ CSV ↔ NDJSON transforms for pipelines and shell use" readme = "README.md" license = { text = "MIT" } diff --git a/src/data_transform/__init__.py b/src/data_transform/__init__.py index 8c772b7..0814627 100644 --- a/src/data_transform/__init__.py +++ b/src/data_transform/__init__.py @@ -4,10 +4,14 @@ from .csv_io import escape_csv_field, parse_csv, parse_csv_rows, stringify_csv from .shape import ( filter_rows, + flatten_object, get_path, + head_rows, omit_keys, pick_keys, + rename_keys, sort_rows, + tail_rows, unique_rows, ) @@ -24,9 +28,13 @@ "get_path", "pick_keys", "omit_keys", + "rename_keys", + "flatten_object", "filter_rows", "sort_rows", "unique_rows", + "head_rows", + "tail_rows", ] -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/data_transform/cli.py b/src/data_transform/cli.py index 2115e51..534c5f2 100644 --- a/src/data_transform/cli.py +++ b/src/data_transform/cli.py @@ -10,7 +10,18 @@ from . import __version__ from .convert import convert, detect_format, infer_format, parse, serialize -from .shape import filter_rows, get_path, omit_keys, pick_keys, sort_rows, unique_rows +from .shape import ( + filter_rows, + flatten_object, + get_path, + head_rows, + omit_keys, + pick_keys, + rename_keys, + sort_rows, + tail_rows, + unique_rows, +) FORMAT_CHOICES = ["json", "yaml", "csv", "ndjson"] @@ -44,6 +55,25 @@ def write_output(out: str, output_path: Optional[str]) -> None: sys.stdout.write(out) +def _parse_count(token: str, command: str) -> int: + """Parse a non-negative integer count from a CLI token.""" + if token == "" or any(ch < "0" or ch > "9" for ch in token): + raise ValueError( + f"{command}: N must be a non-negative integer, got {token!r}" + ) + return int(token) + + +def _parse_rename_pairs(parts: Sequence[str]) -> dict[str, str]: + """Build an OLD→NEW map from an even list of tokens. Later pairs win.""" + if len(parts) < 2 or len(parts) % 2 != 0: + raise ValueError("Usage: rename OLD NEW [OLD NEW ...] [file]") + mapping: dict[str, str] = {} + for index in range(0, len(parts), 2): + mapping[parts[index]] = parts[index + 1] + return mapping + + def _split_tokens_and_file(tokens: Sequence[str]) -> tuple[list[str], Optional[str]]: """ Split trailing optional file from PATH/KEY tokens. @@ -485,6 +515,178 @@ def unique_cmd( sys.exit(1) +@main.command("rename") +@click.argument("tokens", nargs=-1, required=True) +@click.option("-o", "--output", default=None, help="write to file instead of stdout") +@click.option( + "-f", + "--from", + "from_format", + type=click.Choice(FORMAT_CHOICES, case_sensitive=False), + default=None, + help="input format: json | yaml | csv | ndjson", +) +@click.option("--minify", is_flag=True, default=False, help="emit compact JSON") +def rename_cmd( + tokens: tuple[str, ...], + output: Optional[str], + from_format: Optional[str], + minify: bool, +) -> None: + """Rename top-level keys (OLD NEW pairs) on an object or each object in an array. + + Missing OLD keys are left unchanged. Non-object items in an array are + unchanged. Later pairs win when OLD is repeated. + + Usage: data-transform rename OLD NEW [OLD NEW ...] [file] + """ + try: + parts, input_file = _split_tokens_and_file(tokens) + mapping = _parse_rename_pairs(parts) + + text, name = load_input(input_file) + src = resolve_from(text, name, from_format) + data = parse(text, src) # type: ignore[arg-type] + result = rename_keys(data, mapping) + write_output( + serialize(result, "json", pretty=not minify, minify=minify), output + ) + except Exception as err: # noqa: BLE001 + click.echo(f"error: {err}", err=True) + sys.exit(1) + + +@main.command("flatten") +@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(FORMAT_CHOICES, case_sensitive=False), + default=None, + help="input format: json | yaml | csv | ndjson", +) +@click.option("--minify", is_flag=True, default=False, help="emit compact JSON") +@click.option( + "--sep", + default=".", + show_default=True, + help="separator between nested keys", +) +def flatten_cmd( + file: Optional[str], + output: Optional[str], + from_format: Optional[str], + minify: bool, + sep: str, +) -> None: + """Flatten nested objects into separator-joined top-level keys. + + Maps over a list root. Nested lists stay as values. Empty objects stay + empty. A non-object, non-list root is unchanged. + + Usage: data-transform flatten [file] + """ + try: + text, name = load_input(file) + src = resolve_from(text, name, from_format) + data = parse(text, src) # type: ignore[arg-type] + result = flatten_object(data, sep) + write_output( + serialize(result, "json", pretty=not minify, minify=minify), output + ) + except Exception as err: # noqa: BLE001 + click.echo(f"error: {err}", err=True) + sys.exit(1) + + +def _run_take( + command: str, + tokens: Sequence[str], + output: Optional[str], + from_format: Optional[str], + minify: bool, +) -> None: + """Shared runner for head/tail.""" + try: + parts, input_file = _split_tokens_and_file(tokens) + if len(parts) != 1: + raise ValueError(f"Usage: {command} N [file]") + count = _parse_count(parts[0], command) + + text, name = load_input(input_file) + src = resolve_from(text, name, from_format) + data = parse(text, src) # type: ignore[arg-type] + if command == "head": + result = head_rows(data, count) + else: + result = tail_rows(data, count) + write_output( + serialize(result, "json", pretty=not minify, minify=minify), output + ) + except Exception as err: # noqa: BLE001 + click.echo(f"error: {err}", err=True) + sys.exit(1) + + +# ignore_unknown_options lets a leading "-1" through as N so the count check +# can reject it, instead of Click treating it as a short flag. +@main.command("head", context_settings={"ignore_unknown_options": True}) +@click.argument("tokens", nargs=-1, required=True) +@click.option("-o", "--output", default=None, help="write to file instead of stdout") +@click.option( + "-f", + "--from", + "from_format", + type=click.Choice(FORMAT_CHOICES, case_sensitive=False), + default=None, + help="input format: json | yaml | csv | ndjson", +) +@click.option("--minify", is_flag=True, default=False, help="emit compact JSON") +def head_cmd( + tokens: tuple[str, ...], + output: Optional[str], + from_format: Optional[str], + minify: bool, +) -> None: + """Return the first N items of a list. + + N must be a non-negative integer. Counts past the end return the whole + list. Root must be a list. + + Usage: data-transform head N [file] + """ + _run_take("head", tokens, output, from_format, minify) + + +@main.command("tail", context_settings={"ignore_unknown_options": True}) +@click.argument("tokens", nargs=-1, required=True) +@click.option("-o", "--output", default=None, help="write to file instead of stdout") +@click.option( + "-f", + "--from", + "from_format", + type=click.Choice(FORMAT_CHOICES, case_sensitive=False), + default=None, + help="input format: json | yaml | csv | ndjson", +) +@click.option("--minify", is_flag=True, default=False, help="emit compact JSON") +def tail_cmd( + tokens: tuple[str, ...], + output: Optional[str], + from_format: Optional[str], + minify: bool, +) -> None: + """Return the last N items of a list. + + N must be a non-negative integer. Counts past the end return the whole + list. Root must be a list. + + Usage: data-transform tail N [file] + """ + _run_take("tail", tokens, output, from_format, minify) + if __name__ == "__main__": main() diff --git a/src/data_transform/shape.py b/src/data_transform/shape.py index f60e3e6..25285cf 100644 --- a/src/data_transform/shape.py +++ b/src/data_transform/shape.py @@ -1,4 +1,4 @@ -"""Path extraction and object key shaping helpers.""" +"""Path extraction, key shaping, and list slicing helpers.""" from __future__ import annotations @@ -90,6 +90,71 @@ def _omit_one(item: Any, drop: set[str]) -> Any: return {k: v for k, v in item.items() if k not in drop} +def rename_keys(data: Any, mapping: Mapping[str, str]) -> Any: + """ + Rename top-level keys on an object, or on each object in a list. + + Non-mapping items in a list are left unchanged. A non-mapping, non-list + root is returned unchanged. Keys absent from ``mapping`` are kept (missing + OLD keys are no-ops). Renames apply in one pass over the original keys, so + a key is not renamed again after it is written. When two original keys + produce the same new name, the later key wins. + """ + if not isinstance(mapping, Mapping): + raise ValueError("rename: expected a mapping of old keys to new keys") + pairs = dict(mapping) + if isinstance(data, list): + return [_rename_one(item, pairs) for item in data] + return _rename_one(data, pairs) + + +def _rename_one(item: Any, mapping: Mapping[Any, Any]) -> Any: + if not isinstance(item, Mapping): + return item + renamed: dict[Any, Any] = {} + for key, value in item.items(): + new_key = mapping[key] if key in mapping else key + renamed[new_key] = value + return renamed + + +def flatten_object(data: Any, sep: str = ".") -> Any: + """ + Flatten nested mappings into ``sep``-joined top-level keys. + + A list root is mapped item-wise; non-mapping items are left unchanged. + Nested lists are kept as values and are not expanded into indexed keys. + Empty mappings are left as values. A non-mapping, non-list root is + returned unchanged. An empty separator raises ValueError. When two paths + collide, the later value wins. + """ + if not isinstance(sep, str) or sep == "": + raise ValueError("flatten: separator must be a non-empty string") + if isinstance(data, list): + return [_flatten_one(item, sep) for item in data] + return _flatten_one(data, sep) + + +def _flatten_one(item: Any, sep: str) -> Any: + if not isinstance(item, Mapping): + return item + flat: dict[str, Any] = {} + _flatten_mapping(item, sep, "", flat) + return flat + + +def _flatten_mapping( + mapping: Mapping[Any, Any], sep: str, prefix: str, out: dict[str, Any] +) -> None: + for key, value in mapping.items(): + name = key if isinstance(key, str) else str(key) + path = f"{prefix}{sep}{name}" if prefix else name + if isinstance(value, Mapping) and value: + _flatten_mapping(value, sep, path, out) + else: + out[path] = value + + def _require_list(data: Any, command: str) -> list[Any]: """Raise ValueError unless data is a list; return it typed.""" if not isinstance(data, list): @@ -244,3 +309,37 @@ def unique_rows(data: Any, key: str | None = None) -> list[Any]: seen.add(marker) out.append(item) return out + + +def _require_count(n: Any, command: str) -> int: + """Raise ValueError unless n is a non-negative integer (not bool).""" + if isinstance(n, bool) or not isinstance(n, int) or n < 0: + raise ValueError(f"{command}: N must be a non-negative integer, got {n!r}") + return n + + +def head_rows(data: Any, n: int) -> list[Any]: + """ + Return the first ``n`` items of a list root. + + ``n`` must be a non-negative integer. Counts larger than the list return + the whole list. ``n == 0`` returns an empty list. + """ + rows = _require_list(data, "head") + count = _require_count(n, "head") + return rows[:count] + + +def tail_rows(data: Any, n: int) -> list[Any]: + """ + Return the last ``n`` items of a list root. + + ``n`` must be a non-negative integer. Counts larger than the list return + the whole list. ``n == 0`` returns an empty list. + """ + rows = _require_list(data, "tail") + count = _require_count(n, "tail") + # rows[-0:] returns the whole list, so zero is handled on its own. + if count == 0: + return [] + return rows[-count:] diff --git a/tests/test_cli.py b/tests/test_cli.py index d0df168..b73887f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -193,3 +193,186 @@ def test_unique_by_key_and_whole(): ) assert code == 0, stderr assert json.loads(stdout) == [{"a": 1}, {"b": 2}] + + +def test_rename_pairs_from_stdin(): + code, stdout, stderr = run_cli( + ["rename", "name", "title", "note", "comment", "-f", "json", "--minify"], + '{"id":1,"name":"Ada","note":"x"}', + ) + assert code == 0, stderr + assert json.loads(stdout) == {"id": 1, "title": "Ada", "comment": "x"} + + +def test_rename_array_missing_key_and_file(): + code, stdout, stderr = run_cli( + ["rename", "name", "title", "-f", "json", "--minify"], + '[{"name":"Ada"},{"id":2},1]', + ) + assert code == 0, stderr + assert json.loads(stdout) == [{"title": "Ada"}, {"id": 2}, 1] + + code, stdout, stderr = run_cli( + ["rename", "name", "title", str(FIXTURE / "sample.json"), "--minify"] + ) + assert code == 0, stderr + data = json.loads(stdout) + assert data[0]["title"] == "alpha" + assert "name" not in data[0] + assert data[0]["note"] == "hello, world" + assert data[1]["title"] == "beta" + + +def test_rename_odd_pairs_and_duplicate_old(): + code, stdout, stderr = run_cli( + ["rename", "only", "-f", "json"], + '{"a":1}', + ) + assert code == 1 + assert stdout == "" + assert "error:" in stderr + assert "OLD NEW" in stderr + + code, stdout, stderr = run_cli( + ["rename", "a", "x", "a", "y", "-f", "json", "--minify"], + '{"a":1,"b":2}', + ) + assert code == 0, stderr + assert json.loads(stdout) == {"y": 1, "b": 2} + + +def test_flatten_nested_and_custom_sep(): + code, stdout, stderr = run_cli( + ["flatten", "-f", "json", "--minify"], + '{"user":{"name":"Ada","addr":{"city":"London"}},"tags":["a","b"]}', + ) + assert code == 0, stderr + assert json.loads(stdout) == { + "user.name": "Ada", + "user.addr.city": "London", + "tags": ["a", "b"], + } + + code, stdout, stderr = run_cli( + ["flatten", "--sep", "_", "-f", "json", "--minify"], + '{"user":{"name":"Ada"}}', + ) + assert code == 0, stderr + assert json.loads(stdout) == {"user_name": "Ada"} + + +def test_flatten_list_root_passthrough_and_empty_sep(): + code, stdout, stderr = run_cli( + ["flatten", "-f", "json", "--minify"], + '[{"user":{"name":"Ada"}},2]', + ) + assert code == 0, stderr + assert json.loads(stdout) == [{"user.name": "Ada"}, 2] + + code, stdout, stderr = run_cli(["flatten", "-f", "json", "--minify"], "42") + assert code == 0, stderr + assert json.loads(stdout) == 42 + + code, stdout, stderr = run_cli( + ["flatten", "--sep", "", "-f", "json"], + '{"a":{"b":1}}', + ) + assert code == 1 + assert "error:" in stderr + assert "separator" in stderr + + +def test_head_and_tail_from_stdin_and_file(): + code, stdout, stderr = run_cli( + ["head", "2", "-f", "json", "--minify"], + '[{"id":1},{"id":2},{"id":3}]', + ) + assert code == 0, stderr + assert json.loads(stdout) == [{"id": 1}, {"id": 2}] + + code, stdout, stderr = run_cli( + ["tail", "1", "-f", "json", "--minify"], + '[{"id":1},{"id":2},{"id":3}]', + ) + assert code == 0, stderr + assert json.loads(stdout) == [{"id": 3}] + + code, stdout, stderr = run_cli( + ["head", "1", str(FIXTURE / "sample.json"), "--minify"] + ) + assert code == 0, stderr + data = json.loads(stdout) + assert data == [ + {"id": 1, "name": "alpha", "note": "hello, world"}, + ] + + +def test_head_tail_clamp_zero_and_errors(tmp_path): + code, stdout, stderr = run_cli( + ["head", "0", "-f", "json", "--minify"], + "[1,2,3]", + ) + assert code == 0, stderr + assert json.loads(stdout) == [] + + code, stdout, stderr = run_cli( + ["tail", "0", "-f", "json", "--minify"], + "[1,2,3]", + ) + assert code == 0, stderr + assert json.loads(stdout) == [] + + code, stdout, stderr = run_cli( + ["head", "9", "-f", "json", "--minify"], + "[1,2]", + ) + assert code == 0, stderr + assert json.loads(stdout) == [1, 2] + + code, stdout, stderr = run_cli( + ["tail", "9", "-f", "json", "--minify"], + "[1,2]", + ) + assert code == 0, stderr + assert json.loads(stdout) == [1, 2] + + dest = tmp_path / "out.json" + code, stdout, stderr = run_cli( + ["head", "1", "-f", "json", "--minify", "-o", str(dest)], + "[1,2,3]", + ) + assert code == 0, stderr + assert stdout == "" + assert json.loads(dest.read_text(encoding="utf-8")) == [1] + + code, stdout, stderr = run_cli( + ["head", "1", "-f", "json"], + '{"a":1}', + ) + assert code == 1 + assert "error:" in stderr + assert "list" in stderr.lower() + + code, stdout, stderr = run_cli( + ["tail", "nope", "-f", "json"], + "[1,2,3]", + ) + assert code == 1 + assert "error:" in stderr + assert "non-negative" in stderr + + code, stdout, stderr = run_cli( + ["head", "-1", "-f", "json"], + "[1,2,3]", + ) + assert code == 1 + assert "error:" in stderr + assert "non-negative" in stderr + + code, stdout, stderr = run_cli( + ["head", "1", "2", "-f", "json"], + "[1,2,3]", + ) + assert code == 1 + assert "error:" in stderr + assert "Usage:" in stderr diff --git a/tests/test_shape.py b/tests/test_shape.py index 0192463..8333c19 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -1,11 +1,16 @@ import pytest +import data_transform from data_transform.shape import ( filter_rows, + flatten_object, get_path, + head_rows, omit_keys, pick_keys, + rename_keys, sort_rows, + tail_rows, unique_rows, ) @@ -155,3 +160,124 @@ def test_unique_whole_and_by_key(): def test_unique_requires_list(): with pytest.raises(ValueError, match="root must be a list"): unique_rows({"id": 1}) + + +def test_public_exports_include_reshape_helpers(): + assert data_transform.__version__ == "0.4.0" + for name in ("rename_keys", "flatten_object", "head_rows", "tail_rows"): + assert getattr(data_transform, name) is not None + + +def test_rename_keys_object_preserves_order_and_values(): + inner = {"x": 1} + data = {"id": 1, "name": "Ada", "note": inner} + result = rename_keys(data, {"name": "title", "note": "comment"}) + assert list(result) == ["id", "title", "comment"] + assert result == {"id": 1, "title": "Ada", "comment": inner} + assert result["comment"] is inner + + +def test_rename_keys_array_and_missing_are_noop(): + data = [{"name": "Ada"}, {"id": 2}, 1, "keep"] + assert rename_keys(data, {"name": "title", "missing": "gone"}) == [ + {"title": "Ada"}, + {"id": 2}, + 1, + "keep", + ] + assert rename_keys(42, {"a": "b"}) == 42 + assert rename_keys("hi", {"a": "b"}) == "hi" + + +def test_rename_keys_one_pass_swap_and_collision(): + assert rename_keys({"a": 1, "b": 2}, {"a": "b", "b": "c"}) == {"b": 1, "c": 2} + assert rename_keys({"a": 1, "b": 2}, {"a": "b", "b": "a"}) == {"b": 1, "a": 2} + # Later original key wins when several keys share a destination. + assert rename_keys({"a": 1, "b": 2, "c": 3}, {"a": "c", "b": "c"}) == {"c": 3} + collided = rename_keys({"a": 1, "b": 2, "c": 3}, {"a": "z", "c": "z"}) + assert collided == {"z": 3, "b": 2} + + +def test_rename_keys_requires_mapping(): + with pytest.raises(ValueError, match="mapping"): + rename_keys({"a": 1}, [("a", "b")]) # type: ignore[arg-type] + + +def test_flatten_nested_object_and_keeps_lists(): + tags = [{"id": 1}, {"id": 2}] + data = { + "user": {"name": "Ada", "addr": {"city": "London"}}, + "tags": tags, + "ok": True, + } + result = flatten_object(data) + assert result == { + "user.name": "Ada", + "user.addr.city": "London", + "tags": tags, + "ok": True, + } + assert result["tags"] is tags + + +def test_flatten_list_root_custom_sep_and_passthrough(): + data = [{"user": {"name": "Ada"}}, 2, "x", {"tags": [1, 2]}] + assert flatten_object(data, sep="_") == [ + {"user_name": "Ada"}, + 2, + "x", + {"tags": [1, 2]}, + ] + assert flatten_object(42) == 42 + assert flatten_object("hi") == "hi" + assert flatten_object(None) is None + assert flatten_object({}) == {} + assert flatten_object([]) == [] + + +def test_flatten_empty_nested_mapping_stays_and_collision_later_wins(): + assert flatten_object({"name": "Ada", "meta": {}}) == { + "name": "Ada", + "meta": {}, + } + assert flatten_object({"a.b": 1, "a": {"b": 2}}) == {"a.b": 2} + assert flatten_object({"a": {"b": 2}, "a.b": 1}) == {"a.b": 1} + assert flatten_object({1: {"a": 2}}) == {"1.a": 2} + + +def test_flatten_rejects_empty_separator(): + with pytest.raises(ValueError, match="separator"): + flatten_object({"a": {"b": 1}}, sep="") + with pytest.raises(ValueError, match="separator"): + flatten_object({"a": 1}, sep=None) # type: ignore[arg-type] + + +def test_head_and_tail_rows(): + data = [{"id": 1}, {"id": 2}, {"id": 3}] + assert head_rows(data, 2) == [{"id": 1}, {"id": 2}] + assert tail_rows(data, 1) == [{"id": 3}] + assert head_rows(data, 0) == [] + assert tail_rows(data, 0) == [] + assert head_rows(data, 10) == data + assert tail_rows(data, 10) == data + assert head_rows(data, 3) == data + assert tail_rows(data, 3) == data + assert head_rows([], 2) == [] + assert tail_rows([], 2) == [] + # Slices are copies. + assert head_rows(data, 2) is not data + assert tail_rows(data, 2) is not data + + +def test_head_and_tail_reject_bad_count_and_non_list(): + for fn, label in ((head_rows, "head"), (tail_rows, "tail")): + with pytest.raises(ValueError, match="root must be a list"): + fn({"id": 1}, 1) + with pytest.raises(ValueError, match="non-negative integer"): + fn([1, 2], -1) + with pytest.raises(ValueError, match="non-negative integer"): + fn([1, 2], True) # type: ignore[arg-type] + with pytest.raises(ValueError, match="non-negative integer"): + fn([1, 2], 1.5) # type: ignore[arg-type] + with pytest.raises(ValueError, match=label): + fn("abc", 1)