gcf-python 2.2.0__tar.gz → 2.2.2__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 (36) hide show
  1. {gcf_python-2.2.0 → gcf_python-2.2.2}/CHANGELOG.md +23 -0
  2. {gcf_python-2.2.0 → gcf_python-2.2.2}/PKG-INFO +11 -7
  3. {gcf_python-2.2.0 → gcf_python-2.2.2}/README.md +9 -5
  4. {gcf_python-2.2.0 → gcf_python-2.2.2}/pyproject.toml +2 -2
  5. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/__init__.py +3 -2
  6. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/decode_generic.py +19 -10
  7. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/generic.py +92 -34
  8. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_conformance_v2.py +2 -2
  9. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_generic.py +51 -1
  10. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_roundtrip_v2.py +98 -22
  11. gcf_python-2.2.2/uv.lock +8 -0
  12. {gcf_python-2.2.0 → gcf_python-2.2.2}/.github/FUNDING.yml +0 -0
  13. {gcf_python-2.2.0 → gcf_python-2.2.2}/.github/workflows/ci.yml +0 -0
  14. {gcf_python-2.2.0 → gcf_python-2.2.2}/.github/workflows/publish.yml +0 -0
  15. {gcf_python-2.2.0 → gcf_python-2.2.2}/.gitignore +0 -0
  16. {gcf_python-2.2.0 → gcf_python-2.2.2}/LICENSE +0 -0
  17. {gcf_python-2.2.0 → gcf_python-2.2.2}/assets/gcf-python-diagram.png +0 -0
  18. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/__main__.py +0 -0
  19. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/cli.py +0 -0
  20. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/constants.py +0 -0
  21. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/decode.py +0 -0
  22. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/delta.py +0 -0
  23. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/encode.py +0 -0
  24. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/scalar.py +0 -0
  25. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/session.py +0 -0
  26. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/stream.py +0 -0
  27. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/stream_generic.py +0 -0
  28. {gcf_python-2.2.0 → gcf_python-2.2.2}/src/gcf/types.py +0 -0
  29. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/__init__.py +0 -0
  30. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_decode.py +0 -0
  31. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_delta.py +0 -0
  32. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_encode.py +0 -0
  33. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_roundtrip.py +0 -0
  34. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_session.py +0 -0
  35. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_stream.py +0 -0
  36. {gcf_python-2.2.0 → gcf_python-2.2.2}/tests/test_stream_generic.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.2.2 (2026-07-10)
4
+
5
+ ### Fixes
6
+
7
+ - **Losslessness (nested null):** a nested object that is null at an intermediate level (e.g. `{"meta": {"owner": None}}`) is no longer flattened. Previously its leaves encoded as absent (`~`) and unflattened to a missing key, silently dropping the null. Such fields now fall back to the attachment mechanism; a top-level `None` still flattens losslessly (emits `-`, reconstructs via the all-null rule). Enforced by the shared conformance fixtures `flatten/017`–`019`. Prototype pollution does not affect Python (dicts have no mutable prototype).
8
+
9
+ ### Tests
10
+
11
+ - `test_flatten_roundtrip`: aligned arrays whose shared fields are fixed-shape nested objects with a field or an intermediate nested level sometimes null/absent — the shape the prior scalar-only generator never produced, leaving the flatten/unflatten path unexercised. Verified to fail on the pre-fix encoder and pass on the fix.
12
+
13
+ ## v2.2.1 (2026-06-23)
14
+
15
+ ### Flatten Opt-Out
16
+
17
+ - Added `GenericOptions` dataclass with `no_flatten` field to disable nested object flattening
18
+ - `encode_generic(data, GenericOptions(no_flatten=True))` produces attachment syntax instead of path columns
19
+ - Backward compatible: `encode_generic(data)` behavior unchanged (flatten on by default)
20
+ - Fixed: field names containing `>` no longer appear as tabular columns (spec rule 7.4.6.1.4)
21
+ - Fixed: field names containing `>` no longer eligible for flattening analysis
22
+ - Fixed: decoder no longer treats literal `>` in key names as a path separator
23
+ - Fixed: decoder accepts orphan attachments (fields excluded from column list)
24
+ - 12 targeted edge case tests for `>` in field names
25
+
3
26
  ## v2.2.0 (2026-06-22)
