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.
- {gcf_python-2.1.1 → gcf_python-2.2.1}/CHANGELOG.md +24 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/PKG-INFO +8 -8
- {gcf_python-2.1.1 → gcf_python-2.2.1}/README.md +6 -6
- {gcf_python-2.1.1 → gcf_python-2.2.1}/pyproject.toml +2 -2
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/__init__.py +2 -1
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/decode_generic.py +91 -9
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/generic.py +224 -34
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_conformance_v2.py +2 -2
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_generic.py +56 -10
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_roundtrip_v2.py +24 -22
- {gcf_python-2.1.1 → gcf_python-2.2.1}/.github/FUNDING.yml +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/.github/workflows/ci.yml +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/.github/workflows/publish.yml +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/.gitignore +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/LICENSE +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/assets/gcf-python-diagram.png +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/__main__.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/cli.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/constants.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/decode.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/delta.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/encode.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/scalar.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/session.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/stream.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/stream_generic.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/src/gcf/types.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/__init__.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_decode.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_delta.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_encode.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_roundtrip.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_session.py +0 -0
- {gcf_python-2.1.1 → gcf_python-2.2.1}/tests/test_stream.py +0 -0
- {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.
|
|
4
|
-
Summary:
|
|
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.
|
|
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
|
-
|
|
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) | **
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) | **
|
|
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
|
|
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.
|
|
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.
|
|
8
|
-
description = "
|
|
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
|
|
521
|
+
if row_has_id:
|
|
442
522
|
inline_idx = 0
|
|
443
523
|
|
|
444
|
-
while i < len(lines)
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
165
|
-
out.append(f"{header_prefix}[{len(arr)}]{{{
|
|
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]] = []
|
|
319
|
+
attachments: list[tuple[str, Any, bool, list[str] | None]] = []
|
|
170
320
|
row_has_attachment = False
|
|
171
321
|
|
|
172
|
-
for
|
|
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
|
-
|
|
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
|
|
64
|
-
assert
|
|
65
|
-
# Rows with
|
|
66
|
-
assert "
|
|
67
|
-
assert "
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|