edison-parser 0.1__tar.gz
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_parser-0.1/LICENSE +21 -0
- edison_parser-0.1/MANIFEST.in +7 -0
- edison_parser-0.1/PKG-INFO +426 -0
- edison_parser-0.1/README.md +401 -0
- edison_parser-0.1/pyproject.toml +182 -0
- edison_parser-0.1/setup.cfg +4 -0
- edison_parser-0.1/src/edison/__init__.py +3 -0
- edison_parser-0.1/src/edison/envelope.py +69 -0
- edison_parser-0.1/src/edison/parser.py +471 -0
- edison_parser-0.1/src/edison/py.typed +0 -0
- edison_parser-0.1/src/edison/tokenizer.py +373 -0
- edison_parser-0.1/src/edison_parser.egg-info/PKG-INFO +426 -0
- edison_parser-0.1/src/edison_parser.egg-info/SOURCES.txt +16 -0
- edison_parser-0.1/src/edison_parser.egg-info/dependency_links.txt +1 -0
- edison_parser-0.1/src/edison_parser.egg-info/requires.txt +2 -0
- edison_parser-0.1/src/edison_parser.egg-info/scm_file_list.json +28 -0
- edison_parser-0.1/src/edison_parser.egg-info/scm_version.json +8 -0
- edison_parser-0.1/src/edison_parser.egg-info/top_level.txt +1 -0
|
@@ -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,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>
|