4
27
 
5
28
  ### Spec v3.2: Nested Object Flattening
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gcf-python
3
- Version: 2.2.0
4
- Summary: Drop-in JSON replacement for AI pipelines. 79% fewer tokens. 90.7% comprehension across 10 models. Zero dependencies.
3
+ Version: 2.2.2
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.8%, JSON 54.1%). 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.8% | 54.1% |
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
 
@@ -236,7 +236,11 @@ GCF wins 13/15 datasets on the expanded [token efficiency benchmark](https://git
236
236
 
237
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 174 conformance fixtures, 43,000,000,000+ lossless round-trips verified across 5 formats and 6 languages. All implementations at v2.2.1+ (Go v1.3.1). Cross-language 6x6 matrix verified.
240
+
241
+ ## Adopted by
242
+
243
+ [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) (46K stars, Google Chrome DevTools team) · [Speakeasy](https://speakeasy.com) (API tooling, customers include Google, Verizon, Mistral AI, DocuSign, Vercel) · [OmniRoute](https://omniroute.online) (6.1K stars) · [NetClaw](https://github.com/automateyournetwork/netclaw) (556 stars) · [ctx](https://github.com/stevesolun/ctx) (510 stars) · [NeuroNest](https://neuronest.cc) · [Open Data Products SDK](https://opendataproducts.org/sdk/) (Linux Foundation) · [Raycast](https://raycast.com/blackwell-systems/json-to-gcf-converter) · [and more](https://gcformat.com/ecosystem/adopters.html)
240
244
 
241
245
  ## License
242
246
 
@@ -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.8%, JSON 54.1%). 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.8% | 54.1% |
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
 
@@ -211,7 +211,11 @@ GCF wins 13/15 datasets on the expanded [token efficiency benchmark](https://git
211
211
 
212
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 174 conformance fixtures, 43,000,000,000+ lossless round-trips verified across 5 formats and 6 languages. All implementations at v2.2.1+ (Go v1.3.1). Cross-language 6x6 matrix verified.
215
+
216
+ ## Adopted by
217
+
218
+ [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) (46K stars, Google Chrome DevTools team) · [Speakeasy](https://speakeasy.com) (API tooling, customers include Google, Verizon, Mistral AI, DocuSign, Vercel) · [OmniRoute](https://omniroute.online) (6.1K stars) · [NetClaw](https://github.com/automateyournetwork/netclaw) (556 stars) · [ctx](https://github.com/stevesolun/ctx) (510 stars) · [NeuroNest](https://neuronest.cc) · [Open Data Products SDK](https://opendataproducts.org/sdk/) (Linux Foundation) · [Raycast](https://raycast.com/blackwell-systems/json-to-gcf-converter) · [and more](https://gcformat.com/ecosystem/adopters.html)
215
219
 
216
220
  ## License
217
221
 
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gcf-python"
7
- version = "2.2.0"
8
- description = "Drop-in JSON replacement for AI pipelines. 79% fewer tokens. 90.7% comprehension across 10 models. Zero dependencies."
7
+ version = "2.2.2"
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,7 +62,8 @@ __all__ = [
62
62
  "encode",
63
63
  "encode_delta",
64
64
  "encode_generic",
65
+ "GenericOptions",
65
66
  "encode_with_session",
66
67
  ]
67
68
 
68
- __version__ = "1.0.0"
69
+ __version__ = "2.2.2"
@@ -360,6 +360,14 @@ 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
 
@@ -423,7 +431,11 @@ def _parse_tabular_body(
423
431
  path_column_map: dict[str, list[str]] = {}
424
432
  for f in fields:
425
433
  if ">" in f:
426
- path_column_map[f] = f.split(">")
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
427
439
 
428
440
  # Track inline schemas and shared array schemas.
429
441
  inline_schemas: dict[str, list[str]] = {}
@@ -506,10 +518,10 @@ def _parse_tabular_body(
506
518
  all_att_fields = traditional_att_fields + inline_att_fields
507
519
  attachment_values: dict[str, Any] = {}
508
520
 
509
- if row_has_id and all_att_fields:
521
+ if row_has_id:
510
522
  inline_idx = 0
511
523
 
512
- while i < len(lines) and len(attachment_values) < len(all_att_fields):
524
+ while i < len(lines):
513
525
  a_line = lines[i]
514
526
  a_content: str | None = None
515
527
  if depth == 0 or a_line.startswith(ind):
@@ -601,13 +613,6 @@ def _parse_tabular_body(
601
613
  if extra_name in attachment_values:
602
614
  raise ValueError(f"duplicate_attachment: {extra_name}")
603
615
 
604
- if not row_has_id or not all_att_fields:
605
- att_indent = ind + " "
606
- if i < len(lines) and lines[i].startswith(att_indent):
607
- peek = lines[i][len(att_indent):]
608
- if peek.startswith("."):
609
- raise ValueError(f"orphan_attachment: {peek}")
610
-
611
616
  row: dict[str, Any] = {}
612
617
  for f in fields:
613
618
  if f in missing_fields:
@@ -616,6 +621,10 @@ def _parse_tabular_body(
616
621
  row[f] = cell_values[f]
617
622
  elif f in attachment_values:
618
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
619
628
  # Unflatten path columns into nested objects.
620
629
  if path_column_map:
621
630
  nested = _unflatten_paths(path_column_map, flat_values, flat_absent)
@@ -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. Open-weight models currently comprehend the expanded form
18
+ better; this gap is expected to close."""
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:
@@ -152,10 +165,21 @@ def _analyze_flattenable(
152
165
  arr: list[dict], field_name: str, parent_path: str
153
166
  ) -> list[dict] | None:
154
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
155
171
  canonical_shape: dict[str, str] | None = None # key -> "scalar" | "nested"
156
172
 
157
173
  for item in arr:
158
- if field_name not in item or item[field_name] is None:
174
+ if field_name not in item:
175
+ continue
176
+ # A nested (non-top-level) null cannot be flattened losslessly: its leaves
177
+ # would encode as absent ("~") and unflatten back to a missing key, not None.
178
+ # Bail to the attachment path. A top-level None is fine (emits "-" and
179
+ # reconstructs via the all-null rule), so just skip the row from shape analysis.
180
+ if item[field_name] is None:
181
+ if parent_path != "":
182
+ return None
159
183
  continue
160
184
  v = item[field_name]
161
185
  if not isinstance(v, dict):
@@ -249,26 +273,39 @@ def _resolve_key_chain(item: Any, keys: list[str]) -> tuple[Any, bool]:
249
273
 
250
274
 
251
275
  def _encode_tabular(
252
- header_prefix: str, arr: list[dict], fields: list[str], out: list[str], depth: int
276
+ header_prefix: str, arr: list[dict], fields: list[str], out: list[str], depth: int, opts: GenericOptions
253
277
  ) -> None:
254
278
  prefix = _indent(depth)
255
279
 
256
280
  # Phase 0: Analyze fields for flattening.
257
281
  flatten_map: dict[str, list[dict]] = {}
258
- for f in fields:
259
- leaves = _analyze_flattenable(arr, f, "")
260
- if leaves and len(leaves) > 0:
261
- flatten_map[f] = leaves
282
+ if not opts.no_flatten:
283
+ for f in fields:
284
+ leaves = _analyze_flattenable(arr, f, "")
285
+ if leaves and len(leaves) > 0:
286
+ flatten_map[f] = leaves
287
+
288
+ # Fields whose names contain ">" must not appear as tabular columns
289
+ # because the decoder would interpret them as flattened path columns.
290
+ # Track them for per-row attachment emission (spec rule 7.4.6.1.4).
291
+ gt_fields = {f for f in fields if f not in flatten_map and ">" in f}
262
292
 
263
293
  # Build expanded column list.
264
294
  columns: list[dict] = []
265
295
  for f in fields:
296
+ if f in gt_fields:
297
+ continue
266
298
  if f in flatten_map:
267
299
  for leaf in flatten_map[f]:
268
300
  columns.append({"header": format_key(leaf["path"]), "type": "flat", "field": f, "keys": leaf["keys"]})
269
301
  else:
270
302
  columns.append({"header": format_key(f), "type": "original", "field": f, "keys": []})
271
303
 
304
+ # If all fields were excluded (all contain ">"), fall back to expanded.
305
+ if not columns:
306
+ _encode_expanded(header_prefix, arr, out, depth, opts)
307
+ return
308
+
272
309
  # Pre-compute inline schemas and shared array schemas (skip flattened fields).
273
310
  inline_schemas: dict[str, list[str]] = {}
274
311
  shared_arr_schemas: dict[str, list[str]] = {}
@@ -333,6 +370,15 @@ def _encode_tabular(
333
370
  else:
334
371
  cells.append(format_scalar(v, "|"))
335
372
 
373
+ # Emit fields with ">" in their names as per-row attachments.
374
+ for f in fields:
375
+ if f not in gt_fields:
376
+ continue
377
+ if f not in item:
378
+ continue
379
+ row_has_attachment = True
380
+ attachments.append((f, item[f], False, None))
381
+
336
382
  row = "|".join(cells)
337
383
  if row_has_attachment:
338
384
  out.append(f"{prefix}@{i} {row}")
@@ -351,17 +397,25 @@ def _encode_tabular(
351
397
  elif isinstance(att_val, list):
352
398
  sas = shared_arr_schemas.get(att_name)
353
399
  if sas and i > 0:
354
- _encode_attachment_array_shared(prefix, fk, att_val, out, depth + 2, sas)
400
+ _encode_attachment_array_shared(prefix, fk, att_val, out, depth + 2, sas, opts)
355
401
  else:
356
- _encode_attachment_array(prefix, fk, att_val, out, depth + 2)
402
+ _encode_attachment_array(prefix, fk, att_val, out, depth + 2, opts)
357
403
  elif isinstance(att_val, dict):
358
404
  out.append(f"{prefix}.{fk} {{}}")
359
- _encode_object(att_val, out, depth + 2)
405
+ _encode_object(att_val, out, depth + 2, opts)
406
+ else:
407
+ # Scalar attachment (e.g. field names containing ">").
408
+ if att_val is None:
409
+ out.append(f"{prefix}.{fk} =-")
410
+ else:
411
+ out.append(f"{prefix}.{fk} ={format_scalar(att_val)}")
360
412
 
361
413
 
362
414
  def _encode_attachment_array(
363
- att_prefix: str, fk: str, arr: list, out: list[str], depth: int
415
+ att_prefix: str, fk: str, arr: list, out: list[str], depth: int, opts: GenericOptions | None = None
364
416
  ) -> None:
417
+ if opts is None:
418
+ opts = GenericOptions()
365
419
  if not arr:
366
420
  out.append(f"{att_prefix}.{fk} [0]")
367
421
  elif _all_primitives(arr):
@@ -370,13 +424,13 @@ def _encode_attachment_array(
370
424
  else:
371
425
  fields = _tabular_fields(arr)
372
426
  if fields is not None:
373
- _encode_tabular(f"{att_prefix}.{fk} ", arr, fields, out, depth)
427
+ _encode_tabular(f"{att_prefix}.{fk} ", arr, fields, out, depth, opts)
374
428
  else:
375
- _encode_expanded(f"{att_prefix}.{fk} ", arr, out, depth)
429
+ _encode_expanded(f"{att_prefix}.{fk} ", arr, out, depth, opts)
376
430
 
377
431
 
378
432
  def _encode_attachment_array_shared(
379
- att_prefix: str, fk: str, arr: list, out: list[str], depth: int, shared_fields: list[str]
433
+ att_prefix: str, fk: str, arr: list, out: list[str], depth: int, shared_fields: list[str], opts: GenericOptions | None = None
380
434
  ) -> None:
381
435
  if not arr:
382
436
  out.append(f"{att_prefix}.{fk} [0]")
@@ -403,25 +457,29 @@ def _encode_attachment_array_shared(
403
457
  out.append(f"{prefix}{'|'.join(cells)}")
404
458
  else:
405
459
  # Fields don't match: fall back to full encoding.
406
- _encode_attachment_array(att_prefix, fk, arr, out, depth)
460
+ _encode_attachment_array(att_prefix, fk, arr, out, depth, opts)
407
461
 
408
462
 
409
- def _encode_expanded(header_prefix: str, arr: list, out: list[str], depth: int) -> None:
463
+ def _encode_expanded(header_prefix: str, arr: list, out: list[str], depth: int, opts: GenericOptions | None = None) -> None:
464
+ if opts is None:
465
+ opts = GenericOptions()
410
466
  prefix = _indent(depth)
411
467
  out.append(f"{header_prefix}[{len(arr)}]")
412
468
  for i, item in enumerate(arr):
413
469
  if isinstance(item, dict):
414
470
  out.append(f"{prefix}@{i} {{}}")
415
- _encode_object(item, out, depth + 1)
471
+ _encode_object(item, out, depth + 1, opts)
416
472
  elif isinstance(item, list):
417
- _encode_expanded_array_item(prefix, i, item, out, depth)
473
+ _encode_expanded_array_item(prefix, i, item, out, depth, opts)
418
474
  else:
419
475
  out.append(f"{prefix}@{i} ={format_scalar(item)}")
420
476
 
421
477
 
422
478
  def _encode_expanded_array_item(
423
- prefix: str, idx: int, arr: list, out: list[str], depth: int
479
+ prefix: str, idx: int, arr: list, out: list[str], depth: int, opts: GenericOptions | None = None
424
480
  ) -> None:
481
+ if opts is None:
482
+ opts = GenericOptions()
425
483
  if not arr:
426
484
  out.append(f"{prefix}@{idx} [0]")
427
485
  elif _all_primitives(arr):
@@ -430,9 +488,9 @@ def _encode_expanded_array_item(
430
488
  else:
431
489
  fields = _tabular_fields(arr)
432
490
  if fields is not None:
433
- _encode_tabular(f"{prefix}@{idx} ", arr, fields, out, depth + 1)
491
+ _encode_tabular(f"{prefix}@{idx} ", arr, fields, out, depth + 1, opts)
434
492
  else:
435
- _encode_expanded(f"{prefix}@{idx} ", arr, out, depth + 1)
493
+ _encode_expanded(f"{prefix}@{idx} ", arr, out, depth + 1, opts)
436
494
 
437
495
 
438
496
  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():
@@ -164,3 +165,52 @@ def test_encode_string_with_pipe():
164
165
  data = {"val": "a|b"}
165
166
  output = encode_generic(data)
166
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
 
@@ -214,33 +214,109 @@ def _structural_equal(a, b):
214
214
  return a == b
215
215
 
216
216
 
217
+ def _gen_flat_shape(r, depth, max_depth):
218
+ """A fixed nested schema: the string "scalar" or a dict of named sub-shapes."""
219
+ if depth >= max_depth or r.random() < 0.45:
220
+ return "scalar"
221
+ shape = {}
222
+ for _ in range(1 + r.randint(0, 2)):
223
+ shape[_gen_bare_key(r)] = _gen_flat_shape(r, depth + 1, max_depth)
224
+ return shape if shape else "scalar"
225
+
226
+
227
+ def _materialize_flat_shape(r, shape):
228
+ if shape == "scalar":
229
+ return _gen_scalar(r)
230
+ obj = {}
231
+ for k, s in shape.items():
232
+ # A nested sub-object is sometimes None (intermediate null — the case the
233
+ # pre-fix encoder dropped) instead of a full object.
234
+ obj[k] = None if s != "scalar" and r.random() < 0.3 else _materialize_flat_shape(r, s)
235
+ return obj
236
+
237
+
238
+ def _gen_flattenable_array(r):
239
+ schema = {"id": "scalar"}
240
+ order = ["id"]
241
+ has_nested = False
242
+ for _ in range(1 + r.randint(0, 2)):
243
+ k = _gen_bare_key(r)
244
+ if k in schema:
245
+ continue
246
+ s = _gen_flat_shape(r, 1, 3)
247
+ schema[k] = s
248
+ order.append(k)
249
+ if s != "scalar":
250
+ has_nested = True
251
+ if not has_nested:
252
+ k = _gen_bare_key(r)
253
+ schema[k] = {_gen_bare_key(r): {_gen_bare_key(r): "scalar"}}
254
+ order.append(k)
255
+ arr = []
256
+ for _ in range(2 + r.randint(0, 5)):
257
+ row = {}
258
+ for f in order:
259
+ x = r.random()
260
+ if x < 0.12:
261
+ continue # field absent this row
262
+ elif x < 0.24:
263
+ row[f] = None # field present-null (top-level null)
264
+ else:
265
+ row[f] = _materialize_flat_shape(r, schema[f])
266
+ arr.append(row)
267
+ return arr
268
+
269
+
270
+ def test_flatten_roundtrip():
271
+ """Aligned arrays whose shared fields are fixed-shape nested objects with a
272
+ field or an intermediate nested level sometimes null/absent — the v3.2 flatten
273
+ path the scalar-only generator never produces, so flatten/unflatten and its
274
+ null-at-depth losslessness edge would otherwise be unexercised."""
275
+ r = _rng(7)
276
+ for i in range(ITERATIONS):
277
+ val = _gen_flattenable_array(r)
278
+ for no_flatten in (False, True):
279
+ gcf = encode_generic(val, GenericOptions(no_flatten=no_flatten))
280
+ decoded = decode_generic(gcf)
281
+ a = _json_norm(val)
282
+ b = _json_norm(decoded)
283
+ assert _structural_equal(a, b), (
284
+ f"iteration {i} no_flatten={no_flatten}: round-trip mismatch\n"
285
+ f" input: {json.dumps(val)}\n"
286
+ f" decoded: {json.dumps(decoded)}\n"
287
+ f" gcf: {gcf!r}"
288
+ )
289
+
290
+
217
291
  def test_random_roundtrip():
218
292
  r = _rng(42)
219
293
  for i in range(ITERATIONS):
220
294
  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
- )
295
+ for no_flatten in (False, True):
296
+ gcf = encode_generic(val, GenericOptions(no_flatten=no_flatten))
297
+ decoded = decode_generic(gcf)
298
+ a = _json_norm(val)
299
+ b = _json_norm(decoded)
300
+ assert _structural_equal(a, b), (
301
+ f"iteration {i} no_flatten={no_flatten}: round-trip mismatch\n"
302
+ f" input: {json.dumps(val)}\n"
303
+ f" decoded: {json.dumps(decoded)}\n"
304
+ f" gcf: {gcf!r}"
305
+ )
231
306
 
232
307
 
233
308
  def test_adversarial_roundtrip():
234
309
  r = _rng(99)
235
310
  for i in range(ITERATIONS):
236
311
  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
- )
312
+ for no_flatten in (False, True):
313
+ gcf = encode_generic(val, GenericOptions(no_flatten=no_flatten))
314
+ decoded = decode_generic(gcf)
315
+ a = _json_norm(val)
316
+ b = _json_norm(decoded)
317
+ assert _structural_equal(a, b), (
318
+ f"iteration {i} no_flatten={no_flatten}: round-trip mismatch\n"
319
+ f" input: {json.dumps(val)}\n"
320
+ f" decoded: {json.dumps(decoded)}\n"
321
+ f" gcf: {gcf!r}"
322
+ )
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.9"
4
+
5
+ [[package]]
6
+ name = "gcf-python"
7
+ version = "2.0.0"
8
+ source = { editable = "." }
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