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 +3 -0
- edison/envelope.py +69 -0
- edison/parser.py +471 -0
- edison/py.typed +0 -0
- edison/tokenizer.py +373 -0
- edison_parser-0.1.dist-info/METADATA +426 -0
- edison_parser-0.1.dist-info/RECORD +10 -0
- edison_parser-0.1.dist-info/WHEEL +5 -0
- edison_parser-0.1.dist-info/licenses/LICENSE +21 -0
- edison_parser-0.1.dist-info/top_level.txt +1 -0
edison/__init__.py
ADDED
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,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
|