Skip to content

Commit 8f3c385

Browse files
feat: add decode_generic for tabular/generic GCF decoding
1 parent 172c297 commit 8f3c385

3 files changed

Lines changed: 262 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v0.5.0 (2026-06-06)
4+
5+
- `decode_generic`: decode any GCF text (tabular or graph) back to Python objects
6+
- `StreamEncoder`: zero-buffering streaming encode (added in v0.4.0)
7+
38
## v0.3.0 (2026-06-05)
49

510
- `encode_generic`: primitive arrays inlined as `name[N]: val1,val2,val3`

src/gcf/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from .encode import encode
4141
from .generic import encode_generic
4242
from .session import Session, encode_with_session
43+
from .decode_generic import decode_generic
4344
from .stream import StreamEncoder
4445
from .types import Components, DeltaPayload, Edge, Payload, Symbol
4546

@@ -55,6 +56,7 @@
5556
"StreamEncoder",
5657
"Symbol",
5758
"decode",
59+
"decode_generic",
5860
"encode",
5961
"encode_delta",
6062
"encode_generic",

src/gcf/decode_generic.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""GCF generic decoder: parses any GCF text (tabular or graph) back to Python objects."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from .decode import decode
8+
9+
10+
def decode_generic(input_text: str) -> Any:
11+
"""Decode any GCF text back into Python objects.
12+
13+
Handles tabular arrays, key-value pairs, nested sections, inline
14+
primitive arrays, and graph profile payloads.
15+
16+
Returns dicts, lists, and primitives matching the original structure.
17+
"""
18+
input_text = input_text.rstrip("\n\r")
19+
if not input_text:
20+
return None
21+
22+
lines = input_text.split("\n")
23+
24+
# Graph profile fallback.
25+
if lines[0].startswith("GCF "):
26+
p = decode(input_text)
27+
return {
28+
"tool": p.tool,
29+
"tokenBudget": p.token_budget,
30+
"tokensUsed": p.tokens_used,
31+
"packRoot": p.pack_root,
32+
"symbols": [
33+
{
34+
"qualifiedName": s.qualified_name,
35+
"kind": s.kind,
36+
"score": s.score,
37+
"provenance": s.provenance,
38+
"distance": s.distance,
39+
}
40+
for s in p.symbols
41+
],
42+
"edges": [
43+
{
44+
"source": e.source,
45+
"target": e.target,
46+
"edgeType": e.edge_type,
47+
**({"status": e.status} if e.status else {}),
48+
}
49+
for e in p.edges
50+
],
51+
}
52+
53+
result: dict[str, Any] = {}
54+
_parse_object(lines, 0, 0, result)
55+
return result
56+
57+
58+
def _parse_object(lines: list[str], start: int, depth: int, out: dict[str, Any]) -> int:
59+
indent = " " * depth
60+
i = start
61+
62+
while i < len(lines):
63+
raw = lines[i].rstrip("\r")
64+
if raw == "" or raw.startswith("# "):
65+
i += 1
66+
continue
67+
68+
if depth > 0 and not raw.startswith(indent):
69+
break
70+
71+
content = raw[len(indent):] if depth > 0 else raw
72+
73+
if content.startswith("## _summary"):
74+
i += 1
75+
continue
76+
77+
if content.startswith("## "):
78+
header = content[3:]
79+
bracket_idx = header.find(" [")
80+
81+
if bracket_idx >= 0:
82+
name = header[:bracket_idx]
83+
rest = header[bracket_idx + 2:]
84+
close_bracket = rest.find("]")
85+
86+
if close_bracket >= 0:
87+
after_bracket = rest[close_bracket + 1:]
88+
89+
if after_bracket.startswith("{"):
90+
field_end = after_bracket.find("}")
91+
if field_end >= 0:
92+
fields = after_bracket[1:field_end].split(",")
93+
i += 1
94+
rows, consumed = _parse_tabular_rows(lines, i, depth, fields)
95+
out[name] = rows
96+
i += consumed
97+
continue
98+
else:
99+
count_str = rest[:close_bracket]
100+
if count_str == "0":
101+
out[name] = []
102+
i += 1
103+
continue
104+
i += 1
105+
items, consumed = _parse_non_uniform_array(lines, i, depth)
106+
out[name] = items
107+
i += consumed
108+
continue
109+
110+
name = header
111+
bi = name.find(" [")
112+
if bi >= 0:
113+
name = name[:bi]
114+
i += 1
115+
nested: dict[str, Any] = {}
116+
consumed = _parse_object(lines, i, depth + 1, nested)
117+
out[name] = nested
118+
i += consumed
119+
continue
120+
121+
# Inline primitive array.
122+
bracket_idx = content.find("[")
123+
if bracket_idx > 0:
124+
colon_idx = content.find("]: ")
125+
if colon_idx > bracket_idx:
126+
name = content[:bracket_idx]
127+
vals_str = content[colon_idx + 3:]
128+
out[name] = [_parse_value(v.strip()) for v in vals_str.split(",")]
129+
i += 1
130+
continue
131+
132+
# Key=value.
133+
eq_idx = content.find("=")
134+
if eq_idx > 0:
135+
key = content[:eq_idx]
136+
val = content[eq_idx + 1:]
137+
out[key] = _parse_value(val)
138+
i += 1
139+
continue
140+
141+
i += 1
142+
143+
return i - start
144+
145+
146+
def _parse_tabular_rows(
147+
lines: list[str], start: int, depth: int, fields: list[str]
148+
) -> tuple[list[Any], int]:
149+
indent = " " * depth
150+
rows: list[Any] = []
151+
i = start
152+
153+
while i < len(lines):
154+
raw = lines[i].rstrip("\r")
155+
if raw == "":
156+
i += 1
157+
continue
158+
159+
if depth > 0 and not raw.startswith(indent):
160+
break
161+
content = raw[len(indent):] if depth > 0 else raw
162+
163+
if content.startswith("## "):
164+
break
165+
if content.startswith("# "):
166+
i += 1
167+
continue
168+
169+
row_data = content
170+
has_nested = False
171+
if row_data.startswith("@"):
172+
sp = row_data.find(" ")
173+
if sp > 0:
174+
row_data = row_data[sp + 1:]
175+
has_nested = True
176+
177+
vals = row_data.split("|")
178+
row: dict[str, Any] = {}
179+
for j, f in enumerate(fields):
180+
row[f] = _parse_value(vals[j]) if j < len(vals) else None
181+
182+
i += 1
183+
184+
if has_nested:
185+
nested_indent = indent + " "
186+
while i < len(lines):
187+
nl = lines[i].rstrip("\r")
188+
if not nl.startswith(nested_indent):
189+
break
190+
nc = nl[len(nested_indent):]
191+
192+
if nc.startswith("."):
193+
field_name = nc[1:]
194+
i += 1
195+
nested: dict[str, Any] = {}
196+
consumed = _parse_object(lines, i, depth + 2, nested)
197+
row[field_name] = nested
198+
i += consumed
199+
else:
200+
break
201+
202+
rows.append(row)
203+
204+
return rows, i - start
205+
206+
207+
def _parse_non_uniform_array(
208+
lines: list[str], start: int, depth: int
209+
) -> tuple[list[Any], int]:
210+
indent = " " * depth
211+
items: list[Any] = []
212+
i = start
213+
214+
while i < len(lines):
215+
raw = lines[i].rstrip("\r")
216+
if raw == "":
217+
i += 1
218+
continue
219+
if depth > 0 and not raw.startswith(indent):
220+
break
221+
content = raw[len(indent):] if depth > 0 else raw
222+
if content.startswith("## "):
223+
break
224+
225+
if content.startswith("@"):
226+
sp = content.find(" ")
227+
if sp > 0:
228+
items.append(_parse_value(content[sp + 1:]))
229+
i += 1
230+
else:
231+
break
232+
233+
return items, i - start
234+
235+
236+
def _parse_value(s: str) -> Any:
237+
if s == "-":
238+
return None
239+
if s == "true":
240+
return True
241+
if s == "false":
242+
return False
243+
if s == '""':
244+
return ""
245+
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
246+
return s[1:-1].replace('\\"', '"').replace("\\\\", "\\")
247+
try:
248+
return int(s)
249+
except ValueError:
250+
pass
251+
try:
252+
return float(s)
253+
except ValueError:
254+
pass
255+
return s

0 commit comments

Comments
 (0)