Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,23 @@ After install, the `data-transform` console script is on your `PATH`.
| `omit` | Drop listed top-level keys |
| `rename` | Rename top-level keys (`OLD NEW` pairs) |
| `flatten` | Flatten nested objects into joined keys |
| `unflatten` | Expand joined keys back into nested objects |
| `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 |
| `group-by` | Group array of objects by top-level KEY |
| `count` | Count items, per KEY value, or per group |

### Options (most commands)

- `[file]` — input path; omit or use `-` for stdin
- `-o, --output <file>` — write to a file instead of stdout
- `-f, --from <format>` — force input format: `json` | `yaml` | `csv` | `ndjson`
- `--minify` — compact JSON output (`to-json`, `select`, `pick`, `omit`, `rename`, `flatten`, `filter`, `sort`, `unique`, `head`, `tail`)
- `--minify` — compact JSON output (`to-json`, `select`, `pick`, `omit`, `rename`, `flatten`, `unflatten`, `filter`, `sort`, `unique`, `head`, `tail`, `group-by`, `count`)
- `--desc` — reverse sort order (`sort` only)
- `--sep <text>` — separator between nested keys (`flatten` only, default `.`)
- `--sep <text>` — separator between nested keys (`flatten` and `unflatten`, default `.`)

Format is taken from `--from`, else the file extension (`.ndjson` / `.jsonl` → ndjson), else simple content heuristics.

Expand Down Expand Up @@ -129,6 +132,23 @@ echo '{"user":{"name":"Ada"}}' \
# → {"user_name":"Ada"}
```

### Unflatten

`unflatten` is the inverse of `flatten`: it splits top-level keys on `--sep` (default `.`) and rebuilds nested objects. Like `flatten`, it maps over a list root and returns non-object roots and items unchanged. It does not rebuild lists, so a segment like `0` stays an object key. Keys are split literally, so an empty segment (`a..b`) becomes an empty key. If an object value and joined keys write to the same branch, they are merged. If a path runs into a non-object value, the later key wins. `unflatten` undoes `flatten` exactly as long as the original keys don't contain the separator.

```bash
echo '{"user.name":"Ada","user.addr.city":"London","tags":["a","b"]}' \
| data-transform unflatten -f json --minify
# → {"user":{"name":"Ada","addr":{"city":"London"}},"tags":["a","b"]}

echo '[{"user_name":"Ada"},{"user_name":"Bob"}]' \
| data-transform unflatten --sep _ -f json --minify
# → [{"user":{"name":"Ada"}},{"user":{"name":"Bob"}}]

# Round trip
data-transform flatten nested.json | data-transform unflatten
```

### Filter, sort, unique

These commands require a **list** root (JSON/YAML/CSV/NDJSON array). Non-object
Expand Down Expand Up @@ -185,6 +205,38 @@ echo '[1,2,3]' | data-transform head 0 -f json --minify
# → []
```

### Group-by and count

`group-by KEY` requires a **list** root. It returns an object that maps each value of top-level `KEY` to the list of items that have it. Groups appear in the order they are first seen, and items keep their input order. Group labels are the value itself for strings; any other value becomes compact JSON text (`1` → `"1"`, `true` → `"true"`, `null` → `"null"`). This means `1` and `"1"` end up in the same group, which suits CSV input where every value is a string. Non-object items and objects without `KEY` are dropped.

`count` counts records:

- `count` on a list prints its length as a number.
- `count KEY` on a list prints an object of per-value counts, using the same labels and dropping rules as `group-by`.
- `count` on the output of `group-by` (an object whose values are all lists) prints the size of each group.

Any other root is an error.

```bash
echo '[{"team":"a","id":1},{"team":"b","id":2},{"team":"a","id":3},{"id":4}]' \
| data-transform group-by team -f json --minify
# → {"a":[{"team":"a","id":1},{"team":"a","id":3}],"b":[{"team":"b","id":2}]}

echo '[{"id":1},{"id":2},{"id":3}]' | data-transform count -f json
# → 3

echo '[{"team":"a"},{"team":"b"},{"team":"a"}]' \
| data-transform count team -f json --minify
# → {"a":2,"b":1}

# Count per group from group-by output
data-transform group-by team people.json | data-transform count --minify
# → {"a":2,"b":1}

# Works with NDJSON / CSV too
data-transform count status events.ndjson
```

### Library use

```python
Expand All @@ -197,11 +249,14 @@ from data_transform import (
omit_keys,
rename_keys,
flatten_object,
unflatten_object,
filter_rows,
sort_rows,
unique_rows,
head_rows,
tail_rows,
group_rows,
count_rows,
)

