gcf-python 2.1.1__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {gcf_python-2.1.1 → gcf_python-2.2.1}/CHANGELOG.md +24 -0
  2. {gcf_python-2.1.1 → gcf_python-2.2.1}/PKG-INFO +8 -8
  3. {gcf_python-2.1.1 → gcf_python-2.2.1}/README.md +6 -6
  4. {gcf_python-2.1.1 → gcf_python-2.2.1}/pyproject.toml +2 -2
  5. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/__init__.py +2 -1
  6. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/decode_generic.py +91 -9
  7. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/generic.py +224 -34
  8. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_conformance_v2.py +2 -2
  9. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_generic.py +56 -10
  10. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_roundtrip_v2.py +24 -22
  11. {gcf_python-2.1.1 → gcf_python-2.2.1}/.github/FUNDING.yml +0 -0
  12. {gcf_python-2.1.1 → gcf_python-2.2.1}/.github/workflows/ci.yml +0 -0
  13. {gcf_python-2.1.1 → gcf_python-2.2.1}/.github/workflows/publish.yml +0 -0
  14. {gcf_python-2.1.1 → gcf_python-2.2.1}/.gitignore +0 -0
  15. {gcf_python-2.1.1 → gcf_python-2.2.1}/LICENSE +0 -0
  16. {gcf_python-2.1.1 → gcf_python-2.2.1}/assets/gcf-python-diagram.png +0 -0
  17. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/__main__.py +0 -0
  18. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/cli.py +0 -0
  19. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/constants.py +0 -0
  20. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/decode.py +0 -0
  21. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/delta.py +0 -0
  22. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/encode.py +0 -0
  23. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/scalar.py +0 -0
  24. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/session.py +0 -0
  25. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/stream.py +0 -0
  26. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/stream_generic.py +0 -0
  27. {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/types.py +0 -0
  28. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/__init__.py +0 -0
  29. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_decode.py +0 -0
  30. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_delta.py +0 -0
  31. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_encode.py +0 -0
  32. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_roundtrip.py +0 -0
  33. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_session.py +0 -0
  34. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_stream.py +0 -0
  35. {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_stream_generic.py +0 -0
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.2.1 (2026-06-23)
4
+
5
+ ### Flatten Opt-Out
6
+
7
+ - Added `GenericOptions` dataclass with `no_flatten` field to disable nested object flattening
8
+ - `encode_generic(data, GenericOptions(no_flatten=True))` produces attachment syntax instead of path columns
9
+ - Backward compatible: `encode_generic(data)` behavior unchanged (flatten on by default)
10
+ - Fixed: field names containing `>` no longer appear as tabular columns (spec rule 7.4.6.1.4)
11
+ - Fixed: field names containing `>` no longer eligible for flattening analysis
12
+ - Fixed: decoder no longer treats literal `>` in key names as a path separator
13
+ - Fixed: decoder accepts orphan attachments (fields excluded from column list)
14
+ - 12 targeted edge case tests for `>` in field names
15
+
16
+ ## v2.2.0 (2026-06-22)
17
+
18
+ ### Spec v3.2: Nested Object Flattening
19
+
20
+ - Encoder automatically flattens fixed-shape nested objects into `>` path column names (e.g., `"customer>name"` instead of `^` + `.customer {}` attachment)
21
+ - Decoder reconstructs nested objects from `>` path columns
22
+ - 20-48% fewer tokens on deeply nested API data (Jira, Stripe, K8s, calendar events)
23
+ - 100% comprehension on every frontier model (validated across 9 models, 7 providers)
24
+ - Zero regression on lossless round-trips (230 tests, conformance + property-based)
25
+ - Falls back to attachment mechanism for: variable-length arrays, objects with different keys across rows, objects with `>` in key names, empty nested objects
26
+
3
27
  ## v2.1.0 (2026-06-14)
4
28
 
5
29
  ### Spec v3.1
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gcf-python
3
- Version: 2.1.1
4
- Summary: Drop-in JSON replacement for AI pipelines. 79% fewer tokens. 90.7% comprehension across 10 models. Zero dependencies.
3
+ Version: 2.2.1
4
+ Summary: The AI-native wire format for structured data. 50-92% fewer tokens than JSON. 100% comprehension on every frontier model. Zero dependencies.
5
5
  Project-URL: Homepage, https://github.com/blackwell-systems/gcf-python
6
6
  Project-URL: Documentation, https://blackwell-systems.github.io/gcf/
7
7
  Project-URL: Specification, https://github.com/blackwell-systems/gcf
@@ -32,7 +32,7 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  Python implementation of [GCF](https://gcformat.com/) — the most token-efficient wire format for LLMs. A drop-in alternative to JSON and TOON for any structured data.
34
34
 
35
- **100% comprehension on every frontier model tested. 25.5% fewer tokens than TOON, 53% fewer than JSON across 15 datasets. 90.7% on structurally complex code graphs (vs TOON 68.5%, JSON 53.6%). 1,700+ LLM evaluations. Zero training.**
35
+ **100% comprehension on every frontier model tested. 29% fewer tokens than TOON, 56% fewer than JSON across 16 datasets. 91.2% on structurally complex code graphs (vs TOON 68.2%, JSON 53.4%). 2,400+ LLM evaluations. Zero training.**
36
36
 
37
37
  Docs: [gcformat.com](https://gcformat.com/) · [Playground](https://gcformat.com/playground.html) · [GCF vs TOON](https://gcformat.com/guide/vs-toon.html)
38
38
 
@@ -206,16 +206,16 @@ Works on dicts, lists, and primitives. Lists of uniform dicts get tabular rows.
206
206
 
207
207
  ## Benchmarks
208
208
 
209
- 1,700+ LLM evaluations across 10 models, 3 providers, and 51 independent test runs.
209
+ 2,400+ LLM evaluations across 10 models, 3 providers, and 51 independent test runs.
210
210
 
211
211
  | | GCF | TOON | JSON |
212
212
  |---|---|---|---|
213
- | **Comprehension** (23 runs, 10 models) | **90.7%** | 68.5% | 53.6% |
213
+ | **Comprehension** (23 runs, 10 models) | **91.2%** | 68.2% | 53.4% |
214
214
  | **Generation** (28 runs, 9 models) | **5/5** | 1.0/5 | 5.0/5 |
215
215
  | **Input tokens** (500 symbols) | **11,090** | 16,378 | 53,341 |
216
216
  | **Output tokens** (100 symbols) | **5,976** | 8,937 | 16,121 |
217
217
 
218
- GCF wins 13/15 datasets on the expanded [token efficiency benchmark](https://github.com/blackwell-systems/toon/tree/gcf-comparison). Full results: [gcformat.com/guide/benchmarks](https://gcformat.com/guide/benchmarks.html)
218
+ GCF wins 15/16 datasets on the expanded [token efficiency benchmark](https://github.com/blackwell-systems/toon/tree/gcf-comparison). Full results: [gcformat.com/guide/benchmarks](https://gcformat.com/guide/benchmarks.html)
219
219
 
220
220
  ## Implementations
221
221
 
@@ -234,9 +234,9 @@ GCF wins 13/15 datasets on the expanded [token efficiency benchmark](https://git
234
234
  | n8n | `npm install n8n-nodes-gcf` | [gcf-n8n-nodes](https://github.com/blackwell-systems/gcf-n8n-nodes) (workflow encode/decode) |
235
235
  | Tree-sitter | `npm install tree-sitter-gcf` | [tree-sitter-gcf](https://github.com/blackwell-systems/tree-sitter-gcf) |
236
236
 
237
- Zero runtime dependencies. MIT licensed. All implementations support both generic profile (`encodeGeneric`) and graph profile (`encode`). CLI included in all 6 languages.
237
+ **Zero runtime dependencies. Permanently.** All six implementations depend only on their language's standard library. No transitive dependencies. No supply chain risk. This is a permanent commitment: GCF will never take on external runtime dependencies. MIT licensed. All implementations support both generic profile (`encodeGeneric`) and graph profile (`encode`). CLI included in all 6 languages.
238
238
 
239
- **Specification:** [SPEC v3.1 Stable](https://github.com/blackwell-systems/gcf/blob/main/SPEC.md) with 157 conformance fixtures, 33,000,000,000+ lossless round-trips verified across 5 formats and 6 languages. All implementations at v2.1.0+ (Go v1.2.0). Cross-language 6x6 matrix verified.
239
+ **Specification:** [SPEC v3.2 Stable](https://github.com/blackwell-systems/gcf/blob/main/SPEC.md) with 173 conformance fixtures, 43,000,000,000+ lossless round-trips verified across 5 formats and 6 languages. All implementations at v2.2.0+ (Go v1.3.0). Cross-language 6x6 matrix verified.
240
240
 
241
241
  ## License
242
242
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  Python implementation of [GCF](https://gcformat.com/) — the most token-efficient wire format for LLMs. A drop-in alternative to JSON and TOON for any structured data.
9
9
 
10
- **100% comprehension on every frontier model tested. 25.5% fewer tokens than TOON, 53% fewer than JSON across 15 datasets. 90.7% on structurally complex code graphs (vs TOON 68.5%, JSON 53.6%). 1,700+ LLM evaluations. Zero training.**
10
+ **100% comprehension on every frontier model tested. 29% fewer tokens than TOON, 56% fewer than JSON across 16 datasets. 91.2% on structurally complex code graphs (vs TOON 68.2%, JSON 53.4%). 2,400+ LLM evaluations. Zero training.**
11
11
 
12
12
  Docs: [gcformat.com](https://gcformat.com/) · [Playground](https://gcformat.com/playground.html) · [GCF vs TOON](https://gcformat.com/guide/vs-toon.html)
13
13
 
@@ -181,16 +181,16 @@ Works on dicts, lists, and primitives. Lists of uniform dicts get tabular rows.
181
181
 
182
182
  ## Benchmarks
183
183
 
184
- 1,700+ LLM evaluations across 10 models, 3 providers, and 51 independent test runs.
184
+ 2,400+ LLM evaluations across 10 models, 3 providers, and 51 independent test runs.
185
185
 
186
186
  | | GCF | TOON | JSON |
187
187
  |---|---|---|---|
188
- | **Comprehension** (23 runs, 10 models) | **90.7%** | 68.5% | 53.6% |
188
+ | **Comprehension** (23 runs, 10 models) | **91.2%** | 68.2% | 53.4% |
189
189
  | **Generation** (28 runs, 9 models) | **5/5** | 1.0/5 | 5.0/5 |
190
190
  | **Input tokens** (500 symbols) | **11,090** | 16,378 | 53,341 |
191
191
  | **Output tokens** (100 symbols) | **5,976** | 8,937 | 16,121 |
192
192
 
193
- GCF wins 13/15 datasets on the expanded [token efficiency benchmark](https://github.com/blackwell-systems/toon/tree/gcf-comparison). Full results: [gcformat.com/guide/benchmarks](https://gcformat.com/guide/benchmarks.html)
193
+ GCF wins 15/16 datasets on the expanded [token efficiency benchmark](https://github.com/blackwell-systems/toon/tree/gcf-comparison). Full results: [gcformat.com/guide/benchmarks](https://gcformat.com/guide/benchmarks.html)
194
194
 
195
195
  ## Implementations
196
196
 
@@ -209,9 +209,9 @@ GCF wins 13/15 datasets on the expanded [token efficiency benchmark](https://git
209
209
  | n8n | `npm install n8n-nodes-gcf` | [gcf-n8n-nodes](https://github.com/blackwell-systems/gcf-n8n-nodes) (workflow encode/decode) |
210
210
  | Tree-sitter | `npm install tree-sitter-gcf` | [tree-sitter-gcf](https://github.com/blackwell-systems/tree-sitter-gcf) |
211
211
 
212
- Zero runtime dependencies. MIT licensed. All implementations support both generic profile (`encodeGeneric`) and graph profile (`encode`). CLI included in all 6 languages.
212
+ **Zero runtime dependencies. Permanently.** All six implementations depend only on their language's standard library. No transitive dependencies. No supply chain risk. This is a permanent commitment: GCF will never take on external runtime dependencies. MIT licensed. All implementations support both generic profile (`encodeGeneric`) and graph profile (`encode`). CLI included in all 6 languages.
213
213
 
214
- **Specification:** [SPEC v3.1 Stable](https://github.com/blackwell-systems/gcf/blob/main/SPEC.md) with 157 conformance fixtures, 33,000,000,000+ lossless round-trips verified across 5 formats and 6 languages. All implementations at v2.1.0+ (Go v1.2.0). Cross-language 6x6 matrix verified.
214
+ **Specification:** [SPEC v3.2 Stable](https://github.com/blackwell-systems/gcf/blob/main/SPEC.md) with 173 conformance fixtures, 43,000,000,000+ lossless round-trips verified across 5 formats and 6 languages. All implementations at v2.2.0+ (Go v1.3.0). Cross-language 6x6 matrix verified.
215
215
 
216
216
  ## License
217
217
 
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gcf-python"
7
- version = "2.1.1"
8
- description = "Drop-in JSON replacement for AI pipelines. 79% fewer tokens. 90.7% comprehension across 10 models. Zero dependencies."
7
+ version = "2.2.1"
8
+ description = "The AI-native wire format for structured data. 50-92% fewer tokens than JSON. 100% comprehension on every frontier model. Zero dependencies."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  requires-python = ">=3.9"
@@ -38,7 +38,7 @@ from .constants import KIND_ABBREV, KIND_EXPAND
38
38
  from .decode import DecodeError, decode
39
39
  from .delta import encode_delta
40
40
  from .encode import encode
41
- from .generic import encode_generic
41
+ from .generic import encode_generic, GenericOptions
42
42
  from .session import Session, encode_with_session
43
43
  from .decode_generic import decode_generic
44
44
  from .stream import StreamEncoder
@@ -62,6 +62,7 @@ __all__ = [
62
62
  "encode",
63
63
  "encode_delta",
64
64
  "encode_generic",
65
+ "GenericOptions",
65
66
  "encode_with_session",
66
67
  ]
67
68
 
@@ -360,9 +360,66 @@ def _parse_attachment(
360
360
  arr, consumed = _parse_array_from_header(lines, line_idx, depth, after_name)
361
361
  return name, arr, consumed, None
362
362
 
363
+ # Scalar: =value (field names containing ">" excluded from tabular columns).
364
+ if after_name.startswith("="):
365
+ val_str = after_name[1:]
366
+ parsed = parse_scalar(val_str, tabular_context=True)
367
+ if parsed is MISSING:
368
+ return name, None, 1, None
369
+ return name, parsed, 1, None
370
+
363
371
  raise ValueError(f"invalid attachment form: {after_name}")
364
372
 
365
373
 
374
+ def _unflatten_paths(
375
+ path_columns: dict[str, list[str]],
376
+ flat_values: dict[str, Any],
377
+ flat_absent: set[str],
378
+ ) -> dict[str, Any]:
379
+ """Reconstruct nested objects from flat > path columns."""
380
+ groups: dict[str, list[str]] = {}
381
+ group_order: list[str] = []
382
+ for field_name, paths in path_columns.items():
383
+ if not paths:
384
+ continue
385
+ top = paths[0]
386
+ if top not in groups:
387
+ groups[top] = []
388
+ group_order.append(top)
389
+ groups[top].append(field_name)
390
+
391
+ result: dict[str, Any] = {}
392
+
393
+ for top in group_order:
394
+ field_names = groups[top]
395
+ all_absent = all(f in flat_absent for f in field_names)
396
+ all_null = all(
397
+ (f not in flat_absent and flat_values.get(f) is None)
398
+ for f in field_names
399
+ )
400
+
401
+ if all_absent:
402
+ continue
403
+ if all_null:
404
+ result[top] = None
405
+ continue
406
+
407
+ for field_name in field_names:
408
+ if field_name in flat_absent:
409
+ continue
410
+ paths = path_columns[field_name]
411
+ val = flat_values.get(field_name)
412
+
413
+ current = result
414
+ for k in paths[:-1]:
415
+ if k not in current:
416
+ current[k] = {}
417
+ current = current[k]
418
+ current[paths[-1]] = val
419
+
420
+ return result
421
+
422
+
366
423
  def _parse_tabular_body(
367
424
  lines: list[str], start: int, depth: int, fields: list[str], expected_count: int
368
425
  ) -> tuple[list[Any], int]:
@@ -370,6 +427,16 @@ def _parse_tabular_body(
370
427
  rows: list[Any] = []
371
428
  i = start
372
429
 
430
+ # Detect path columns: fields containing ">".
431
+ path_column_map: dict[str, list[str]] = {}
432
+ for f in fields:
433
+ if ">" in f:
434
+ parts = f.split(">")
435
+ # Only treat as a path column if all segments are non-empty.
436
+ # A literal key like ">" would split into ["", ""].
437
+ if all(p for p in parts):
438
+ path_column_map[f] = parts
439
+
373
440
  # Track inline schemas and shared array schemas.
374
441
  inline_schemas: dict[str, list[str]] = {}
375
442
  shared_array_schemas: dict[str, list[str]] = {}
@@ -409,9 +476,22 @@ def _parse_tabular_body(
409
476
  inline_att_order: list[str] = []
410
477
  missing_fields: set[str] = set()
411
478
 
479
+ # Collect path column values for unflattening.
480
+ flat_values: dict[str, Any] = {}
481
+ flat_absent: set[str] = set()
482
+
412
483
  for j, f in enumerate(fields):
413
484
  cell_val = vals[j]
414
485
 
486
+ # Path columns: store values for later unflattening.
487
+ if f in path_column_map:
488
+ parsed = parse_scalar(cell_val, tabular_context=True)
489
+ if parsed is MISSING:
490
+ flat_absent.add(f)
491
+ else:
492
+ flat_values[f] = parsed
493
+ continue
494
+
415
495
  # Check for ^{fields} inline schema declaration.
416
496
  if cell_val.startswith("^{") and cell_val.endswith("}"):
417
497
  schema_str = cell_val[1:]
@@ -438,10 +518,10 @@ def _parse_tabular_body(
438
518
  all_att_fields = traditional_att_fields + inline_att_fields
439
519
  attachment_values: dict[str, Any] = {}
440
520
 
441
- if row_has_id and all_att_fields:
521
+ if row_has_id:
442
522
  inline_idx = 0
443
523
 
444
- while i < len(lines) and len(attachment_values) < len(all_att_fields):
524
+ while i < len(lines):
445
525
  a_line = lines[i]
446
526
  a_content: str | None = None
447
527
  if depth == 0 or a_line.startswith(ind):
@@ -533,13 +613,6 @@ def _parse_tabular_body(
533
613
  if extra_name in attachment_values:
534
614
  raise ValueError(f"duplicate_attachment: {extra_name}")
535
615
 
536
- if not row_has_id or not all_att_fields:
537
- att_indent = ind + " "
538
- if i < len(lines) and lines[i].startswith(att_indent):
539
- peek = lines[i][len(att_indent):]
540
- if peek.startswith("."):
541
- raise ValueError(f"orphan_attachment: {peek}")
542
-
543
616
  row: dict[str, Any] = {}
544
617
  for f in fields:
545
618
  if f in missing_fields:
@@ -548,6 +621,15 @@ def _parse_tabular_body(
548
621
  row[f] = cell_values[f]
549
622
  elif f in attachment_values:
550
623
  row[f] = attachment_values[f]
624
+ # Also add any orphan attachment values (fields excluded from column list, e.g. ">" fields).
625
+ for k, v in attachment_values.items():
626
+ if k not in row:
627
+ row[k] = v
628
+ # Unflatten path columns into nested objects.
629
+ if path_column_map:
630
+ nested = _unflatten_paths(path_column_map, flat_values, flat_absent)
631
+ row.update(nested)
632
+
551
633
  rows.append(row)
552
634
 
553
635
  if expected_count >= 0 and len(rows) >= expected_count:
@@ -2,42 +2,55 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from dataclasses import dataclass
5
6
  from typing import Any
6
7
 
7
8
  from .scalar import format_scalar, format_key
8
9
 
9
10
 
10
- def encode_generic(data: Any) -> str:
11
+ @dataclass
12
+ class GenericOptions:
13
+ """Options for controlling generic encoding behavior."""
14
+ no_flatten: bool = False
15
+ """When True, disables promotion of fixed-shape nested objects to path
16
+ columns (e.g. "customer>name"). Nested objects use attachment syntax
17
+ instead. Set when targeting open-weight models that show lower
18
+ comprehension on flattened encoding."""
19
+
20
+
21
+ def encode_generic(data: Any, opts: GenericOptions | None = None) -> str:
22
+ if opts is None:
23
+ opts = GenericOptions()
11
24
  out: list[str] = ["GCF profile=generic"]
12
- _encode_root_value(data, out)
25
+ _encode_root_value(data, out, opts)
13
26
  return "\n".join(out) + "\n"
14
27
 
15
28
 
16
- def _encode_root_value(v: Any, out: list[str]) -> None:
29
+ def _encode_root_value(v: Any, out: list[str], opts: GenericOptions) -> None:
17
30
  if v is None:
18
31
  out.append("=-")
19
32
  elif isinstance(v, dict):
20
- _encode_object(v, out, 0)
33
+ _encode_object(v, out, 0, opts)
21
34
  elif isinstance(v, list):
22
- _encode_root_array(v, out)
35
+ _encode_root_array(v, out, opts)
23
36
  else:
24
37
  out.append(f"={format_scalar(v)}")
25
38
 
26
39
 
27
- def _encode_object(d: dict, out: list[str], depth: int) -> None:
40
+ def _encode_object(d: dict, out: list[str], depth: int, opts: GenericOptions) -> None:
28
41
  prefix = _indent(depth)
29
42
  for key, value in d.items():
30
43
  fk = format_key(key)
31
44
  if isinstance(value, dict):
32
45
  out.append(f"{prefix}## {fk}")
33
- _encode_object(value, out, depth + 1)
46
+ _encode_object(value, out, depth + 1, opts)
34
47
  elif isinstance(value, list):
35
- _encode_named_array(fk, value, out, depth)
48
+ _encode_named_array(fk, value, out, depth, opts)
36
49
  else:
37
50
  out.append(f"{prefix}{fk}={format_scalar(value)}")
38
51
 
39
52
 
40
- def _encode_root_array(arr: list, out: list[str]) -> None:
53
+ def _encode_root_array(arr: list, out: list[str], opts: GenericOptions) -> None:
41
54
  if not arr:
42
55
  out.append("## [0]")
43
56
  return
@@ -47,12 +60,12 @@ def _encode_root_array(arr: list, out: list[str]) -> None:
47
60
  return
48
61
  fields = _tabular_fields(arr)
49
62
  if fields is not None:
50
- _encode_tabular("## ", arr, fields, out, 0)
63
+ _encode_tabular("## ", arr, fields, out, 0, opts)
51
64
  return
52
- _encode_expanded("## ", arr, out, 0)
65
+ _encode_expanded("## ", arr, out, 0, opts)
53
66
 
54
67
 
55
- def _encode_named_array(name: str, arr: list, out: list[str], depth: int) -> None:
68
+ def _encode_named_array(name: str, arr: list, out: list[str], depth: int, opts: GenericOptions) -> None:
56
69
  prefix = _indent(depth)
57
70
  if not arr:
58
71
  out.append(f"{prefix}## {name} [0]")
@@ -63,9 +76,9 @@ def _encode_named_array(name: str, arr: list, out: list[str], depth: int) -> Non
63
76
  return
64
77
  fields = _tabular_fields(arr)
65
78
  if fields is not None:
66
- _encode_tabular(f"{prefix}## {name} ", arr, fields, out, depth)
79
+ _encode_tabular(f"{prefix}## {name} ", arr, fields, out, depth, opts)
67
80
  return
68
- _encode_expanded(f"{prefix}## {name} ", arr, out, depth)
81
+ _encode_expanded(f"{prefix}## {name} ", arr, out, depth, opts)
69
82
 
70
83
 
71
84
  def _tabular_fields(arr: list) -> list[str] | None:
@@ -145,15 +158,152 @@ def _shared_array_schema(arr: list[dict], field_name: str) -> list[str] | None:
145
158
  return canonical_fields
146
159
 
147
160
 
161
+ # ── Nested object flattening (v3.2) ──────────────────────────────────────
162
+
163
+
164
+ def _analyze_flattenable(
165
+ arr: list[dict], field_name: str, parent_path: str
166
+ ) -> list[dict] | None:
167
+ """Analyze whether a field can be flattened. Returns list of leaf descriptors or None."""
168
+ # Field names containing ">" cannot be flattened (would create ambiguous paths).
169
+ if ">" in field_name:
170
+ return None
171
+ canonical_shape: dict[str, str] | None = None # key -> "scalar" | "nested"
172
+
173
+ for item in arr:
174
+ if field_name not in item or item[field_name] is None:
175
+ continue
176
+ v = item[field_name]
177
+ if not isinstance(v, dict):
178
+ return None
179
+ if isinstance(v, list):
180
+ return None
181
+
182
+ keys = list(v.keys())
183
+
184
+ if canonical_shape is None:
185
+ canonical_shape = {}
186
+ for k in keys:
187
+ if ">" in k:
188
+ return None
189
+ val = v[k]
190
+ if isinstance(val, list):
191
+ return None
192
+ elif isinstance(val, dict):
193
+ canonical_shape[k] = "nested"
194
+ else:
195
+ canonical_shape[k] = "scalar"
196
+ else:
197
+ if len(keys) != len(canonical_shape):
198
+ return None
199
+ for k in keys:
200
+ if k not in canonical_shape:
201
+ return None
202
+ val = v[k]
203
+ expected = canonical_shape[k]
204
+ if expected == "scalar":
205
+ if isinstance(val, (dict, list)):
206
+ return None
207
+ elif expected == "nested":
208
+ if isinstance(val, list):
209
+ return None
210
+ if val is not None and not isinstance(val, dict):
211
+ return None
212
+
213
+ if canonical_shape is None:
214
+ return None
215
+
216
+ current_path = f"{parent_path}>{field_name}" if parent_path else field_name
217
+ parent_keys = parent_path.split(">") + [field_name] if parent_path else [field_name]
218
+
219
+ leaves: list[dict] = []
220
+ for k in canonical_shape:
221
+ if canonical_shape[k] == "scalar":
222
+ leaves.append({"path": f"{current_path}>{k}", "keys": parent_keys + [k]})
223
+ else:
224
+ sub_arr = []
225
+ for item in arr:
226
+ if field_name not in item or item[field_name] is None:
227
+ sub_arr.append({})
228
+ else:
229
+ sub_arr.append(item[field_name])
230
+ sub_leaves = _analyze_flattenable(sub_arr, k, current_path)
231
+ if sub_leaves is None or len(sub_leaves) == 0:
232
+ return None
233
+ leaves.extend(sub_leaves)
234
+
235
+ # Guard: reject if any row has non-null object with all-null leaves.
236
+ if leaves:
237
+ for item in arr:
238
+ if field_name not in item or item[field_name] is None:
239
+ continue
240
+ all_null = all(
241
+ _resolve_key_chain(item, leaf["keys"])[0] is None
242
+ and _resolve_key_chain(item, leaf["keys"])[1]
243
+ for leaf in leaves
244
+ )
245
+ if all_null:
246
+ return None
247
+
248
+ return leaves
249
+
250
+
251
+ def _resolve_key_chain(item: Any, keys: list[str]) -> tuple[Any, bool]:
252
+ """Traverse an object by key chain. Returns (value, exists)."""
253
+ if not keys or not isinstance(item, dict):
254
+ return None, False
255
+ if keys[0] not in item:
256
+ return None, False
257
+ current = item[keys[0]]
258
+ if current is None:
259
+ return None, True
260
+ for k in keys[1:]:
261
+ if not isinstance(current, dict) or k not in current:
262
+ return None, False
263
+ current = current[k]
264
+ return current, True
265
+
266
+
148
267
  def _encode_tabular(
149
- header_prefix: str, arr: list[dict], fields: list[str], out: list[str], depth: int
268
+ header_prefix: str, arr: list[dict], fields: list[str], out: list[str], depth: int, opts: GenericOptions
150
269
  ) -> None:
151
270
  prefix = _indent(depth)
152
271
 
153
- # Pre-compute inline schemas and shared array schemas.
272
+ # Phase 0: Analyze fields for flattening.
273
+ flatten_map: dict[str, list[dict]] = {}
274
+ if not opts.no_flatten:
275
+ for f in fields:
276
+ leaves = _analyze_flattenable(arr, f, "")
277
+ if leaves and len(leaves) > 0:
278
+ flatten_map[f] = leaves
279
+
280
+ # Fields whose names contain ">" must not appear as tabular columns
281
+ # because the decoder would interpret them as flattened path columns.
282
+ # Track them for per-row attachment emission (spec rule 7.4.6.1.4).
283
+ gt_fields = {f for f in fields if f not in flatten_map and ">" in f}
284
+
285
+ # Build expanded column list.
286
+ columns: list[dict] = []
287
+ for f in fields:
288
+ if f in gt_fields:
289
+ continue
290
+ if f in flatten_map:
291
+ for leaf in flatten_map[f]:
292
+ columns.append({"header": format_key(leaf["path"]), "type": "flat", "field": f, "keys": leaf["keys"]})
293
+ else:
294
+ columns.append({"header": format_key(f), "type": "original", "field": f, "keys": []})
295
+
296
+ # If all fields were excluded (all contain ">"), fall back to expanded.
297
+ if not columns:
298
+ _encode_expanded(header_prefix, arr, out, depth, opts)
299
+ return
300
+
301
+ # Pre-compute inline schemas and shared array schemas (skip flattened fields).
154
302
  inline_schemas: dict[str, list[str]] = {}
155
303
  shared_arr_schemas: dict[str, list[str]] = {}
156
304
  for f in fields:
305
+ if f in flatten_map:
306
+ continue
157
307
  ifs = _inline_schema_fields(arr, f)
158
308
  if ifs is not None:
159
309
  inline_schemas[f] = ifs
@@ -161,15 +311,34 @@ def _encode_tabular(
161
311
  if sas is not None:
162
312
  shared_arr_schemas[f] = sas
163
313
 
164
- fmt_fields = ",".join(format_key(f) for f in fields)
165
- out.append(f"{header_prefix}[{len(arr)}]{{{fmt_fields}}}")
314
+ header_fields = ",".join(col["header"] for col in columns)
315
+ out.append(f"{header_prefix}[{len(arr)}]{{{header_fields}}}")
166
316
 
167
317
  for i, item in enumerate(arr):
168
318
  cells: list[str] = []
169
- attachments: list[tuple[str, Any, bool, list[str] | None]] = [] # (name, value, inline, inline_fields)
319
+ attachments: list[tuple[str, Any, bool, list[str] | None]] = []
170
320
  row_has_attachment = False
171
321
 
172
- for f in fields:
322
+ for col in columns:
323
+ if col["type"] == "flat":
324
+ keys = col["keys"]
325
+ if keys[0] not in item:
326
+ cells.append("~")
327
+ else:
328
+ top_val = item[keys[0]]
329
+ if top_val is None:
330
+ cells.append("-")
331
+ else:
332
+ val, exists = _resolve_key_chain(item, keys)
333
+ if not exists:
334
+ cells.append("~")
335
+ elif val is None:
336
+ cells.append("-")
337
+ else:
338
+ cells.append(format_scalar(val, "|"))
339
+ continue
340
+
341
+ f = col["field"]
173
342
  if f not in item:
174
343
  cells.append("~")
175
344
  continue
@@ -193,6 +362,15 @@ def _encode_tabular(
193
362
  else:
194
363
  cells.append(format_scalar(v, "|"))
195
364
 
365
+ # Emit fields with ">" in their names as per-row attachments.
366
+ for f in fields:
367
+ if f not in gt_fields:
368
+ continue
369
+ if f not in item:
370
+ continue
371
+ row_has_attachment = True
372
+ attachments.append((f, item[f], False, None))
373
+
196
374
  row = "|".join(cells)
197
375
  if row_has_attachment:
198
376
  out.append(f"{prefix}@{i} {row}")
@@ -211,17 +389,25 @@ def _encode_tabular(
211
389
  elif isinstance(att_val, list):
212
390
  sas = shared_arr_schemas.get(att_name)
213
391
  if sas and i > 0:
214
- _encode_attachment_array_shared(prefix, fk, att_val, out, depth + 2, sas)
392
+ _encode_attachment_array_shared(prefix, fk, att_val, out, depth + 2, sas, opts)
215
393
  else:
216
- _encode_attachment_array(prefix, fk, att_val, out, depth + 2)
394
+ _encode_attachment_array(prefix, fk, att_val, out, depth + 2, opts)
217
395
  elif isinstance(att_val, dict):
218
396
  out.append(f"{prefix}.{fk} {{}}")
219
- _encode_object(att_val, out, depth + 2)
397
+ _encode_object(att_val, out, depth + 2, opts)
398
+ else:
399
+ # Scalar attachment (e.g. field names containing ">").
400
+ if att_val is None:
401
+ out.append(f"{prefix}.{fk} =-")
402
+ else:
403
+ out.append(f"{prefix}.{fk} ={format_scalar(att_val)}")
220
404
 
221
405
 
222
406
  def _encode_attachment_array(
223
- att_prefix: str, fk: str, arr: list, out: list[str], depth: int
407
+ att_prefix: str, fk: str, arr: list, out: list[str], depth: int, opts: GenericOptions | None = None
224
408
  ) -> None:
409
+ if opts is None:
410
+ opts = GenericOptions()
225
411
  if not arr:
226
412
  out.append(f"{att_prefix}.{fk} [0]")
227
413
  elif _all_primitives(arr):
@@ -230,13 +416,13 @@ def _encode_attachment_array(
230
416
  else:
231
417
  fields = _tabular_fields(arr)
232
418
  if fields is not None:
233
- _encode_tabular(f"{att_prefix}.{fk} ", arr, fields, out, depth)
419
+ _encode_tabular(f"{att_prefix}.{fk} ", arr, fields, out, depth, opts)
234
420
  else:
235
- _encode_expanded(f"{att_prefix}.{fk} ", arr, out, depth)
421
+ _encode_expanded(f"{att_prefix}.{fk} ", arr, out, depth, opts)
236
422
 
237
423
 
238
424
  def _encode_attachment_array_shared(
239
- att_prefix: str, fk: str, arr: list, out: list[str], depth: int, shared_fields: list[str]
425
+ att_prefix: str, fk: str, arr: list, out: list[str], depth: int, shared_fields: list[str], opts: GenericOptions | None = None
240
426
  ) -> None:
241
427
  if not arr:
242
428
  out.append(f"{att_prefix}.{fk} [0]")
@@ -263,25 +449,29 @@ def _encode_attachment_array_shared(
263
449
  out.append(f"{prefix}{'|'.join(cells)}")
264
450
  else:
265
451
  # Fields don't match: fall back to full encoding.
266
- _encode_attachment_array(att_prefix, fk, arr, out, depth)
452
+ _encode_attachment_array(att_prefix, fk, arr, out, depth, opts)
267
453
 
268
454
 
269
- def _encode_expanded(header_prefix: str, arr: list, out: list[str], depth: int) -> None:
455
+ def _encode_expanded(header_prefix: str, arr: list, out: list[str], depth: int, opts: GenericOptions | None = None) -> None:
456
+ if opts is None:
457
+ opts = GenericOptions()
270
458
  prefix = _indent(depth)
271
459
  out.append(f"{header_prefix}[{len(arr)}]")
272
460
  for i, item in enumerate(arr):
273
461
  if isinstance(item, dict):
274
462
  out.append(f"{prefix}@{i} {{}}")
275
- _encode_object(item, out, depth + 1)
463
+ _encode_object(item, out, depth + 1, opts)
276
464
  elif isinstance(item, list):
277
- _encode_expanded_array_item(prefix, i, item, out, depth)
465
+ _encode_expanded_array_item(prefix, i, item, out, depth, opts)
278
466
  else:
279
467
  out.append(f"{prefix}@{i} ={format_scalar(item)}")
280
468
 
281
469
 
282
470
  def _encode_expanded_array_item(
283
- prefix: str, idx: int, arr: list, out: list[str], depth: int
471
+ prefix: str, idx: int, arr: list, out: list[str], depth: int, opts: GenericOptions | None = None
284
472
  ) -> None:
473
+ if opts is None:
474
+ opts = GenericOptions()
285
475
  if not arr:
286
476
  out.append(f"{prefix}@{idx} [0]")
287
477
  elif _all_primitives(arr):
@@ -290,9 +480,9 @@ def _encode_expanded_array_item(
290
480
  else:
291
481
  fields = _tabular_fields(arr)
292
482
  if fields is not None:
293
- _encode_tabular(f"{prefix}@{idx} ", arr, fields, out, depth + 1)
483
+ _encode_tabular(f"{prefix}@{idx} ", arr, fields, out, depth + 1, opts)
294
484
  else:
295
- _encode_expanded(f"{prefix}@{idx} ", arr, out, depth + 1)
485
+ _encode_expanded(f"{prefix}@{idx} ", arr, out, depth + 1, opts)
296
486
 
297
487
 
298
488
  def _all_primitives(arr: list) -> bool:
@@ -98,9 +98,9 @@ def test_conformance(rel_path, data):
98
98
  ), f"decode mismatch:\n got: {got}\n exp: {data['expected']}"
99
99
 
100
100
  elif op == "roundtrip":
101
- # Encode, verify GCF output matches expected, then decode and verify round-trip.
101
+ # Encode, verify GCF output matches expected (if provided), then decode and verify round-trip.
102
102
  got = encode_generic(data["input"])
103
- if isinstance(data["expected"], str):
103
+ if "expected" in data and isinstance(data["expected"], str):
104
104
  assert got == data["expected"], f"encode mismatch:\n got: {got!r}\n exp: {data['expected']!r}"
105
105
  decoded = decode_generic(got)
106
106
  assert _structural_equal(
@@ -1,6 +1,7 @@
1
1
  """Tests for GCF generic encoding."""
2
2
 
3
- from gcf import encode_generic
3
+ import pytest
4
+ from gcf import encode_generic, decode_generic, GenericOptions
4
5
 
5
6
 
6
7
  def test_encode_flat_tabular_list():
@@ -60,15 +61,11 @@ def test_encode_mixed_data():
60
61
  }
61
62
  output = encode_generic(data)
62
63
 
63
- # Header includes all fields (v2.0: nested fields in union too).
64
- assert "## projects [2]{name,status,config}" in output
65
- # Rows with nested data get @id prefix and ^ marker.
66
- assert "@0 Alpha|active|^" in output
67
- assert "@1 Beta|draft|^" in output
68
- # Nested config uses .field {} attachment.
69
- assert ".config {}" in output
70
- assert "env=" in output
71
- assert "region=" in output
64
+ # Header includes flattened path columns (v3.2).
65
+ assert '## projects [2]{name,status,"config>env","config>region"}' in output
66
+ # Rows with flattened data are pure tabular.
67
+ assert "Alpha|active|prod|us-east" in output
68
+ assert "Beta|draft|staging|eu-west" in output
72
69
 
73
70
 
74
71
  def test_encode_none_value():
@@ -168,3 +165,52 @@ def test_encode_string_with_pipe():
168
165
  data = {"val": "a|b"}
169
166
  output = encode_generic(data)
170
167
  assert 'val="a|b"' in output
168
+
169
+
170
+ def test_no_flatten_option():
171
+ """noFlatten produces attachment syntax instead of path columns."""
172
+ data = {
173
+ "orders": [
174
+ {"id": "ORD-1", "customer": {"name": "Alice", "email": "alice@co.com"}, "total": 99.99},
175
+ {"id": "ORD-2", "customer": {"name": "Bob", "email": "bob@co.com"}, "total": 49.99},
176
+ ],
177
+ }
178
+ with_flatten = encode_generic(data)
179
+ assert "customer>" in with_flatten
180
+
181
+ no_flatten = encode_generic(data, GenericOptions(no_flatten=True))
182
+ assert "customer>" not in no_flatten
183
+ assert ".customer" in no_flatten
184
+
185
+ # Both round-trip.
186
+ assert decode_generic(with_flatten) == data
187
+ assert decode_generic(no_flatten) == data
188
+
189
+
190
+ GT_EDGE_CASES = [
191
+ ("literal > key", [{">": 1}, {">": 2}]),
192
+ ("> at start", [{">foo": "a", "id": 1}, {">foo": "b", "id": 2}]),
193
+ ("> at end", [{"foo>": "a", "id": 1}, {"foo>": "b", "id": 2}]),
194
+ ("double >>", [{"a>>b": "x"}, {"a>>b": "y"}]),
195
+ ("multiple > in key", [{"a>b>c": "x"}, {"a>b>c": "y"}]),
196
+ ("> field with null", [{"a>b": None, "id": 1}, {"a>b": "hello", "id": 2}]),
197
+ ("> field with object", [{"a>b": {"x": 1}, "id": 1}, {"a>b": {"x": 2}, "id": 2}]),
198
+ ("> field with array", [{"a>b": [1, 2], "id": 1}, {"a>b": [3], "id": 2}]),
199
+ ("all fields have >", [{">": 1, "a>b": 2}, {">": 3, "a>b": 4}]),
200
+ ("mix of > literal and flattened", [
201
+ {"id": 1, "x>y": "lit", "nested": {"a": "v1", "b": "v2"}},
202
+ {"id": 2, "x>y": "lit2", "nested": {"a": "v3", "b": "v4"}},
203
+ ]),
204
+ ("> field absent in some rows", [{"id": 1, "a>b": "present"}, {"id": 2}]),
205
+ ("key looks like flattened path", [{"id": 1, "customer>name": "Alice"}, {"id": 2, "customer>name": "Bob"}]),
206
+ ]
207
+
208
+
209
+ @pytest.mark.parametrize("name,data", GT_EDGE_CASES)
210
+ def test_gt_field_edge_cases(name, data):
211
+ """Field names containing > round-trip correctly in both flatten modes."""
212
+ for no_flatten in (False, True):
213
+ opts = GenericOptions(no_flatten=no_flatten)
214
+ encoded = encode_generic(data, opts)
215
+ decoded = decode_generic(encoded)
216
+ assert decoded == data, f"{name} (no_flatten={no_flatten}): round-trip mismatch\n gcf: {encoded!r}"
@@ -7,7 +7,7 @@ import string
7
7
 
8
8
  import pytest
9
9
 
10
- from gcf import encode_generic, decode_generic
10
+ from gcf import encode_generic, decode_generic, GenericOptions
11
11
 
12
12
  ITERATIONS = int(os.environ.get("GCF_ITERATIONS", "100000"))
13
13
 
@@ -55,7 +55,7 @@ def _gen_number(r):
55
55
  ])()
56
56
 
57
57
 
58
- SPECIAL = ' |,="\\#@\n\t~^+-.'
58
+ SPECIAL = ' |,="\\#@\n\t~^+-.>'
59
59
  BARE = string.ascii_letters + string.digits
60
60
 
61
61
 
@@ -218,29 +218,31 @@ def test_random_roundtrip():
218
218
  r = _rng(42)
219
219
  for i in range(ITERATIONS):
220
220
  val = _gen_value(r, 0, 4)
221
- gcf = encode_generic(val)
222
- decoded = decode_generic(gcf)
223
- a = _json_norm(val)
224
- b = _json_norm(decoded)
225
- assert _structural_equal(a, b), (
226
- f"iteration {i}: round-trip mismatch\n"
227
- f" input: {json.dumps(val)}\n"
228
- f" decoded: {json.dumps(decoded)}\n"
229
- f" gcf: {gcf!r}"
230
- )
221
+ for no_flatten in (False, True):
222
+ gcf = encode_generic(val, GenericOptions(no_flatten=no_flatten))
223
+ decoded = decode_generic(gcf)
224
+ a = _json_norm(val)
225
+ b = _json_norm(decoded)
226
+ assert _structural_equal(a, b), (
227
+ f"iteration {i} no_flatten={no_flatten}: round-trip mismatch\n"
228
+ f" input: {json.dumps(val)}\n"
229
+ f" decoded: {json.dumps(decoded)}\n"
230
+ f" gcf: {gcf!r}"
231
+ )
231
232
 
232
233
 
233
234
  def test_adversarial_roundtrip():
234
235
  r = _rng(99)
235
236
  for i in range(ITERATIONS):
236
237
  val = _gen_adversarial_value(r, 0, 3)
237
- gcf = encode_generic(val)
238
- decoded = decode_generic(gcf)
239
- a = _json_norm(val)
240
- b = _json_norm(decoded)
241
- assert _structural_equal(a, b), (
242
- f"iteration {i}: round-trip mismatch\n"
243
- f" input: {json.dumps(val)}\n"
244
- f" decoded: {json.dumps(decoded)}\n"
245
- f" gcf: {gcf!r}"
246
- )
238
+ for no_flatten in (False, True):
239
+ gcf = encode_generic(val, GenericOptions(no_flatten=no_flatten))
240
+ decoded = decode_generic(gcf)
241
+ a = _json_norm(val)
242
+ b = _json_norm(decoded)
243
+ assert _structural_equal(a, b), (
244
+ f"iteration {i} no_flatten={no_flatten}: round-trip mismatch\n"
245
+ f" input: {json.dumps(val)}\n"
246
+ f" decoded: {json.dumps(decoded)}\n"
247
+ f" gcf: {gcf!r}"
248
+ )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes