Skip to content

Commit 40d7166

Browse files
feat: add encode_generic for arbitrary structured data
New function encode_generic(data: Any) -> str that encodes any Python value into GCF tabular format. Unlike encode (graph Payload type only), this handles dicts, lists, and primitives using GCF's tabular encoding grammar: positional rows, pipe separators, section headers. Matches the algorithm from the TOON benchmark formatter. Backs the benchmark claims with shipped library code.
1 parent 59892e2 commit 40d7166

3 files changed

Lines changed: 320 additions & 0 deletions

File tree

src/gcf/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .decode import DecodeError, decode
3939
from .delta import encode_delta
4040
from .encode import encode
41+
from .generic import encode_generic
4142
from .session import Session, encode_with_session
4243
from .types import Components, DeltaPayload, Edge, Payload, Symbol
4344

@@ -54,6 +55,7 @@
5455
"decode",
5556
"encode",
5657
"encode_delta",
58+
"encode_generic",
5759
"encode_with_session",
5860
]
5961

src/gcf/generic.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""GCF generic encoder: serializes arbitrary Python values into GCF tabular format."""
2+
3+
from typing import Any
4+
5+
6+
def encode_generic(data: Any) -> str:
7+
"""Encode any Python value into GCF tabular format.
8+
9+
Unlike encode() which handles the graph Payload type, encode_generic()
10+
works on arbitrary dicts, lists, and primitives using GCF's tabular
11+
encoding grammar.
12+
13+
Args:
14+
data: Any Python value (dict, list, primitive, or None).
15+
16+
Returns:
17+
GCF-formatted text string.
18+
"""
19+
lines: list[str] = []
20+
_encode_value(data, lines, depth=0)
21+
return "\n".join(lines) + "\n" if lines else "\n"
22+
23+
24+
def _encode_value(value: Any, lines: list[str], depth: int) -> None:
25+
"""Dispatch encoding based on value type."""
26+
if isinstance(value, dict):
27+
_encode_dict(value, lines, depth)
28+
elif isinstance(value, list):
29+
_encode_array(value, "items", lines, depth)
30+
else:
31+
lines.append(_indent(depth) + _format_value(value))
32+
33+
34+
def _encode_dict(d: dict, lines: list[str], depth: int) -> None:
35+
"""Encode a dict into key=value pairs with section headers for nested values."""
36+
prefix = _indent(depth)
37+
for key, value in d.items():
38+
if isinstance(value, list):
39+
_encode_array(value, key, lines, depth)
40+
elif isinstance(value, dict):
41+
lines.append(f"{prefix}## {key}")
42+
_encode_dict(value, lines, depth + 1)
43+
else:
44+
lines.append(f"{prefix}{key}={_format_value(value)}")
45+
46+
47+
def _encode_array(items: list, name: str, lines: list[str], depth: int) -> None:
48+
"""Encode a list, using tabular format for uniform dict lists."""
49+
prefix = _indent(depth)
50+
51+
if not items:
52+
lines.append(f"{prefix}## {name} [0]")
53+
return
54+
55+
if _is_uniform_dict_list(items):
56+
_encode_tabular(items, name, lines, depth)
57+
else:
58+
lines.append(f"{prefix}## {name} [{len(items)}]")
59+
for i, item in enumerate(items):
60+
if isinstance(item, dict):
61+
lines.append(f"{prefix}@{i}")
62+
_encode_dict(item, lines, depth + 1)
63+
else:
64+
lines.append(f"{prefix}@{i} {_format_value(item)}")
65+
66+
67+
def _encode_tabular(items: list[dict], name: str, lines: list[str], depth: int) -> None:
68+
"""Encode a uniform list of dicts as a tabular section."""
69+
prefix = _indent(depth)
70+
71+
# Collect all keys from the first item to determine field order.
72+
all_keys = list(items[0].keys())
73+
primitive_fields = [k for k in all_keys if not isinstance(items[0][k], (dict, list))]
74+
nested_fields = [k for k in all_keys if isinstance(items[0][k], (dict, list))]
75+
76+
# Header with field names (primitive fields only in the column spec).
77+
header = f"{prefix}## {name} [{len(items)}]{{{','.join(primitive_fields)}}}"
78+
lines.append(header)
79+
80+
for i, item in enumerate(items):
81+
row_values = [_format_value(item.get(f)) for f in primitive_fields]
82+
row_str = "|".join(row_values)
83+
84+
if nested_fields:
85+
lines.append(f"{prefix}@{i} {row_str}")
86+
inner_prefix = _indent(depth + 1)
87+
for nk in nested_fields:
88+
nv = item.get(nk)
89+
if isinstance(nv, list):
90+
_encode_array(nv, nk, lines, depth + 1)
91+
elif isinstance(nv, dict):
92+
lines.append(f"{inner_prefix}## {nk}")
93+
_encode_dict(nv, lines, depth + 2)
94+
else:
95+
lines.append(f"{prefix}{row_str}")
96+
97+
98+
def _is_uniform_dict_list(items: list) -> bool:
99+
"""Check whether a list contains uniform dicts (same keys across items).
100+
101+
Samples up to the first 5 items. Considers the list uniform if key
102+
overlap is at least 70% between consecutive items and the first item.
103+
"""
104+
if not items or not isinstance(items[0], dict):
105+
return False
106+
107+
sample = items[:5]
108+
if not all(isinstance(item, dict) for item in sample):
109+
return False
110+
111+
if not sample:
112+
return False
113+
114+
reference_keys = set(sample[0].keys())
115+
if not reference_keys:
116+
return False
117+
118+
for item in sample[1:]:
119+
item_keys = set(item.keys())
120+
union = reference_keys | item_keys
121+
intersection = reference_keys & item_keys
122+
if not union or len(intersection) / len(union) < 0.7:
123+
return False
124+
125+
return True
126+
127+
128+
def _format_value(value: Any) -> str:
129+
"""Format a single value for GCF output.
130+
131+
None becomes "-". Booleans are lowercased. Numbers are unquoted.
132+
Strings containing "|" or newlines are quoted. Everything else is direct.
133+
"""
134+
if value is None:
135+
return "-"
136+
if isinstance(value, bool):
137+
return "true" if value else "false"
138+
if isinstance(value, (int, float)):
139+
return str(value)
140+
s = str(value)
141+
if "|" in s or "\n" in s:
142+
return f'"{s}"'
143+
return s
144+
145+
146+
def _indent(depth: int) -> str:
147+
"""Return indentation string for the given depth (2 spaces per level)."""
148+
return " " * depth

tests/test_generic.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Tests for GCF generic encoding."""
2+
3+
from gcf import encode_generic
4+
5+
6+
def test_encode_flat_tabular_list():
7+
"""Flat tabular list of dicts produces header with fields and pipe-separated rows."""
8+
data = {
9+
"employees": [
10+
{"name": "Alice", "role": "engineer", "level": 5},
11+
{"name": "Bob", "role": "designer", "level": 3},
12+
{"name": "Carol", "role": "manager", "level": 7},
13+
],
14+
}
15+
output = encode_generic(data)
16+
17+
assert "## employees [3]{name,role,level}" in output
18+
assert "Alice|engineer|5" in output
19+
assert "Bob|designer|3" in output
20+
assert "Carol|manager|7" in output
21+
# Pure flat rows should not have @id prefix.
22+
lines = output.strip().splitlines()
23+
row_lines = [l for l in lines if "|" in l]
24+
for line in row_lines:
25+
assert not line.strip().startswith("@")
26+
27+
28+
def test_encode_nested_dict():
29+
"""Nested dicts produce ## section headers and indented key=value pairs."""
30+
data = {
31+
"server": {
32+
"host": "localhost",
33+
"port": 8080,
34+
},
35+
"debug": True,
36+
}
37+
output = encode_generic(data)
38+
39+
assert "## server" in output
40+
assert " host=localhost" in output
41+
assert " port=8080" in output
42+
assert "debug=true" in output
43+
44+
45+
def test_encode_mixed_data():
46+
"""Mixed data with tabular rows containing nested fields uses @id prefix."""
47+
data = {
48+
"projects": [
49+
{
50+
"name": "Alpha",
51+
"status": "active",
52+
"config": {"env": "prod", "region": "us-east"},
53+
},
54+
{
55+
"name": "Beta",
56+
"status": "draft",
57+
"config": {"env": "staging", "region": "eu-west"},
58+
},
59+
],
60+
}
61+
output = encode_generic(data)
62+
63+
# Header lists only primitive fields.
64+
assert "## projects [2]{name,status}" in output
65+
# Rows with nested data get @id prefix.
66+
assert "@0 Alpha|active" in output
67+
assert "@1 Beta|draft" in output
68+
# Nested config values are indented.
69+
assert "## config" in output
70+
assert "env=" in output
71+
assert "region=" in output
72+
73+
74+
def test_encode_none_value():
75+
"""None is encoded as a dash."""
76+
data = {"value": None}
77+
output = encode_generic(data)
78+
assert "value=-" in output
79+
80+
81+
def test_encode_none_in_tabular():
82+
"""None values in tabular rows render as dashes."""
83+
data = {
84+
"items": [
85+
{"a": 1, "b": None},
86+
{"a": 2, "b": "hello"},
87+
],
88+
}
89+
output = encode_generic(data)
90+
assert "1|-" in output
91+
assert "2|hello" in output
92+
93+
94+
def test_encode_pipe_separators_in_tabular():
95+
"""Tabular rows use pipe separators between fields."""
96+
data = {
97+
"rows": [
98+
{"x": 10, "y": 20, "z": 30},
99+
{"x": 40, "y": 50, "z": 60},
100+
],
101+
}
102+
output = encode_generic(data)
103+
assert "10|20|30" in output
104+
assert "40|50|60" in output
105+
106+
107+
def test_encode_no_repeated_field_names_in_rows():
108+
"""Field names appear only in the header, not repeated in each row."""
109+
data = {
110+
"people": [
111+
{"name": "Alice", "age": 30},
112+
{"name": "Bob", "age": 25},
113+
],
114+
}
115+
output = encode_generic(data)
116+
117+
# Field names appear exactly once (in the header).
118+
lines = output.strip().splitlines()
119+
header_lines = [l for l in lines if l.strip().startswith("## people")]
120+
assert len(header_lines) == 1
121+
assert "name" in header_lines[0]
122+
assert "age" in header_lines[0]
123+
124+
# Data rows do not contain field names.
125+
data_lines = [l for l in lines if not l.strip().startswith("##")]
126+
for line in data_lines:
127+
assert "name=" not in line
128+
assert "age=" not in line
129+
130+
131+
def test_encode_boolean_formatting():
132+
"""Booleans are lowercased (true/false)."""
133+
data = {"enabled": True, "verbose": False}
134+
output = encode_generic(data)
135+
assert "enabled=true" in output
136+
assert "verbose=false" in output
137+
138+
139+
def test_encode_empty_list():
140+
"""Empty list produces a header with count zero."""
141+
data = {"items": []}
142+
output = encode_generic(data)
143+
assert "## items [0]" in output
144+
145+
146+
def test_encode_non_uniform_list():
147+
"""Non-uniform list items get @N indices without tabular headers."""
148+
data = {
149+
"things": [
150+
{"a": 1},
151+
{"completely": "different", "keys": True},
152+
],
153+
}
154+
output = encode_generic(data)
155+
assert "## things [2]" in output
156+
assert "@0" in output
157+
assert "@1" in output
158+
159+
160+
def test_encode_primitive_value():
161+
"""A bare primitive is encoded directly."""
162+
assert encode_generic(42) == "42\n"
163+
assert encode_generic("hello") == "hello\n"
164+
165+
166+
def test_encode_string_with_pipe():
167+
"""Strings containing pipe characters are quoted."""
168+
data = {"val": "a|b"}
169+
output = encode_generic(data)
170+
assert 'val="a|b"' in output

0 commit comments

Comments
 (0)