Skip to content

Commit 8a92fa4

Browse files
azonerclaude
andauthored
feat: add errh_json JSON error visitor (issue #50) (#163)
Adds pyx12/errh_json.py — a visitor over the err_handler tree that emits a nested JSON document mirroring the ISA / GS / ST / segment / element hierarchy. Inherits from pyx12.error_visitor.error_visitor (same shape as error_999_visitor) and accumulates a Python dict during the walk, dumped once via json.dump in visit_root_post. This is the first slice of the issue #50 work — no callers wired up yet; pyx12/x12n_document.py and pyx12/scripts/x12valid.py integration follows in subsequent PRs per the plan. JSON schema: - interchanges[] -> groups[] -> transactions[] -> segments[] -> elements[] - each level carries its identifying fields plus an "errors" array - errors at ISA/GS/ST level are (cde, str) tuples; seg/ele errors are (cde, str, val) tuples - empty branches still emitted (schema is structural, not pruned) Tests build a synthetic err_handler tree exercising every level without invoking the document orchestrator. Coverage: 9 tests, all levels, empty-tree case, and indent argument pass-through. Verification: - pytest pyx12/test/: 548 passed (539 + 9 new) - mypy --strict pyx12: clean (85 files) - ruff check / ruff format --check: clean Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ad53f1b commit 8a92fa4

2 files changed

Lines changed: 352 additions & 0 deletions

File tree

pyx12/errh_json.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
######################################################################
2+
# Copyright
3+
# John Holland <john@zoner.org>
4+
# All rights reserved.
5+
#
6+
# This software is licensed as described in the file LICENSE.txt, which
7+
# you should have received as part of this distribution.
8+
#
9+
######################################################################
10+
11+
"""
12+
Generates a JSON document of validation errors.
13+
Visitor - Visits an error_handler composite (issue #50).
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import json
19+
from typing import Any, TextIO
20+
21+
import pyx12.error_visitor
22+
23+
24+
class errh_json_visitor(pyx12.error_visitor.error_visitor):
25+
"""
26+
Visit an error_handler composite. Accumulate a nested dict mirroring
27+
the ISA / GS / ST / segment / element hierarchy and dump it once as
28+
JSON in visit_root_post.
29+
30+
Streaming JSON during the walk would require closing arrays mid-walk;
31+
the err_handler tree always fits in memory for any X12 document, so
32+
accumulate-then-dump is the right shape.
33+
"""
34+
35+
fd: TextIO
36+
indent: int | None
37+
doc: dict[str, Any]
38+
_stack: list[dict[str, Any]]
39+
_cur_seg: dict[str, Any] | None
40+
41+
def __init__(self, fd: TextIO, indent: int | None = 2) -> None:
42+
"""
43+
:param fd: target file
44+
:type fd: file descriptor
45+
:param indent: json.dump indent argument; pass None for compact output
46+
:type indent: int | None
47+
"""
48+
self.fd = fd
49+
self.indent = indent
50+
self.doc = {"interchanges": []}
51+
self._stack = []
52+
self._cur_seg = None
53+
54+
def visit_root_post(self, errh: Any) -> None:
55+
"""
56+
:param errh: Error handler
57+
:type errh: L{error_handler.err_handler}
58+
"""
59+
json.dump(self.doc, self.fd, indent=self.indent, default=str)
60+
61+
def visit_isa_pre(self, err_isa: Any) -> None:
62+
"""
63+
:param err_isa: ISA error node
64+
:type err_isa: L{error_handler.err_isa}
65+
"""
66+
isa_dict: dict[str, Any] = {
67+
"isa_trn_set_id": err_isa.isa_trn_set_id,
68+
"ta1_req": err_isa.ta1_req,
69+
"orig_date": err_isa.orig_date,
70+
"orig_time": err_isa.orig_time,
71+
"cur_line": err_isa.get_cur_line(),
72+
"errors": [],
73+
"groups": [],
74+
}
75+
self.doc["interchanges"].append(isa_dict)
76+
self._stack.append(isa_dict)
77+
78+
def visit_isa_post(self, err_isa: Any) -> None:
79+
isa_dict = self._stack[-1]
80+
isa_dict["errors"] = [
81+
{"err_cde": cde, "err_str": err_str} for (cde, err_str) in err_isa.errors
82+
]
83+
self._stack.pop()
84+
85+
def visit_gs_pre(self, err_gs: Any) -> None:
86+
gs_dict: dict[str, Any] = {
87+
"gs_control_num": err_gs.gs_control_num,
88+
"fic": err_gs.fic,
89+
"vriic": err_gs.vriic,
90+
"ack_code": err_gs.ack_code,
91+
"st_count_orig": err_gs.st_count_orig,
92+
"st_count_recv": err_gs.st_count_recv,
93+
"cur_line": err_gs.get_cur_line(),
94+
"errors": [],
95+
"transactions": [],
96+
}
97+
self._stack[-1]["groups"].append(gs_dict)
98+
self._stack.append(gs_dict)
99+
100+
def visit_gs_post(self, err_gs: Any) -> None:
101+
gs_dict = self._stack[-1]
102+
gs_dict["errors"] = [
103+
{"err_cde": cde, "err_str": err_str} for (cde, err_str) in err_gs.errors
104+
]
105+
self._stack.pop()
106+
107+
def visit_st_pre(self, err_st: Any) -> None:
108+
st_dict: dict[str, Any] = {
109+
"trn_set_id": err_st.trn_set_id,
110+
"trn_set_control_num": err_st.trn_set_control_num,
111+
"vriic": err_st.vriic,
112+
"ack_code": err_st.ack_code,
113+
"cur_line": err_st.get_cur_line(),
114+
"errors": [],
115+
"segments": [],
116+
}
117+
self._stack[-1]["transactions"].append(st_dict)
118+
self._stack.append(st_dict)
119+
120+
def visit_st_post(self, err_st: Any) -> None:
121+
st_dict = self._stack[-1]
122+
st_dict["errors"] = [
123+
{"err_cde": cde, "err_str": err_str} for (cde, err_str) in err_st.errors
124+
]
125+
self._stack.pop()
126+
127+
def visit_seg(self, err_seg: Any) -> None:
128+
seg_dict: dict[str, Any] = {
129+
"seg_id": err_seg.seg_id,
130+
"seg_count": err_seg.seg_count,
131+
"pos": err_seg.pos,
132+
"name": err_seg.name,
133+
"ls_id": err_seg.ls_id,
134+
"cur_line": err_seg.get_cur_line(),
135+
"errors": [
136+
{"err_cde": cde, "err_str": err_str, "err_val": err_val}
137+
for (cde, err_str, err_val) in err_seg.errors
138+
],
139+
"elements": [],
140+
}
141+
self._stack[-1]["segments"].append(seg_dict)
142+
self._cur_seg = seg_dict
143+
144+
def visit_ele(self, err_ele: Any) -> None:
145+
# err_ele.accept is only invoked from err_seg.accept (ISA / GS / ST
146+
# accept methods do not walk their .elements list), so a non-None
147+
# _cur_seg is guaranteed at this point.
148+
if self._cur_seg is None:
149+
return
150+
ele_dict: dict[str, Any] = {
151+
"ele_pos": err_ele.ele_pos,
152+
"subele_pos": err_ele.subele_pos,
153+
"repeat_pos": err_ele.repeat_pos,
154+
"ele_ref_num": err_ele.ele_ref_num,
155+
"name": err_ele.name,
156+
"errors": [
157+
{"err_cde": cde, "err_str": err_str, "err_val": err_val}
158+
for (cde, err_str, err_val) in err_ele.errors
159+
],
160+
}
161+
self._cur_seg["elements"].append(ele_dict)

pyx12/test/test_errh_json.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import json
2+
import unittest
3+
from io import StringIO
4+
from unittest.mock import MagicMock
5+
6+
import pyx12.errh_json
7+
import pyx12.error_handler
8+
import pyx12.segment
9+
10+
11+
def _fake_src(isa_id="000000001", gs_id="1", st_id="0001", cur_line=1, st_count=1):
12+
src = MagicMock()
13+
src.get_isa_id.return_value = isa_id
14+
src.get_gs_id.return_value = gs_id
15+
src.get_st_id.return_value = st_id
16+
src.get_cur_line.return_value = cur_line
17+
src.st_count = st_count
18+
return src
19+
20+
21+
def _isa_segment():
22+
raw = (
23+
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER "
24+
"*200101*1200*U*00501*000000001*0*P*:~"
25+
)
26+
return pyx12.segment.Segment(raw, "~", "*", ":")
27+
28+
29+
def _gs_segment():
30+
return pyx12.segment.Segment(
31+
"GS*HC*SENDER*RECEIVER*20200101*1200*1*X*005010X222A1", "~", "*", ":"
32+
)
33+
34+
35+
def _st_segment():
36+
return pyx12.segment.Segment("ST*837*0001*005010X222A1", "~", "*", ":")
37+
38+
39+
def _build_tree_with_errors() -> pyx12.error_handler.err_handler:
40+
"""Build a small but complete err_handler tree exercising every level
41+
that the JSON visitor cares about: an ISA-level error, a GS-level
42+
error, an ST-level error, a segment with seg-level errors plus two
43+
element children carrying their own errors."""
44+
eh = pyx12.error_handler.err_handler()
45+
src = _fake_src()
46+
eh.add_isa_loop(_isa_segment(), src)
47+
eh.cur_isa_node.add_error("100", "ISA-level error")
48+
49+
eh.add_gs_loop(_gs_segment(), src)
50+
eh.cur_gs_node.add_error("4", "GS-level error")
51+
eh.cur_gs_node.ack_code = "R"
52+
eh.cur_gs_node.st_count_orig = 1
53+
eh.cur_gs_node.st_count_recv = 1
54+
55+
eh.add_st_loop(_st_segment(), src)
56+
eh.cur_st_node.add_error("23", "ST-level error")
57+
eh.cur_st_node.ack_code = "R"
58+
59+
seg_map = MagicMock()
60+
seg_map.name = "Claim Information"
61+
seg_map.pos = 130
62+
eh.add_seg(seg_map, _seg_segment_clm(), seg_count=5, cur_line=42, ls_id="2300")
63+
eh.seg_error("8", "Segment has data element errors", None, 42)
64+
65+
ele_map_a = MagicMock()
66+
ele_map_a.data_ele = "1028"
67+
ele_map_a.name = "Claim Submitter's Identifier"
68+
ele_map_a.seq = 1
69+
ele_map_a.parent.is_composite.return_value = False
70+
eh.add_ele(ele_map_a)
71+
eh.ele_error("7", "Invalid Code Value", "BAD", "CLM01")
72+
73+
ele_map_b = MagicMock()
74+
ele_map_b.data_ele = "782"
75+
ele_map_b.name = "Monetary Amount"
76+
ele_map_b.seq = 2
77+
ele_map_b.parent.is_composite.return_value = False
78+
eh.add_ele(ele_map_b)
79+
eh.ele_error("6", "Invalid character in data element", "X", "CLM02")
80+
81+
return eh
82+
83+
84+
def _seg_segment_clm():
85+
return pyx12.segment.Segment("CLM*ABC*100***11:B:1", "~", "*", ":")
86+
87+
88+
class ErrhJsonVisitorOutput(unittest.TestCase):
89+
"""The JSON visitor walks the err_handler tree and emits a nested
90+
document with one entry per ISA / GS / ST / segment / element level."""
91+
92+
def setUp(self):
93+
eh = _build_tree_with_errors()
94+
out = StringIO()
95+
visitor = pyx12.errh_json.errh_json_visitor(out, indent=None)
96+
eh.accept(visitor)
97+
out.seek(0)
98+
self.doc = json.loads(out.read())
99+
100+
def test_top_level_shape(self):
101+
self.assertEqual(list(self.doc.keys()), ["interchanges"])
102+
self.assertEqual(len(self.doc["interchanges"]), 1)
103+
104+
def test_isa_level_error_captured(self):
105+
isa = self.doc["interchanges"][0]
106+
self.assertEqual(isa["isa_trn_set_id"], "000000001")
107+
self.assertEqual(isa["errors"], [{"err_cde": "100", "err_str": "ISA-level error"}])
108+
self.assertEqual(len(isa["groups"]), 1)
109+
110+
def test_gs_level_error_captured(self):
111+
gs = self.doc["interchanges"][0]["groups"][0]
112+
self.assertEqual(gs["fic"], "HC")
113+
self.assertEqual(gs["vriic"], "005010X222A1")
114+
self.assertEqual(gs["ack_code"], "R")
115+
self.assertEqual(gs["errors"], [{"err_cde": "4", "err_str": "GS-level error"}])
116+
self.assertEqual(len(gs["transactions"]), 1)
117+
118+
def test_st_level_error_captured(self):
119+
st = self.doc["interchanges"][0]["groups"][0]["transactions"][0]
120+
self.assertEqual(st["trn_set_id"], "837")
121+
self.assertEqual(st["trn_set_control_num"], "0001")
122+
self.assertEqual(st["ack_code"], "R")
123+
self.assertEqual(st["errors"], [{"err_cde": "23", "err_str": "ST-level error"}])
124+
self.assertEqual(len(st["segments"]), 1)
125+
126+
def test_seg_level_error_captured(self):
127+
seg = self.doc["interchanges"][0]["groups"][0]["transactions"][0]["segments"][0]
128+
self.assertEqual(seg["seg_id"], "CLM")
129+
self.assertEqual(seg["seg_count"], 5)
130+
self.assertEqual(seg["pos"], 130)
131+
self.assertEqual(seg["name"], "Claim Information")
132+
self.assertEqual(seg["ls_id"], "2300")
133+
self.assertEqual(
134+
seg["errors"],
135+
[{"err_cde": "8", "err_str": "Segment has data element errors", "err_val": None}],
136+
)
137+
self.assertEqual(len(seg["elements"]), 2)
138+
139+
def test_element_level_errors_captured(self):
140+
elements = self.doc["interchanges"][0]["groups"][0]["transactions"][0]["segments"][0][
141+
"elements"
142+
]
143+
self.assertEqual(elements[0]["ele_pos"], 1)
144+
self.assertEqual(elements[0]["ele_ref_num"], "1028")
145+
self.assertEqual(elements[0]["name"], "Claim Submitter's Identifier")
146+
self.assertEqual(
147+
elements[0]["errors"],
148+
[{"err_cde": "7", "err_str": "Invalid Code Value", "err_val": "BAD"}],
149+
)
150+
self.assertEqual(elements[1]["ele_pos"], 2)
151+
self.assertEqual(elements[1]["ele_ref_num"], "782")
152+
153+
154+
class ErrhJsonVisitorEmptyTree(unittest.TestCase):
155+
"""A fresh err_handler with no children should produce a well-formed
156+
JSON document with an empty interchanges list — no exceptions."""
157+
158+
def test_empty_tree_emits_empty_document(self):
159+
eh = pyx12.error_handler.err_handler()
160+
out = StringIO()
161+
visitor = pyx12.errh_json.errh_json_visitor(out, indent=None)
162+
eh.accept(visitor)
163+
out.seek(0)
164+
doc = json.loads(out.read())
165+
self.assertEqual(doc, {"interchanges": []})
166+
167+
168+
class ErrhJsonVisitorIndent(unittest.TestCase):
169+
"""The indent argument is passed through to json.dump."""
170+
171+
def test_default_indent_produces_pretty_output(self):
172+
eh = pyx12.error_handler.err_handler()
173+
out = StringIO()
174+
visitor = pyx12.errh_json.errh_json_visitor(out) # default indent=2
175+
eh.accept(visitor)
176+
out.seek(0)
177+
text = out.read()
178+
self.assertIn("\n", text)
179+
180+
def test_indent_none_produces_compact_output(self):
181+
eh = pyx12.error_handler.err_handler()
182+
out = StringIO()
183+
visitor = pyx12.errh_json.errh_json_visitor(out, indent=None)
184+
eh.accept(visitor)
185+
out.seek(0)
186+
text = out.read()
187+
self.assertNotIn("\n", text)
188+
189+
190+
if __name__ == "__main__":
191+
unittest.main()

0 commit comments

Comments
 (0)