pierre-sdk 0.1.0.dev4__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.
- pierre/__init__.py +59 -0
- pierre/_client.py +544 -0
- pierre/_compat.py +14 -0
- pierre/_content.py +168 -0
- pierre/_credentials.py +321 -0
- pierre/_errors.py +205 -0
- pierre/_generated/__init__.py +2 -0
- pierre/_generated/client.py +54 -0
- pierre/_generated/headers.py +69 -0
- pierre/_generated/namespaces/__init__.py +2 -0
- pierre/_generated/namespaces/meta.py +125 -0
- pierre/_generated/namespaces/repos.py +5658 -0
- pierre/_generated/schemas.py +1437 -0
- pierre/_generated/scopes.py +12 -0
- pierre/_generated/webhooks.py +30 -0
- pierre/_helpers/__init__.py +1 -0
- pierre/_helpers/commits.py +220 -0
- pierre/_helpers/repos.py +58 -0
- pierre/_operation.py +66 -0
- pierre/_page.py +154 -0
- pierre/_response.py +92 -0
- pierre/_retry.py +65 -0
- pierre/_runtime.py +763 -0
- pierre/_types.py +73 -0
- pierre/_version.py +3 -0
- pierre/auth.py +84 -0
- pierre/git.py +161 -0
- pierre/py.typed +0 -0
- pierre/types.py +14 -0
- pierre/webhooks.py +145 -0
- pierre_sdk-0.1.0.dev4.dist-info/METADATA +153 -0
- pierre_sdk-0.1.0.dev4.dist-info/RECORD +34 -0
- pierre_sdk-0.1.0.dev4.dist-info/WHEEL +4 -0
- pierre_sdk-0.1.0.dev4.dist-info/licenses/LICENSE +189 -0
pierre/_content.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Upload content in each form that the SDK accepts, as byte chunks."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import (
|
|
5
|
+
IO,
|
|
6
|
+
Any,
|
|
7
|
+
AsyncIterable,
|
|
8
|
+
AsyncIterator,
|
|
9
|
+
Iterable,
|
|
10
|
+
Iterator,
|
|
11
|
+
List,
|
|
12
|
+
Tuple,
|
|
13
|
+
Union,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from pierre._errors import PierreError
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"MAX_CHUNK_BYTES",
|
|
20
|
+
"Content",
|
|
21
|
+
"async_chunks",
|
|
22
|
+
"async_close",
|
|
23
|
+
"chunks",
|
|
24
|
+
"close",
|
|
25
|
+
"is_bytes",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# One piece of content: text, which the SDK sends as UTF-8, or bytes.
|
|
29
|
+
ContentPiece = Union[str, bytes, bytearray, memoryview]
|
|
30
|
+
|
|
31
|
+
# Content of a file or a diff: one piece, a binary file, or an iterable of pieces. `AsyncPierre`
|
|
32
|
+
# also takes an async iterable of pieces and a file whose `read` is async.
|
|
33
|
+
Content = Union[
|
|
34
|
+
ContentPiece,
|
|
35
|
+
IO[bytes],
|
|
36
|
+
Iterable[ContentPiece],
|
|
37
|
+
AsyncIterable[ContentPiece],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# The gateway limits a decoded chunk to 4 MiB.
|
|
41
|
+
MAX_CHUNK_BYTES = 4 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
_CONTENT_FORMS = "a string, bytes, a binary file, or an iterable of bytes"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_bytes(value: object) -> bool:
|
|
47
|
+
return isinstance(value, (bytes, bytearray, memoryview))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _piece(value: object) -> bytes:
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
return value.encode("utf-8")
|
|
53
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
54
|
+
return bytes(value)
|
|
55
|
+
raise PierreError(
|
|
56
|
+
f"Each piece of `content` must be bytes or a string, not {type(value).__name__}."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pieces(content: object) -> Iterator[bytes]:
|
|
61
|
+
if isinstance(content, str) or is_bytes(content):
|
|
62
|
+
yield _piece(content)
|
|
63
|
+
return
|
|
64
|
+
read = getattr(content, "read", None)
|
|
65
|
+
if callable(read):
|
|
66
|
+
while True:
|
|
67
|
+
data = read(MAX_CHUNK_BYTES)
|
|
68
|
+
if inspect.isawaitable(data):
|
|
69
|
+
if inspect.iscoroutine(data):
|
|
70
|
+
data.close()
|
|
71
|
+
raise PierreError(
|
|
72
|
+
"`Pierre` cannot read a file whose `read` is async. Use `AsyncPierre`."
|
|
73
|
+
)
|
|
74
|
+
if not data:
|
|
75
|
+
return
|
|
76
|
+
yield _piece(data)
|
|
77
|
+
if hasattr(content, "__aiter__"):
|
|
78
|
+
raise PierreError("`Pierre` cannot read an async iterable. Use `AsyncPierre`.")
|
|
79
|
+
if isinstance(content, Iterable):
|
|
80
|
+
for item in content:
|
|
81
|
+
yield _piece(item)
|
|
82
|
+
return
|
|
83
|
+
raise PierreError(f"`content` must be {_CONTENT_FORMS}.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _async_pieces(content: object) -> AsyncIterator[bytes]:
|
|
87
|
+
if isinstance(content, str) or is_bytes(content):
|
|
88
|
+
yield _piece(content)
|
|
89
|
+
return
|
|
90
|
+
read = getattr(content, "read", None)
|
|
91
|
+
if callable(read):
|
|
92
|
+
while True:
|
|
93
|
+
data = read(MAX_CHUNK_BYTES)
|
|
94
|
+
if inspect.isawaitable(data):
|
|
95
|
+
data = await data
|
|
96
|
+
if not data:
|
|
97
|
+
return
|
|
98
|
+
yield _piece(data)
|
|
99
|
+
if isinstance(content, AsyncIterable):
|
|
100
|
+
async for item in content:
|
|
101
|
+
yield _piece(item)
|
|
102
|
+
return
|
|
103
|
+
if isinstance(content, Iterable):
|
|
104
|
+
for item in content:
|
|
105
|
+
yield _piece(item)
|
|
106
|
+
return
|
|
107
|
+
raise PierreError(f"`content` must be {_CONTENT_FORMS}, or an async iterable of bytes.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Chunker:
|
|
111
|
+
"""Splits pieces into chunks of at most `MAX_CHUNK_BYTES`, and joins small pieces up to that
|
|
112
|
+
size."""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self._parts: List[bytes] = []
|
|
116
|
+
self._size = 0
|
|
117
|
+
|
|
118
|
+
def add(self, piece: bytes) -> Iterator[bytes]:
|
|
119
|
+
"""Gives each full chunk. A full chunk goes out only when more bytes follow, so that the
|
|
120
|
+
last chunk can have `eof`."""
|
|
121
|
+
offset = 0
|
|
122
|
+
while offset < len(piece):
|
|
123
|
+
if self._size == MAX_CHUNK_BYTES:
|
|
124
|
+
yield self.take()
|
|
125
|
+
length = min(len(piece) - offset, MAX_CHUNK_BYTES - self._size)
|
|
126
|
+
self._parts.append(piece[offset : offset + length])
|
|
127
|
+
self._size += length
|
|
128
|
+
offset += length
|
|
129
|
+
|
|
130
|
+
def take(self) -> bytes:
|
|
131
|
+
data = self._parts[0] if len(self._parts) == 1 else b"".join(self._parts)
|
|
132
|
+
self._parts = []
|
|
133
|
+
self._size = 0
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def chunks(content: object) -> Iterator[Tuple[bytes, bool]]:
|
|
138
|
+
"""The chunks of content, each with `eof`. The last chunk has `eof`, and empty content gives
|
|
139
|
+
one empty chunk."""
|
|
140
|
+
chunker = _Chunker()
|
|
141
|
+
for piece in _pieces(content):
|
|
142
|
+
for full in chunker.add(piece):
|
|
143
|
+
yield full, False
|
|
144
|
+
yield chunker.take(), True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def async_chunks(content: object) -> AsyncIterator[Tuple[bytes, bool]]:
|
|
148
|
+
"""`chunks` for `AsyncPierre`, which also reads async iterables and async files."""
|
|
149
|
+
chunker = _Chunker()
|
|
150
|
+
async for piece in _async_pieces(content):
|
|
151
|
+
for full in chunker.add(piece):
|
|
152
|
+
yield full, False
|
|
153
|
+
yield chunker.take(), True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def close(content: Any) -> None:
|
|
157
|
+
"""Closes a file that a content function returned. The SDK opened it, so the SDK closes it."""
|
|
158
|
+
method = getattr(content, "close", None)
|
|
159
|
+
if callable(method) and callable(getattr(content, "read", None)):
|
|
160
|
+
method()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def async_close(content: Any) -> None:
|
|
164
|
+
method = getattr(content, "close", None)
|
|
165
|
+
if callable(method) and callable(getattr(content, "read", None)):
|
|
166
|
+
result = method()
|
|
167
|
+
if inspect.isawaitable(result):
|
|
168
|
+
await result
|
pierre/_credentials.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Credentials and token signing."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
Awaitable,
|
|
14
|
+
Callable,
|
|
15
|
+
Dict,
|
|
16
|
+
List,
|
|
17
|
+
Literal,
|
|
18
|
+
Mapping,
|
|
19
|
+
Optional,
|
|
20
|
+
Sequence,
|
|
21
|
+
Tuple,
|
|
22
|
+
Union,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
26
|
+
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
|
|
27
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
|
28
|
+
|
|
29
|
+
from pierre._errors import PierreError
|
|
30
|
+
from pierre._types import RefPolicy, TokenRequest
|
|
31
|
+
|
|
32
|
+
# A token that the SDK signs for one request lives 5 minutes.
|
|
33
|
+
REQUEST_TOKEN_SECONDS = 5 * 60
|
|
34
|
+
# The longest token lifetime that the SDK signs.
|
|
35
|
+
MAX_TOKEN_SECONDS = 60 * 60
|
|
36
|
+
# The `sub` claim of the tokens that the SDK signs.
|
|
37
|
+
SUBJECT = "pierre-sdk-python"
|
|
38
|
+
|
|
39
|
+
# Ref policies after the check: `(pattern, ops)` pairs, with `None` for a rule with no `ops`.
|
|
40
|
+
Rules = Tuple[Tuple[str, Optional[Tuple[str, ...]]], ...]
|
|
41
|
+
|
|
42
|
+
GetToken = Callable[[TokenRequest], Union[str, Awaitable[str]]]
|
|
43
|
+
|
|
44
|
+
_PEM = re.compile(r"-----BEGIN PRIVATE KEY-----([\s\S]+?)-----END PRIVATE KEY-----")
|
|
45
|
+
|
|
46
|
+
# The signing algorithm and hash of each EC curve.
|
|
47
|
+
_CURVES: Dict[str, Tuple[str, hashes.HashAlgorithm]] = {
|
|
48
|
+
"secp256r1": ("ES256", hashes.SHA256()),
|
|
49
|
+
"secp384r1": ("ES384", hashes.SHA384()),
|
|
50
|
+
"secp521r1": ("ES512", hashes.SHA512()),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_ref_policies(policies: object, name: str) -> Optional[Rules]:
|
|
55
|
+
"""Checks the shape of ref policies. The gateway checks the pattern forms and the ops."""
|
|
56
|
+
if policies is None:
|
|
57
|
+
return None
|
|
58
|
+
if isinstance(policies, (str, bytes)) or not isinstance(policies, Sequence):
|
|
59
|
+
raise PierreError(f"`{name}` must be a list of rules.")
|
|
60
|
+
rules: List[Tuple[str, Optional[Tuple[str, ...]]]] = []
|
|
61
|
+
for index, policy in enumerate(policies):
|
|
62
|
+
at = f"`{name}[{index}]`"
|
|
63
|
+
if not isinstance(policy, Mapping):
|
|
64
|
+
raise PierreError(f"{at} must be a dict with `pattern` and `ops`.")
|
|
65
|
+
pattern = policy.get("pattern")
|
|
66
|
+
if not isinstance(pattern, str) or pattern == "":
|
|
67
|
+
raise PierreError(f"{at}.pattern must be a non-empty string.")
|
|
68
|
+
ops = policy.get("ops")
|
|
69
|
+
if ops is None:
|
|
70
|
+
rules.append((pattern, None))
|
|
71
|
+
continue
|
|
72
|
+
if (
|
|
73
|
+
isinstance(ops, (str, bytes))
|
|
74
|
+
or not isinstance(ops, Sequence)
|
|
75
|
+
or any(not isinstance(op, str) or op == "" for op in ops)
|
|
76
|
+
):
|
|
77
|
+
raise PierreError(f"{at}.ops must be a list of op names.")
|
|
78
|
+
rules.append((pattern, tuple(ops)))
|
|
79
|
+
return tuple(rules)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def copy_ref_policies(policies: object) -> object:
|
|
83
|
+
"""A copy of ref policies and their ops, so that a later change by the caller does not change
|
|
84
|
+
a request. The check runs later, so a value of another type stays as it is."""
|
|
85
|
+
if not isinstance(policies, (list, tuple)):
|
|
86
|
+
return policies
|
|
87
|
+
copied: List[object] = []
|
|
88
|
+
for policy in policies:
|
|
89
|
+
if isinstance(policy, Mapping):
|
|
90
|
+
rule = dict(policy)
|
|
91
|
+
if isinstance(rule.get("ops"), (list, tuple)):
|
|
92
|
+
rule["ops"] = list(rule["ops"])
|
|
93
|
+
copied.append(rule)
|
|
94
|
+
else:
|
|
95
|
+
copied.append(policy)
|
|
96
|
+
return copied
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def policies_of(rules: Rules) -> List[RefPolicy]:
|
|
100
|
+
"""The rules as new `RefPolicy` dicts."""
|
|
101
|
+
policies: List[RefPolicy] = []
|
|
102
|
+
for pattern, ops in rules:
|
|
103
|
+
policies.append(
|
|
104
|
+
{"pattern": pattern} if ops is None else {"pattern": pattern, "ops": list(ops)}
|
|
105
|
+
)
|
|
106
|
+
return policies
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def refs_claim(rules: Rules) -> Optional[List[List[Any]]]:
|
|
110
|
+
"""The `refs` claim of the rules, or `None` for no rule."""
|
|
111
|
+
if len(rules) == 0:
|
|
112
|
+
return None
|
|
113
|
+
return [[pattern, list(ops or ())] for pattern, ops in rules]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check_ttl(ttl: object, name: str) -> int:
|
|
117
|
+
"""Checks a token lifetime: a whole number of seconds from 1 to 3600."""
|
|
118
|
+
if isinstance(ttl, bool) or not isinstance(ttl, int) or ttl < 1 or ttl > MAX_TOKEN_SECONDS:
|
|
119
|
+
raise PierreError(
|
|
120
|
+
f"`{name}` must be a whole number of seconds from 1 to {MAX_TOKEN_SECONDS}."
|
|
121
|
+
)
|
|
122
|
+
return ttl
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class Claims:
|
|
127
|
+
"""The claims of a token that the SDK signs."""
|
|
128
|
+
|
|
129
|
+
iss: str
|
|
130
|
+
sub: str
|
|
131
|
+
repo: Optional[str]
|
|
132
|
+
scopes: Tuple[str, ...]
|
|
133
|
+
# The lifetime in seconds: `exp` is `iat` plus this value.
|
|
134
|
+
ttl: int
|
|
135
|
+
rules: Rules
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class TokenNeeds:
|
|
140
|
+
"""The token that a request needs."""
|
|
141
|
+
|
|
142
|
+
scopes: Tuple[str, ...]
|
|
143
|
+
repo: Optional[str]
|
|
144
|
+
ttl: int
|
|
145
|
+
rules: Rules
|
|
146
|
+
|
|
147
|
+
def request(self) -> TokenRequest:
|
|
148
|
+
"""The request that `get_token` receives."""
|
|
149
|
+
request: TokenRequest = {"scopes": list(self.scopes), "ttl": self.ttl}
|
|
150
|
+
if self.repo is not None:
|
|
151
|
+
request["repo"] = self.repo
|
|
152
|
+
if len(self.rules) > 0:
|
|
153
|
+
request["ref_policies"] = policies_of(self.rules)
|
|
154
|
+
return request
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _base64url(data: bytes) -> str:
|
|
158
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _json(value: object) -> bytes:
|
|
162
|
+
return json.dumps(value, separators=(",", ":")).encode("utf-8")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class _SigningKey:
|
|
167
|
+
key: Union[ec.EllipticCurvePrivateKey, rsa.RSAPrivateKey]
|
|
168
|
+
alg: str
|
|
169
|
+
# The lowercase hex SHA-256 of the SPKI DER of the public key.
|
|
170
|
+
kid: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _load_signing_key(pem: str) -> _SigningKey:
|
|
174
|
+
"""Loads a PKCS #8 PEM private key, and finds its algorithm and key ID."""
|
|
175
|
+
match = _PEM.search(pem)
|
|
176
|
+
if match is None:
|
|
177
|
+
raise PierreError('The private key must be a PEM block of type "PRIVATE KEY" (PKCS #8).')
|
|
178
|
+
try:
|
|
179
|
+
key = serialization.load_pem_private_key(match.group(0).encode("ascii"), password=None)
|
|
180
|
+
except (TypeError, ValueError, UnicodeEncodeError):
|
|
181
|
+
key = None
|
|
182
|
+
if isinstance(key, ec.EllipticCurvePrivateKey) and key.curve.name in _CURVES:
|
|
183
|
+
alg = _CURVES[key.curve.name][0]
|
|
184
|
+
elif isinstance(key, rsa.RSAPrivateKey):
|
|
185
|
+
alg = "RS256"
|
|
186
|
+
else:
|
|
187
|
+
raise PierreError(
|
|
188
|
+
"The private key is not a PKCS #8 EC (P-256, P-384, or P-521) or RSA key in PEM form."
|
|
189
|
+
)
|
|
190
|
+
spki = key.public_key().public_bytes(
|
|
191
|
+
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
|
|
192
|
+
)
|
|
193
|
+
return _SigningKey(key=key, alg=alg, kid=hashlib.sha256(spki).hexdigest())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Signer:
|
|
197
|
+
"""A private key that signs tokens. It loads the key on the first signature, and loads it
|
|
198
|
+
again after a failure, for example when the key file appears later."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, load: Callable[[], str]) -> None:
|
|
201
|
+
self._load = load
|
|
202
|
+
self._key: Optional[_SigningKey] = None
|
|
203
|
+
|
|
204
|
+
def sign(self, claims: Claims) -> str:
|
|
205
|
+
if self._key is None:
|
|
206
|
+
self._key = _load_signing_key(self._load())
|
|
207
|
+
signing = self._key
|
|
208
|
+
now = int(time.time())
|
|
209
|
+
header = {"alg": signing.alg, "typ": "JWT", "kid": signing.kid}
|
|
210
|
+
payload: Dict[str, Any] = {"iss": claims.iss, "sub": claims.sub}
|
|
211
|
+
if claims.repo is not None:
|
|
212
|
+
payload["repo"] = claims.repo
|
|
213
|
+
payload["scopes"] = list(claims.scopes)
|
|
214
|
+
payload["iat"] = now
|
|
215
|
+
payload["exp"] = now + claims.ttl
|
|
216
|
+
refs = refs_claim(claims.rules)
|
|
217
|
+
if refs is not None:
|
|
218
|
+
payload["refs"] = refs
|
|
219
|
+
signing_input = f"{_base64url(_json(header))}.{_base64url(_json(payload))}".encode("ascii")
|
|
220
|
+
if isinstance(signing.key, ec.EllipticCurvePrivateKey):
|
|
221
|
+
hash_algorithm = _CURVES[signing.key.curve.name][1]
|
|
222
|
+
der = signing.key.sign(signing_input, ec.ECDSA(hash_algorithm))
|
|
223
|
+
r, s = decode_dss_signature(der)
|
|
224
|
+
size = (signing.key.curve.key_size + 7) // 8
|
|
225
|
+
signature = r.to_bytes(size, "big") + s.to_bytes(size, "big")
|
|
226
|
+
else:
|
|
227
|
+
signature = signing.key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
228
|
+
return f"{signing_input.decode('ascii')}.{_base64url(signature)}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Credentials:
|
|
232
|
+
"""The source of the token of each request: a private key, a fixed token, or `get_token`."""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
kind: Literal["private_key", "token", "get_token"],
|
|
237
|
+
*,
|
|
238
|
+
org: Optional[str] = None,
|
|
239
|
+
signer: Optional[Signer] = None,
|
|
240
|
+
token: Optional[str] = None,
|
|
241
|
+
get_token: Optional[GetToken] = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
self.kind = kind
|
|
244
|
+
self.org = org
|
|
245
|
+
self.signer = signer
|
|
246
|
+
self._token = token
|
|
247
|
+
self._get_token = get_token
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def fixed(self) -> bool:
|
|
251
|
+
"""Whether the token is fixed, so the SDK cannot set its claims."""
|
|
252
|
+
return self.kind == "token"
|
|
253
|
+
|
|
254
|
+
def sign(self, needs: TokenNeeds, subject: str = SUBJECT) -> str:
|
|
255
|
+
if self.signer is None or self.org is None:
|
|
256
|
+
raise PierreError("The client has no private key.")
|
|
257
|
+
return self.signer.sign(
|
|
258
|
+
Claims(
|
|
259
|
+
iss=self.org,
|
|
260
|
+
sub=subject,
|
|
261
|
+
repo=needs.repo,
|
|
262
|
+
scopes=needs.scopes,
|
|
263
|
+
ttl=needs.ttl,
|
|
264
|
+
rules=needs.rules,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def token(self, needs: TokenNeeds) -> str:
|
|
269
|
+
"""The token of a request in `Pierre`."""
|
|
270
|
+
if self.kind == "private_key":
|
|
271
|
+
return self.sign(needs)
|
|
272
|
+
if self.kind == "token":
|
|
273
|
+
return self._token or ""
|
|
274
|
+
result = self._call_get_token(needs)
|
|
275
|
+
if inspect.isawaitable(result):
|
|
276
|
+
if inspect.iscoroutine(result):
|
|
277
|
+
result.close()
|
|
278
|
+
raise PierreError(
|
|
279
|
+
"`Pierre` needs a `get_token` that returns a token string. "
|
|
280
|
+
"Give an async `get_token` to `AsyncPierre`."
|
|
281
|
+
)
|
|
282
|
+
return _checked_token(result)
|
|
283
|
+
|
|
284
|
+
async def async_token(self, needs: TokenNeeds) -> str:
|
|
285
|
+
"""The token of a request in `AsyncPierre`. `get_token` can be sync or async. A sync
|
|
286
|
+
`get_token` runs in a thread, so that it does not block the event loop and the timeout
|
|
287
|
+
can stop the wait."""
|
|
288
|
+
if self.kind != "get_token":
|
|
289
|
+
return self.token(needs)
|
|
290
|
+
if inspect.iscoroutinefunction(self._get_token):
|
|
291
|
+
result = self._call_get_token(needs)
|
|
292
|
+
else:
|
|
293
|
+
result = await asyncio.to_thread(self._call_get_token, needs)
|
|
294
|
+
if inspect.isawaitable(result):
|
|
295
|
+
result = await result
|
|
296
|
+
return _checked_token(result)
|
|
297
|
+
|
|
298
|
+
def _call_get_token(self, needs: TokenNeeds) -> object:
|
|
299
|
+
if self._get_token is None:
|
|
300
|
+
raise PierreError("The client has no `get_token`.")
|
|
301
|
+
return self._get_token(needs.request())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _checked_token(token: object) -> str:
|
|
305
|
+
if not isinstance(token, str) or token == "":
|
|
306
|
+
raise PierreError("`get_token` must return a token string.")
|
|
307
|
+
return token
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def token_issuer(token: str) -> Optional[str]:
|
|
311
|
+
"""The `iss` claim of a token, or `None`. The SDK does not verify the token."""
|
|
312
|
+
parts = token.split(".")
|
|
313
|
+
if len(parts) < 2:
|
|
314
|
+
return None
|
|
315
|
+
payload = parts[1]
|
|
316
|
+
try:
|
|
317
|
+
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
318
|
+
except (ValueError, TypeError):
|
|
319
|
+
return None
|
|
320
|
+
issuer = claims.get("iss") if isinstance(claims, dict) else None
|
|
321
|
+
return issuer if isinstance(issuer, str) and issuer != "" else None
|
pierre/_errors.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The errors of the SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Literal, Mapping, Optional, Type, Union, cast
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from pierre._generated.schemas import (
|
|
8
|
+
NotesWriteResult,
|
|
9
|
+
RefUpdateResult,
|
|
10
|
+
RestoredCommit,
|
|
11
|
+
WrittenCommit,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# The docs page with the table of problem codes, in the Markdown form for agents.
|
|
15
|
+
ERROR_CODES_DOC_URL = "https://code.storage/docs/api/overview.md#error-codes"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PierreError(Exception):
|
|
19
|
+
"""The base class of every error that the SDK raises."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIConnectionError(PierreError):
|
|
23
|
+
"""The request got no response."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class APITimeoutError(APIConnectionError):
|
|
27
|
+
"""The request got no response before its timeout."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _text(value: object) -> Optional[str]:
|
|
31
|
+
return value if isinstance(value, str) and value != "" else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _problem(body: object) -> Mapping[str, Any]:
|
|
35
|
+
return body if isinstance(body, dict) else {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class APIError(PierreError):
|
|
39
|
+
"""An HTTP error response. Branch on `code`, not on the message."""
|
|
40
|
+
|
|
41
|
+
status: int
|
|
42
|
+
# The problem `code`, for example `repository_not_found`.
|
|
43
|
+
code: Optional[str]
|
|
44
|
+
title: Optional[str]
|
|
45
|
+
detail: Optional[str]
|
|
46
|
+
# The `X-Request-Id` response header, or `None` when the server does not send it.
|
|
47
|
+
request_id: Optional[str]
|
|
48
|
+
# The headers of the error response, for example `retry-after`.
|
|
49
|
+
headers: httpx.Headers
|
|
50
|
+
# The parsed response body, with any extension members, or its text when it is not JSON.
|
|
51
|
+
body: object
|
|
52
|
+
# The docs page that explains the codes.
|
|
53
|
+
doc_url: str
|
|
54
|
+
|
|
55
|
+
def __init__(self, status: int, body: object, headers: Optional[httpx.Headers] = None) -> None:
|
|
56
|
+
problem = _problem(body)
|
|
57
|
+
code = _text(problem.get("code"))
|
|
58
|
+
title = _text(problem.get("title"))
|
|
59
|
+
detail = (
|
|
60
|
+
_text(problem.get("detail"))
|
|
61
|
+
or _text(problem.get("error"))
|
|
62
|
+
or _text(problem.get("message"))
|
|
63
|
+
)
|
|
64
|
+
prefix = f"{status}" if code is None else f"{status} {code}"
|
|
65
|
+
super().__init__(f"{prefix}: {detail or title or 'request failed'}")
|
|
66
|
+
self.status = status
|
|
67
|
+
self.code = code
|
|
68
|
+
self.title = title
|
|
69
|
+
self.detail = detail
|
|
70
|
+
self.headers = headers if headers is not None else httpx.Headers()
|
|
71
|
+
self.request_id = self.headers.get("x-request-id")
|
|
72
|
+
self.body = body
|
|
73
|
+
self.doc_url = ERROR_CODES_DOC_URL
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BadRequestError(APIError):
|
|
77
|
+
"""A 400 response."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AuthenticationError(APIError):
|
|
81
|
+
"""A 401 response."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PermissionDeniedError(APIError):
|
|
85
|
+
"""A 403 response."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NotFoundError(APIError):
|
|
89
|
+
"""A 404 response."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConflictError(APIError):
|
|
93
|
+
"""A 409 response."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RateLimitError(APIError):
|
|
97
|
+
"""A 429 response."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class InternalServerError(APIError):
|
|
101
|
+
"""A 5xx response."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MergeConflictError(ConflictError):
|
|
105
|
+
"""A merge conflict. It carries the conflict fields of the response."""
|
|
106
|
+
|
|
107
|
+
conflict_type: Optional[str]
|
|
108
|
+
conflict_paths: object
|
|
109
|
+
merge_base_sha: Optional[str]
|
|
110
|
+
|
|
111
|
+
def __init__(self, status: int, body: object, headers: Optional[httpx.Headers] = None) -> None:
|
|
112
|
+
super().__init__(status, body, headers)
|
|
113
|
+
problem = _problem(body)
|
|
114
|
+
self.conflict_type = _text(problem.get("conflict_type"))
|
|
115
|
+
self.conflict_paths = problem.get("conflict_paths")
|
|
116
|
+
self.merge_base_sha = _text(problem.get("merge_base_sha"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RefGuardError(ConflictError):
|
|
120
|
+
"""A Git write whose ref guard failed: a response with the code `ref_guard_failed`. `guard`
|
|
121
|
+
names the request field of the guard, for example `expected_target_sha`. `current_sha` is the
|
|
122
|
+
commit of the guarded ref, or 40 zeros when the ref does not exist; send it as the guard of the
|
|
123
|
+
next attempt. The body of a commit write, a restore, or a notes write also keeps `result`, which
|
|
124
|
+
`body` gives."""
|
|
125
|
+
|
|
126
|
+
guard: Optional[str]
|
|
127
|
+
current_sha: Optional[str]
|
|
128
|
+
|
|
129
|
+
def __init__(self, status: int, body: object, headers: Optional[httpx.Headers] = None) -> None:
|
|
130
|
+
super().__init__(status, body, headers)
|
|
131
|
+
problem = _problem(body)
|
|
132
|
+
self.guard = _text(problem.get("guard"))
|
|
133
|
+
self.current_sha = _text(problem.get("current_sha"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class RefUpdateError(APIError):
|
|
137
|
+
"""A Git write whose ref update failed: an error response whose body has `result`. It carries
|
|
138
|
+
`result` and `commit`. A Git write method returns only when the ref update succeeded."""
|
|
139
|
+
|
|
140
|
+
# The result of the ref update. `status` gives the reason, for example `conflict` or
|
|
141
|
+
# `precondition_failed`. A notes write sends a `NotesWriteResult`, which has only `success`,
|
|
142
|
+
# `status`, and `message`.
|
|
143
|
+
result: Union[RefUpdateResult, NotesWriteResult]
|
|
144
|
+
# The commit of the write, or `None` when the response has none. A restore has no `blob_count`.
|
|
145
|
+
commit: Optional[Union[WrittenCommit, RestoredCommit]]
|
|
146
|
+
|
|
147
|
+
def __init__(self, status: int, body: object, headers: Optional[httpx.Headers] = None) -> None:
|
|
148
|
+
super().__init__(status, body, headers)
|
|
149
|
+
problem = _problem(body)
|
|
150
|
+
self.result = cast(Union[RefUpdateResult, NotesWriteResult], problem.get("result"))
|
|
151
|
+
self.commit = problem.get("commit")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
WebhookVerificationFailure = Literal[
|
|
155
|
+
"missing_signature",
|
|
156
|
+
"malformed_signature",
|
|
157
|
+
"signature_mismatch",
|
|
158
|
+
"timestamp_out_of_range",
|
|
159
|
+
"invalid_payload",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class WebhookVerificationError(PierreError):
|
|
164
|
+
"""`verify_webhook` rejected the delivery. `reason` says why."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, reason: WebhookVerificationFailure, message: str) -> None:
|
|
167
|
+
super().__init__(message)
|
|
168
|
+
self.reason: WebhookVerificationFailure = reason
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_BY_STATUS: Dict[int, Type[APIError]] = {
|
|
172
|
+
400: BadRequestError,
|
|
173
|
+
401: AuthenticationError,
|
|
174
|
+
403: PermissionDeniedError,
|
|
175
|
+
404: NotFoundError,
|
|
176
|
+
409: ConflictError,
|
|
177
|
+
429: RateLimitError,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# The codes of a merge conflict. Today the conflict body can also have `conflict_type` only.
|
|
181
|
+
_MERGE_CONFLICT_CODES = frozenset({"merge_conflict"})
|
|
182
|
+
|
|
183
|
+
# The code of a failed ref guard. Its body can also have `result`, so it is read first.
|
|
184
|
+
_REF_GUARD_FAILED = "ref_guard_failed"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def api_error(status: int, body: object, headers: httpx.Headers) -> APIError:
|
|
188
|
+
"""Picks the error class of an error response from the status and the body."""
|
|
189
|
+
problem = _problem(body)
|
|
190
|
+
code = _text(problem.get("code"))
|
|
191
|
+
if code == _REF_GUARD_FAILED:
|
|
192
|
+
return RefGuardError(status, body, headers)
|
|
193
|
+
if isinstance(problem.get("result"), dict):
|
|
194
|
+
return RefUpdateError(status, body, headers)
|
|
195
|
+
if (code is not None and code in _MERGE_CONFLICT_CODES) or isinstance(
|
|
196
|
+
problem.get("conflict_type"), str
|
|
197
|
+
):
|
|
198
|
+
return MergeConflictError(status, body, headers)
|
|
199
|
+
error_class = _BY_STATUS.get(status, InternalServerError if status >= 500 else APIError)
|
|
200
|
+
return error_class(status, body, headers)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def problem_code(body: object) -> Optional[str]:
|
|
204
|
+
"""The problem `code` of a parsed body, or `None`."""
|
|
205
|
+
return _text(_problem(body).get("code"))
|