Skip to content

Commit 0696cab

Browse files
isonantic 1.1.0: delegate ISON parsing to the ison-py core parser
The Python isonantic package carried its own embedded mini-parser (_parse_ison_to_blocks/_tokenize_line/_infer_type) that had drifted from the core: quoted tokens were re-inferred ("123" -> int, "true" -> bool), short rows were silently dropped, and malformed input was silently skipped. Duplicated parser logic is how the ISONL bug appeared seven times across this repo; this removes the last copy. - _parse_ison_to_blocks now delegates to ison_parser.loads and only adapts the parsed Document (core References mapped to isonantic References); the private tokenizer/type-inference helpers are gone - Syntax authority stays in the core: malformed ISON raises ISONSyntaxError; short rows surface as validation errors on the missing fields instead of vanishing - LLM recovery hardened without re-implementing leniency: _drop_unparseable_lines iteratively removes the exact lines the core parser rejects (LLM chatter) and retries - New TestCoreParserDelegation suite pins the contract; 44 tests pass - Version 1.1.0 (behavioral change); requires ison-py>=1.0.3 The sibling isonantic-ts/go/rust/cpp packages are validation-only (no embedded text parsing) and need no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 27749f8 commit 0696cab

3 files changed

Lines changed: 144 additions & 153 deletions

File tree

isonantic/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "isonantic"
7-
version = "1.0.1"
7+
version = "1.1.0"
88
description = "ISONantic - A Pydantic-like data validation library for ISON format"
99
readme = "README.md"
1010
license = {text = "MIT"}

isonantic/src/isonantic/parsing.py

Lines changed: 82 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
Any, Dict, Generic, List, Optional, Type, TypeVar, Union
1111
)
1212

