Skip to content

Commit e3d18e6

Browse files
refactor: remove version references, clean up function names
- Remove v2.0 from comments - Rename _parse_attachment_v3 to _parse_attachment
1 parent 85270ff commit e3d18e6

4 files changed

Lines changed: 342 additions & 38 deletions

File tree

src/gcf/decode_generic.py

Lines changed: 193 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""GCF v2.0 generic decoder: parses GCF generic or graph profile text back to Python objects."""
1+
"""GCF generic generic decoder: parses GCF generic or graph profile text back to Python objects."""
22

33
from __future__ import annotations
44

@@ -269,13 +269,102 @@ def _find_closing_brace(s: str) -> int:
269269
return -1
270270

271271

272+
def _parse_attachment_name(rest: str) -> tuple[str, str]:
273+
if rest and rest[0] == '"':
274+
j = 1
275+
while j < len(rest):
276+
if rest[j] == "\\":
277+
j += 2
278+
continue
279+
if rest[j] == '"':
280+
name = parse_quoted_string(rest[:j + 1])
281+
return name, rest[j + 1:]
282+
j += 1
283+
return "", rest
284+
sp = rest.find(" ")
285+
if sp >= 0:
286+
return rest[:sp], rest[sp:]
287+
return rest, ""
288+
289+
290+
def _parse_attachment(
291+
lines: list[str], line_idx: int, rest: str, depth: int, shared_schemas: dict[str, list[str]]
292+
) -> tuple[str, Any, int, list[str] | None]:
293+
"""Returns (name, value, consumed, parsed_fields)."""
294+
name, after_name = _parse_attachment_name(rest)
295+
if not name and not rest.startswith('""'):
296+
raise ValueError(f"invalid attachment: {rest}")
297+
after_name = after_name.lstrip()
298+
299+
if after_name.startswith("{}"):
300+
nested: dict[str, Any] = {}
301+
consumed = _parse_object_body(lines, line_idx + 1, depth, nested)
302+
return name, nested, consumed + 1, None
303+
304+
if after_name.startswith("["):
305+
cb = after_name.find("]")
306+
if cb < 0:
307+
raise ValueError("invalid_count: missing ]")
308+
after_close = after_name[cb + 1:]
309+
310+
# [N]{fields}: has its own schema.
311+
if after_close.startswith("{"):
312+
end_brace = _find_closing_brace(after_close)
313+
parsed_fields: list[str] | None = None
314+
if end_brace >= 0:
315+
try:
316+
parsed_fields = split_field_decl(after_close[:end_brace + 1])
317+
except Exception:
318+
pass
319+
arr, consumed = _parse_array_from_header(lines, line_idx, depth, after_name)
320+
return name, arr, consumed, parsed_fields
321+
322+
# [N]: inline primitive array: don't use shared schema.
323+
if after_close.startswith(": ") or after_close == ":":
324+
arr, consumed = _parse_array_from_header(lines, line_idx, depth, after_name)
325+
return name, arr, consumed, None
326+
327+
# [N] without {fields}: check for shared schema.
328+
if name in shared_schemas:
329+
sf = shared_schemas[name]
330+
count_str = after_name[1:cb]
331+
count = -1 if count_str == "?" else int(count_str)
332+
if count == 0:
333+
return name, [], 1, None
334+
# Peek: if next line starts with @, it's expanded.
335+
use_shared = True
336+
next_idx = line_idx + 1
337+
ind = " " * depth
338+
if next_idx < len(lines):
339+
nc = lines[next_idx]
340+
if depth > 0 and nc.startswith(ind):
341+
nc = nc[len(ind):]
342+
if nc.lstrip().startswith("@"):
343+
use_shared = False
344+
if use_shared:
345+
rows, consumed = _parse_tabular_body(lines, line_idx + 1, depth, sf, count)
346+
if count >= 0 and len(rows) != count:
347+
raise ValueError(f"count_mismatch: declared {count}, got {len(rows)}")
348+
return name, rows, consumed + 1, None
349+
350+
# No shared schema: standard parsing.
351+
arr, consumed = _parse_array_from_header(lines, line_idx, depth, after_name)
352+
return name, arr, consumed, None
353+
354+
raise ValueError(f"invalid attachment form: {after_name}")
355+
356+
272357
def _parse_tabular_body(
273358
lines: list[str], start: int, depth: int, fields: list[str], expected_count: int
274359
) -> tuple[list[Any], int]:
275360
ind = " " * depth
276361
rows: list[Any] = []
277362
i = start
278363

364+
# Track inline schemas and shared array schemas.
365+
inline_schemas: dict[str, list[str]] = {}
366+
shared_array_schemas: dict[str, list[str]] = {}
367+
279368
while i < len(lines):
280369
line = lines[i]
281370
if depth > 0 and not line.startswith(ind):
@@ -286,54 +375,137 @@ def _parse_tabular_body(
286375
if content and content[0] == " ":
287376
trimmed = content.lstrip()
288377
if trimmed.startswith("."):
289-
raise ValueError(f"orphan_attachment: {trimmed}")
378+
break
290379
break
291380

381+
# Strip @N prefix (must be @digits).
292382
row_data = content
293383
row_has_id = False
294384
if row_data.startswith("@"):
295385
sp = row_data.find(" ")
296386
if sp > 0:
297-
row_data = row_data[sp + 1:]
298-
row_has_id = True
387+
id_str = row_data[1:sp]
388+
if id_str.isdigit():
389+
row_data = row_data[sp + 1:]
390+
row_has_id = True
299391

300392
vals = split_respecting_quotes(row_data, "|")
301393
if len(vals) != len(fields):
302394
raise ValueError(f"row_width_mismatch: expected {len(fields)}, got {len(vals)}")
303395

396+
# Parse cells.
304397
cell_values: dict[str, Any] = {}
305-
attachment_fields: list[str] = []
398+
traditional_att_fields: list[str] = []
399+
inline_att_fields: list[str] = []
400+
inline_att_order: list[str] = []
306401
missing_fields: set[str] = set()
402+
307403
for j, f in enumerate(fields):
308-
parsed = parse_scalar(vals[j], tabular_context=True)
404+
cell_val = vals[j]
405+
406+
# Check for ^{fields} inline schema declaration.
407+
if cell_val.startswith("^{") and cell_val.endswith("}"):
408+
schema_str = cell_val[1:]
409+
ifs = split_field_decl(schema_str)
410+
inline_schemas[f] = ifs
411+
inline_att_fields.append(f)
412+
inline_att_order.append(f)
413+
continue
414+
415+
parsed = parse_scalar(cell_val, tabular_context=True)
309416
if parsed is MISSING:
310417
missing_fields.add(f)
311418
elif parsed is ATTACHMENT:
312-
attachment_fields.append(f)
419+
if f in inline_schemas:
420+
inline_att_fields.append(f)
421+
inline_att_order.append(f)
422+
else:
423+
traditional_att_fields.append(f)
313424
else:
314425
cell_values[f] = parsed
315426
i += 1
316427

428+
# Parse attachments in line order.
429+
all_att_fields = traditional_att_fields + inline_att_fields
317430
attachment_values: dict[str, Any] = {}
318-
if row_has_id and attachment_fields:
319-
att_indent = ind + " "
320-
while i < len(lines):
321-
al = lines[i]
322-
if not al.startswith(att_indent):
431+
432+
if row_has_id and all_att_fields:
433+
inline_idx = 0
434+
435+
while i < len(lines) and len(attachment_values) < len(all_att_fields):
436+
a_line = lines[i]
437+
a_content: str | None = None
438+
if a_line.startswith(ind + " "):
439+
a_content = a_line[len(ind) + 2:]
440+
elif depth == 0 or a_line.startswith(ind):
441+
a_content = a_line[len(ind):] if depth > 0 else a_line
442+
else:
323443
break
324-
ac = al[len(att_indent):]
325-
if not ac.startswith("."):
444+
if a_content is None:
326445
break
327-
name, val, consumed = _parse_attachment(lines, i, ac[1:], depth + 2)
328-
if name in attachment_values:
329-
raise ValueError(f"duplicate_attachment: {name}")
330-
attachment_values[name] = val
331-
i += consumed
332-
for f in attachment_fields:
446+
447+
# Line starts with ".": traditional or prefixed inline.
448+
if a_content.startswith("."):
449+
rest = a_content[1:]
450+
att_name, after_name = _parse_attachment_name(rest)
451+
after_name_stripped = after_name.lstrip()
452+
453+
# Prefixed inline data.
454+
ifs = inline_schemas.get(att_name)
455+
if ifs and not after_name_stripped.startswith("{}") and not after_name_stripped.startswith("["):
456+
inline_vals = split_respecting_quotes(after_name_stripped, "|")
457+
if len(inline_vals) != len(ifs):
458+
raise ValueError(f"inline_width_mismatch: {att_name} expected {len(ifs)}, got {len(inline_vals)}")
459+
obj: dict[str, Any] = {}
460+
for k, inf in enumerate(ifs):
461+
p = parse_scalar(inline_vals[k], tabular_context=True)
462+
if p is not MISSING:
463+
obj[inf] = p
464+
attachment_values[att_name] = obj
465+
i += 1
466+
continue
467+
468+
# Traditional attachment.
469+
att_name_t, att_val, consumed, parsed_fields = _parse_attachment(
470+
lines, i, rest, depth + 2, shared_array_schemas
471+
)
472+
if not rows and parsed_fields:
473+
shared_array_schemas[att_name_t] = parsed_fields
474+
attachment_values[att_name_t] = att_val
475+
i += consumed
476+
continue
477+
478+
# No-prefix line: positional inline data.
479+
found_inline = False
480+
next_inline_field = ""
481+
while inline_idx < len(inline_att_order):
482+
candidate = inline_att_order[inline_idx]
483+
if candidate not in attachment_values:
484+
next_inline_field = candidate
485+
found_inline = True
486+
break
487+
inline_idx += 1
488+
if not found_inline:
489+
break
490+
491+
ifs = inline_schemas[next_inline_field]
492+
inline_vals = split_respecting_quotes(a_content, "|")
493+
if len(inline_vals) != len(ifs):
494+
raise ValueError(f"inline_width_mismatch: {next_inline_field} expected {len(ifs)}, got {len(inline_vals)}")
495+
obj = {}
496+
for k, inf in enumerate(ifs):
497+
p = parse_scalar(inline_vals[k], tabular_context=True)
498+
if p is not MISSING:
499+
obj[inf] = p
500+
attachment_values[next_inline_field] = obj
501+
inline_idx += 1
502+
i += 1
503+
504+
for f in all_att_fields:
333505
if f not in attachment_values:
334506
raise ValueError(f"missing_attachment: {f}")
335507

336-
if not row_has_id or not attachment_fields:
508+
if not row_has_id or not all_att_fields:
337509
att_indent = ind + " "
338510
if i < len(lines) and lines[i].startswith(att_indent):
339511
peek = lines[i][len(att_indent):]

0 commit comments

Comments
 (0)