mevault 0.1.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.
mevault/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """
2
+ mevault — Python SDK for requesting secrets from the local MeVault broker.
3
+
4
+ Quickstart
5
+ ----------
6
+
7
+ Synchronous::
8
+
9
+ from mevault import get_secret, get_optional_secret
10
+ from mevault.exceptions import VaultLocked, SecretNotFound
11
+
12
+ db_password = get_secret("DB_PASSWORD")
13
+ api_key = get_optional_secret("OPTIONAL_API_KEY", default="")
14
+
15
+ Asynchronous::
16
+
17
+ import asyncio
18
+ from mevault import async_get_secret
19
+
20
+ async def main():
21
+ token = await async_get_secret("AUTH_TOKEN")
22
+
23
+ asyncio.run(main())
24
+ """
25
+
26
+ from .client import async_get_secret, get_optional_secret, get_secret
27
+ from .exceptions import (
28
+ AccessDenied,
29
+ MeVaultError,
30
+ MeVaultUnavailable,
31
+ ProtocolError,
32
+ SecretNotFound,
33
+ SessionExpired,
34
+ VaultLocked,
35
+ )
36
+ from .types import SecretValue
37
+
38
+ __all__ = [
39
+ # Functions
40
+ "get_secret",
41
+ "get_optional_secret",
42
+ "async_get_secret",
43
+ # Exceptions
44
+ "MeVaultError",
45
+ "MeVaultUnavailable",
46
+ "VaultLocked",
47
+ "SecretNotFound",
48
+ "AccessDenied",
49
+ "ProtocolError",
50
+ "SessionExpired",
51
+ # Types
52
+ "SecretValue",
53
+ ]
mevault/client.py ADDED
@@ -0,0 +1,343 @@
1
+ """
2
+ MeVault client — synchronous and asyncio interfaces.
3
+
4
+ Design constraints
5
+ ------------------
6
+ - Named-pipe transport only (no subprocess, no env-var injection).
7
+ - No vault password accepted or stored.
8
+ - Secret values are NEVER logged; only error codes are logged.
9
+ - Hard 10-second timeout on every operation.
10
+ - Hard 1 MiB cap on response size.
11
+ - One connection per request; connection closed immediately after response.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ import sys
18
+ from typing import Optional
19
+
20
+ from .exceptions import (
21
+ AccessDenied,
22
+ MeVaultUnavailable,
23
+ ProtocolError,
24
+ SecretNotFound,
25
+ SessionExpired,
26
+ VaultLocked,
27
+ )
28
+ from .protocol import (
29
+ KNOWN_ERROR_CODES,
30
+ MAX_RESPONSE_BYTES,
31
+ PIPE_NAME,
32
+ PROTOCOL_VERSION,
33
+ BrokerResponse,
34
+ GetSecretRequest,
35
+ )
36
+ from .types import SecretValue
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # Default timeout for all I/O operations (seconds).
41
+ _TIMEOUT: float = 10.0
42
+
43
+ # Windows error code: pipe server is busy (all instances occupied).
44
+ _ERROR_PIPE_BUSY: int = 231
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Internal helpers
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ def _map_error(error_code: Optional[str], message: Optional[str]) -> Exception:
53
+ """Convert a broker error code into the appropriate SDK exception."""
54
+ detail = message or error_code or "unknown error"
55
+ mapping = {
56
+ "vault_locked": VaultLocked,
57
+ "session_expired": SessionExpired,
58
+ "access_denied": AccessDenied,
59
+ "secret_not_found": SecretNotFound,
60
+ }
61
+ exc_class = mapping.get(error_code or "", MeVaultUnavailable) # type: ignore[arg-type]
62
+ # Log only the error code, never the message (which might contain secret names).
63
+ logger.debug("broker returned error code: %s", error_code)
64
+ return exc_class(detail)
65
+
66
+
67
+ def _parse_response(raw_bytes: bytes) -> BrokerResponse:
68
+ """
69
+ Parse a raw response line from the broker.
70
+
71
+ Raises ProtocolError on any malformed or oversized payload.
72
+ """
73
+ if len(raw_bytes) > MAX_RESPONSE_BYTES:
74
+ raise ProtocolError(
75
+ f"Response exceeded maximum allowed size of {MAX_RESPONSE_BYTES} bytes"
76
+ )
77
+
78
+ try:
79
+ data = json.loads(raw_bytes.decode("utf-8"))
80
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
81
+ raise ProtocolError(f"Malformed JSON response from broker: {exc}") from exc
82
+
83
+ if not isinstance(data, dict):
84
+ raise ProtocolError("Broker response was not a JSON object")
85
+
86
+ ok = data.get("ok")
87
+ if not isinstance(ok, bool):
88
+ raise ProtocolError("Broker response missing boolean 'ok' field")
89
+
90
+ # Validate protocol version if the broker echoes it.
91
+ version = data.get("version")
92
+ if version is not None and version != PROTOCOL_VERSION:
93
+ raise ProtocolError(
94
+ f"Protocol version mismatch: expected {PROTOCOL_VERSION}, got {version}"
95
+ )
96
+
97
+ return BrokerResponse(
98
+ ok=ok,
99
+ value=data.get("value"),
100
+ error=data.get("error"),
101
+ message=data.get("message"),
102
+ version=version,
103
+ )
104
+
105
+
106
+ def _build_request(name: str) -> bytes:
107
+ """Serialise a GetSecretRequest to a newline-terminated JSON byte string."""
108
+ req = GetSecretRequest(name=name)
109
+ return (json.dumps(req.to_dict()) + "\n").encode("utf-8")
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Platform guard — MeVault only runs on Windows
114
+ # ---------------------------------------------------------------------------
115
+
116
+ if sys.platform != "win32":
117
+ def get_secret(name: str) -> str:
118
+ """Raise MeVaultUnavailable: MeVault only runs on Windows."""
119
+ raise MeVaultUnavailable("MeVault is only supported on Windows")
120
+
121
+ def get_optional_secret(name: str, default: Optional[str] = None) -> Optional[str]:
122
+ """Return *default*: MeVault only runs on Windows."""
123
+ return default
124
+
125
+ async def async_get_secret(name: str) -> str:
126
+ """Raise MeVaultUnavailable: MeVault only runs on Windows."""
127
+ raise MeVaultUnavailable("MeVault is only supported on Windows")
128
+
129
+ else:
130
+ # ------------------------------------------------------------------
131
+ # Windows-only transport
132
+ # ------------------------------------------------------------------
133
+
134
+ import ctypes
135
+ import ctypes.wintypes
136
+
137
+ def _wait_and_open(pipe_name: str, timeout_ms: int = 10000): # type: ignore[return]
138
+ """
139
+ Open a Windows named pipe, waiting if all server instances are busy.
140
+
141
+ Uses WaitNamedPipeW from kernel32 so the caller blocks instead of
142
+ busy-looping when the broker is serving another connection.
143
+
144
+ Returns a raw binary file object (unbuffered) on success.
145
+ Raises MeVaultUnavailable if the pipe cannot be opened within *timeout_ms*.
146
+ """
147
+ deadline_ms = timeout_ms
148
+ while True:
149
+ try:
150
+ # Named pipes opened via open() in binary+unbuffered mode work
151
+ # correctly on Windows for byte-stream pipes.
152
+ return open(pipe_name, "r+b", buffering=0) # noqa: WPS515
153
+ except OSError as exc:
154
+ winerr = getattr(exc, "winerror", None)
155
+ if winerr == _ERROR_PIPE_BUSY:
156
+ # All pipe instances are in use; ask the kernel to wait.
157
+ wait_ms = min(deadline_ms, 1000)
158
+ ok = ctypes.windll.kernel32.WaitNamedPipeW(pipe_name, wait_ms)
159
+ if not ok:
160
+ raise MeVaultUnavailable(
161
+ f"MeVault broker pipe busy after {timeout_ms} ms"
162
+ ) from exc
163
+ deadline_ms -= wait_ms
164
+ if deadline_ms <= 0:
165
+ raise MeVaultUnavailable(
166
+ "MeVault broker pipe wait timed out"
167
+ ) from exc
168
+ elif exc.errno == 2 or isinstance(exc, FileNotFoundError):
169
+ raise MeVaultUnavailable(
170
+ "MeVault broker is not running (pipe not found)"
171
+ ) from exc
172
+ elif isinstance(exc, PermissionError):
173
+ raise MeVaultUnavailable(
174
+ "Permission denied when connecting to MeVault broker"
175
+ ) from exc
176
+ else:
177
+ raise MeVaultUnavailable(
178
+ f"Cannot open MeVault pipe: {exc}"
179
+ ) from exc
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Synchronous implementation (Windows named pipe via open())
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def _sync_transact(name: str) -> str:
186
+ """
187
+ Open the named pipe, send a get_secret request, and return the secret value.
188
+
189
+ The pipe is opened with WaitNamedPipe semantics so the call blocks
190
+ correctly when the broker is momentarily busy serving another client.
191
+ The connection is closed once the response line is read regardless of outcome.
192
+ """
193
+ request_bytes = _build_request(name)
194
+
195
+ try:
196
+ pipe = _wait_and_open(PIPE_NAME, timeout_ms=int(_TIMEOUT * 1000))
197
+ except MeVaultUnavailable:
198
+ raise
199
+ except OSError as exc:
200
+ raise MeVaultUnavailable(
201
+ f"Could not connect to MeVault broker: {exc}"
202
+ ) from exc
203
+
204
+ try:
205
+ pipe.write(request_bytes)
206
+
207
+ # Read response line by line (capped at MAX_RESPONSE_BYTES + 1).
208
+ response_bytes = b""
209
+ while True:
210
+ chunk = pipe.read(4096)
211
+ if not chunk:
212
+ break
213
+ response_bytes += chunk
214
+ if b"\n" in response_bytes:
215
+ break
216
+ if len(response_bytes) > MAX_RESPONSE_BYTES:
217
+ raise ProtocolError(
218
+ f"Response exceeded maximum allowed size of {MAX_RESPONSE_BYTES} bytes"
219
+ )
220
+ except ProtocolError:
221
+ raise
222
+ except OSError as exc:
223
+ raise MeVaultUnavailable(
224
+ f"I/O error while communicating with MeVault broker: {exc}"
225
+ ) from exc
226
+ finally:
227
+ try:
228
+ pipe.close()
229
+ except OSError:
230
+ pass
231
+
232
+ if not response_bytes:
233
+ raise ProtocolError("Broker closed the connection without sending a response")
234
+
235
+ # Parse only the first line.
236
+ line = response_bytes.split(b"\n")[0]
237
+ response = _parse_response(line)
238
+
239
+ if response.ok:
240
+ if response.value is None:
241
+ raise ProtocolError("Broker returned ok=true but no 'value' field")
242
+ # Value is intentionally NOT logged here.
243
+ return response.value
244
+
245
+ raise _map_error(response.error, response.message)
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Public synchronous API
249
+ # ---------------------------------------------------------------------------
250
+
251
+ def get_secret(name: str) -> str:
252
+ """
253
+ Retrieve a secret from the MeVault broker synchronously.
254
+
255
+ Parameters
256
+ ----------
257
+ name:
258
+ The name of the secret to retrieve.
259
+
260
+ Returns
261
+ -------
262
+ str
263
+ The secret value as a plain string.
264
+
265
+ Raises
266
+ ------
267
+ MeVaultUnavailable
268
+ The broker pipe could not be reached.
269
+ VaultLocked
270
+ The vault is locked.
271
+ SecretNotFound
272
+ No secret with that name exists.
273
+ AccessDenied
274
+ The calling process is not permitted to read this secret.
275
+ SessionExpired
276
+ The broker session has expired.
277
+ ProtocolError
278
+ The broker sent a malformed response.
279
+ """
280
+ return _sync_transact(name)
281
+
282
+ def get_optional_secret(name: str, default: Optional[str] = None) -> Optional[str]:
283
+ """
284
+ Retrieve a secret, returning *default* if the secret does not exist.
285
+
286
+ All other errors (VaultLocked, AccessDenied, etc.) are still raised.
287
+
288
+ Parameters
289
+ ----------
290
+ name:
291
+ The name of the secret to retrieve.
292
+ default:
293
+ Value to return when the secret is not found. Defaults to ``None``.
294
+
295
+ Returns
296
+ -------
297
+ str or None
298
+ The secret value, or *default* if not found.
299
+ """
300
+ try:
301
+ return get_secret(name)
302
+ except SecretNotFound:
303
+ return default
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Asynchronous implementation — wraps sync call in thread pool
307
+ # ---------------------------------------------------------------------------
308
+
309
+ async def async_get_secret(name: str) -> str:
310
+ """
311
+ Retrieve a secret from the MeVault broker asynchronously.
312
+
313
+ Runs the synchronous named-pipe call in a thread-pool executor so the
314
+ event loop is never blocked. This is the correct approach for Windows
315
+ named pipes: ``asyncio.open_connection()`` cannot open a UNC pipe path.
316
+
317
+ Parameters
318
+ ----------
319
+ name:
320
+ The name of the secret to retrieve.
321
+
322
+ Returns
323
+ -------
324
+ str
325
+ The secret value as a plain string.
326
+
327
+ Raises
328
+ ------
329
+ MeVaultUnavailable
330
+ The broker pipe could not be reached.
331
+ VaultLocked
332
+ The vault is locked.
333
+ SecretNotFound
334
+ No secret with that name exists.
335
+ AccessDenied
336
+ The calling process is not permitted to read this secret.
337
+ SessionExpired
338
+ The broker session has expired.
339
+ ProtocolError
340
+ The broker sent a malformed response.
341
+ """
342
+ loop = asyncio.get_event_loop()
343
+ return await loop.run_in_executor(None, get_secret, name)
mevault/exceptions.py ADDED
@@ -0,0 +1,34 @@
1
+ """
2
+ MeVault exception hierarchy.
3
+
4
+ All exceptions raised by the MeVault SDK derive from MeVaultError so callers
5
+ can catch the whole family with a single except clause if they prefer.
6
+ """
7
+
8
+
9
+ class MeVaultError(Exception):
10
+ """Base class for all MeVault SDK errors."""
11
+
12
+
13
+ class MeVaultUnavailable(MeVaultError):
14
+ """The MeVault broker pipe could not be reached (not running or not found)."""
15
+
16
+
17
+ class VaultLocked(MeVaultError):
18
+ """The vault is locked; the user must unlock MeVault before secrets can be read."""
19
+
20
+
21
+ class SecretNotFound(MeVaultError):
22
+ """The requested secret does not exist in the vault."""
23
+
24
+
25
+ class AccessDenied(MeVaultError):
26
+ """The calling process is not authorised to read this secret."""
27
+
28
+
29
+ class ProtocolError(MeVaultError):
30
+ """The broker returned an unexpected or malformed response."""
31
+
32
+
33
+ class SessionExpired(MeVaultError):
34
+ """The broker session has expired; the user must re-authenticate."""
mevault/protocol.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ Wire-protocol constants and data structures for the MeVault named-pipe IPC.
3
+
4
+ Protocol summary
5
+ ----------------
6
+ - One JSON line per message, terminated with ``\\n``.
7
+ - One connection per request; the client closes the connection after reading
8
+ the response.
9
+ - The broker may include an optional ``"version"`` field in the response;
10
+ if present it must match PROTOCOL_VERSION.
11
+
12
+ Request (client → broker)::
13
+
14
+ {"op": "get_secret", "name": "<SECRET_NAME>"}
15
+
16
+ Success response (broker → client)::
17
+
18
+ {"ok": true, "value": "<secret-value>"}
19
+
20
+ Error response (broker → client)::
21
+
22
+ {"ok": false, "error": "<error-code>", "message": "<optional detail>"}
23
+
24
+ Known error codes
25
+ -----------------
26
+ vault_locked, session_expired, access_denied, secret_not_found,
27
+ identity_unknown, grant_invalid, internal_error, invalid_request
28
+ """
29
+
30
+ from dataclasses import dataclass, field
31
+ from typing import Optional
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Constants
36
+ # ---------------------------------------------------------------------------
37
+
38
+ PIPE_NAME: str = r"\\.\pipe\mevault-runtime"
39
+ PROTOCOL_VERSION: int = 1
40
+ MAX_RESPONSE_BYTES: int = 1_048_576 # 1 MiB
41
+
42
+ # All error codes the broker may return.
43
+ KNOWN_ERROR_CODES: frozenset = frozenset(
44
+ {
45
+ "vault_locked",
46
+ "session_expired",
47
+ "access_denied",
48
+ "secret_not_found",
49
+ "identity_unknown",
50
+ "grant_invalid",
51
+ "internal_error",
52
+ "invalid_request",
53
+ }
54
+ )
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Request / response dataclasses
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ @dataclass
63
+ class GetSecretRequest:
64
+ """Represents a ``get_secret`` request sent to the broker."""
65
+
66
+ name: str
67
+ op: str = field(default="get_secret", init=False)
68
+
69
+ def to_dict(self) -> dict:
70
+ return {"op": self.op, "name": self.name}
71
+
72
+
73
+ @dataclass
74
+ class BrokerResponse:
75
+ """Parsed broker response."""
76
+
77
+ ok: bool
78
+ value: Optional[str] = None # present when ok is True
79
+ error: Optional[str] = None # error code when ok is False
80
+ message: Optional[str] = None # optional human-readable detail
81
+ version: Optional[int] = None # optional protocol version echo
mevault/types.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ Value types used by the MeVault SDK.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class SecretValue:
10
+ """
11
+ Wraps a secret string.
12
+
13
+ The repr and str are deliberately redacted so that logging framework
14
+ calls, exception tracebacks, and interactive sessions never accidentally
15
+ expose the underlying value.
16
+
17
+ Retrieve the raw string only when you genuinely need it:
18
+
19
+ value = secret.get()
20
+ """
21
+
22
+ _value: str
23
+
24
+ def get(self) -> str:
25
+ """Return the raw secret string."""
26
+ return self._value
27
+
28
+ def __repr__(self) -> str:
29
+ return "SecretValue(<redacted>)"
30
+
31
+ def __str__(self) -> str:
32
+ return "<redacted>"
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: mevault
3
+ Version: 0.1.0
4
+ Summary: MeVault Python SDK — request secrets from the local MeVault broker
5
+ Project-URL: Homepage, https://github.com/thecalebyte/mevault-cli
6
+ Project-URL: Documentation, https://github.com/thecalebyte/mevault-cli/blob/main/docs/sdk.md
7
+ Project-URL: Bug Tracker, https://github.com/thecalebyte/mevault-cli/issues
8
+ License: Apache-2.0
9
+ Keywords: credentials,mevault,secrets,security
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # MeVault Python SDK
25
+
26
+ Request secrets from the [MeVault](https://github.com/thecalebyte/mevault-cli) broker in Python — no passwords, no env-var leakage, no subprocesses.
27
+
28
+ ## Requirements
29
+
30
+ - Python 3.9+
31
+ - Windows (the broker communicates over a Windows named pipe)
32
+ - MeVault broker running and vault unlocked
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install mevault
38
+ ```
39
+
40
+ Or from source:
41
+
42
+ ```bash
43
+ cd sdk/python
44
+ pip install -e .
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ### Synchronous
50
+
51
+ ```python
52
+ from mevault import get_secret, get_optional_secret
53
+ from mevault.exceptions import VaultLocked, SecretNotFound, MeVaultUnavailable
54
+
55
+ # Raises SecretNotFound if the secret doesn't exist.
56
+ db_password = get_secret("DB_PASSWORD")
57
+
58
+ # Returns None (or a custom default) if the secret doesn't exist.
59
+ api_key = get_optional_secret("OPTIONAL_API_KEY", default="")
60
+ ```
61
+
62
+ ### Asynchronous
63
+
64
+ ```python
65
+ import asyncio
66
+ from mevault import async_get_secret
67
+
68
+ async def main():
69
+ token = await async_get_secret("AUTH_TOKEN")
70
+ # use token ...
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ ## Error handling
76
+
77
+ | Exception | When raised |
78
+ |---|---|
79
+ | `MeVaultUnavailable` | Broker not running / pipe not found |
80
+ | `VaultLocked` | Vault is locked — user must unlock MeVault |
81
+ | `SecretNotFound` | No secret with that name |
82
+ | `AccessDenied` | Process not permitted to read the secret |
83
+ | `SessionExpired` | Broker session expired — user must re-authenticate |
84
+ | `ProtocolError` | Malformed or oversized broker response |
85
+
86
+ All exceptions derive from `MeVaultError`.
87
+
88
+ ```python
89
+ from mevault.exceptions import MeVaultError
90
+
91
+ try:
92
+ value = get_secret("MY_SECRET")
93
+ except MeVaultError as exc:
94
+ # Handle any MeVault error generically.
95
+ print(f"MeVault error: {exc}")
96
+ ```
97
+
98
+ ## Security notes
99
+
100
+ - Secret values are **never** logged or printed by the SDK.
101
+ - `SecretValue.__repr__` and `__str__` return `<redacted>` to prevent accidental exposure in logs or tracebacks.
102
+ - The SDK does **not** accept vault passwords and does **not** spawn subprocesses.
103
+ - The SDK does **not** place secrets in environment variables.
104
+ - Each request uses a fresh pipe connection, closed immediately after the response is received.
@@ -0,0 +1,8 @@
1
+ mevault/__init__.py,sha256=r7Ntpq9p5pb_He7-UHZ7-A18dVOtYIfYyC9TTpxW0K8,1149
2
+ mevault/client.py,sha256=yiTwBD_v_3EsaExk5XbRkkYyzSdowAjAO4cwjR1Uhvg,12043
3
+ mevault/exceptions.py,sha256=2GslvhceJVU-Ho7s-jJT891fMEJBvEMzX_MMy-_-O7Y,968
4
+ mevault/protocol.py,sha256=ZriFt6bFj0lTaoK8rEm20S8cgOTvX4vjnwYvH-opYMA,2376
5
+ mevault/types.py,sha256=az7yvWqxg-h6aOgxL2hzg8z7nqJePfj5WPyUSSVHiYE,710
6
+ mevault-0.1.0.dist-info/METADATA,sha256=TcUXRkPB_wc70TpfnWWTXf7NWcpS3yKZ3BMEWnTgyek,3166
7
+ mevault-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ mevault-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any