yaml_text = convert('{"a":1}', "json", "yaml")
Expand All @@ -211,11 +266,15 @@ 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"]})
nested = unflatten_object({"user.name": "Ada"})
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)
by_team = group_rows([{"team": "a"}, {"team": "b"}], "team")
total = count_rows([{"id": 1}, {"id": 2}])
per_team = count_rows([{"team": "a"}, {"team": "a"}], "team")
```

## NDJSON notes
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "data-transform-cli"
version = "0.4.0"
version = "0.5.0"
description = "CLI for YAML ↔ JSON ↔ CSV ↔ NDJSON transforms for pipelines and shell use"
readme = "README.md"
license = { text = "MIT" }
Expand Down
8 changes: 7 additions & 1 deletion src/data_transform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
from .convert import convert, detect_format, infer_format, parse, serialize
from .csv_io import escape_csv_field, parse_csv, parse_csv_rows, stringify_csv
from .shape import (
count_rows,
filter_rows,
flatten_object,
get_path,
group_rows,
head_rows,
omit_keys,
pick_keys,
rename_keys,
sort_rows,
tail_rows,
unflatten_object,
unique_rows,
)

Expand All @@ -30,11 +33,14 @@
"omit_keys",
"rename_keys",
"flatten_object",
"unflatten_object",
"filter_rows",
"sort_rows",
"unique_rows",
"head_rows",
"tail_rows",
"group_rows",
"count_rows",
]

__version__ = "0.4.0"
__version__ = "0.5.0"
138 changes: 138 additions & 0 deletions src/data_transform/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@
from . import __version__
from .convert import convert, detect_format, infer_format, parse, serialize
from .shape import (
count_rows,
filter_rows,
flatten_object,
get_path,
group_rows,
head_rows,
omit_keys,
pick_keys,
rename_keys,
sort_rows,
tail_rows,
unflatten_object,
unique_rows,
)

Expand Down Expand Up @@ -601,6 +604,51 @@ def flatten_cmd(
sys.exit(1)


@main.command("unflatten")
@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 unflatten_cmd(
file: Optional[str],
output: Optional[str],
from_format: Optional[str],
minify: bool,
sep: str,
) -> None:
"""Expand separator-joined top-level keys into nested objects.

The inverse of flatten. Maps over a list root. Lists are not rebuilt
from numeric segments. A non-object, non-list root is unchanged.

Usage: data-transform unflatten [file]
"""
try:
text, name = load_input(file)
src = resolve_from(text, name, from_format)
data = parse(text, src) # type: ignore[arg-type]
result = unflatten_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],
Expand Down Expand Up @@ -688,5 +736,95 @@ def tail_cmd(
_run_take("tail", tokens, output, from_format, minify)


@main.command("group-by")
@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 group_by_cmd(
tokens: tuple[str, ...],
output: Optional[str],
from_format: Optional[str],
minify: bool,
) -> None:
"""Group an array of objects by top-level KEY.

Emits an object of KEY value → list of items, in first-seen order.
Non-string values become JSON text labels. Non-object items and items
missing KEY are dropped. Root must be a list.

Usage: data-transform group-by KEY [file]
"""
try:
parts, input_file = _split_tokens_and_file(tokens)
if len(parts) != 1:
raise ValueError("Usage: group-by KEY [file]")
key = parts[0]

text, name = load_input(input_file)
src = resolve_from(text, name, from_format)
data = parse(text, src) # type: ignore[arg-type]
result = group_rows(data, key)
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("count")
@click.argument("tokens", nargs=-1, 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")
def count_cmd(
tokens: tuple[str, ...],
output: Optional[str],
from_format: Optional[str],
minify: bool,
) -> None:
"""Count records in a list, per KEY value, or per group.

Without KEY, a list root prints its length and group-by output prints
the size of each group. With KEY, prints an object of KEY value → count
(same labels and dropping rules as group-by).

Usage: data-transform count [KEY] [file]
"""
try:
parts, input_file = _split_tokens_and_file(tokens)
key: Optional[str] = None
if len(parts) == 1:
key = parts[0]
elif len(parts) > 1:
raise ValueError("Usage: count [KEY] [file]")

text, name = load_input(input_file)
src = resolve_from(text, name, from_format)
data = parse(text, src) # type: ignore[arg-type]
result = count_rows(data, key)
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)


if __name__ == "__main__":
main()
Loading
Loading