edison-parser 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
edison/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .tokenizer import * # ruff:ignore[undefined-local-with-import-star]
2
+ from .envelope import * # ruff:ignore[undefined-local-with-import-star]
3
+ from .parser import * # ruff:ignore[undefined-local-with-import-star]
edison/envelope.py ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python
2
+
3
+ import logging
4
+ from typing import NamedTuple
5
+
6
+ from .tokenizer import tokenize_segments_iter
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+ __all__ = [
11
+ "EnvelopeFooters",
12
+ "EnvelopeHeaders",
13
+ "extract_envelope_footers",
14
+ "extract_envelope_headers",
15
+ ]
16
+
17
+
18
+ class EnvelopeHeaders(NamedTuple):
19
+ unb: str | None = None
20
+ unh: str | None = None
21
+
22
+
23
+ class EnvelopeFooters(NamedTuple):
24
+ unt: str | None = None
25
+ unz: str | None = None
26
+
27
+
28
+ def extract_envelope_headers(edi: str) -> EnvelopeHeaders:
29
+ r"""Extract EDIFACT envelope headers from the beginning of a message.
30
+
31
+ This uses the iterative tokenizer from the start and stops as soon as the
32
+ message header boundary is found.
33
+
34
+ >>> extract_envelope_headers("UNB+1'UNH+2+ORDERS:D:96A:UN'BGM'")
35
+ EnvelopeHeaders(unb='UNB+1', unh='UNH+2+ORDERS:D:96A:UN')
36
+ """
37
+ unb = unh = None
38
+
39
+ for segment in tokenize_segments_iter(edi):
40
+ tag = segment[:3]
41
+ if tag == "UNB" and unb is None:
42
+ unb = segment
43
+ elif tag == "UNH":
44
+ unh = segment
45
+ break
46
+
47
+ return EnvelopeHeaders(unb, unh)
48
+
49
+
50
+ def extract_envelope_footers(edi: str) -> EnvelopeFooters:
51
+ r"""Extract EDIFACT envelope footers from the end of a message.
52
+
53
+ This uses the iterative tokenizer from the end and stops as soon as the
54
+ message footer boundary is found.
55
+
56
+ >>> extract_envelope_footers("BGM'UNT+3'UNZ+4'")
57
+ EnvelopeFooters(unt='UNT+3', unz='UNZ+4')
58
+ """
59
+ unt = unz = None
60
+
61
+ for segment in tokenize_segments_iter(edi, reverse=True):
62
+ tag = segment[:3]
63
+ if tag == "UNZ" and unz is None:
64
+ unz = segment
65
+ elif tag == "UNT":
66
+ unt = segment
67
+ break
68
+
69
+ return EnvelopeFooters(unt, unz)
edison/parser.py ADDED
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env python
2
+
3
+ import logging
4
+ from bisect import bisect_right
5
+ from operator import itemgetter
6
+ from collections.abc import Iterator, Callable, Sequence
7
+ from typing import Any, NamedTuple, Literal, TypedDict, TYPE_CHECKING
8
+ from typing_extensions import TypeIs
9
+ import platform
10
+
11
+ from .tokenizer import tokenize_segments, tokenize_segments_with_blb
12
+
13
+ PYPY = platform.python_implementation() == "PyPy"
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ if TYPE_CHECKING:
18
+ import pydot
19
+
20
+
21
+ __all__ = ["BaseParser"]
22
+
23
+
24
+ type VersionTuple = tuple[int | None, int | None]
25
+ type SchemaVersion = tuple[int, int]
26
+
27
+ type EDITag = str
28
+ type EDIPath = tuple[EDITag, ...]
29
+ type EDIAction = str | None # method to call when this segment is found
30
+ type EDIRepetition = int # number of times you can pass on a node
31
+ type EDIFlag = Literal["M", "C"] # mandatory or conditional
32
+
33
+ # NodeId is the index of a node in the schema, starting at 0 for the root node.
34
+ type NodeId = int
35
+
36
+ # RawEDISegment is the input schema, which is a recursive lists of lists.
37
+ # EDISegment is the normalized view used while building transitions.
38
+ type RawEDISegment = list[Any]
39
+
40
+
41
+ class EDISegment(NamedTuple):
42
+ id_: NodeId
43
+ tag: EDITag
44
+ children_raw: Sequence[RawEDISegment] = ()
45
+ action: EDIAction = None
46
+ repetition: EDIRepetition = 99999
47
+ flag: EDIFlag = "C"
48
+
49
+
50
+ class EDISegmentFactory:
51
+ def __init__(self, start_id: NodeId) -> None:
52
+ self._next_id = start_id
53
+
54
+ def create_from_raw(self, raw_segment: RawEDISegment) -> EDISegment:
55
+ node = EDISegment(self._next_id, *raw_segment)
56
+ if not isinstance(node.children_raw, (list, tuple)):
57
+ raise ValueError(f"{raw_segment!r} should have a list in second position")
58
+ self._next_id += 1
59
+ return node
60
+
61
+
62
+ class Transition(NamedTuple):
63
+ id_: NodeId
64
+ path: EDIPath
65
+ action: str | None
66
+
67
+
68
+ type NodeTransitions = dict[EDITag, Transition]
69
+ type NodeTransitionsList = list[tuple[EDITag, Transition]]
70
+ type SegmentTokenizer = Callable[[str], Iterator[str]]
71
+
72
+
73
+ class CompiledSchema(NamedTuple):
74
+ version: SchemaVersion
75
+ tokenize: SegmentTokenizer
76
+ transitions: dict[NodeId, NodeTransitions]
77
+
78
+
79
+ def format_path(path: EDIPath, sep: str = "/") -> str:
80
+ """Format a path in transition object.
81
+
82
+ >>> format_path(())
83
+ '/'
84
+ >>> format_path(("PTK",))
85
+ '/PTK'
86
+ >>> format_path(("PTK", "COR"))
87
+ '/PTK/COR'
88
+ """
89
+ return f"{sep}{sep.join(path)}"
90
+
91
+
92
+ def build_transitions(schema: list[RawEDISegment]) -> dict[NodeId, NodeTransitions]:
93
+ transitions: dict[NodeId, NodeTransitions] = {}
94
+ transitions_cache: dict[NodeId, Transition] = {}
95
+
96
+ def get_transition(parent_path: EDIPath, node: EDISegment) -> Transition:
97
+ # We cache the transition objects to avoid creating multiple instances for
98
+ # the same node. Indeed the transitions object we return is a mapping of
99
+ # tag -> Transition(node_id, path, action), so this object can be reused
100
+ # for all incoming transitions to the same node.
101
+ # We also delay the creation of the path until we actually need it.
102
+ try:
103
+ return transitions_cache[node.id_]
104
+ except KeyError:
105
+ transition = Transition(node.id_, (*parent_path, node.tag), node.action)
106
+ transitions_cache[node.id_] = transition
107
+ return transition
108
+
109
+ factory = EDISegmentFactory(start_id=0)
110
+ root = factory.create_from_raw(["", schema])
111
+ stack: list[tuple[EDISegment, EDIPath, NodeTransitionsList]] = [(root, (), [])]
112
+
113
+ while stack:
114
+ node, node_path, parent_transitions = stack.pop()
115
+ child_nodes = [factory.create_from_raw(raw) for raw in node.children_raw]
116
+ transitions[node.id_] = node_trans = {}
117
+
118
+ # We store the index of the next mandatory segment for each child.
119
+ next_mandatory_indexes: list[int | None] = [None] * len(child_nodes)
120
+ next_mandatory_index = None
121
+
122
+ for i in range(len(child_nodes) - 1, 0, -1):
123
+ child = child_nodes[i]
124
+ if child.flag == "M":
125
+ next_mandatory_index = i
126
+ next_mandatory_indexes[i - 1] = next_mandatory_index
127
+
128
+ # We store the index of the first mandatory child.
129
+ for i, child in enumerate(child_nodes):
130
+ if child.flag == "M":
131
+ first_mandatory_index = i
132
+ break
133
+ else: # no break
134
+ first_mandatory_index = None
135
+
136
+ # Now we iterate over the children to push their children in the stack
137
+ for i, child in enumerate(child_nodes):
138
+ child_path = (*node_path, child.tag)
139
+
140
+ # In cases where you have several segments with the same name in a group
141
+ # we only keep the transition to the first in the parent
142
+ if child.tag not in node_trans:
143
+ # We do not transition after the first mandatory child.
144
+ if first_mandatory_index is None or i <= first_mandatory_index:
145
+ node_trans[child.tag] = get_transition(node_path, child)
146
+
147
+ # repetition is the number of times you can pass on a node, today we only
148
+ # consider 1 or more-than-one options, to put a transition to self or not
149
+ if child.repetition > 1:
150
+ i_start = i
151
+ else:
152
+ i_start = i + 1
153
+
154
+ # Sibling transitions stop at the first mandatory sibling.
155
+ next_mandatory_index = next_mandatory_indexes[i]
156
+
157
+ if next_mandatory_index is None:
158
+ i_end = None
159
+ else:
160
+ i_end = next_mandatory_index + 1
161
+
162
+ sibling_transitions = [
163
+ (sibling.tag, get_transition(node_path, sibling))
164
+ for sibling in child_nodes[i_start:i_end]
165
+ ]
166
+
167
+ # Siblings are before parents because local transitions inside group
168
+ # have priority over parents
169
+ # Also note that siblings may contain the child if repetition > 1
170
+ # in that case child transitions have priority over self if same tag name
171
+ if i_end is None:
172
+ child_parent_transitions = sibling_transitions + parent_transitions
173
+ else:
174
+ child_parent_transitions = sibling_transitions
175
+
176
+ stack.append((child, child_path, child_parent_transitions))
177
+
178
+ # We do not add links to parents if there is a mandatory child.
179
+ if first_mandatory_index is None:
180
+ for parent_tag, transition in parent_transitions:
181
+ if parent_tag not in node_trans:
182
+ node_trans[parent_tag] = transition
183
+
184
+ return transitions
185
+
186
+
187
+ def _has_blb_segment(transitions: dict[NodeId, NodeTransitions]) -> bool:
188
+ """Scan transitions to find out if a BLB segment is there."""
189
+ return any("BLB" in node_trans for node_trans in transitions.values())
190
+
191
+
192
+ # PYDOT GLOBAL OPTIONS
193
+ #
194
+ class NodeOptions(TypedDict):
195
+ fontname: str
196
+ shape: str
197
+ style: str
198
+
199
+
200
+ class EdgeOptions(TypedDict):
201
+ fontname: str
202
+
203
+
204
+ N_OPTIONS: NodeOptions = {
205
+ "fontname": "Monospace",
206
+ "shape": "box",
207
+ "style": "filled",
208
+ }
209
+ E_OPTIONS: EdgeOptions = {
210
+ "fontname": "Monospace",
211
+ }
212
+
213
+
214
+ # SHOW MISC.
215
+ #
216
+ COLORIZE = {
217
+ "bold": "\033[1m{0}\033[0m",
218
+ "grey": "\033[01;90m{0}\033[0m",
219
+ "white": "\033[01;97m{0}\033[0m",
220
+ "red": "\033[01;31m{0}\033[0m",
221
+ "green": "\033[01;32m{0}\033[0m",
222
+ "yellow": "\033[01;33m{0}\033[0m",
223
+ "blue": "\033[01;34m{0}\033[0m",
224
+ "magenta": "\033[01;35m{0}\033[0m",
225
+ "cyan": "\033[01;36m{0}\033[0m",
226
+ }
227
+
228
+ STYLE = {
229
+ "bold": "\033[1m{0}\033[0m",
230
+ "dim": "\033[2m{0}\033[0m",
231
+ "italic": "\033[3m{0}\033[0m",
232
+ "underline": "\033[4m{0}\033[0m",
233
+ "blink": "\033[5m{0}\033[0m",
234
+ "reverse": "\033[7m{0}\033[0m",
235
+ "hidden": "\033[8m{0}\033[0m",
236
+ "strikethrough": "\033[9m{0}\033[0m",
237
+ }
238
+
239
+ blink = STYLE["blink"].format
240
+
241
+
242
+ def is_full_version(version: VersionTuple | None) -> TypeIs[tuple[int, int]]:
243
+ if version is None:
244
+ return False
245
+ if version[0] is None or version[1] is None:
246
+ log.warning("Message version %r contains None", version)
247
+ return False
248
+ return True
249
+
250
+
251
+ class BaseParser:
252
+ """This is a generic parser that should be subclassed wih a schemas."""
253
+
254
+ __slots__ = []
255
+
256
+ # Those can/should be overriden when subclassing
257
+ #
258
+ header: list[RawEDISegment] = []
259
+ footer: list[RawEDISegment] = []
260
+ schemas: dict[SchemaVersion, list[RawEDISegment]] = {}
261
+
262
+ # Assigned in compile() during class definition
263
+ #
264
+ _compiled_schemas: list[CompiledSchema] = []
265
+
266
+ def __init_subclass__(cls, **kwargs: Any) -> None:
267
+ super().__init_subclass__(**kwargs)
268
+ cls.compile()
269
+
270
+ @classmethod
271
+ def compile(cls) -> None:
272
+ """This is automatically called when a BaseParser subclass is defined."""
273
+ cls._compiled_schemas = []
274
+ for version in sorted(cls.schemas):
275
+ schema = [*cls.header, *cls.schemas[version], *cls.footer]
276
+ transitions = build_transitions(schema)
277
+ tokenize: SegmentTokenizer
278
+ if _has_blb_segment(transitions):
279
+ tokenize = tokenize_segments_with_blb
280
+ else:
281
+ tokenize = tokenize_segments
282
+ cls._compiled_schemas.append(CompiledSchema(version, tokenize, transitions))
283
+
284
+ def set_up(self) -> None:
285
+ """To be subclassed to trigger something at the beginning of parsing."""
286
+
287
+ def tear_down(self) -> None:
288
+ """To be subclassed to trigger something at the end of parsing."""
289
+
290
+ @classmethod
291
+ def find_transitions(
292
+ cls,
293
+ version: VersionTuple | None = None,
294
+ ) -> CompiledSchema:
295
+ # Each (min_version, tokenize, transitions) in cls._compiled_schemas is sorted
296
+ # by min_version. We want to find the largest min_version that is less than
297
+ # or equal to the given version. We can use bisect_right to find the insertion
298
+ # point for the given version in the sorted list of min_versions.
299
+ # We have a fast path before doing the bisect because in practice most input
300
+ # version are higher than the latest defined schema version.
301
+ if not cls._compiled_schemas:
302
+ raise ValueError("No transitions found, did you forget to set a schema?")
303
+
304
+ latest = cls._compiled_schemas[-1]
305
+ if not is_full_version(version):
306
+ return latest
307
+ if version >= latest.version: # fast path, common
308
+ return latest
309
+
310
+ index = bisect_right(cls._compiled_schemas, version, key=itemgetter(0))
311
+ if index > 0:
312
+ return cls._compiled_schemas[index - 1]
313
+
314
+ raise ValueError(
315
+ f"Schemas do not cover all versions for {cls.__name__}:"
316
+ f" {[t.version for t in cls._compiled_schemas]}"
317
+ )
318
+
319
+ def parse(self, edi: str, version: VersionTuple | None = None) -> None:
320
+ _, tokenize, transitions = self.find_transitions(version)
321
+ id_ = 0 # starting at root
322
+ self.set_up()
323
+ for segment in tokenize(edi):
324
+ tag = segment[:3]
325
+ if (tr := transitions[id_].get(tag)) is not None:
326
+ id_, _, action = tr
327
+ if action is not None:
328
+ getattr(self, action)(segment)
329
+ self.tear_down()
330
+
331
+ @classmethod
332
+ def validate(cls, edi: str, version: VersionTuple | None = None) -> bool:
333
+ _, tokenize, transitions = cls.find_transitions(version)
334
+ id_: NodeId = 0 # starting at root
335
+ path: EDIPath = ()
336
+ for segment in tokenize(edi):
337
+ tag = segment[:3]
338
+ if (tr := transitions[id_].get(tag)) is not None:
339
+ id_, path, _ = tr
340
+ continue
341
+ # If we reach this point, it means the segment is not valid.
342
+ if not transitions[id_]:
343
+ log.warning(
344
+ "[%s] Cannot continue from %s, but still got %r",
345
+ cls.__name__,
346
+ format_path(path),
347
+ tag,
348
+ )
349
+ else: # only other case is 'tag is not in transitions[id_]'
350
+ log.warning(
351
+ "[%s] Cannot continue from %s with %r, should be one of:\n%s",
352
+ cls.__name__,
353
+ format_path(path),
354
+ tag,
355
+ "\n".join(
356
+ f"{k!r} -> {format_path(v.path)}"
357
+ for k, v in transitions[id_].items()
358
+ ),
359
+ )
360
+ return False
361
+ return True
362
+
363
+ # Color related stuff
364
+ #
365
+ _depth_colors = [
366
+ "bold",
367
+ "blue",
368
+ "green",
369
+ "yellow",
370
+ "magenta",
371
+ "blue",
372
+ "green",
373
+ "yellow",
374
+ "magenta",
375
+ ]
376
+ _hidden: set[str] = set()
377
+
378
+ @classmethod
379
+ def show(
380
+ cls,
381
+ edi: str,
382
+ version: VersionTuple | None = None,
383
+ formatting: bool = True,
384
+ ) -> str:
385
+ _, tokenize, transitions = cls.find_transitions(version)
386
+ res = []
387
+ id_, depth = 0, 0 # starting at root
388
+
389
+ for segment in tokenize(edi):
390
+ tag = segment[:3]
391
+ segment = f"{segment}'"
392
+
393
+ if (tr := transitions[id_].get(tag)) is not None:
394
+ id_, path, _ = tr
395
+ depth = len(path)
396
+ known = True
397
+ else:
398
+ known = False
399
+
400
+ if not formatting:
401
+ color = None
402
+ elif not known:
403
+ color = "red"
404
+ elif tag in cls._hidden:
405
+ color = "grey"
406
+ else:
407
+ color = cls._depth_colors[(depth - 1) % len(cls._depth_colors)]
408
+
409
+ if not known:
410
+ filler = "-" * 2 * (depth - 1)
411
+ else:
412
+ filler = " " * 2 * (depth - 1)
413
+
414
+ if color is not None:
415
+ # "blink" actually adds an almost-invisible property to the filler,
416
+ # so we can filter on it in CSS and set the user-select to none.
417
+ filler = COLORIZE[color].format(blink(filler))
418
+ segment = COLORIZE[color].format(segment)
419
+
420
+ res.append(filler + segment)
421
+
422
+ return "\n".join(res)
423
+
424
+ @classmethod
425
+ def build_dot_object(
426
+ cls,
427
+ version: VersionTuple | None = None,
428
+ *,
429
+ include_up_links: bool = True,
430
+ ) -> "pydot.Dot":
431
+ import pydot
432
+
433
+ _, _, transitions = cls.find_transitions(version)
434
+ colors = cls._depth_colors
435
+ dot = pydot.Dot(graph_type="digraph")
436
+
437
+ nodes = {id_: pydot.Node(str(id_), **N_OPTIONS) for id_ in transitions}
438
+
439
+ for source_id in transitions:
440
+ for tag in transitions[source_id]:
441
+ target_id, path, _ = transitions[source_id][tag]
442
+ fillcolor = colors[(len(path) - 1) % len(colors)]
443
+ node = nodes[target_id]
444
+ node.set("label", "\\n".join(path))
445
+ node.set("fillcolor", fillcolor)
446
+ node.set(
447
+ "fontcolor",
448
+ "white" if fillcolor in {"bold", "blue"} else "black",
449
+ )
450
+
451
+ for source_id in transitions:
452
+ source_label = nodes[source_id].get("label")
453
+ if source_label in cls._hidden:
454
+ continue
455
+ dot.add_node(nodes[source_id])
456
+ for tag in transitions[source_id]:
457
+ target_id, path, _ = transitions[source_id][tag]
458
+ target_label = nodes[target_id].get("label")
459
+ if target_label in cls._hidden:
460
+ continue
461
+ if (
462
+ not include_up_links
463
+ and source_label
464
+ and len(source_label) > len(target_label)
465
+ ):
466
+ continue
467
+ dot.add_edge(
468
+ pydot.Edge(nodes[source_id], nodes[target_id], **E_OPTIONS)
469
+ )
470
+
471
+ return dot
edison/py.typed ADDED
File without changes
edison/tokenizer.py ADDED
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python
2
+
3
+ import re
4
+ import logging
5
+ from collections.abc import Iterator
6
+ import platform
7
+
8
+ PYPY = platform.python_implementation() == "PyPy"
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ __all__ = [
13
+ "translate_ebcdic_separators",
14
+ "normalize_ebcdic_separators",
15
+ "escape",
16
+ "unescape",
17
+ "tokenize_segments",
18
+ "tokenize_segments_iter",
19
+ "tokenize_segments_with_blb",
20
+ ]
21
+
22
+
23
+ # EBCDIC/ESCAPING CORNER
24
+ #
25
+ # Note that the backslash itself is not escaped, as in practice
26
+ # this character never appears in messages. If it ever does, it
27
+ # will only be an issue if it is just before an EBCDIC symbol, in
28
+ # that case the symbol will look like it is escaped, and this can
29
+ # mess up the parsing. This is most likely never going to happen.
30
+
31
+ ASCII_SYMBOLS = "+:*'"
32
+ EBCDIC_SYMBOLS = "\x1d\x1f\x19\x1c"
33
+
34
+ TABLE_FORMAT = str.maketrans(EBCDIC_SYMBOLS, ASCII_SYMBOLS)
35
+ TABLE_ESC = [(a, f"\\{a}") for a in ASCII_SYMBOLS]
36
+
37
+ _RE_FIND_ESCAPED = re.compile(rb"\\[+:*']")
38
+ _RE_SUB_ESCAPED = re.compile(r"\\([+:*'])")
39
+
40
+ # This will be used on a memoryview, where find() is not possible
41
+ _RE_QUOTE_B = re.compile(rb"'")
42
+
43
+
44
+ def translate_ebcdic_separators(edi: str) -> str:
45
+ r"""Converts EBCDIC symbols to ASCII, with no escaping.
46
+
47
+ >>> translate_ebcdic_separators("ORG\x1D00\x1C")
48
+ "ORG+00'"
49
+ """
50
+ return edi.translate(TABLE_FORMAT)
51
+
52
+
53
+ def normalize_ebcdic_separators(edi: str, scan_limit: int = 100) -> str:
54
+ r"""Converts EBCDIC symbols to ASCII, with escaping if necessary.
55
+
56
+ We only look for EBCDIC symbols in the first hundred characters
57
+ for performance reasons, as on very large strings it adds up.
58
+
59
+ If already converted, this function is no-op.
60
+
61
+ >>> normalize_ebcdic_separators("UNB\x1DSENDER\x1FREC")
62
+ 'UNB+SENDER:REC'
63
+ >>> normalize_ebcdic_separators("UNB\x1DSENDER\x1FREC+\x1C") # escaping
64
+ "UNB+SENDER:REC\\+'"
65
+ >>> normalize_ebcdic_separators(r"UNB+SENDER:REC\+'") # no-op
66
+ "UNB+SENDER:REC\\+'"
67
+ """
68
+ if edi.find("\x1d", 0, scan_limit) == -1: # not found
69
+ return edi
70
+ return translate_ebcdic_separators(escape(edi))
71
+
72
+
73
+ def escape(data: str) -> str:
74
+ r"""Escaping ASCII symbols that are used as EBCDIC control characters.
75
+
76
+ This function is typically used right before generating EDIFACT or XML,
77
+ to make sure there is a difference between control characters and true
78
+ content that happens to be +:*'.
79
+
80
+ Escaping is basically a re.sub(r"([+:*'])", r"\\\1", data), but it is
81
+ actually faster to do repeated replace().
82
+
83
+ >>> escape("REC+")
84
+ 'REC\\+'
85
+ """
86
+ for c, ec in TABLE_ESC:
87
+ data = data.replace(c, ec)
88
+ return data
89
+
90
+
91
+ def unescape(
92
+ data: str,
93
+ _re_escaped: re.Pattern[str] = _RE_SUB_ESCAPED,
94
+ ) -> str:
95
+ r"""Unescaping is the opposite of escaping: removing backslash where relevant.
96
+
97
+ This function is typically used right after parsing the EDIFACT content,
98
+ where we want to get rid of the escaping backslashes that were useful
99
+ when tokenizing the segments.
100
+
101
+ Unescaping is basically a re.sub(r"\\([+:*'])", r"\1", data), but on PyPy
102
+ it is faster to do repeated replace().
103
+ And if Unicode characters are involved, then PyPy has a bug (#4028) where
104
+ the performance is completely broken using regular expressions.
105
+
106
+ >>> unescape(r"REC\+")
107
+ 'REC+'
108
+ """
109
+ if not PYPY:
110
+ return _re_escaped.sub(r"\1", data)
111
+
112
+ for c, ec in TABLE_ESC:
113
+ data = data.replace(ec, c)
114
+ return data
115
+
116
+
117
+ # SEGMENTS TOKENIZING
118
+ #
119
+ _EOL = "& \t\r\n"
120
+ _EOL_ORD = frozenset(_EOL.encode())
121
+
122
+
123
+ def tokenize_segments(edi: str) -> Iterator[str]:
124
+ r"""Tokenizer of EDIFACT segments.
125
+
126
+ The only trick here is to split on quotes, but not if they were escaped
127
+ with a backslash. For this we first replace escaped quotes but a special
128
+ character, then we split normally on quotes.
129
+
130
+ This is basically a re.split(r'(?<!\\)"', edi), but it is actually faster.
131
+
132
+ >>> list(tokenize_segments(r"ERC'IFT+1+USER\'DATA'"))
133
+ ['ERC', "IFT+1+USER\\'DATA"]
134
+ >>> list(tokenize_segments("SDI'SPI+'UNZ'TRUNC"))
135
+ ['SDI', 'SPI+', 'UNZ']
136
+ """
137
+ edi = normalize_ebcdic_separators(edi)
138
+
139
+ segments = edi.replace(r"\'", "\x1a").split("'")
140
+ segments.pop() # remove the last empty segment after the last quote
141
+ for segment in segments:
142
+ yield segment.replace("\x1a", r"\'").lstrip(_EOL)
143
+
144
+
145
+ def tokenize_segments_iter(
146
+ edi: str,
147
+ reverse: bool = False,
148
+ ) -> Iterator[str]:
149
+ r"""Tokenizer of EDIFACT segments, the iterator version.
150
+
151
+ This is the iterator version of the normal tokenizer, in the sense that it
152
+ lazily yields segments without processing the whole string. It is used to
153
+ parse EDIFACT headers, where you only care about the first segments.
154
+ This is not the default tokenizer as it is 2-3x slower than the normal version,
155
+ if you use it to tokenize the full message. But it is 2-15x faster to use this
156
+ version if you just want the first segments, depending on message size.
157
+
158
+ Note that we exclude manually the escaped quotes, we could do it using a
159
+ lookbehind clause with a regular expression, but it is a bit slower.
160
+
161
+ We support a reverse mode to tokenize from the end of the EDI, this is useful
162
+ when testing for truncation.
163
+
164
+ >>> # Same tests as tokenize_segments
165
+ >>> list(tokenize_segments_iter(r"ERC'IFT+1+USER\'DATA'"))
166
+ ['ERC', "IFT+1+USER\\'DATA"]
167
+ >>> list(tokenize_segments_iter("SDI'SPI+'UNZ'TRUNC"))
168
+ ['SDI', 'SPI+', 'UNZ']
169
+ >>>
170
+ >>> # The reverse mode that starts from the end
171
+ >>> list(tokenize_segments_iter(r"ERC'IFT+1+USER\'DATA'", reverse=True))
172
+ ["IFT+1+USER\\'DATA", 'ERC']
173
+ >>> list(tokenize_segments_iter("SDI'SPI+'UNZ'TRUNC", reverse=True))
174
+ ['UNZ', 'SPI+', 'SDI']
175
+ """
176
+ edi = normalize_ebcdic_separators(edi)
177
+
178
+ if not reverse:
179
+ # * find_start_index is the index for the beginning of the find()
180
+ # * start_index is the index for the beginning of the segment
181
+ find_start_index = start_index = 0
182
+ while True:
183
+ index = edi.find("'", find_start_index)
184
+ if index == -1: # between the last quote and the end
185
+ return
186
+ if index == 0 or edi[index - 1] != "\\":
187
+ yield edi[start_index:index].lstrip(_EOL)
188
+ start_index = index + 1
189
+ find_start_index = index + 1
190
+ else:
191
+ # * rfind_end_index is the index for the end of the rfind()
192
+ # * end_index is the index for the end of the segment
193
+ # We use those end indices "plus one" as we use them for slicing.
194
+ rfind_end_index_plus_one = end_index_plus_one = edi_len = len(edi)
195
+ while True:
196
+ index = edi.rfind("'", 0, rfind_end_index_plus_one)
197
+ if index == -1:
198
+ if end_index_plus_one < edi_len: # before last quote
199
+ yield edi[:end_index_plus_one].lstrip(_EOL)
200
+ return
201
+ if index == 0 or edi[index - 1] != "\\":
202
+ if end_index_plus_one < edi_len: # before last quote
203
+ yield edi[index + 1 : end_index_plus_one].lstrip(_EOL)
204
+ end_index_plus_one = index
205
+ rfind_end_index_plus_one = index
206
+
207
+
208
+ def tokenize_segments_with_blb(
209
+ edi: str,
210
+ _re_quote: re.Pattern[bytes] = _RE_QUOTE_B,
211
+ ) -> Iterator[str]:
212
+ r"""Tokenizer of EDIFACT segments, handling BLB segments as well.
213
+
214
+ Note that this tokenizer could be used everywhere instead of tokenize_segments,
215
+ but it 10x slower. This version understands that BLB segments may contain
216
+ quotes, so we cannot just tokenize on them. When finding a BLB segment, it will
217
+ read the number of bytes specified, and basically "jump over" it.
218
+
219
+ >>> list(tokenize_segments_with_blb("BLB+8++EQN'PTK''"))
220
+ ["BLB+8++EQN'PTK'"]
221
+ >>> list(tokenize_segments_with_blb("BLB+8++EQN'PTK''UNT'"))
222
+ ["BLB+8++EQN'PTK'", 'UNT']
223
+ >>> list(tokenize_segments_with_blb("BLB'AAA'"))
224
+ ['BLB', 'AAA']
225
+ >>>
226
+ >>> # Even though the BLB count is 0, the BLB extends until a quote is found
227
+ >>> list(tokenize_segments_with_blb("UNH'BLB+0++<xml>A</xml>'UNT'"))
228
+ ['UNH', 'BLB+0++<xml>A</xml>', 'UNT']
229
+ >>>
230
+ >>> # Quotes are jumped over, because of the BLB count
231
+ >>> list(tokenize_segments_with_blb("UNH'BLB+26++<xml>Quotes''inside'</xml>'UNT'"))
232
+ ['UNH', "BLB+26++<xml>Quotes''inside'</xml>", 'UNT']
233
+ >>>
234
+ >>> # Escaped characters (not normal backslashes) are excluded from the BLB count
235
+ >>> for s in tokenize_segments_with_blb(r"UNH'BLB+2++1\''BLB+2++\\:'UNT'"):
236
+ ... print(s)
237
+ UNH
238
+ BLB+2++1\'
239
+ BLB+2++\\:
240
+ UNT
241
+ >>>
242
+ >>> # Unicode characters are counted as their bytes length
243
+ >>> list(tokenize_segments_with_blb("UNH'BLB+11++TRADEMARK®'UNT'"))
244
+ ['UNH', 'BLB+11++TRADEMARK®', 'UNT']
245
+ """
246
+ edi = normalize_ebcdic_separators(edi)
247
+
248
+ # The BLB length is given in bytes, so to perform the BLB extraction properly,
249
+ # we need to convert the EDI to bytes(). In this context, every character is
250
+ # actually an int, so comparisons are done against their ASCII numbers via ord().
251
+ edi_as_bytes = edi.encode()
252
+
253
+ # The additional memoryview() is to avoid copying the data when slicing, before
254
+ # converting to str(). It is a bit faster and reduces memory consumption for large
255
+ # messages, but then we cannot use view.find(b"'"), and rely on a regex instead.
256
+ view = memoryview(edi_as_bytes)
257
+
258
+ index, edi_len = 0, len(view)
259
+
260
+ while index < edi_len:
261
+ while index < edi_len and view[index] in _EOL_ORD: # skip over EOL, if any
262
+ index += 1
263
+
264
+ if view[index : index + 3].tobytes() == b"BLB": # BLB+270++BLOB
265
+ minimum_end_index = _get_blb_end_index(view, index, edi_len)
266
+ else:
267
+ minimum_end_index = index + 3
268
+
269
+ # We now look for a true quote, one that is not coming after a backslash:
270
+ # * if we find one, we yield the segment and move the index further
271
+ # * if we never find one, it means the segment is truncated and we exit
272
+ for match in _re_quote.finditer(view, pos=minimum_end_index):
273
+ quote_index = match.start()
274
+ if quote_index == 0 or view[quote_index - 1] != 92: # ord("\\")
275
+ yield str(view[index:quote_index], "utf8")
276
+ index = quote_index + 1
277
+ break
278
+ else:
279
+ break
280
+
281
+ # We do not return the last truncated segment unless it is a BLB, in that case
282
+ # the parser of the BLB should exclude its own last truncated segment
283
+ if view[index : index + 3].tobytes() == b"BLB":
284
+ yield str(view[index:], "utf8")
285
+
286
+
287
+ def _get_blb_end_index(
288
+ view: memoryview,
289
+ index: int,
290
+ edi_len: int,
291
+ _re_escaped: re.Pattern[bytes] = _RE_FIND_ESCAPED,
292
+ ) -> int:
293
+ r"""Compute the minimum possible index of the end of BLB.
294
+
295
+ From a beginning "index", this function returns an integer representing the
296
+ index of the end of BLB. We read the BLB length and compute that index.
297
+ Note that sometimes the BLB will extend further, so we need to find the next
298
+ true quote in the caller.
299
+
300
+ >>> _get_blb_end_index(memoryview(b"BLB+3++ABC'UNT'"), 0, 15)
301
+ 10
302
+ >>> _get_blb_end_index(memoryview(b"BLB+3++AB\\+'UNT'"), 0, 16)
303
+ 11
304
+ >>> _get_blb_end_index(memoryview(b"BLB'"), 0, 4)
305
+ 3
306
+ >>> _get_blb_end_index(memoryview(b"BLB+30"), 0, 6)
307
+ 6
308
+ >>> _get_blb_end_index(memoryview(b"BLB+30++AAA"), 0, 11)
309
+ 38
310
+ >>> _get_blb_end_index(memoryview(b"BLB+COMPUTED++AAA'"), 0, 18)
311
+ 16
312
+ >>> _get_blb_end_index(memoryview(b"BLB+ERROR++AAA"), 0, 14)
313
+ 11
314
+ """
315
+ # We begin by jumping over the segment name
316
+ index += 3
317
+
318
+ # We move the index forward, until the beginning of the actual BLB content
319
+ field_number = 0
320
+ while field_number != 3 and index < edi_len:
321
+ if view[index] == 39: # ord("'")
322
+ return index
323
+ if view[index] == 43: # ord("+")
324
+ field_number += 1
325
+ if field_number == 1:
326
+ blb_len_start = index + 1
327
+ elif field_number == 2:
328
+ blb_len_end = index - 1
329
+ index += 1
330
+
331
+ # This means the message was probably truncated, because we reached the
332
+ # end of message, despite only trying to move until the beginning of the
333
+ # actual BLB content.
334
+ if index == edi_len:
335
+ return index
336
+
337
+ blb_len_view = view[blb_len_start : blb_len_end + 1]
338
+
339
+ # This can happen as some test messages have a 'COMPUTED' in place of the
340
+ # length. In that case we return the end of message, as it seems this works
341
+ # best in most cases, meaning the BLB is effectively "running until the end
342
+ # of message".
343
+ # Note that in the general case this is a bad idea: some messages have
344
+ # multiple BLB segments, or other segments after the first BLB.
345
+ # We could also return the current index, and leave it to the caller to
346
+ # extend it until the next true quote, but this is going to cause issues
347
+ # if the BLB itself contains quotes.
348
+ if blb_len_view.tobytes() == b"COMPUTED":
349
+ log.info("BLB length was set to 'COMPUTED', assuming the rest is BLB")
350
+ return edi_len - 2 # penultimate character (last would be the quote)
351
+
352
+ try:
353
+ blb_len = int(blb_len_view)
354
+ except ValueError:
355
+ # In that case we return the current index, as we do not want to
356
+ # "go too far" in estimating the BLB end index. The caller will then
357
+ # extend it until the next true quote. Obviously, this is going to
358
+ # cause issues if the BLB itself contains quotes ¯\_(ツ)_/¯
359
+ log.error("Could not parse BLB length from %r", blb_len_view.tobytes())
360
+ return index
361
+
362
+ blb_end_index = index + blb_len
363
+
364
+ # We have to scan the BLB for potential escaped delimiters:
365
+ # if we find any, we have to increment the BLB end index
366
+ for match in _re_escaped.finditer(view, pos=index):
367
+ # match spans 2 characters and we want match.end() before/at blb_end_index
368
+ if match.start() < blb_end_index:
369
+ blb_end_index += 1
370
+ else:
371
+ break
372
+
373
+ return blb_end_index
@@ -0,0 +1,426 @@
1
+ Metadata-Version: 2.4
2
+ Name: edison-parser
3
+ Version: 0.1
4
+ Summary: An EDIFACT parser with a real syntax tree.
5
+ Author-email: Alex Prengère <alex.prengere@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AmadeusITGroup/edison
8
+ Project-URL: Changelog, https://github.com/AmadeusITGroup/edison/blob/main/CHANGELOG.md
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Programming Language :: Python :: Implementation :: CPython
17
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: pydot>=3.0.0
23
+ Requires-Dist: typing-extensions>=4.10
24
+ Dynamic: license-file
25
+
26
+ <div align="center">
27
+ <img src="docs/edison.png" width="360" height="360" alt="Edison">
28
+ <h1>Edison</h1>
29
+ <p><strong>An EDIFACT parser with a real syntax tree.</strong></p>
30
+ </div>
31
+
32
+ Edison is a Python library for parsing EDIFACT messages. Instead of treating a
33
+ message as a flat stream of segments, it models valid messages as a tree of
34
+ transitions. You define the schema once, then parse, validate, or pretty-print
35
+ messages against that structure.
36
+
37
+ ## Highlights
38
+
39
+ - Define EDIFACT syntax trees directly in Python.
40
+ - Attach parsing callbacks to the segments you care about.
41
+ - Select schemas by message version.
42
+ - Model conditional, mandatory, and repeatable segments.
43
+ - Handle escaped EDIFACT separators and EBCDIC control characters.
44
+ - Properly tokenize segments with specified lengths like `BLB`
45
+ - Export transition graphs with `pydot`.
46
+
47
+ ## Installation
48
+
49
+ Edison requires Python 3.12 or newer.
50
+
51
+ ```bash
52
+ pip install edison-parser
53
+ ```
54
+
55
+ ## Quick start
56
+
57
+ Subclass `BaseParser`, define one or more schemas, and attach callback method
58
+ names to the schema nodes you want to process.
59
+
60
+ ```python
61
+ from edison import BaseParser
62
+
63
+
64
+ class PIZZAQParser(BaseParser):
65
+ schemas = {
66
+ (0, 0): [
67
+ ["UNH"],
68
+ ["CVR"],
69
+ ["REF"],
70
+ [
71
+ "PIZ",
72
+ [
73
+ ["TYP", [], "parse_typ"],
74
+ ["REF", [], "parse_ref"],
75
+ ],
76
+ ],
77
+ ["UNT"],
78
+ ],
79
+ }
80
+
81
+ def __init__(self):
82
+ self.summary = {}
83
+ self.pizza = None
84
+
85
+ def parse_typ(self, segment: str):
86
+ self.pizza = segment[4:]
87
+
88
+ def parse_ref(self, segment: str):
89
+ refs = segment[4:].split("*")
90
+ self.summary[self.pizza] = len(refs)
91
+
92
+
93
+ edi = """
94
+ UNH+1+PIZZAQ:1:1:1A+00012480825035'
95
+ CVR+USD'
96
+ REF+LUIS'
97
+ PIZ+1'
98
+ TYP+MARGARITA'
99
+ REF+1*2'
100
+ PIZ+2'
101
+ TYP+PESTO'
102
+ REF+3'
103
+ UNT+19+1'
104
+ """
105
+
106
+ parser = PIZZAQParser()
107
+ parser.parse(edi)
108
+
109
+ assert parser.summary == {"MARGARITA": 2, "PESTO": 1}
110
+ assert PIZZAQParser.validate(edi)
111
+ print(PIZZAQParser.show(edi))
112
+ ```
113
+
114
+ <div align="center">
115
+ <img src="docs/Image.png" width="360" height="360" alt="Edison">
116
+ <p><strong>Tree-based structure.</strong></p>
117
+ </div>
118
+
119
+ ## Schema format
120
+
121
+ A schema is a dictionary keyed by the minimum supported version:
122
+
123
+ ```python
124
+ schemas = {
125
+ (0, 0): [
126
+ ["UNH"],
127
+ ["BGM"],
128
+ ["UNT"],
129
+ ],
130
+ }
131
+ ```
132
+
133
+ Each segment node accepts these positional fields:
134
+
135
+ ```python
136
+ ["TAG", children, action, repetition, flag]
137
+ ```
138
+
139
+ | Field | Default | Meaning |
140
+ | --- | --- | --- |
141
+ | `TAG` | required | Three-character EDIFACT segment tag. |
142
+ | `children` | `[]` | Nested segment group. |
143
+ | `action` | `None` | Parser method name called with the raw segment. |
144
+ | `repetition` | `99999` | `1` means single-use; any higher value means repeatable. |
145
+ | `flag` | `"C"` | `"C"` for conditional, `"M"` for mandatory. |
146
+
147
+ ## Main API
148
+
149
+ | API | Use |
150
+ | --- | --- |
151
+ | `parser.parse(edi, version=None)` | Walk an EDI message and call matching segment actions. |
152
+ | `Parser.validate(edi, version=None)` | Return `True` when all segments follow the schema transitions. |
153
+ | `Parser.show(edi, version=None, colorize=True)` | Return a formatted, indented representation of an EDI message. |
154
+ | `Parser.build_dot_object(version=None, include_up_links=True)` | Build a `pydot.Dot` transition graph. |
155
+ | `extract_envelope_headers(edi)` | Quickly return the `UNB` and `UNH` envelope segments. |
156
+ | `extract_envelope_footers(edi)` | Quickly return the `UNT` and `UNZ` envelope segments. |
157
+ | `escape(data)` / `unescape(data)` | Escape or unescape EDIFACT separators: `+`, `:`, `*`, `'`. |
158
+ | `normalize_ebcdic_separators(edi)` | Convert EBCDIC EDIFACT separators to ASCII safely. |
159
+
160
+ ## Advanced usage
161
+
162
+ <details>
163
+ <summary>Versioned syntax trees</summary>
164
+
165
+ Define multiple syntax trees when the same message evolves across versions. The
166
+ parser selects the highest schema version less than or equal to the `version`
167
+ argument, while parsing callbacks can stay the same.
168
+
169
+ ```python
170
+ from edison import BaseParser
171
+
172
+
173
+ class RouteParser(BaseParser):
174
+ schemas = {
175
+ (0, 0): [
176
+ ["UNH"],
177
+ ["ORG", [], "parse_origin"],
178
+ ["DST", [], "parse_destination"],
179
+ ["UNT"],
180
+ ],
181
+ (2, 0): [
182
+ ["UNH"],
183
+ [
184
+ "LEG",
185
+ [
186
+ ["ORG", [], "parse_origin"],
187
+ ["DST", [], "parse_destination"],
188
+ ],
189
+ ],
190
+ ["UNT"],
191
+ ],
192
+ }
193
+
194
+ def __init__(self):
195
+ self.route = {}
196
+
197
+ def parse_origin(self, segment: str):
198
+ self.route["origin"] = segment[4:]
199
+
200
+ def parse_destination(self, segment: str):
201
+ self.route["destination"] = segment[4:]
202
+
203
+
204
+ legacy = RouteParser()
205
+ legacy.parse("UNH'ORG+PAR'DST+NCE'UNT'", version=(1, 0))
206
+
207
+ modern = RouteParser()
208
+ modern.parse("UNH'LEG+1'ORG+PAR'DST+NCE'UNT'", version=(2, 0))
209
+
210
+ assert legacy.route == modern.route == {
211
+ "origin": "PAR",
212
+ "destination": "NCE",
213
+ }
214
+ assert RouteParser.validate("UNH'ORG+PAR'DST+NCE'UNT'", version=(1, 0))
215
+ assert RouteParser.validate("UNH'LEG+1'ORG+PAR'DST+NCE'UNT'", version=(2, 0))
216
+ ```
217
+
218
+ When `version` is omitted, incomplete, or newer than all known schemas, Edison
219
+ uses the latest schema. If a requested version is older than the first schema,
220
+ `find_transitions()` raises `ValueError`.
221
+
222
+ </details>
223
+
224
+ <details>
225
+ <summary>Mandatory and repeatable segments</summary>
226
+
227
+ `repetition` controls whether the transition graph can come back to the same
228
+ node:
229
+
230
+ - `1` means the node can be used once in a given sequence.
231
+ - Any value greater than `1` means the node is repeatable. The transition graph
232
+ only distinguishes `1` from "repeatable"; it does not count exact occurrence
233
+ limits such as `2` versus `99999`.
234
+
235
+ Mandatory nodes constrain valid paths through the tree. Once a mandatory node is
236
+ next in a sequence, the parser cannot skip over it to reach a later sibling, a
237
+ parent, or a child that would bypass it. A mandatory child does not make its
238
+ parent mandatory by itself: if the whole group is required, mark the group node
239
+ as `"M"` too.
240
+
241
+ ```python
242
+ from edison import BaseParser
243
+
244
+
245
+ class OrderParser(BaseParser):
246
+ schemas = {
247
+ (0, 0): [
248
+ ["UNH"],
249
+ [
250
+ "ITM",
251
+ [
252
+ ["DSC"],
253
+ ["QTY", [], "parse_quantity", 1, "M"],
254
+ ["PRI"],
255
+ ],
256
+ None,
257
+ 99999,
258
+ "M",
259
+ ],
260
+ ["UNT"],
261
+ ],
262
+ }
263
+
264
+ def __init__(self):
265
+ self.quantities = []
266
+
267
+ def parse_quantity(self, segment: str):
268
+ self.quantities.append(segment[4:])
269
+
270
+
271
+ assert OrderParser.validate("UNH'ITM'DSC'QTY+2'PRI'ITM'QTY+1'UNT'")
272
+ assert not OrderParser.validate("UNH'UNT'") # ITM is mandatory.
273
+ assert not OrderParser.validate("UNH'ITM'PRI'UNT'") # QTY is mandatory.
274
+ assert not OrderParser.validate("UNH'ITM'QTY+2'QTY+3'UNT'") # QTY repeats once.
275
+ ```
276
+
277
+ </details>
278
+
279
+ <details>
280
+ <summary>Shared headers, footers, and parser hooks</summary>
281
+
282
+ Use `header` and `footer` when several schemas share envelope segments. They are
283
+ automatically prepended and appended to every schema at class compilation time.
284
+
285
+ ```python
286
+ from edison import BaseParser
287
+
288
+
289
+ class EnvelopeParser(BaseParser):
290
+ header = [["UNH", [], "on_segment"]]
291
+ footer = [["UNT", [], "on_segment"]]
292
+ schemas = {(0, 0): [["BGM", [], "on_segment"]]}
293
+
294
+ def __init__(self):
295
+ self.segments = []
296
+
297
+ def set_up(self):
298
+ self.segments.clear()
299
+
300
+ def on_segment(self, segment: str):
301
+ self.segments.append(segment)
302
+
303
+ def tear_down(self):
304
+ self.count = len(self.segments)
305
+ ```
306
+
307
+ `set_up()` runs before parsing starts, and `tear_down()` runs after parsing
308
+ finishes.
309
+
310
+ </details>
311
+
312
+ <details>
313
+ <summary>Transition graphs</summary>
314
+
315
+ `pydot` is installed with Edison. To write PNG files, make sure Graphviz is
316
+ available on your system, then call `build_dot_object()` on a `BaseParser`
317
+ subclass.
318
+
319
+ ```python
320
+ from edison import BaseParser
321
+
322
+
323
+ class Parser(BaseParser):
324
+ schemas = {
325
+ (0, 0): [
326
+ ["UNH"],
327
+ [
328
+ "BGM",
329
+ [
330
+ ["DTM"],
331
+ ],
332
+ ],
333
+ ["UNT"],
334
+ ],
335
+ }
336
+
337
+
338
+ dot = Parser.build_dot_object()
339
+ dot.write_png("transitions.png")
340
+ ```
341
+
342
+ You can pass the same version selector used by parsing, and hide upward links
343
+ for a simpler tree-like graph:
344
+
345
+ ```python
346
+ dot = Parser.build_dot_object(version=(13, 2), include_up_links=False)
347
+ dot.write_png("transitions-v13.png")
348
+ ```
349
+
350
+ Because `build_dot_object()` returns the underlying `pydot.Dot` object, you can
351
+ customize it before writing.
352
+
353
+ </details>
354
+
355
+ <details>
356
+ <summary>Escaping and EBCDIC EDIFACT separators</summary>
357
+
358
+ Edison understands escaped EDIFACT separators and can normalize EBCDIC separator
359
+ characters often found in binary payloads.
360
+
361
+ ```python
362
+ from edison import normalize_ebcdic_separators, escape, unescape
363
+
364
+
365
+ assert escape("REC+") == r"REC\+"
366
+ assert unescape(r"REC\+") == "REC+"
367
+ assert normalize_ebcdic_separators("UNB\x1dSENDER\x1fREC") == "UNB+SENDER:REC"
368
+ ```
369
+
370
+ The segment tokenizer also handles escaped quotes, so content such as
371
+ `IFT+1+AIRLINE\'SERVER'` stays in a single EDIFACT segment.
372
+
373
+ </details>
374
+
375
+ <details>
376
+ <summary>EDIFACT envelope extraction</summary>
377
+
378
+ For the common case of reading EDIFACT envelope boundaries, use
379
+ `extract_envelope_headers()` to lazily return the first `UNB`/`UNH` segments or
380
+ `extract_envelope_footers()` to lazily return the last `UNT`/`UNZ` segments:
381
+
382
+ ```python
383
+ from edison import extract_envelope_footers, extract_envelope_headers
384
+
385
+ edi = "UNB+SENDER+RECEIVER'UNH+1+PIZZAQ:1:1:1A'BGM+1'UNT+3+1'UNZ+1+1'"
386
+ headers = extract_envelope_headers(edi)
387
+ footers = extract_envelope_footers(edi)
388
+
389
+ assert headers.unb == "UNB+SENDER+RECEIVER"
390
+ assert headers.unh == "UNH+1+PIZZAQ:1:1:1A"
391
+ assert footers.unt == "UNT+3+1"
392
+ assert footers.unz == "UNZ+1+1"
393
+ ```
394
+
395
+ </details>
396
+
397
+ ## Development
398
+
399
+ <details>
400
+ <summary>Local commands</summary>
401
+
402
+ This repository uses `uv`, `tox`, `pytest`, `ruff`, `black`, `ty`, and
403
+ pre-commit hooks.
404
+
405
+ ```bash
406
+ uv tool install tox --with tox-uv
407
+ tox -e py
408
+ ```
409
+
410
+ Build distributions with:
411
+
412
+ ```bash
413
+ uv build --sdist --wheel -o dist/
414
+ ```
415
+
416
+ Run the full pre-commit suite with:
417
+
418
+ ```bash
419
+ uv tool install prek==0.4.10
420
+ prek run --all-files
421
+ ```
422
+
423
+ CI runs tests through tox on CPython 3.12 and 3.14 across Linux, Windows, and
424
+ macOS, then builds source and wheel distributions.
425
+
426
+ </details>
@@ -0,0 +1,10 @@
1
+ edison/__init__.py,sha256=g1IVyG0eemMdRQInqjMSwDCZfNjOx6epv-C9c9_fvfE,218
2
+ edison/envelope.py,sha256=o2Y58wth0Jy58_AG-PpIqbvJ5G65B5jn06CTdq1iKBI,1759
3
+ edison/parser.py,sha256=yYx-hOT0P3U7YpYK7RtUs7xko16mjJuzs1UZWlKgt9I,15890
4
+ edison/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ edison/tokenizer.py,sha256=Q2NQirhJsX0pnlNJVri7oFqcySvqJsT4MKrjhtm3Qqw,14079
6
+ edison_parser-0.1.dist-info/licenses/LICENSE,sha256=qpBUePzboadefuykg6yqvOFSNtWCl0Q86qlnBG0Xod4,1065
7
+ edison_parser-0.1.dist-info/METADATA,sha256=JSw3GD6QmLmE7czLsSzepqZjEHoAYzPRNBmxvLvITz4,11534
8
+ edison_parser-0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ edison_parser-0.1.dist-info/top_level.txt,sha256=pimY8xrIwHa0a7JsZ6Ikp6bCyLQK90iA8a28SyyStc0,7
10
+ edison_parser-0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Amadeus s.a.s
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ edison