qvard 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.
qvard/__init__.py ADDED
@@ -0,0 +1,137 @@
1
+ """Qvard payments API client. See README.md."""
2
+ import http.client
3
+ import json
4
+ import socket
5
+ import urllib.error
6
+ import urllib.request
7
+ from types import SimpleNamespace
8
+ from typing import Any, Callable, Dict, Optional, Tuple
9
+ from urllib.parse import quote
10
+
11
+ from . import webhooks
12
+ from .errors import PAYOUT_UNKNOWN_RESULT, QvardError, QvardNetworkError, error_from_response, network_error
13
+
14
+ __all__ = ['Qvard', 'QvardError', 'QvardNetworkError', 'convert_for_display', 'webhooks']
15
+ __version__ = '0.1.0'
16
+
17
+ # A 3xx is an error here, not a hop: a redirect could send the Authorization header to another host.
18
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
19
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
20
+ return None
21
+
22
+
23
+ _OPENER = urllib.request.build_opener(_NoRedirect)
24
+
25
+
26
+ def _urllib_send(method: str, url: str, headers: Dict[str, str], data: Optional[bytes],
27
+ timeout_ms: int) -> Tuple[int, str]:
28
+ # shortcut: timeout_ms bounds each socket operation, not the whole request (Node bounds the whole request),
29
+ # upgrade to a total deadline if a server that trickles bytes becomes a real case
30
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
31
+ try:
32
+ with _OPENER.open(request, timeout=timeout_ms / 1000) as res:
33
+ return res.status, res.read().decode('utf-8', 'replace')
34
+ except urllib.error.HTTPError as e:
35
+ with e:
36
+ return e.code, e.read().decode('utf-8', 'replace')
37
+
38
+
39
+ def _parse_json(text: str) -> Any:
40
+ try:
41
+ return json.loads(text)
42
+ except ValueError:
43
+ return None # a proxy's HTML page, an empty body
44
+
45
+
46
+ class Qvard:
47
+ """Client for the payments API.
48
+
49
+ `secret_key` is your secret key, `qvard_sk_...`. Never put it in browser code: `qvard_pk_` is the public key.
50
+ Raises ValueError on an empty key, a `qvard_pk_` key, and a key without the `qvard_sk_` prefix.
51
+ `transport` replaces the HTTP layer (tests): called as (method, url, headers, body_bytes_or_None, timeout_ms),
52
+ returns (status, text), raises OSError on a network failure.
53
+ """
54
+
55
+ def __init__(self, secret_key: str, base_url: str = 'https://api.qvard.com', timeout_ms: int = 30000,
56
+ transport: Optional[Callable[..., Tuple[int, str]]] = None):
57
+ if not secret_key:
58
+ raise ValueError('Qvard: secret key is required')
59
+ if secret_key.startswith('qvard_pk_'):
60
+ raise ValueError('Qvard: publishable key (qvard_pk_) is for the browser only; use the secret key (qvard_sk_) on the server')
61
+ if not secret_key.startswith('qvard_sk_'):
62
+ raise ValueError('Qvard: legacy keys without the qvard_sk_ prefix are not supported; rotate the key in the cabinet')
63
+
64
+ self._secret_key = secret_key
65
+ self._base_url = base_url.rstrip('/')
66
+ self._timeout_ms = timeout_ms
67
+ self._transport = transport or _urllib_send
68
+
69
+ # Only the listed fields are sent: `publishable_key` cannot reach the server from this client.
70
+ self.payments = SimpleNamespace(create=self._payment_create, get=self._payment_get)
71
+ self.payouts = SimpleNamespace(create=self._payout_create)
72
+ self.methods = SimpleNamespace(info=self._method_info)
73
+ self.rates = SimpleNamespace(list=self._rates_list)
74
+ # Webhook signature checks. Needs no network, only the shop secret key.
75
+ self.webhooks = webhooks
76
+
77
+ def _payment_create(self, id: str, price: float, currency: Optional[str] = None,
78
+ payment_system_id: Optional[int] = None) -> Dict[str, Any]:
79
+ """Create a payment. Returns the uuid and the page to send the payer to."""
80
+ return self._request('POST', '/api/v1/payment', {
81
+ 'id': id, 'price': price, 'currency': currency, 'paymentSystemId': payment_system_id,
82
+ })
83
+
84
+ def _payment_get(self, id: str) -> Dict[str, Any]:
85
+ """One payment of your shop, by the uuid that `create` returned. Another shop's payment is `error.notfound`."""
86
+ return self._request('GET', f'/api/v1/payment/{quote(str(id), safe="")}')
87
+
88
+ def _payout_create(self, id: str, price: float, method_id: int, address: str,
89
+ comment: Optional[str] = None) -> Any:
90
+ """Send money out. Not retried automatically. On a timeout, a network error, a 5xx or an answer that is not JSON
91
+ the result is unknown: the `friendly_message` says so. Repeat only with the same `id` (see README)."""
92
+ return self._request('POST', '/api/v1/payout', {
93
+ 'id': id, 'price': price, 'methodId': method_id, 'comment': comment, 'address': address,
94
+ }, payout=True)
95
+
96
+ def _method_info(self, id: int) -> Dict[str, Any]:
97
+ """Status of a payment method of your shop, by its id from the cabinet."""
98
+ return self._request('GET', f'/api/v1/method/{quote(str(id), safe="")}/info')
99
+
100
+ def _rates_list(self) -> Dict[str, Any]:
101
+ """Current rates, for display only: the server computes the amount a payment costs."""
102
+ return self._request('GET', '/api/v1/rates')
103
+
104
+ def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None, payout: bool = False) -> Any:
105
+ headers = {'Authorization': f'Bearer {self._secret_key}', 'Content-Type': 'application/json'}
106
+ data = None
107
+ if body is not None:
108
+ data = json.dumps({k: v for k, v in body.items() if v is not None}).encode('utf-8')
109
+ try:
110
+ status, text = self._transport(method, self._base_url + path, headers, data, self._timeout_ms)
111
+ except (OSError, http.client.HTTPException) as e:
112
+ raise network_error(e, isinstance(getattr(e, 'reason', e), socket.timeout), payout) from e
113
+
114
+ parsed = _parse_json(text)
115
+ if payout and (status >= 500 or (200 <= status <= 299 and not isinstance(parsed, dict))):
116
+ # The answer is lost or unreadable: the payout may have been created.
117
+ err = error_from_response(status, parsed)
118
+ raise QvardError(status, err.message, err.errors, err.request_id, PAYOUT_UNKNOWN_RESULT)
119
+ if not 200 <= status <= 299:
120
+ raise error_from_response(status, parsed)
121
+
122
+ return parsed.get('data') if isinstance(parsed, dict) else None
123
+
124
+
125
+ def convert_for_display(amount: float, from_: str, to: str, rates: Dict[str, float]) -> float:
126
+ """Display only: converts `amount` between two currencies with the rates from `rates.list()` (units per 1 USD).
127
+ The server computes the amount a payment actually costs; do not charge this number.
128
+ Raises ValueError when a currency has no rate (USD is always 1)."""
129
+ def per_usd(code: str) -> Optional[float]:
130
+ return 1 if code == 'USD' else rates.get(code)
131
+
132
+ from_rate = per_usd(from_)
133
+ to_rate = per_usd(to)
134
+ if not from_rate or not to_rate:
135
+ raise ValueError(f"Qvard: no rate for {to if from_rate else from_}")
136
+
137
+ return (amount / from_rate) * to_rate
qvard/errors.py ADDED
@@ -0,0 +1,67 @@
1
+ """Errors and the friendly-message dictionary. The dictionary is a copy of sdk/conformance/errors.json:
2
+ the package ships without that folder, and tests/test_qvard.py fails if the two drift apart."""
3
+ from typing import Any, Dict, Optional
4
+
5
+ ERROR_MESSAGES: Dict[str, str] = {
6
+ 'error.wrong.api.key': 'Invalid or missing API key',
7
+ 'error.notfound': 'Not found in your shop',
8
+ 'error.shop.not.active': 'Shop is not active',
9
+ 'error.payment.duplicate_id': 'A payment with this id already exists and cannot be replaced (different amount, or already paid)',
10
+ 'error.origin.not_allowed': "Request origin is not in the shop's allowed list",
11
+ 'error.origin.not_configured': 'The shop has no allowed origins configured for this key',
12
+ 'error.internal': "Internal error on Qvard's side; quote traceId to support",
13
+ 'Error validation': 'Request validation failed; see errors for each field',
14
+ 'unsupported_currency': 'Currency is not supported',
15
+ 'invalid_amount_precision': 'Amount has more decimal places than this currency allows',
16
+ 'rate_unavailable': 'No exchange rate is available for this currency right now',
17
+ 'ThrottlerException: Too Many Requests': 'Too many requests, retry later',
18
+ }
19
+
20
+ # Shown instead of the generic network text for payouts: the money may already have moved.
21
+ PAYOUT_UNKNOWN_RESULT = 'Result unknown: check whether the payout went through before retrying (reuse the same id).'
22
+
23
+
24
+ class QvardError(Exception):
25
+ """An answer from the server. `message` is the raw key (`error.*`) or text, as the server sent it."""
26
+
27
+ def __init__(self, status: int, message: str, errors: Optional[Any] = None,
28
+ request_id: Optional[str] = None, friendly_message: Optional[str] = None):
29
+ super().__init__(message)
30
+ self.status = status
31
+ self.message = message
32
+ self.errors = errors
33
+ # The server's `traceId`; quote it to Qvard support.
34
+ self.request_id = request_id
35
+ self.friendly_message = ERROR_MESSAGES.get(message, message) if friendly_message is None else friendly_message
36
+
37
+
38
+ class QvardNetworkError(QvardError):
39
+ """No answer arrived (DNS, connection, TLS) or the request timed out. `status` is 0."""
40
+
41
+ def __init__(self, message: str, friendly_message: str):
42
+ super().__init__(0, message, None, None, friendly_message)
43
+
44
+
45
+ def error_from_response(status: int, body: Any) -> QvardError:
46
+ json_body = body if isinstance(body, dict) else {}
47
+ message = json_body.get('message')
48
+ errors = json_body.get('errors')
49
+ request_id = json_body.get('traceId')
50
+
51
+ return QvardError(
52
+ status,
53
+ message if isinstance(message, str) else f'HTTP {status}',
54
+ errors if isinstance(errors, (dict, list)) else None,
55
+ request_id if isinstance(request_id, str) else None,
56
+ )
57
+
58
+
59
+ def network_error(cause: BaseException, timed_out: bool, payout: bool) -> QvardNetworkError:
60
+ if timed_out:
61
+ plain = 'Request timed out'
62
+ message = 'timeout'
63
+ else:
64
+ plain = 'Network error'
65
+ message = f'network: {cause}'
66
+
67
+ return QvardNetworkError(message, PAYOUT_UNKNOWN_RESULT if payout else plain)
qvard/webhooks.py ADDED
@@ -0,0 +1,51 @@
1
+ """Checks `X-Qvard-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`: HMAC-SHA256 with the shop's secret key over `t + "." + rawBody`.
2
+ Pass the RAW body (bytes or str, as received), not re-serialised JSON."""
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import re
7
+ import time
8
+ from typing import Any, Optional, Union
9
+
10
+
11
+ def verify(raw_body: Union[str, bytes], header: Optional[str], secret: str,
12
+ tolerance_sec: int = 300, now: Optional[int] = None) -> bool:
13
+ """Returns False for a bad, missing or stale signature; raises ValueError only when no secret is given.
14
+ `tolerance_sec` bounds |now - t| in both directions. Any `v1` may match (two while a key rotation is live).
15
+ `now` (unix seconds) overrides the clock; for tests."""
16
+ if not secret:
17
+ raise ValueError('Qvard: webhook secret is required')
18
+
19
+ t = None
20
+ v1 = []
21
+ for part in (header or '').split(','):
22
+ key, *rest = part.split('=')
23
+ value = '='.join(rest).strip()
24
+ if key.strip() == 't' and t is None:
25
+ t = value
26
+ elif key.strip() == 'v1':
27
+ v1.append(value)
28
+ if not t or not re.fullmatch(r'[0-9]+', t):
29
+ return False
30
+ if abs((int(time.time()) if now is None else now) - int(t)) > tolerance_sec:
31
+ return False
32
+
33
+ expected = hmac.new(secret.encode('utf-8'), t.encode('ascii') + b'.' + _bytes(raw_body), hashlib.sha256).hexdigest()
34
+
35
+ return any(
36
+ re.fullmatch(r'[0-9a-fA-F]{64}', hex_sig) is not None and hmac.compare_digest(expected, hex_sig.lower())
37
+ for hex_sig in v1
38
+ )
39
+
40
+
41
+ def construct_event(raw_body: Union[str, bytes], header: Optional[str], secret: str,
42
+ now: Optional[int] = None) -> Any:
43
+ """Verifies, then parses the raw body. Raises ValueError when the signature does not verify."""
44
+ if not verify(raw_body, header, secret, now=now):
45
+ raise ValueError('Qvard: invalid webhook signature, or timestamp outside the tolerance')
46
+
47
+ return json.loads(_bytes(raw_body).decode('utf-8'))
48
+
49
+
50
+ def _bytes(raw_body: Union[str, bytes]) -> bytes:
51
+ return raw_body.encode('utf-8') if isinstance(raw_body, str) else raw_body
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: qvard
3
+ Version: 0.1.0
4
+ Summary: Qvard payments API client for Python: payments, payouts, rates and webhook signature checks
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+
8
+ # qvard (Python)
9
+
10
+ Python client for the Qvard payments API. Zero dependencies (standard library only), Python 3.9 or newer.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ pip install qvard
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ import os
22
+ from qvard import Qvard
23
+
24
+ qvard = Qvard(os.environ['QVARD_SECRET_KEY']) # qvard_sk_...
25
+
26
+ payment = qvard.payments.create(id='order-1001', price=10, currency='EUR')
27
+ # send the payer to payment['redirectUrl']
28
+
29
+ info = qvard.payments.get(payment['id'])
30
+ print(info['status']) # wait | partial | completed | rejected
31
+ ```
32
+
33
+ Options: `Qvard(secret_key, base_url='https://api.qvard.com', timeout_ms=30000)`. `timeout_ms` is in milliseconds.
34
+
35
+ ## Keys
36
+
37
+ - Use the secret key `qvard_sk_...` on your server only. Never put it in browser or mobile code. A publishable key (`qvard_pk_...`) is rejected by the constructor because it is for the browser only.
38
+ - Legacy keys without the `qvard_sk_` prefix are rejected by the constructor. Rotate the key in the cabinet (Shop settings → API keys) to get a `qvard_sk_...` key.
39
+ - The SDK sends `Authorization: Bearer <key>`. It never sends `x-api-key`.
40
+
41
+ ## Methods
42
+
43
+ | Call | Request | Returns |
44
+ | --- | --- | --- |
45
+ | `payments.create(id=, price=, currency=None, payment_system_id=None)` | `POST /api/v1/payment` | `{id, redirectUrl, telegramUrl?}` |
46
+ | `payments.get(id)` | `GET /api/v1/payment/:id` | `{id, status, createdAt, paidAt, price, currency, paidAmount, token, expiresAt}` |
47
+ | `payouts.create(id=, price=, method_id=, address=, comment=None)` | `POST /api/v1/payout` | `True` |
48
+ | `methods.info(id)` | `GET /api/v1/method/:id/info` | `{server, isReady, isActive}` |
49
+ | `rates.list()` | `GET /api/v1/rates` | `{base, updatedAt, rates}` |
50
+
51
+ The results are the server's `data` as dictionaries, with the server's field names (camelCase).
52
+
53
+ - `id` in `payments.create` is your own order id. The server requires it. The same id with the same amount and currency returns the existing payment. A different amount replaces an unpaid one, and the server answers `409` once money is on the way.
54
+ - `payments.get` answers `error.notfound` for a payment of another shop, the same as for an unknown uuid.
55
+ - `price` is sent as you give it. The server converts it to USD at the current rate and stores that rate with the payment. Unset optional arguments (`None`) are not sent.
56
+ - `convert_for_display(amount, from_, to, rates)` is exported for showing prices only. Pass `qvard.rates.list()['rates']`. The total a payer pays is computed by the server; do not charge the value this function returns. It raises `ValueError` for a currency without a rate.
57
+
58
+ ## Payouts
59
+
60
+ `payouts.create` sends money out of your balance. Read this before you call it.
61
+
62
+ - The SDK never retries a payout.
63
+ - The result is **unknown** on a timeout, a network error, a `5xx` answer (including `502`, `503` and `504` from a gateway), or a `2xx` answer whose body is not a JSON object (an array, a string, `null` or an empty body). The error's `friendly_message` says: `Result unknown: check whether the payout went through before retrying (reuse the same id).`. A payout may have been created. In that case the error's `status` is the HTTP status and `message` is the server's key, or `HTTP <status>` when there is none.
64
+ - A `4xx` answer is a refusal: the payout was not created, and `message` is the reason (for example `Insufficient balance`).
65
+ - To retry safely, send the same `id` and the same arguments as the first attempt. See the next section.
66
+
67
+ ### Payout idempotency
68
+
69
+ What protects a payout from being paid twice is the `id` field of the request body, which is your payout id.
70
+
71
+ - The server requires `id`: without it the answer is `400`.
72
+ - The server stores `id` on the withdrawal as its idempotency key. Before it creates a withdrawal it looks for an existing withdrawal of your shop with the same method and the same key. If it finds one, it returns that one and creates nothing.
73
+ - The lookup runs under a database lock for your shop and method, so two simultaneous requests with the same `id` cannot both create a payout.
74
+ - Scope: the key is matched within your shop and the `method_id`. The same `id` on a different method creates a second payout. The same `id` in a different shop is independent.
75
+ - A repeated request with the same `id` returns the original payout, and the API answers `True` exactly as it does for a new payout. The response does not say that it was a replay.
76
+ - A repeated request with the same `id` but a different `price`, `address` or `comment` is not compared. The original payout stands and the new values are ignored.
77
+ - The lookup finds only payouts that were created. A request that was rejected with `400` (for example `Insufficient balance`) created nothing, so sending it again with the same `id` is a new attempt.
78
+ - After a key rotation the previous key is not accepted on `POST /api/v1/payout`, even during the 24-hour grace period.
79
+ - v1 has no endpoint to look up a payout by your id. To check whether a payout from a timed-out call exists, look in the cabinet.
80
+
81
+ ## Webhooks
82
+
83
+ Qvard calls your callback URL with a JSON body and the header `X-Qvard-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`. The signature is HMAC-SHA256, keyed with your secret key, over `t + "." + body`. Two `v1` values are sent for 24 hours after a key rotation, and either one is valid.
84
+
85
+ Verify the signature over the **raw** body, before any JSON parsing. Read the body as bytes from your framework (not from a parsed form or JSON object):
86
+
87
+ ```python
88
+ import os
89
+ from qvard import Qvard
90
+
91
+ secret = os.environ['QVARD_SECRET_KEY']
92
+ qvard = Qvard(secret)
93
+
94
+ def handle_webhook(raw_body: bytes, signature_header: str) -> int:
95
+ """Returns the HTTP status to answer with."""
96
+ try:
97
+ event = qvard.webhooks.construct_event(raw_body, signature_header, secret)
98
+ except ValueError:
99
+ return 400
100
+ # event['id'] is YOUR order id (the one you passed to payments.create), not the payment uuid.
101
+ # Check event['amount'] and event['currency'] against your order before you fulfil it.
102
+ return 200
103
+ ```
104
+
105
+ - `qvard.webhooks.verify(raw_body, header, secret, tolerance_sec=300)` returns `True` or `False`. It raises `ValueError` only when no secret is given.
106
+ - `qvard.webhooks.construct_event(raw_body, header, secret)` returns the parsed event, or raises `ValueError` when the signature fails.
107
+ - `raw_body` may be `bytes` or `str`. The check accepts a timestamp within `tolerance_sec` (300 seconds) of now, in either direction, and accepts any `v1` that matches. The comparison is constant-time.
108
+ - The event body is `{id, status, paymentLink?, customerEmail?, amount, currency, amountUsd, rate}`. There is no event `type` field.
109
+ - Answer `200`. Qvard treats any other status as a failed delivery and retries it.
110
+
111
+ ## Errors
112
+
113
+ Every failed call raises `QvardError`:
114
+
115
+ - `status`: the HTTP status.
116
+ - `message` (also `str(error)`): the server's key, for example `error.wrong.api.key`, or the text it sent.
117
+ - `friendly_message`: an English explanation from the shared dictionary (`sdk/conformance/errors.json`, copied into the package). An unknown key is returned as is.
118
+ - `errors`: for a rejected request body, a map from each field to its first message.
119
+ - `request_id`: the server's `traceId`. Quote it to Qvard support.
120
+
121
+ A request that got no answer (connection error, DNS, timeout) raises `QvardNetworkError`, a subclass of `QvardError` with `status` 0.
122
+
123
+ ```python
124
+ from qvard import QvardError, QvardNetworkError
125
+
126
+ try:
127
+ qvard.payments.get(uuid)
128
+ except QvardNetworkError as e:
129
+ print('no answer:', e.friendly_message)
130
+ except QvardError as e:
131
+ print(e.status, e.message, e.friendly_message, e.request_id)
132
+ ```
133
+
134
+ | `message` | Status | Meaning |
135
+ | --- | --- | --- |
136
+ | `error.wrong.api.key` | 401 | Missing or unknown key |
137
+ | `error.notfound` | 404 | Not in your shop |
138
+ | `error.shop.not.active` | 403 | Shop is not active |
139
+ | `error.origin.not_allowed` | 403 | Origin not in the allowed list (publishable-key flow only) |
140
+ | `error.origin.not_configured` | 403 | No allowed origins configured (publishable-key flow only) |
141
+ | `error.payment.duplicate_id` | 409 | Same id, but a different amount, or the payment is already paid |
142
+ | `Error validation` | 400 | Body rejected; see `errors` |
143
+ | `unsupported_currency` | 422 | Currency not supported |
144
+ | `invalid_amount_precision` | 422 | Too many decimal places for the currency |
145
+ | `rate_unavailable` | 422 | No rate for the currency right now |
146
+ | `ThrottlerException: Too Many Requests` | 429 | Rate limit |
147
+ | `error.internal` | 500 | Failure inside Qvard; quote `request_id` |
148
+
149
+ Payout rejections come back as plain English text, for example `Insufficient balance`, `Method not found` or `Invalid TRON withdrawal address`, with status 400. The dictionary does not list them; `friendly_message` equals `message` for them.
150
+
151
+ ## Differences from the Node.js package
152
+
153
+ - Redirects are not followed. A `3xx` answer is raised as a `QvardError`, so the secret key is never sent to another host. (`@qvard-sdk/node` does the same.)
154
+ - `timeout_ms` bounds each socket operation (connecting, each read), not the whole request.
155
+
156
+ ## Tests
157
+
158
+ No test dependencies. From `sdk/python`:
159
+
160
+ ```sh
161
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
162
+ ```
163
+
164
+ The tests read the shared fixtures from `../conformance`, so they run inside the `sdk/` folder.
@@ -0,0 +1,7 @@
1
+ qvard/__init__.py,sha256=TD6Wc2rb8wm1twrPbSOiC9mxihy7CNZpNynMkvE_Jr0,6945
2
+ qvard/errors.py,sha256=dpcjh3vyx1MpmvGZ6QfJR-dbN3E18zLHAsJb8YIf8Mw,3153
3
+ qvard/webhooks.py,sha256=C69LqnXChw9wQpm4JlyPlrdXhdpp7HqKIHjvt4c2XnM,2124
4
+ qvard-0.1.0.dist-info/METADATA,sha256=IEFYJCvaD9dq0SOR19t4_aeuyja_GE_0hP1-RsR32HY,9494
5
+ qvard-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ qvard-0.1.0.dist-info/top_level.txt,sha256=7DcNB7l1qX_fRfDIIrG1nIBJ6dDhIhNIB7fgTAWYnfI,6
7
+ qvard-0.1.0.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
+
@@ -0,0 +1 @@
1
+ qvard