13+
import ison_parser
14+
1315
from .models import ISONModel, TableModel
1416
from .fields import Reference
1517
from .exceptions import ValidationError, LLMParseError
@@ -197,6 +199,29 @@ def parse_llm_output(
197199
suggestion="Check that all required fields are present and properly formatted",
198200
recoverable=True,
199201
)
202+
except ison_parser.ISONSyntaxError as e:
203+
# LLM chatter (e.g. trailing prose) can survive extraction; drop the
204+
# lines the core parser rejects and retry before giving up
205+
if auto_fix:
206+
cleaned = _drop_unparseable_lines(ison_data)
207+
if cleaned is not None:
208+
try:
209+
return parse_ison(cleaned, model, strict=strict)
210+
except ValidationError as e2:
211+
raise LLMParseError(
212+
f"Validation failed: {e2}",
213+
raw_output=response,
214+
extracted_ison=cleaned,
215+
suggestion="Check that all required fields are present and properly formatted",
216+
recoverable=True,
217+
)
218+
except Exception:
219+
pass
220+
raise LLMParseError(
221+
f"Parse error: {e}",
222+
raw_output=response,
223+
extracted_ison=ison_data,
224+
)
200225
except Exception as e:
201226
raise LLMParseError(
202227
f"Parse error: {e}",
@@ -245,167 +270,48 @@ def validate_llm_ison(
245270
def _parse_ison_to_blocks(data: str) -> List[Dict[str, Any]]:
246271
"""
247272
Parse raw ISON string to block dictionaries.
248-
273+
274+
Delegates format parsing to the core `ison_parser` package (the single
275+
source of truth for ISON syntax, escaping, and type inference); this
276+
layer only adapts the parsed Document to isonantic's block-dict shape
277+
and maps core References to isonantic References.
278+
249279
Returns list of blocks with:
250-
- kind: "table", "object", or "meta"
280+
- kind: block kind (e.g. "table", "object", "meta")
251281
- name: block name
252282
- fields: list of field names
253-
- rows: list of row dictionaries
283+
- rows: list of row dictionaries (dot-path fields nested)
254284
"""
255-
blocks = []
256-
lines = data.strip().split("\n")
257-
258-
current_block = None
259-
260-
i = 0
261-
while i < len(lines):
262-
line = lines[i].strip()
263-
264-
# Skip empty lines and comments
265-
if not line or line.startswith("#"):
266-
i += 1
267-
continue
268-
269-
# Check for block header
270-
if "." in line and not line.startswith('"'):
271-
parts = line.split(".", 1)
272-
if parts[0] in ("table", "object", "meta"):
273-
# Save previous block
274-
if current_block:
275-
blocks.append(current_block)
276-
277-
# Start new block
278-
current_block = {
279-
"kind": parts[0],
280-
"name": parts[1],
281-
"fields": [],
282-
"rows": [],
283-
}
284-
i += 1
285-
286-
# Next line should be fields
287-
if i < len(lines):
288-
field_line = lines[i].strip()
289-
if field_line and not field_line.startswith("#"):
290-
current_block["fields"] = _tokenize_line(field_line)
291-
i += 1
292-
293-
continue
294-
295-
# Data row
296-
if current_block and current_block["fields"]:
297-
tokens = _tokenize_line(line)
298-
299-
if len(tokens) == len(current_block["fields"]):
300-
row = {}
301-
for j, field in enumerate(current_block["fields"]):
302-
value = _infer_type(tokens[j])
303-
_set_nested_value(row, field, value)
304-
305-
current_block["rows"].append(row)
306-
307-
i += 1
308-
309-
# Save last block
310-
if current_block:
311-
blocks.append(current_block)
312-
313-
return blocks
314-
285+
if not data or not data.strip():
286+
return []
315287

316-
def _tokenize_line(line: str) -> List[str]:
317-
"""Tokenize a line handling quoted strings"""
318-
tokens = []
319-
pos = 0
320-
321-
while pos < len(line):
322-
# Skip whitespace
323-
while pos < len(line) and line[pos] in " \t":
324-
pos += 1
325-
326-
if pos >= len(line):
327-
break
328-
329-
if line[pos] == '"':
330-
# Quoted string
331-
pos += 1
332-
start = pos
333-
result = []
334-
335-
while pos < len(line):
336-
if line[pos] == "\\":
337-
pos += 1
338-
if pos < len(line):
339-
escape_map = {
340-
'"': '"',
341-
'\\': '\\',
342-
'n': '\n',
343-
't': '\t',
344-
'r': '\r',
345-
}
346-
result.append(escape_map.get(line[pos], line[pos]))
347-
pos += 1
348-
elif line[pos] == '"':
349-
pos += 1
350-
break
351-
else:
352-
result.append(line[pos])
353-
pos += 1
354-
355-
tokens.append("".join(result))
356-
else:
357-
# Unquoted token
358-
start = pos
359-
while pos < len(line) and line[pos] not in " \t":
360-
pos += 1
361-
tokens.append(line[start:pos])
362-
363-
return tokens
288+
doc = ison_parser.loads(data)
364289

290+
blocks = []
291+
for block in doc.blocks:
292+
rows = [
293+
{key: _from_core_value(value) for key, value in row.items()}
294+
for row in block.rows
295+
]
296+
blocks.append({
297+
"kind": block.kind,
298+
"name": block.name,
299+
"fields": list(block.fields),
300+
"rows": rows,
301+
})
365302

366-
def _infer_type(token: str) -> Any:
367-
"""Infer type from token"""
368-
# Boolean
369-
if token == "true":
370-
return True
371-
if token == "false":
372-
return False
373-
374-
# Null
375-
if token == "null":
376-
return None
377-
378-
# Integer
379-
if re.match(r"^-?[0-9]+$", token):
380-
return int(token)
381-
382-
# Float
383-
if re.match(r"^-?[0-9]+\.[0-9]+$", token):
384-
return float(token)
385-
386-
# Reference
387-
if token.startswith(":"):
388-
value = token[1:]
389-
if ":" in value:
390-
parts = value.split(":", 1)
391-
return Reference(id=parts[1], ref_type=parts[0])
392-
return Reference(id=value)
393-
394-
# String
395-
return token
303+
return blocks
396304

397305

398-
def _set_nested_value(obj: dict, path: str, value: Any):
399-
"""Set value in nested dict using dot-path"""
400-
parts = path.split(".")
401-
current = obj
402-
403-
for i, part in enumerate(parts[:-1]):
404-
if part not in current:
405-
current[part] = {}
406-
current = current[part]
407-
408-
current[parts[-1]] = value
306+
def _from_core_value(value: Any) -> Any:
307+
"""Map a core ison_parser value to isonantic's types"""
308+
if isinstance(value, ison_parser.Reference):
309+
return Reference(id=value.id, ref_type=value.type)
310+
if isinstance(value, dict):
311+
return {k: _from_core_value(v) for k, v in value.items()}
312+
if isinstance(value, list):
313+
return [_from_core_value(v) for v in value]
314+
return value
409315

410316

411317
def _extract_ison(response: str) -> Optional[str]:
@@ -455,6 +361,30 @@ def _extract_ison(response: str) -> Optional[str]:
455361
return None
456362

457363

364+
def _drop_unparseable_lines(ison_data: str, max_drops: int = 20) -> Optional[str]:
365+
"""
366+
Recovery pass: iteratively drop lines the core parser rejects.
367+
368+
LLM responses can leave prose interleaved with the extracted ISON. The
369+
core parser stays the sole syntax authority — we never re-implement
370+
leniency, we just remove the exact lines it points at and retry.
371+
372+
Returns the cleaned ISON string, or None if it cannot be salvaged.
373+
"""
374+
lines = ison_data.split("\n")
375+
for _ in range(max_drops):
376+
try:
377+
ison_parser.loads("\n".join(lines))
378+
return "\n".join(lines)
379+
except ison_parser.ISONSyntaxError as e:
380+
if not e.line or not (1 <= e.line <= len(lines)):
381+
return None
382+
del lines[e.line - 1]
383+
except Exception:
384+
return None
385+
return None
386+
387+
458388
def _auto_fix_ison(ison_data: str) -> str:
459389
"""Apply common fixes to LLM-generated ISON"""
460390
lines = ison_data.split("\n")

isonantic/tests/test_isonantic.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,67 @@ def test_special_characters_in_strings(self):
638638
assert "\t" in teams[0].name
639639

640640

641+
# =============================================================================
642+
# Core Parser Delegation
643+
# =============================================================================
644+
645+
class TestCoreParserDelegation:
646+
"""
647+
Pin the contract that isonantic delegates ISON syntax to the core
648+
ison_parser package instead of carrying its own duplicate parser.
649+
"""
650+
651+
def test_quoted_tokens_stay_strings(self):
652+
"""Quoted numerics/keywords must not be re-inferred into other types"""
653+
from isonantic.parsing import _parse_ison_to_blocks
654+
655+
blocks = _parse_ison_to_blocks('table.teams\nid name budget\n1 "123" 100.0')
656+
assert blocks[0]["rows"][0]["name"] == "123"
657+
assert isinstance(blocks[0]["rows"][0]["name"], str)
658+
659+
blocks = _parse_ison_to_blocks('table.teams\nid name budget\n2 "true" 1.0')
660+
assert blocks[0]["rows"][0]["name"] == "true"
661+
assert isinstance(blocks[0]["rows"][0]["name"], str)
662+
663+
def test_core_reference_mapped_to_isonantic_reference(self):
664+
from isonantic.parsing import _parse_ison_to_blocks
665+
666+
blocks = _parse_ison_to_blocks('table.users\nid team\n1 :team:7')
667+
ref = blocks[0]["rows"][0]["team"]
668+
assert isinstance(ref, Reference)
669+
assert ref.id == "7"
670+
assert ref.ref_type == "team"
671+
672+
def test_short_rows_padded_not_silently_dropped(self):
673+
"""
674+
The old embedded parser silently dropped rows whose token count
675+
didn't match the fields; delegation surfaces them as validation
676+
errors on the missing required fields instead.
677+
"""
678+
ison = "table.teams\nid name budget\n1"
679+
with pytest.raises(ValidationError):
680+
parse_ison(ison, Team)
681+
682+
def test_syntax_errors_surface_from_core(self):
683+
import ison_parser
684+
685+
with pytest.raises(ison_parser.ISONSyntaxError):
686+
parse_ison('table.teams\nid name budget\n1 "unterminated', Team)
687+
688+
def test_drop_unparseable_lines_recovery(self):
689+
"""LLM chatter lines the core parser rejects are dropped and retried"""
690+
from isonantic.parsing import _drop_unparseable_lines
691+
692+
chatty = 'table.teams\nid name budget\n1 "A" 10.0\n\nHope this helps!'
693+
cleaned = _drop_unparseable_lines(chatty)
694+
assert cleaned is not None
695+
assert "Hope this helps!" not in cleaned
696+
697+
teams = parse_ison(cleaned, Team)
698+
assert len(teams) == 1
699+
assert teams[0].name == "A"
700+
701+
641702
# =============================================================================
642703
# Run Tests
643704
# =============================================================================

0 commit comments

Comments
 (0)