libsh 0.0.0__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.
libsh/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """
2
+ Scott's common package.
3
+ """
4
+
5
+ from .format import (
6
+ Microseconds,
7
+ Milliseconds,
8
+ Pretty,
9
+ Range,
10
+ Samples,
11
+ Seconds,
12
+ Unit,
13
+ )
14
+ from .logs import get_logger, setup_logging, setup_logging_from_env
15
+
16
+ __all__ = [
17
+ "get_logger",
18
+ "setup_logging",
19
+ "setup_logging_from_env",
20
+ "Microseconds",
21
+ "Milliseconds",
22
+ "Pretty",
23
+ "Range",
24
+ "Samples",
25
+ "Seconds",
26
+ "Unit",
27
+ ]
libsh/format.py ADDED
@@ -0,0 +1,45 @@
1
+ from typing import NamedTuple
2
+
3
+ from rich.pretty import pretty_repr
4
+
5
+
6
+ class Pretty(NamedTuple):
7
+ value: object
8
+
9
+ def __str__(self) -> str:
10
+ return pretty_repr(self.value)
11
+
12
+
13
+ class Unit(NamedTuple):
14
+ value: float
15
+
16
+
17
+ class Seconds(Unit):
18
+ def __str__(self) -> str:
19
+ return f"{self.value:.3}s"
20
+
21
+
22
+ class Milliseconds(Unit):
23
+ def __str__(self) -> str:
24
+ return f"{self.value:.3}ms"
25
+
26
+
27
+ class Microseconds(Unit):
28
+ def __str__(self) -> str:
29
+ return f"{self.value:.3}μs"
30
+
31
+
32
+ class Samples(Unit):
33
+ def __str__(self) -> str:
34
+ return f"{self.value:.3} samples"
35
+
36
+
37
+ _EN_DASH = "–"
38
+
39
+
40
+ class Range(NamedTuple):
41
+ start: Unit
42
+ end: Unit
43
+
44
+ def __str__(self) -> str:
45
+ return f"{self.start}{_EN_DASH}{self.end}"
libsh/logs.py ADDED
@@ -0,0 +1,391 @@
1
+ """Centralized logging configuration using structlog."""
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from io import StringIO
10
+ from typing import Any
11
+
12
+ import structlog
13
+ from structlog.dev import (
14
+ BLUE,
15
+ BRIGHT,
16
+ CYAN,
17
+ DIM,
18
+ GREEN,
19
+ MAGENTA,
20
+ RED,
21
+ RED_BACK,
22
+ RESET_ALL,
23
+ YELLOW,
24
+ Column,
25
+ ConsoleRenderer,
26
+ KeyValueColumnFormatter,
27
+ _pad,
28
+ )
29
+ from structlog.typing import EventDict, Processor
30
+
31
+ from .proc import FloatPrecisionProcessor, LoggerFilterProcessor
32
+
33
+ # Store program start time for relative timestamps
34
+ _PROGRAM_START_TIME = time.time()
35
+
36
+
37
+ def hex_to_ansi_fg(hex_color: int) -> str:
38
+ """Convert hex color (e.g., 0xad8a89) to ANSI 24-bit foreground escape code."""
39
+ r = (hex_color >> 16) & 0xFF
40
+ g = (hex_color >> 8) & 0xFF
41
+ b = hex_color & 0xFF
42
+ return f"\x1b[38;2;{r};{g};{b}m"
43
+
44
+
45
+ @dataclass
46
+ class RegexValueColumnFormatter:
47
+ """
48
+ Format a key-value pair with regex-based value styling.
49
+
50
+ Like KeyValueColumnFormatter, but allows mapping values to styles based on
51
+ regular expression patterns.
52
+
53
+ :param key_style: The style to apply to the key. If None, the key is omitted.
54
+ :param value_style_map: A list of (regex_pattern, style) tuples. The first
55
+ pattern that matches the value will determine its style.
56
+ :param default_value_style: The style to use if no regex patterns match.
57
+ :param reset_style: The style to apply whenever a style is no longer needed.
58
+ :param value_repr: A callable that returns the string representation of the value.
59
+ :param width: The width to pad the value to. If 0, no padding is done.
60
+ :param prefix: A string to prepend to the formatted key-value pair. May contain
61
+ styles.
62
+ :param postfix: A string to append to the formatted key-value pair. May contain
63
+ styles.
64
+ """
65
+
66
+ key_style: str | None
67
+ value_style_map: list[tuple[str, str]]
68
+ default_value_style: str
69
+ reset_style: str
70
+ value_repr: Callable[[object], str]
71
+ width: int = 0
72
+ prefix: str = ""
73
+ postfix: str = ""
74
+
75
+ def __post_init__(self) -> None:
76
+ """Compile regex patterns for efficiency."""
77
+ self._compiled_patterns = [
78
+ (re.compile(pattern), style) for pattern, style in self.value_style_map
79
+ ]
80
+
81
+ def __call__(self, key: str, value: object) -> str:
82
+ sio = StringIO()
83
+
84
+ if self.prefix:
85
+ sio.write(self.prefix)
86
+ sio.write(self.reset_style)
87
+
88
+ if self.key_style is not None:
89
+ sio.write(self.key_style)
90
+ sio.write(key)
91
+ sio.write(self.reset_style)
92
+ sio.write("=")
93
+
94
+ # Determine value style based on regex matching
95
+ value_str = self.value_repr(value)
96
+ value_style = self.default_value_style
97
+
98
+ for pattern, style in self._compiled_patterns:
99
+ if pattern.search(value_str):
100
+ value_style = style
101
+ break
102
+
103
+ sio.write(value_style)
104
+ sio.write(_pad(value_str, self.width))
105
+ sio.write(self.reset_style)
106
+
107
+ if self.postfix:
108
+ sio.write(self.postfix)
109
+ sio.write(self.reset_style)
110
+
111
+ return sio.getvalue()
112
+
113
+
114
+ class NerdStyles:
115
+ reset = RESET_ALL
116
+ bright = BRIGHT
117
+
118
+ level_critical = RED
119
+ level_exception = RED
120
+ level_error = RED
121
+ level_warn = YELLOW
122
+ level_info = GREEN
123
+ level_debug = GREEN
124
+ level_notset = RED_BACK
125
+
126
+ timestamp = DIM
127
+ logger_name = BLUE
128
+ kv_key = CYAN
129
+ kv_value = MAGENTA
130
+
131
+
132
+ def _relative_time_processor(
133
+ _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
134
+ ) -> EventDict:
135
+ """Add relative timestamp since program start with hours:minutes:seconds.milliseconds format."""
136
+ elapsed = time.time() - _PROGRAM_START_TIME
137
+
138
+ # Calculate hours, minutes, seconds
139
+ hours = int(elapsed // 3600)
140
+ minutes = int((elapsed % 3600) // 60)
141
+ seconds = elapsed % 60
142
+
143
+ # ANSI color codes: \x1b[2m for dim/gray, \x1b[90m for darker gray, \x1b[0m to reset
144
+ gray = "\x1b[2m" # Normal gray (like original timestamp)
145
+ dark_gray = "\x1b[90m" # Darker gray for zeros
146
+ reset = "\x1b[0m"
147
+
148
+ separator = f"{gray}:{reset}"
149
+
150
+ # Format hours with darker gray if zero, normal gray otherwise
151
+ hours_str = f"{gray}{hours:02d}{reset}{separator}" if hours != 0 else ""
152
+
153
+ # Format minutes with darker gray if zero, normal gray otherwise
154
+ minutes_str = f"{gray}{minutes:02d}{reset}{separator}" if minutes != 0 and hours != 0 else ""
155
+
156
+ # Always show seconds in normal gray
157
+ seconds_str = f"{gray}{seconds:06.3f}{reset}"
158
+
159
+ # Colons in normal gray too
160
+ time_str = f"{dark_gray}+{reset}{hours_str}{minutes_str}{seconds_str}"
161
+
162
+ event_dict["timestamp"] = time_str
163
+ return event_dict
164
+
165
+
166
+ def _compact_level_processor(
167
+ _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
168
+ ) -> EventDict:
169
+ """Convert log levels to compact 4-character format with 24-bit colors and darker brackets."""
170
+ # 24-bit color codes: \x1b[38;2;R;G;Bm for foreground, \x1b[48;2;R;G;Bm for background
171
+ reset = "\x1b[0m"
172
+
173
+ # Original colors and their 10% darkened versions for brackets
174
+ level_mapping = {
175
+ "debug": {
176
+ "text": f"{hex_to_ansi_fg(0x908CAA)}dbug{reset}", # fg #908caa
177
+ "bracket": hex_to_ansi_fg(0x827E99), # 10% darker
178
+ },
179
+ "info": {
180
+ "text": f"{hex_to_ansi_fg(0x9CCFD8)}info{reset}", # fg #9ccfd8
181
+ "bracket": hex_to_ansi_fg(0x8CBAC2), # 10% darker
182
+ },
183
+ "warning": {
184
+ "text": f"{hex_to_ansi_fg(0xF6C177)}warn{reset}", # fg #f6c177
185
+ "bracket": hex_to_ansi_fg(0xDDAE6B), # 10% darker
186
+ },
187
+ "error": {
188
+ "text": f"{hex_to_ansi_fg(0xEB6F92)}eror{reset}", # fg #eb6f92
189
+ "bracket": hex_to_ansi_fg(0xD46483), # 10% darker
190
+ },
191
+ "exception": {
192
+ "text": f"{hex_to_ansi_fg(0xEB6F92)}exc!{reset}", # fg #eb6f92
193
+ "bracket": hex_to_ansi_fg(0xD46483), # 10% darker
194
+ },
195
+ "critical": {
196
+ "text": f"\x1b[48;2;235;111;146;38;2;33;32;46mcrit{reset}", # bg #eb6f92, fg #21202e
197
+ "bracket": hex_to_ansi_fg(0xD46483), # 10% darker
198
+ },
199
+ }
200
+
201
+ if "level" in event_dict:
202
+ original_level = event_dict["level"]
203
+ if original_level in level_mapping:
204
+ colors = level_mapping[original_level]
205
+ event_dict["level"] = (
206
+ f"{colors['bracket']}[{reset}{colors['text']}{colors['bracket']}]{reset}"
207
+ )
208
+ else:
209
+ event_dict["level"] = original_level
210
+
211
+ return event_dict
212
+
213
+
214
+ def _debug_event_colorer(
215
+ _logger: structlog.stdlib.BoundLogger, _method_name: str, event_dict: EventDict
216
+ ) -> EventDict:
217
+ """Color the event text purple for debug level messages."""
218
+ # Check both the original level and processed level
219
+ level = event_dict.get("level", "")
220
+ if ("debug" in str(level).lower() or level == "debug") and "event" in event_dict:
221
+ # Color debug event text with #908caa
222
+ debug_color = hex_to_ansi_fg(0x908CAA)
223
+ reset = "\x1b[0m"
224
+ event_dict["event"] = f"{debug_color}{event_dict['event']}{reset}"
225
+
226
+ return event_dict
227
+
228
+
229
+ def setup_logging(
230
+ level: str = "INFO",
231
+ json_output: bool = False,
232
+ correlation_id: str | None = None,
233
+ filter_to_logger: str | None = None,
234
+ ) -> None:
235
+ """Configure structured logging for the application."""
236
+ filters = [structlog.stdlib.filter_by_level]
237
+ if filter_to_logger:
238
+ filters += [LoggerFilterProcessor(filter_to_logger)]
239
+
240
+ # Configure processors
241
+ shared_processors: list[Processor] = [
242
+ structlog.stdlib.add_logger_name,
243
+ structlog.stdlib.add_log_level,
244
+ *filters,
245
+ _debug_event_colorer, # Run before level processing
246
+ _compact_level_processor,
247
+ structlog.stdlib.PositionalArgumentsFormatter(),
248
+ structlog.stdlib.ExtraAdder(),
249
+ FloatPrecisionProcessor(digits=3),
250
+ _relative_time_processor,
251
+ structlog.processors.StackInfoRenderer(),
252
+ # structlog.processors.format_exc_info,
253
+ ]
254
+
255
+ # Add correlation ID if provided
256
+ if correlation_id:
257
+ shared_processors.insert(0, structlog.contextvars.merge_contextvars)
258
+ structlog.contextvars.bind_contextvars(correlation_id=correlation_id)
259
+
260
+ # Configure output format
261
+ if json_output:
262
+ log_renderer = structlog.processors.JSONRenderer()
263
+ else:
264
+ event_key: str = "event"
265
+ timestamp_key: str = "timestamp"
266
+ logger_name_formatter = KeyValueColumnFormatter(
267
+ key_style=None,
268
+ value_style=hex_to_ansi_fg(0x7D6B95),
269
+ reset_style=RESET_ALL,
270
+ value_repr=str,
271
+ prefix="[",
272
+ postfix="]",
273
+ )
274
+
275
+ log_renderer = ConsoleRenderer(
276
+ colors=True,
277
+ columns=[
278
+ # Default formatter
279
+ Column(
280
+ "",
281
+ RegexValueColumnFormatter(
282
+ key_style=hex_to_ansi_fg(0x6E6A86),
283
+ value_style_map=[
284
+ (
285
+ # Integers (including negative) get bright green styling
286
+ r"^True|False$",
287
+ hex_to_ansi_fg(0x6E6A86),
288
+ ),
289
+ (
290
+ # Integers (including negative) get bright green styling
291
+ r"^-?\d+$",
292
+ hex_to_ansi_fg(0xF6C177),
293
+ ),
294
+ (
295
+ # Floats (including negative) get bright yellow styling
296
+ r"^-?\d*\.\d+$",
297
+ hex_to_ansi_fg(0xF6C177),
298
+ ),
299
+ (
300
+ # Numeric durations (123ms, 45.67s, 12h, etc.)
301
+ r"^-?\d*\.?\d+(?:h|m|s|ms|us|µs)$",
302
+ hex_to_ansi_fg(0x9CCFD8),
303
+ ),
304
+ ],
305
+ default_value_style="",
306
+ reset_style=RESET_ALL,
307
+ value_repr=str,
308
+ ),
309
+ ),
310
+ Column(
311
+ timestamp_key,
312
+ KeyValueColumnFormatter(
313
+ key_style=None,
314
+ value_style=DIM,
315
+ reset_style=RESET_ALL,
316
+ value_repr=str,
317
+ ),
318
+ ),
319
+ Column(
320
+ "level",
321
+ KeyValueColumnFormatter(
322
+ key_style=None,
323
+ value_style="",
324
+ reset_style=RESET_ALL,
325
+ value_repr=str,
326
+ ),
327
+ ),
328
+ Column("logger_name", logger_name_formatter),
329
+ Column("logger", logger_name_formatter),
330
+ Column(
331
+ event_key,
332
+ KeyValueColumnFormatter(
333
+ key_style=None,
334
+ value_style=BRIGHT,
335
+ reset_style=RESET_ALL,
336
+ value_repr=str,
337
+ width=30,
338
+ ),
339
+ ),
340
+ ],
341
+ )
342
+
343
+ # Configure structlog
344
+ structlog.configure(
345
+ processors=shared_processors + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
346
+ wrapper_class=structlog.stdlib.BoundLogger,
347
+ logger_factory=structlog.stdlib.LoggerFactory(),
348
+ cache_logger_on_first_use=True,
349
+ )
350
+
351
+ formatter = structlog.stdlib.ProcessorFormatter(
352
+ foreign_pre_chain=shared_processors,
353
+ processors=[
354
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
355
+ log_renderer,
356
+ ],
357
+ )
358
+
359
+ # setup logging
360
+ handler = logging.StreamHandler()
361
+ handler.setFormatter(formatter)
362
+ root_logger = logging.getLogger()
363
+ root_logger.addHandler(handler)
364
+ root_logger.setLevel(level)
365
+
366
+ # Propogate the logs of some libraries
367
+ for liblog in [logging.getLogger(_liblog) for _liblog in ["websockets"]]:
368
+ liblog.handlers.clear()
369
+ liblog.setLevel(logging.WARNING)
370
+ liblog.propagate = True
371
+
372
+ # And suppress the logs of others
373
+ # for liblog in [logging.getLogger(_liblog) for _liblog in ["websockets"]]:
374
+ # liblog.handlers.clear()
375
+ # liblog.propagate = False
376
+
377
+
378
+ def get_logger(
379
+ name: str | None = None, *args: list[Any], **initial_values: Any
380
+ ) -> structlog.stdlib.BoundLogger:
381
+ """Get a structured logger instance."""
382
+ return structlog.get_logger(*([name] + list(args)), **initial_values)
383
+
384
+
385
+ def setup_logging_from_env() -> None:
386
+ """Setup logging using environment variables."""
387
+ log_level = os.getenv("LOG_LEVEL", "INFO").upper()
388
+ json_output = os.getenv("JSON_LOGS", "false").lower() in ("true", "1", "yes", "on")
389
+ correlation_id = os.getenv("CORRELATION_ID")
390
+
391
+ setup_logging(level=log_level, json_output=json_output, correlation_id=correlation_id)
libsh/proc/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .float import FloatPrecisionProcessor
2
+ from .logger_filter import LoggerFilterProcessor
3
+
4
+ __all__ = [
5
+ "FloatPrecisionProcessor",
6
+ "LoggerFilterProcessor",
7
+ ]
libsh/proc/float.py ADDED
@@ -0,0 +1,76 @@
1
+ from typing import Any
2
+
3
+ import numpy as np
4
+ from structlog.typing import EventDict, WrappedLogger
5
+
6
+
7
+ class FloatPrecisionProcessor:
8
+ """
9
+ A structlog processor for rounding floats. Both as single numbers or in data structures like
10
+ (nested) lists, dicts, or numpy arrays.
11
+
12
+ Inspired by https://github.com/underyx/structlog-pretty/blob/master/structlog_pretty/processors.py
13
+
14
+ NOTE: It seems that if a processor logs internally, like for debugging purposes, it will be
15
+ detected and removed from the processing stack.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ digits: int = 3,
21
+ only_fields: set[str] = set(),
22
+ not_fields: set[str] = set(),
23
+ np_array_to_list: bool = True,
24
+ ):
25
+ """
26
+ Create a FloatRounder processor. That rounds floats to the given number of digits.
27
+
28
+ :param digits: The number of digits to round to
29
+ :param only_fields: A set specifying the fields to round (None = round all fields except
30
+ not_fields)
31
+ :param not_fields: A set specifying fields not to round
32
+ :param np_array_to_list: Whether to cast np.array to list for nicer printing
33
+ """
34
+ self.digits = digits
35
+ self.np_array_to_list = np_array_to_list
36
+ self.only_fields = only_fields
37
+ self.not_fields = not_fields
38
+
39
+ def _round(self, value: Any):
40
+ """
41
+ Round floats, unpack lists, convert np.arrays to lists
42
+
43
+ :param value: The value/data structure to round
44
+ :returns: The rounded value
45
+ """
46
+ # round floats
47
+ if isinstance(value, float):
48
+ return round(value, self.digits)
49
+ # convert np.array to list
50
+ if self.np_array_to_list:
51
+ if isinstance(value, np.ndarray):
52
+ return self._round(list(value))
53
+ # round values in lists recursively (to handle lists of lists)
54
+ if isinstance(value, list):
55
+ for idx, item in enumerate(value):
56
+ value[idx] = self._round(item)
57
+ return value
58
+ # similarly, round values in dicts recursively
59
+ if isinstance(value, dict):
60
+ for k, v in value.items():
61
+ value[k] = self._round(v)
62
+ return value
63
+ # return any other values as they are
64
+ return value
65
+
66
+ def __call__(self, _: WrappedLogger, __: str, event_dict: EventDict):
67
+ for key, value in event_dict.items():
68
+ if len(self.only_fields) > 0 and key not in self.only_fields:
69
+ continue
70
+ if len(self.not_fields) > 0 and key in self.not_fields:
71
+ continue
72
+ if isinstance(value, bool):
73
+ continue # don't convert True to 1.0
74
+
75
+ event_dict[key] = self._round(value)
76
+ return event_dict
@@ -0,0 +1,47 @@
1
+ from structlog import DropEvent
2
+ from structlog.typing import EventDict, WrappedLogger
3
+
4
+
5
+ class LoggerFilterProcessor:
6
+ """
7
+ A structlog processor that filters log events based on the logger name.
8
+
9
+ This processor allows you to specify a logger name, and only log events
10
+ originating from that logger (or its children) will be processed. All other
11
+ log events will be ignored.
12
+
13
+ Example usage:
14
+ import structlog
15
+
16
+ structlog.configure(
17
+ processors=[
18
+ LoggerFilterProcessor("my_logger"),
19
+ structlog.processors.JSONRenderer()
20
+ ]
21
+ )
22
+ logger = structlog.get_logger("my_logger")
23
+ logger.info("This will be logged")
24
+ """
25
+
26
+ def __init__(self, logger_name: str):
27
+ """
28
+ Initialize the LoggerFilterProcessor.
29
+
30
+ :param logger_name: The name of the logger to filter on. Only log events
31
+ from this logger or its children will be processed.
32
+ """
33
+ self.logger_name = logger_name
34
+
35
+ def __call__(self, logger: WrappedLogger, __: str, event_dict: EventDict):
36
+ """
37
+ Process a log event.
38
+
39
+ :param logger: The logger instance that generated the event.
40
+ :param method_name: The logging method name (e.g., "info", "error").
41
+ :param event_dict: The log event dictionary.
42
+ :return: The event_dict if the event should be processed, or None to ignore it.
43
+ """
44
+ if logger.name == self.logger_name or logger.name.startswith(f"{self.logger_name}."):
45
+ return event_dict
46
+ else:
47
+ raise DropEvent(f"Filtered out by LoggerFilterProcessor, logger={logger.name}")
libsh/py.typed ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.3
2
+ Name: libsh
3
+ Version: 0.0.0
4
+ Summary: Add your description here
5
+ Author: shyndman
6
+ Author-email: shyndman <scotty.hyndman@gmail.com>
7
+ Requires-Dist: numpy<2
8
+ Requires-Dist: pydantic>=2
9
+ Requires-Dist: rich>=14.1.0
10
+ Requires-Dist: structlog>=25.4.0
11
+ Requires-Python: >=3.12
12
+ Project-URL: Bug Tracker, https://github.com/shyndman/libsh-python/issues
13
+ Project-URL: repository, https://github.com/shyndman/libsh-python
@@ -0,0 +1,10 @@
1
+ libsh/__init__.py,sha256=Mge-hY8u3FX7wWVAdcDCIcjbmxHr9JRls_TeTYR69r0,379
2
+ libsh/format.py,sha256=O2nuOdQBrdQmmmjLBgDIB0xGA4Wrgzhgg74U0EMcAYU,712
3
+ libsh/logs.py,sha256=olvQubLorRnIvbhEqZPMIfZnkMAZ3NS1yBJ5OwEPe8g,11792
4
+ libsh/proc/__init__.py,sha256=wqRjogB32YVv3jd02ZuxaPHORPR9O4a51lKYcakLszQ,163
5
+ libsh/proc/float.py,sha256=phbWvLLTQP___YPrQHep1XpZ23GW0yZlNh_X5an8hR8,2505
6
+ libsh/proc/logger_filter.py,sha256=1rtER-BlUv4AzHDLG50vJtXSZsyI_XvoswY8xj4w8RM,1577
7
+ libsh/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ libsh-0.0.0.dist-info/WHEEL,sha256=M6du7VZflc4UPsGphmOXHANdgk8zessdJG0DBUuoA-U,78
9
+ libsh-0.0.0.dist-info/METADATA,sha256=eFmHTjPGSHq7Z7w5Q6EsFmQH7RZKAV0zXZBPkIEUys4,426
10
+ libsh-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any