motion-master-client 6.0.0a124__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.
@@ -0,0 +1,383 @@
1
+ Metadata-Version: 2.4
2
+ Name: motion-master-client
3
+ Version: 6.0.0a124
4
+ Summary: Python client for the Motion Master HTTP API and WebSocket
5
+ License-Expression: GPL-3.0-only
6
+ Project-URL: Homepage, https://motion-master.synapticon.com
7
+ Project-URL: Source, https://github.com/synapticon/motion-master/tree/main/clients/python
8
+ Project-URL: Issues, https://github.com/synapticon/motion-master/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: requests>=2.31
15
+ Requires-Dist: pyyaml>=6
16
+ Requires-Dist: websockets>=13
17
+ Dynamic: license-file
18
+
19
+ # Motion Master reference Python client
20
+
21
+ This is a reference Python client for the Motion Master HTTP API and the WebSocket.
22
+ At startup it reads the OpenAPI spec from the server and calls each operation by its
23
+ `operationId`. See [Why it reads the spec](#why-it-reads-the-spec).
24
+
25
+ There are two ways to get it:
26
+
27
+ - **Install it with pip** to use it in your own program. See [Install with pip](#install-with-pip).
28
+ - **Clone this repository** to run the example scripts, or to change the client. See
29
+ [Setup](#setup).
30
+
31
+ The example scripts are a starting point for your own programs. We recommend an AI coding
32
+ assistant, such as Claude Code. Open it in `clients/python` of a clone and tell it what your
33
+ program must do. It reads the client, the scripts and `swagger.yml`, and writes the program.
34
+
35
+ The recording below shows a first run from a clone. The steps are in [Setup](#setup) and
36
+ [Run](#run).
37
+
38
+ ![A first run of the Python client](https://raw.githubusercontent.com/synapticon/motion-master/main/clients/python/demo.gif)
39
+
40
+ ## Install with pip
41
+
42
+ Python 3.11 or newer is necessary, because the client reads the config with the standard library
43
+ module `tomllib`.
44
+
45
+ ```bash
46
+ pip install motion-master-client
47
+ ```
48
+
49
+ The package has the same version as the server, and is published with each release. A version of
50
+ the client works with the server of the same version. Every version is a pre-release until 6.0.0.
51
+ pip installs a pre-release when no final release exists, so `pip install` needs no `--pre`.
52
+
53
+ The package holds the two modules, `motion_master_client` and `motion_master_objects`. It does not
54
+ hold the example scripts. Write `config.toml` in the directory that your program runs from. Take
55
+ the content from [`config.example.toml`](https://github.com/synapticon/motion-master/blob/main/clients/python/config.example.toml), and set `adapter` as [Setup](#setup) says. Then:
56
+
57
+ ```python
58
+ from motion_master_client import AlState, MotionMasterClient, run
59
+
60
+
61
+ def main():
62
+ client = MotionMasterClient.from_config()
63
+ client.ensure_state(AlState.PREOP)
64
+ for device in client.devices():
65
+ print(device["slavePosition"], device["productName"])
66
+
67
+
68
+ run(main)
69
+ ```
70
+
71
+ `from_config()` reads `config.toml` in the current directory. It writes the spec of the server to
72
+ `swagger.yml` beside that file. To keep the config in another place, give its path:
73
+ `MotionMasterClient.from_config("/path/to/config.toml")`.
74
+
75
+ ## Setup
76
+
77
+ Python 3.11 or newer is necessary, because the client reads the config with the standard library
78
+ module `tomllib`.
79
+
80
+ ```bash
81
+ git clone https://github.com/synapticon/motion-master.git
82
+ cd motion-master/clients/python
83
+ source setup.sh
84
+ cp config.example.toml config.toml
85
+ ```
86
+
87
+ `setup.sh` creates a virtual environment in `.venv`, activates it, upgrades pip in it, and
88
+ installs `requirements.txt` into it. A virtual environment is a directory that holds the
89
+ dependencies of this client apart from your system Python. Source the script, so that the
90
+ activation stays in your shell. Git ignores `.venv`. To remove the environment, delete `.venv`.
91
+
92
+ To do the same by hand, for example on Windows:
93
+
94
+ ```bash
95
+ python -m venv .venv
96
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
97
+ python -m pip install --upgrade pip
98
+ pip install -r requirements.txt
99
+ ```
100
+
101
+ Then edit `config.toml`. Set `adapter` in the `[fieldbus]` table to the network interface that
102
+ the drives are connected to. Give an interface name, such as `eth0`, or a MAC address.
103
+ `python examples/server-info.py` lists the network adapters of the host when the server runs.
104
+
105
+ ## Run
106
+
107
+ 1. Download a build from the [releases](https://github.com/synapticon/motion-master/releases).
108
+ 2. Start the binary. It listens on `127.0.0.1` by default.
109
+ 3. Run a script, for example `python examples/scan.py`.
110
+
111
+ ## Scripts
112
+
113
+ Any script can run first, because it brings the server to the state it needs. A script that
114
+ needs a bus opens the fieldbus driver from `config.toml` when no driver is open. It scans when no
115
+ devices are known. A script that addresses one device takes `--slave`, the position of the device
116
+ on the bus. The default is 1. Each script prints its options with `--help`.
117
+
118
+ ```bash
119
+ python examples/scan.py
120
+ python examples/transition-state.py --state op
121
+ python examples/read-sdo.py --index 0x6064 --subindex 0
122
+ python examples/read-file.py --filename .hardware_description
123
+ python examples/read-all-parameters.py --slave 2
124
+ python examples/monitor.py --count 10
125
+ python examples/reset.py
126
+ ```
127
+
128
+ **The running server is shared.** A Console session that is connected to the same server sees
129
+ every change that a script makes. A script that opens the driver, scans, resets or moves a
130
+ device to another AL state changes what the Console shows.
131
+
132
+ The AL state (Application Layer state, the EtherCAT state machine of a device) column gives
133
+ the lowest state the script needs. A script moves the device up to that state when the device is
134
+ lower. A device that is already higher stays where it is. The states, from low to high, are INIT,
135
+ PREOP, SAFEOP and OP. BOOT is separate: a device enters and leaves BOOT only through INIT.
136
+
137
+ Three values in that column are not states:
138
+
139
+ - "Scanned" means that the script needs the devices known. It opens the driver and scans when
140
+ needed, but it moves no device.
141
+ - "Driver open" means that the script opens the driver when none is open, and scans nothing.
142
+ - "No bus" means that the script opens no driver and leaves the server as it is.
143
+
144
+ ### Server and bus
145
+
146
+ | Script | What it does | AL state |
147
+ | --- | --- | --- |
148
+ | `server-info.py` | Prints the server version, the host, the network adapters and the health of the game loop. | No bus |
149
+ | `set-game-loop-period.py` | Changes the cycle period of the real-time game loop until the server stops. | No bus |
150
+ | `notifications.py` | Prints the notifications that the server sends over the WebSocket. | No bus |
151
+ | `scan.py` | Scans the bus and lists the devices it finds. A scan leaves every device in INIT. | Scanned |
152
+ | `transition-state.py` | Moves the devices to the AL state you give with `--state`, through every state between. | The state you give |
153
+ | `restart-devices.py` | Restarts devices through INIT up to an AL state. An enabled drive is disabled. | Scanned |
154
+ | `reset.py` | Closes the fieldbus driver and clears the device list. | No bus |
155
+ | `diagnostics.py` | Prints the ESC (EtherCAT Slave Controller) error counters and the distributed clock sync of the devices. | Scanned |
156
+ | `capture.py` | Records the EtherCAT frames on the bus adapter for some seconds, and prints how late the frames came back. | Driver open |
157
+
158
+ ### Parameters
159
+
160
+ | Script | What it does | AL state |
161
+ | --- | --- | --- |
162
+ | `read-parameter.py` | Reads one parameter with its name, data type and decoded value. `--cache` reads no bus. | PREOP |
163
+ | `write-parameter.py` | Writes one parameter. It converts the value to the data type, and prints the value before and after. | PREOP |
164
+ | `read-all-parameters.py` | Reads the whole object dictionary of the device with the value of every entry. | PREOP |
165
+ | `read-sdo.py` | Reads one object with an SDO upload (Service Data Object, a mailbox read of one object dictionary entry), and prints its raw bytes. | PREOP |
166
+ | `write-sdo.py` | Writes raw bytes to one object with an SDO download. | PREOP |
167
+ | `store-parameters.py` | Stores the current parameter values in the flash of the device. | PREOP |
168
+ | `restore-default-parameters.py` | Restores the default values of a parameter group. **Values not stored are gone.** | PREOP |
169
+
170
+ ### Files and firmware
171
+
172
+ | Script | What it does | AL state |
173
+ | --- | --- | --- |
174
+ | `list-files.py` | Lists the files stored on the device with their sizes. | PREOP |
175
+ | `read-file.py` | Reads a file from the device over FoE (File access over EtherCAT), and saves it in `files/`. | PREOP |
176
+ | `write-file.py` | Writes a local file to the device over FoE. A file of the same name is replaced. | PREOP |
177
+ | `delete-file.py` | Deletes a file from the flash of a SOMANET device. | PREOP |
178
+ | `read-hardware-description.py` | Prints what a SOMANET device says it is: the device, the assembly and their components. | PREOP |
179
+ | `check-firmware-compatibility.py` | Says whether a firmware package belongs on the device, from the package filename alone. It installs nothing. | PREOP |
180
+ | `firmware-installation.py` | Uploads a firmware package to the server and installs it on the device. The device is not controlled while it is off OP. | Scanned |
181
+
182
+ ### ESC registers and SII
183
+
184
+ | Script | What it does | AL state |
185
+ | --- | --- | --- |
186
+ | `read-register.py` | Reads bytes from an ESC register. | Scanned |
187
+ | `write-register.py` | Writes bytes to an ESC register. | Scanned |
188
+ | `read-sii.py` | Prints the SII (Slave Information Interface, the configuration in the EEPROM of the device), or saves the raw image with `--output`. | Scanned |
189
+ | `write-sii.py` | Writes a raw SII image to the EEPROM. **A wrong image can leave the device unidentifiable.** The device adopts it after a power cycle. | Scanned |
190
+
191
+ ### Process data
192
+
193
+ | Script | What it does | AL state |
194
+ | --- | --- | --- |
195
+ | `read-pdo-mapping.py` | Prints the PDO (Process Data Object) mapping of the device, with the bit offset and the name of each entry. | PREOP |
196
+ | `write-pdo-mapping.py` | Writes the PDO mapping from a JSON file. It moves the device to PREOP for the write, and back to SAFEOP or OP. | PREOP for the write |
197
+ | `monitor.py` | Creates a monitoring and prints its batches of process data from the WebSocket. | OP |
198
+ | `monitoring.py` | Records a move in Cyclic Synchronous Velocity with `client.monitoring()`: 250 for 2 s, then 0. Prints the target, the actual velocity and the CiA402 state, and saves every cycle as a CSV file in `files/`. **The motor turns.** | OP |
199
+ | `dump-process-data.py` | Saves the recorder of the server, every recorded cycle of process data, as a `.mmpd` file in `files/`. | SAFEOP |
200
+
201
+ ### Drive
202
+
203
+ | Script | What it does | AL state |
204
+ | --- | --- | --- |
205
+ | `cia402-state.py` | Prints the CiA 402 state of the drive with its actual and target values. | PREOP |
206
+ | `set-target.py` | Sets the position, velocity or torque target. **An enabled drive moves to it at once.** | PREOP |
207
+ | `release-brake.py` | Releases the brake of a SOMANET drive. It releases only while the drive is in Operation Enabled, and stays released. | PREOP |
208
+ | `engage-brake.py` | Engages the brake of a SOMANET drive. | PREOP |
209
+ | `device-locate.py` | Blinks the LEDs of the device. Only SPoE offers it. | PREOP |
210
+
211
+ ### Procedures
212
+
213
+ | Script | What it does | AL state |
214
+ | --- | --- | --- |
215
+ | `run-procedure.py` | Lists the procedures of a device. With a name, it runs that procedure with parameters given as `name=value`, and prints each step with its result. `--describe` prints the description, caveats and parameters instead. | PREOP, or `--state` |
216
+ | `offset-detection.py` | Runs the offset detection procedure on a SOMANET drive, and prints each step with its result. **It turns the motor and releases the brake.** Ctrl-C cancels the run. | OP |
217
+ | `system-identification.py` | Configures the system identification chirp, and arms it with `--start`. **Once armed, it turns the motor.** | PREOP |
218
+ | `plant-identification.py` | Identifies the plant model with a chirp, and writes `plant_model.csv`. **It turns the motor in both directions.** | OP |
219
+
220
+ ### ESI and ENI
221
+
222
+ | Script | What it does | AL state |
223
+ | --- | --- | --- |
224
+ | `parse-esi.py` | Parses an ESI (EtherCAT Slave Information) file and lists the devices it describes. | No bus |
225
+ | `export-eni.py` | Saves the bus as an ENI (EtherCAT Network Information) file in `files/`, for another master to replay. | SAFEOP |
226
+
227
+ ### Production
228
+
229
+ A production script runs many steps in order on one drive, asks before the steps that need a
230
+ decision, and writes a log and a record of the drive. Its steps are in
231
+ [`examples/production/README.md`](https://github.com/synapticon/motion-master/blob/main/clients/python/examples/production/README.md).
232
+
233
+ | Script | What it does | AL state |
234
+ | --- | --- | --- |
235
+ | `production/integro/commission.py` | Commissions an Integro drive. | PREOP |
236
+ | `production/c021/commission.py` | Commissions a C021 drive up to its assigned name. | PREOP |
237
+
238
+ ## Calls by operationId
239
+
240
+ Every request goes through one method, `MotionMasterClient.call(operationId, ...)`. It takes
241
+ the path parameters as `path`, a JSON body as `body`, and the query string as `query`:
242
+
243
+ ```python
244
+ client.call("init", body={"driver": "soem", "adapter": "eth0"})
245
+ client.call("sdoUpload", path={"slavePosition": 1, "index": 0x6041, "subindex": 0})
246
+ ```
247
+
248
+ The steps that most scripts begin with are methods on the client:
249
+
250
+ ```python
251
+ client = MotionMasterClient.from_config() # reads config.toml
252
+ client.init() # opens the driver, or accepts the one that is open
253
+ client.ensure_state(AlState.PREOP, positions=[1]) # scans when needed, then moves device 1 up
254
+ ```
255
+
256
+ `ensure_scanned()` scans when needed and moves no device. `init()`, `ensure_scanned()` and
257
+ `ensure_state()` log through the `motion_master_client` logger. Turn on logging at INFO to see
258
+ each step.
259
+
260
+ Other methods:
261
+
262
+ - `reset()` closes the fieldbus driver and forgets every device. It commands no AL state.
263
+ - `scan()` scans the bus every time and returns the number of devices it found.
264
+ - `start_capture(adapter, seconds)` starts a capture of the EtherCAT frames that runs until
265
+ `stop_capture()`, which returns the path of the pcap file in the user cache and the summary of
266
+ late frames. Linux and macOS servers only. Both raise `CaptureError` with the server's reason.
267
+ - `process_data_mark()` returns the recorder clock of the server, in epoch microseconds.
268
+ `process_data_dump(from_us, to_us)` returns the `.mmpd` dump of the cycles between two marks as
269
+ bytes, or of the whole recorder without them. It returns `None` when the server has no cycle to
270
+ dump, and raises `ProcessDataDumpError` for any other refusal. `mmpd_flags()` of the result has
271
+ `MMPD_FLAG_INCOMPLETE_START` set when the server no longer held every cycle right after the
272
+ start.
273
+ - `run_procedure()` runs a procedure to its end.
274
+ - `monitor(topic)` streams the batches of a monitoring from the WebSocket.
275
+ - `monitoring(parameters)` returns a `Monitoring` for a `with` block. It creates the monitoring,
276
+ keeps every row while the block runs, and deletes the monitoring at the end. Then `column()`
277
+ returns the values of one object with their timestamps, `values()` returns the values in a time
278
+ window, `first()` returns the time of the first value that meets a condition,
279
+ `print_table()` prints one row every `every_ms`, with the CiA402 state beside a statusword, and
280
+ `write_csv(path)` writes every row to a CSV file. Both name a column like `1:STATUSWORD`, as
281
+ `names` lists them.
282
+ - `notifications()` streams the notifications from the WebSocket.
283
+ - `devices()` returns the scanned devices in bus order, as `GET /api/devices` gives them.
284
+ - `parameters(position)` returns every parameter of a device, with its value and metadata.
285
+ - `hardware_description(position)` returns the parsed `.hardware_description` of a device.
286
+ - `read_value(position, obj)` reads one object and returns the value that the server decoded with
287
+ the data type from the object dictionary.
288
+ - `wait_for_value(position, obj, predicate)` reads one object until the predicate is true, and
289
+ raises `TimeoutError` with the last value when the time runs out.
290
+ - `restart(target, positions)` restarts devices through INIT and walks them up to an AL state. The
291
+ server does the walk.
292
+ - `set_operation_mode()`, `set_target()`, `enable()`, `disable()` and `quick_stop()` drive the
293
+ CiA402 state machine and the targets of one drive, by its position.
294
+ - `run(main)` calls `main` and prints an error that a user can correct as one line instead of a
295
+ traceback. An example is a server that is not running. Set `MM_TRACEBACK=1` to get the
296
+ traceback.
297
+
298
+ The module names objects and states with classes:
299
+
300
+ - `Profile`, `Cia402` and `Somanet` name every object of a SOMANET device as an address, for example
301
+ `Cia402.STATUSWORD`. `Profile` holds the CiA 301 communication area (0x1xxx) and the standard MDP
302
+ objects, `Cia402` the CiA 402 drive profile (0x6xxx), and `Somanet` the SOMANET vendor area
303
+ (0x2xxx) and FSoE. An address unpacks to `(index, subindex)`, so `read_value(1, Cia402.STATUSWORD)`
304
+ and the monitoring parameter `(1, *Cia402.STATUSWORD)` name the object the same way. An address
305
+ also carries the type of the value, for a type checker. The names are the same as in the
306
+ TypeScript client, and the C++ names of the server without their `k`, in capitals. The list holds
307
+ every object of every device in the ESI, so an address can name an object that the drive does not
308
+ have. A read of it then fails.
309
+ - `AlState` names the AL states, `INIT`, `PREOP`, `BOOT`, `SAFEOP` and `OP`. It is an `IntEnum`, so
310
+ a member goes into a request body as a number, and `AlState(value)` converts an `alState` from a
311
+ response. The name of a member is its label. The server reports `UNKNOWN`, 0, for a device that
312
+ does not answer.
313
+ - `Cia402State` names the CiA402 states. It is a `StrEnum` whose members equal the names that the
314
+ API uses: `Cia402State.OPERATION_ENABLED == "OperationEnabled"`. `Cia402.state(statusword)`
315
+ returns the state that a statusword reports. It is meant for recorded rows, where the server
316
+ sends the raw statusword.
317
+
318
+ `motion_master_objects.py` holds the addresses. The server's `generate-object-addresses` command
319
+ writes it from the ESI, with the C++ headers and the TypeScript file, so do not edit it.
320
+
321
+ ## TLS
322
+
323
+ Motion Master accepts TLS connections only. A release binary includes a Let's Encrypt
324
+ certificate for `local.motion-master.synapticon.com`, which resolves to `127.0.0.1`. A public CA
325
+ (certificate authority, an organisation that operating systems trust to sign certificates) signs
326
+ it. So with the default `host` you can set `verify = true`. The client then verifies the
327
+ certificate against the CA store of the system, with no other setup.
328
+
329
+ The template sets `verify = false`, because that works with every certificate. This includes the
330
+ self-signed certificate that the binary uses when it cannot download a real one, for example on a
331
+ host with no internet access. Set `verify = true`, or set `verify` to the path of a `cert.pem`,
332
+ when you know which certificate the server presents.
333
+
334
+ ## Two ports
335
+
336
+ Motion Master serves two endpoints, each on its own event loop. The HTTP API is on `http_port`,
337
+ 61447. The WebSocket is on `ws_port`, 62281. That is why `host` in the config has no port.
338
+ `client.call(...)` uses the HTTP API. `client.monitor(topic)` and `client.notifications()` use
339
+ the WebSocket. `monitor.py` and `notifications.py` show the WebSocket.
340
+
341
+ ## Why it reads the spec
342
+
343
+ The scripts name an `operationId`, such as `init` or `sdoUpload`. They never name a URL or an
344
+ HTTP method. At startup the client reads `GET /api/swagger.yml` and builds one table from
345
+ `operationId` to the method and the URL template. `call()` looks up the ID, fills the
346
+ placeholders of the template from `path`, and sends the request. So the URLs and the methods
347
+ are in one place only, the spec of the server.
348
+
349
+ That gives four results:
350
+
351
+ - **There is one source of truth.** The same binary implements the API and serves the spec. URLs
352
+ in the scripts would be a second copy of the contract, written by hand, and it would be wrong
353
+ after the API changes. An `operationId` is a stable name, so a script keeps working when a URL
354
+ on the server changes.
355
+ - **The client always matches its server.** The spec comes from the running server. So the client
356
+ is correct against the contract of that server, older or newer.
357
+ - **The client is standalone.** There is no bundled spec, no code generation and no checkout of
358
+ this repository. Install it with pip, and point it at a
359
+ server.
360
+ - **A script shows the API.** Each call names the operation that it invokes. There is no URL
361
+ string to read past.
362
+
363
+ The cost is one request at startup, and the server must be reachable when the client is built.
364
+
365
+ ## Layout
366
+
367
+ | File | Purpose |
368
+ | --- | --- |
369
+ | `motion_master_client.py` | `MotionMasterClient`, which calls the HTTP API and the WebSocket. See [Calls by operationId](#calls-by-operationid). |
370
+ | `setup.sh` | Creates and activates the virtual environment. See [Setup](#setup). |
371
+ | `motion_master_objects.py` | The generated object addresses. See [Calls by operationId](#calls-by-operationid). |
372
+ | `requirements.txt` | The Python packages that the client needs. Keep it the same as the dependencies in `pyproject.toml`. |
373
+ | `pyproject.toml` | The package that a release publishes to PyPI. The version is set by `tools/bump-version.sh`. |
374
+ | `LICENSE` | A copy of the GPL v3 licence of the repository, which the package carries. |
375
+ | `config.example.toml` | The config template. Copy it to `config.toml`. |
376
+ | `demo.tape` | The [VHS](https://github.com/charmbracelet/vhs) script that records `demo.gif`. Run `vhs demo.tape`. |
377
+ | `examples/` | The example scripts. See [Scripts](#scripts). |
378
+ | `files/` | Where a script saves a file when `--output` names no other path. Git ignores this directory. |
379
+ | `swagger.yml` | A copy of the spec that the client read from the server at its last startup. `from_config()` writes it beside `config.toml`. Look up an `operationId` here when you write a new script. The client writes it again at each startup, and Git ignores it. |
380
+
381
+ ## Licence
382
+
383
+ GPL v3, the licence of the whole Motion Master repository. See `LICENSE`.
@@ -0,0 +1,7 @@
1
+ motion_master_client.py,sha256=tDwpp3wgx_sNjCbpY06StoEzPVGxk4iPqAxIwZek3lA,44233
2
+ motion_master_objects.py,sha256=2HAZNASJyig3P-6RGDHms6KityAtXCkY3D_fGyKtMLM,114299
3
+ motion_master_client-6.0.0a124.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
4
+ motion_master_client-6.0.0a124.dist-info/METADATA,sha256=pHBYNIVUrj5ZhP1AbDYCju9cL5LrtNTS3YraXlfl7ss,22159
5
+ motion_master_client-6.0.0a124.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ motion_master_client-6.0.0a124.dist-info/top_level.txt,sha256=SLGCbwm4_HjW6x5RKe6DqJ3tj6YC9531ZkK59TS9uMQ,43
7
+ motion_master_client-6.0.0a124.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
+