keytd-manat 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KeyTD
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,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: keytd-manat
3
+ Version: 0.1.0
4
+ Summary: One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
5
+ Author-email: KeyTD <info@keytd.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/KeyTechAndDesign/manat
8
+ Project-URL: Issues, https://github.com/KeyTechAndDesign/manat/issues
9
+ Keywords: azerbaijan,payments,azn,manat,payment-gateway,kapital-bank,azericard,payriff,epoint,goldenpay,yigim
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Office/Business :: Financial
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # Manat for Python
18
+
19
+ One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
20
+ Python 3.10+, no dependencies (standard library only, RSA included).
21
+
22
+ ```bash
23
+ pip install keytd-manat
24
+ ```
25
+
26
+ ```python
27
+ from manat import Manat, azn
28
+ from manat.providers import KapitalBank
29
+
30
+ pay = Manat(KapitalBank(username=os.environ["KB_USER"], password=os.environ["KB_PASSWORD"]))
31
+
32
+ result = pay.create_payment("A-1001", azn("25.50"), "https://shop.az/payment/return") # 2550 qəpik
33
+ if result.next.type == "redirect":
34
+ return redirect(result.next.url)
35
+ ```
36
+
37
+ Store `result.payment_id` with the order. It is what `get_status`, `refund`, `capture` and `cancel` take.
38
+
39
+ ## Providers
40
+
41
+ ```python
42
+ from manat.providers import Azericard, Epoint, GoldenPay, KapitalBank, Payriff, Yigim
43
+
44
+ KapitalBank(username, password, environment="test")
45
+ Azericard(terminal, private_key_pem, "My Shop", "https://shop.az", gateway_public_key=gateway_pem, environment="test")
46
+ Payriff(secret_key)
47
+ Epoint(public_key, private_key)
48
+ GoldenPay(auth_key, merchant_name, environment="test")
49
+ Yigim(merchant, api_key, biller, environment="test")
50
+ ```
51
+
52
+ | Provider | create_payment | verify_callback | get_status | refund | capture | cancel |
53
+ |--------------|:-:|:-:|:-:|:-:|:-:|:-:|
54
+ | Kapital Bank | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
55
+ | Azericard | ✓ (form POST) | ✓ | ✓ | ✓ | ✓ | ✓ |
56
+ | Payriff | ✓ | ✓ | ✓ | ✓ | ✓ | — |
57
+ | Epoint | ✓ | ✓ | ✓ | ✓ | — | — |
58
+ | GoldenPay | ✓ | ✓ | ✓ | ✓ | — | — |
59
+ | Yığım | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
60
+
61
+ Every provider takes `base_url` (or `gateway_url`), `transport` and `timeout` keyword arguments.
62
+
63
+ ## Callbacks
64
+
65
+ Pass what your handler received, with the raw body. Unsigned redirects and webhooks are never trusted: the adapter
66
+ re-reads the payment from the provider's API.
67
+
68
+ ```python
69
+ event = pay.verify_callback(request.method, query=request.args, body=request.get_data(as_text=True))
70
+ if event.status == "paid":
71
+ ... # fulfil event.payment_id; for Azericard store event.provider_ref for refunds
72
+ ```
73
+
74
+ ## Refunds, capture, cancel
75
+
76
+ ```python
77
+ pay.refund(payment_id) # full refund
78
+ pay.refund(payment_id, amount=azn("10.00")) # partial
79
+ pay.capture(payment_id) # after intent="authorize"
80
+ pay.refund(payment_id, provider_ref=event.provider_ref) # Azericard
81
+ ```
82
+
83
+ ## Errors
84
+
85
+ Everything raises `manat.ManatError`. `error.code` is one of `invalid_request`, `invalid_signature`, `provider_error`,
86
+ `network_error`, `unsupported_operation`; `provider_code`, `provider_message` and `raw` hold what the provider said.
87
+
88
+ ## Tests
89
+
90
+ ```bash
91
+ cd python && PYTHONPATH=src:tests python3 -m unittest discover -s tests
92
+ ```
@@ -0,0 +1,76 @@
1
+ # Manat for Python
2
+
3
+ One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
4
+ Python 3.10+, no dependencies (standard library only, RSA included).
5
+
6
+ ```bash
7
+ pip install keytd-manat
8
+ ```
9
+
10
+ ```python
11
+ from manat import Manat, azn
12
+ from manat.providers import KapitalBank
13
+
14
+ pay = Manat(KapitalBank(username=os.environ["KB_USER"], password=os.environ["KB_PASSWORD"]))
15
+
16
+ result = pay.create_payment("A-1001", azn("25.50"), "https://shop.az/payment/return") # 2550 qəpik
17
+ if result.next.type == "redirect":
18
+ return redirect(result.next.url)
19
+ ```
20
+
21
+ Store `result.payment_id` with the order. It is what `get_status`, `refund`, `capture` and `cancel` take.
22
+
23
+ ## Providers
24
+
25
+ ```python
26
+ from manat.providers import Azericard, Epoint, GoldenPay, KapitalBank, Payriff, Yigim
27
+
28
+ KapitalBank(username, password, environment="test")
29
+ Azericard(terminal, private_key_pem, "My Shop", "https://shop.az", gateway_public_key=gateway_pem, environment="test")
30
+ Payriff(secret_key)
31
+ Epoint(public_key, private_key)
32
+ GoldenPay(auth_key, merchant_name, environment="test")
33
+ Yigim(merchant, api_key, biller, environment="test")
34
+ ```
35
+
36
+ | Provider | create_payment | verify_callback | get_status | refund | capture | cancel |
37
+ |--------------|:-:|:-:|:-:|:-:|:-:|:-:|
38
+ | Kapital Bank | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
39
+ | Azericard | ✓ (form POST) | ✓ | ✓ | ✓ | ✓ | ✓ |
40
+ | Payriff | ✓ | ✓ | ✓ | ✓ | ✓ | — |
41
+ | Epoint | ✓ | ✓ | ✓ | ✓ | — | — |
42
+ | GoldenPay | ✓ | ✓ | ✓ | ✓ | — | — |
43
+ | Yığım | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
44
+
45
+ Every provider takes `base_url` (or `gateway_url`), `transport` and `timeout` keyword arguments.
46
+
47
+ ## Callbacks
48
+
49
+ Pass what your handler received, with the raw body. Unsigned redirects and webhooks are never trusted: the adapter
50
+ re-reads the payment from the provider's API.
51
+
52
+ ```python
53
+ event = pay.verify_callback(request.method, query=request.args, body=request.get_data(as_text=True))
54
+ if event.status == "paid":
55
+ ... # fulfil event.payment_id; for Azericard store event.provider_ref for refunds
56
+ ```
57
+
58
+ ## Refunds, capture, cancel
59
+
60
+ ```python
61
+ pay.refund(payment_id) # full refund
62
+ pay.refund(payment_id, amount=azn("10.00")) # partial
63
+ pay.capture(payment_id) # after intent="authorize"
64
+ pay.refund(payment_id, provider_ref=event.provider_ref) # Azericard
65
+ ```
66
+
67
+ ## Errors
68
+
69
+ Everything raises `manat.ManatError`. `error.code` is one of `invalid_request`, `invalid_signature`, `provider_error`,
70
+ `network_error`, `unsupported_operation`; `provider_code`, `provider_message` and `raw` hold what the provider said.
71
+
72
+ ## Tests
73
+
74
+ ```bash
75
+ cd python && PYTHONPATH=src:tests python3 -m unittest discover -s tests
76
+ ```
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keytd-manat"
7
+ version = "0.1.0"
8
+ description = "One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{name = "KeyTD", email = "info@keytd.com"}]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ keywords = ["azerbaijan", "payments", "azn", "manat", "payment-gateway", "kapital-bank", "azericard", "payriff", "epoint", "goldenpay", "yigim"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Office/Business :: Financial",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/KeyTechAndDesign/manat"
22
+ Issues = "https://github.com/KeyTechAndDesign/manat/issues"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: keytd-manat
3
+ Version: 0.1.0
4
+ Summary: One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
5
+ Author-email: KeyTD <info@keytd.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/KeyTechAndDesign/manat
8
+ Project-URL: Issues, https://github.com/KeyTechAndDesign/manat/issues
9
+ Keywords: azerbaijan,payments,azn,manat,payment-gateway,kapital-bank,azericard,payriff,epoint,goldenpay,yigim
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Office/Business :: Financial
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # Manat for Python
18
+
19
+ One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
20
+ Python 3.10+, no dependencies (standard library only, RSA included).
21
+
22
+ ```bash
23
+ pip install keytd-manat
24
+ ```
25
+
26
+ ```python
27
+ from manat import Manat, azn
28
+ from manat.providers import KapitalBank
29
+
30
+ pay = Manat(KapitalBank(username=os.environ["KB_USER"], password=os.environ["KB_PASSWORD"]))
31
+
32
+ result = pay.create_payment("A-1001", azn("25.50"), "https://shop.az/payment/return") # 2550 qəpik
33
+ if result.next.type == "redirect":
34
+ return redirect(result.next.url)
35
+ ```
36
+
37
+ Store `result.payment_id` with the order. It is what `get_status`, `refund`, `capture` and `cancel` take.
38
+
39
+ ## Providers
40
+
41
+ ```python
42
+ from manat.providers import Azericard, Epoint, GoldenPay, KapitalBank, Payriff, Yigim
43
+
44
+ KapitalBank(username, password, environment="test")
45
+ Azericard(terminal, private_key_pem, "My Shop", "https://shop.az", gateway_public_key=gateway_pem, environment="test")
46
+ Payriff(secret_key)
47
+ Epoint(public_key, private_key)
48
+ GoldenPay(auth_key, merchant_name, environment="test")
49
+ Yigim(merchant, api_key, biller, environment="test")
50
+ ```
51
+
52
+ | Provider | create_payment | verify_callback | get_status | refund | capture | cancel |
53
+ |--------------|:-:|:-:|:-:|:-:|:-:|:-:|
54
+ | Kapital Bank | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
55
+ | Azericard | ✓ (form POST) | ✓ | ✓ | ✓ | ✓ | ✓ |
56
+ | Payriff | ✓ | ✓ | ✓ | ✓ | ✓ | — |
57
+ | Epoint | ✓ | ✓ | ✓ | ✓ | — | — |
58
+ | GoldenPay | ✓ | ✓ | ✓ | ✓ | — | — |
59
+ | Yığım | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
60
+
61
+ Every provider takes `base_url` (or `gateway_url`), `transport` and `timeout` keyword arguments.
62
+
63
+ ## Callbacks
64
+
65
+ Pass what your handler received, with the raw body. Unsigned redirects and webhooks are never trusted: the adapter
66
+ re-reads the payment from the provider's API.
67
+
68
+ ```python
69
+ event = pay.verify_callback(request.method, query=request.args, body=request.get_data(as_text=True))
70
+ if event.status == "paid":
71
+ ... # fulfil event.payment_id; for Azericard store event.provider_ref for refunds
72
+ ```
73
+
74
+ ## Refunds, capture, cancel
75
+
76
+ ```python
77
+ pay.refund(payment_id) # full refund
78
+ pay.refund(payment_id, amount=azn("10.00")) # partial
79
+ pay.capture(payment_id) # after intent="authorize"
80
+ pay.refund(payment_id, provider_ref=event.provider_ref) # Azericard
81
+ ```
82
+
83
+ ## Errors
84
+
85
+ Everything raises `manat.ManatError`. `error.code` is one of `invalid_request`, `invalid_signature`, `provider_error`,
86
+ `network_error`, `unsupported_operation`; `provider_code`, `provider_message` and `raw` hold what the provider said.
87
+
88
+ ## Tests
89
+
90
+ ```bash
91
+ cd python && PYTHONPATH=src:tests python3 -m unittest discover -s tests
92
+ ```
@@ -0,0 +1,26 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/keytd_manat.egg-info/PKG-INFO
5
+ src/keytd_manat.egg-info/SOURCES.txt
6
+ src/keytd_manat.egg-info/dependency_links.txt
7
+ src/keytd_manat.egg-info/top_level.txt
8
+ src/manat/__init__.py
9
+ src/manat/_crypto.py
10
+ src/manat/_form.py
11
+ src/manat/_http.py
12
+ src/manat/_json.py
13
+ src/manat/_values.py
14
+ src/manat/client.py
15
+ src/manat/errors.py
16
+ src/manat/money.py
17
+ src/manat/types.py
18
+ src/manat/providers/__init__.py
19
+ src/manat/providers/azericard.py
20
+ src/manat/providers/epoint.py
21
+ src/manat/providers/goldenpay.py
22
+ src/manat/providers/kapitalbank.py
23
+ src/manat/providers/payriff.py
24
+ src/manat/providers/yigim.py
25
+ tests/test_units.py
26
+ tests/test_vectors.py
@@ -0,0 +1,12 @@
1
+ """One API for Azerbaijani online payment providers."""
2
+
3
+ from .client import Manat
4
+ from .errors import ManatError, is_manat_error
5
+ from .money import Money, azn, from_decimal
6
+ from .types import CreatePaymentResult, IncomingCallback, NextAction, PaymentEvent, PaymentState, RefundResult
7
+
8
+ __all__ = [
9
+ "Manat", "ManatError", "is_manat_error", "Money", "azn", "from_decimal",
10
+ "CreatePaymentResult", "IncomingCallback", "NextAction", "PaymentEvent", "PaymentState", "RefundResult",
11
+ ]
12
+ __version__ = "0.1.0"
@@ -0,0 +1,174 @@
1
+ """Hashes, constant-time comparison and RSA-SHA256 (PKCS#1 v1.5) on the standard library alone."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import math
9
+ import re
10
+ import secrets
11
+ from dataclasses import dataclass
12
+ from typing import List, Optional, Tuple
13
+
14
+ _PEM = re.compile(r"-----BEGIN ([A-Z0-9 ]+)-----([A-Za-z0-9+/=\s]+)-----END \1-----")
15
+ # DigestInfo prefix for SHA-256 (RFC 8017, section 9.2).
16
+ _SHA256_PREFIX = bytes.fromhex("3031300d060960864801650304020105000420")
17
+
18
+
19
+ def md5_hex(text: str) -> str:
20
+ return hashlib.md5(text.encode("utf-8")).hexdigest()
21
+
22
+
23
+ def sha1_base64(text: str) -> str:
24
+ return base64.b64encode(hashlib.sha1(text.encode("utf-8")).digest()).decode("ascii")
25
+
26
+
27
+ def safe_equals(actual: Optional[str], expected: str) -> bool:
28
+ return isinstance(actual, str) and hmac.compare_digest(actual.encode("utf-8"), expected.encode("utf-8"))
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class RsaPublicKey:
33
+ n: int
34
+ e: int
35
+
36
+ @property
37
+ def bits(self) -> int:
38
+ return self.n.bit_length()
39
+
40
+ def verify_sha256(self, text: str, signature: bytes) -> bool:
41
+ k = (self.n.bit_length() + 7) // 8
42
+ if len(signature) != k:
43
+ return False
44
+ s = int.from_bytes(signature, "big")
45
+ if s >= self.n:
46
+ return False
47
+ return hmac.compare_digest(pow(s, self.e, self.n).to_bytes(k, "big"), _encode(text, k))
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class RsaPrivateKey:
52
+ n: int
53
+ e: int
54
+ d: int
55
+
56
+ @property
57
+ def bits(self) -> int:
58
+ return self.n.bit_length()
59
+
60
+ def sign_sha256(self, text: str) -> bytes:
61
+ k = (self.n.bit_length() + 7) // 8
62
+ m = int.from_bytes(_encode(text, k), "big")
63
+ # Blinding keeps the private-key exponentiation's timing independent of the message.
64
+ while True:
65
+ r = secrets.randbelow(self.n - 2) + 2
66
+ if math.gcd(r, self.n) == 1:
67
+ break
68
+ blinded = pow(m * pow(r, self.e, self.n) % self.n, self.d, self.n)
69
+ return (blinded * pow(r, -1, self.n) % self.n).to_bytes(k, "big")
70
+
71
+
72
+ def private_key(pem: str) -> RsaPrivateKey:
73
+ label, der = _block(pem)
74
+ if label == "RSA PRIVATE KEY":
75
+ return _rsa_private(der)
76
+ if label == "PRIVATE KEY":
77
+ # PrivateKeyInfo { version, AlgorithmIdentifier, OCTET STRING { RSAPrivateKey } }
78
+ info = _Der(_Der(der).read(0x30))
79
+ info.read(0x02)
80
+ info.read(0x30)
81
+ return _rsa_private(info.read(0x04))
82
+ raise ValueError(f'unsupported PEM block "{label}" (encrypted keys are not supported)')
83
+
84
+
85
+ def public_key(pem: str) -> RsaPublicKey:
86
+ label, der = _block(pem)
87
+ if label == "RSA PUBLIC KEY":
88
+ return _rsa_public(der)
89
+ if label == "PUBLIC KEY":
90
+ return _spki(der)
91
+ if label == "CERTIFICATE":
92
+ # Certificate { TBSCertificate { [0] version?, serial, signature, issuer, validity, subject, spki, … }, … }
93
+ tbs = _Der(_Der(_Der(der).read(0x30)).read(0x30))
94
+ if tbs.peek() == 0xA0:
95
+ tbs.read(0xA0)
96
+ for tag in (0x02, 0x30, 0x30, 0x30, 0x30):
97
+ tbs.read(tag)
98
+ return _spki(_wrap(0x30, tbs.read(0x30)))
99
+ raise ValueError(f'unsupported PEM block "{label}"')
100
+
101
+
102
+ def _encode(text: str, k: int) -> bytes:
103
+ """EMSA-PKCS1-v1_5: 00 01 FF…FF 00 DigestInfo(SHA-256)."""
104
+ t = _SHA256_PREFIX + hashlib.sha256(text.encode("utf-8")).digest()
105
+ if k < len(t) + 11:
106
+ raise ValueError("key too short")
107
+ return b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t
108
+
109
+
110
+ def _block(pem: str) -> Tuple[str, bytes]:
111
+ match = _PEM.search(pem or "")
112
+ if not match:
113
+ raise ValueError("not a PEM key")
114
+ return match.group(1), base64.b64decode(re.sub(r"\s", "", match.group(2)), validate=True)
115
+
116
+
117
+ def _rsa_private(der: bytes) -> RsaPrivateKey:
118
+ key = _Der(_Der(der).read(0x30))
119
+ key.read(0x02) # version
120
+ n, e, d = (int.from_bytes(key.read(0x02), "big") for _ in range(3))
121
+ return RsaPrivateKey(n, e, d)
122
+
123
+
124
+ def _rsa_public(der: bytes) -> RsaPublicKey:
125
+ key = _Der(_Der(der).read(0x30))
126
+ n, e = (int.from_bytes(key.read(0x02), "big") for _ in range(2))
127
+ return RsaPublicKey(n, e)
128
+
129
+
130
+ def _spki(der: bytes) -> RsaPublicKey:
131
+ # SubjectPublicKeyInfo { AlgorithmIdentifier, BIT STRING { 00, RSAPublicKey } }
132
+ info = _Der(_Der(der).read(0x30))
133
+ info.read(0x30)
134
+ return _rsa_public(info.read(0x03)[1:])
135
+
136
+
137
+ def _wrap(tag: int, content: bytes) -> bytes:
138
+ length = len(content)
139
+ if length < 0x80:
140
+ return bytes([tag, length]) + content
141
+ size = (length.bit_length() + 7) // 8
142
+ return bytes([tag, 0x80 | size]) + length.to_bytes(size, "big") + content
143
+
144
+
145
+ class _Der:
146
+ """Just enough DER reading for RSA keys."""
147
+
148
+ def __init__(self, data: bytes) -> None:
149
+ self.data = data
150
+ self.pos = 0
151
+
152
+ def peek(self) -> int:
153
+ return self.data[self.pos] if self.pos < len(self.data) else -1
154
+
155
+ def read(self, tag: int) -> bytes:
156
+ if self.peek() != tag:
157
+ raise ValueError(f"bad key: expected DER tag 0x{tag:02x}")
158
+ self.pos += 1
159
+ length = self.data[self.pos]
160
+ self.pos += 1
161
+ if length & 0x80:
162
+ count = length & 0x7F
163
+ if not 1 <= count <= 4:
164
+ raise ValueError("bad key: DER length")
165
+ length = int.from_bytes(self.data[self.pos:self.pos + count], "big")
166
+ self.pos += count
167
+ if self.pos + length > len(self.data):
168
+ raise ValueError("bad key: truncated")
169
+ value = self.data[self.pos:self.pos + length]
170
+ self.pos += length
171
+ return value
172
+
173
+
174
+ __all__: List[str] = ["md5_hex", "sha1_base64", "safe_equals", "private_key", "public_key", "RsaPrivateKey", "RsaPublicKey"]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Iterable, Mapping, Tuple, Union
4
+ from urllib.parse import unquote_plus
5
+
6
+ _SAFE = frozenset(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789*-._")
7
+
8
+
9
+ def encode(fields: Union[Mapping[str, str], Iterable[Tuple[str, str]]]) -> str:
10
+ """application/x-www-form-urlencoded, encoded like JavaScript's URLSearchParams."""
11
+ pairs = fields.items() if isinstance(fields, Mapping) else fields
12
+ return "&".join(f"{_escape(key)}={_escape(value)}" for key, value in pairs)
13
+
14
+
15
+ def decode(body: str) -> Dict[str, str]:
16
+ """Later duplicates are ignored; malformed %-escapes are kept as text."""
17
+ out: Dict[str, str] = {}
18
+ for pair in (body or "").split("&"):
19
+ if not pair:
20
+ continue
21
+ key, _, value = pair.partition("=")
22
+ out.setdefault(unquote_plus(key), unquote_plus(value))
23
+ return out
24
+
25
+
26
+ def _escape(text: str) -> str:
27
+ out = []
28
+ for byte in (text or "").encode("utf-8"):
29
+ if byte in _SAFE:
30
+ out.append(chr(byte))
31
+ elif byte == 0x20:
32
+ out.append("+")
33
+ else:
34
+ out.append(f"%{byte:02X}")
35
+ return "".join(out)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ import urllib.error
5
+ import urllib.request
6
+ from dataclasses import dataclass
7
+ from typing import Any, Dict, Mapping, Optional, Protocol, Tuple
8
+
9
+ from . import _form, _json
10
+ from .errors import NETWORK_ERROR, ManatError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class HttpResponse:
15
+ status: int
16
+ text: str
17
+
18
+
19
+ class Transport(Protocol):
20
+ """Sends one request. Raise OSError (or http.client.HTTPException) on a network failure."""
21
+
22
+ def send(self, method: str, url: str, headers: Mapping[str, str], body: Optional[bytes], timeout: float) -> HttpResponse: ...
23
+
24
+
25
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
26
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
27
+ return None
28
+
29
+
30
+ class UrllibTransport:
31
+ """Default transport on urllib: TLS verified, redirects not followed."""
32
+
33
+ def __init__(self) -> None:
34
+ self._opener = urllib.request.build_opener(_NoRedirect)
35
+
36
+ def send(self, method: str, url: str, headers: Mapping[str, str], body: Optional[bytes], timeout: float) -> HttpResponse:
37
+ request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
38
+ try:
39
+ with self._opener.open(request, timeout=timeout) as response:
40
+ return HttpResponse(response.status, _decode(response.read(), response.headers.get_content_charset()))
41
+ except urllib.error.HTTPError as error:
42
+ return HttpResponse(error.code, _decode(error.read(), error.headers.get_content_charset() if error.headers else None))
43
+
44
+
45
+ class Http:
46
+ """Encodes bodies, applies the timeout and turns failures into ManatError for one adapter."""
47
+
48
+ def __init__(self, provider: str, transport: Optional[Transport], timeout: float) -> None:
49
+ self.provider = provider
50
+ self.transport: Transport = transport or UrllibTransport()
51
+ self.timeout = timeout
52
+
53
+ def send(self, method: str, url: str, headers: Optional[Mapping[str, str]] = None, json: Any = None,
54
+ form: Optional[Mapping[str, str]] = None) -> HttpResponse:
55
+ lower: Dict[str, str] = {name.lower(): value for name, value in (headers or {}).items()}
56
+ body: Optional[bytes] = None
57
+ if form is not None:
58
+ body = _form.encode(form).encode("utf-8")
59
+ lower.setdefault("content-type", "application/x-www-form-urlencoded")
60
+ elif json is not None:
61
+ body = _json.stringify(json).encode("utf-8")
62
+ lower.setdefault("content-type", "application/json")
63
+ try:
64
+ return self.transport.send(method, url, lower, body, self.timeout)
65
+ except ManatError:
66
+ raise
67
+ except (OSError, http.client.HTTPException) as error:
68
+ raise ManatError(NETWORK_ERROR, f"{self.provider}: {method} {url} {error}", provider=self.provider) from error
69
+
70
+ def json(self, method: str, url: str, headers: Optional[Mapping[str, str]] = None, json: Any = None,
71
+ form: Optional[Mapping[str, str]] = None) -> Tuple[int, Any]:
72
+ response = self.send(method, url, {"accept": "application/json", **{k.lower(): v for k, v in (headers or {}).items()}}, json, form)
73
+ if not response.text:
74
+ return response.status, None
75
+ try:
76
+ return response.status, _json.parse(response.text)
77
+ except ValueError as error:
78
+ raise ManatError(NETWORK_ERROR, f'{self.provider}: expected JSON, got HTTP {response.status} "{response.text[:200]}"',
79
+ provider=self.provider, raw=response.text) from error
80
+
81
+
82
+ def _decode(data: bytes, charset: Optional[str]) -> str:
83
+ try:
84
+ return data.decode(charset or "utf-8")
85
+ except (LookupError, UnicodeDecodeError):
86
+ return data.decode("utf-8", errors="replace")