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
33from __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+
272357def _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