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/_types.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Types that the generated layer and the runtime share."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Generic, List, Literal, Mapping, Sequence, TypeVar, Union
|
|
5
|
+
|
|
6
|
+
from pierre._compat import NotRequired, TypedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NotGiven:
|
|
10
|
+
"""The default of each optional argument. The SDK does not send it, and sends `None` as
|
|
11
|
+
`null`."""
|
|
12
|
+
|
|
13
|
+
def __bool__(self) -> Literal[False]:
|
|
14
|
+
return False
|
|
15
|
+
|
|
16
|
+
def __repr__(self) -> str:
|
|
17
|
+
return "NOT_GIVEN"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
NOT_GIVEN = NotGiven()
|
|
21
|
+
|
|
22
|
+
RefPolicyOp = Union[Literal["no-push", "no-force-push", "verify-sig"], str]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RefPolicy(TypedDict):
|
|
26
|
+
"""One ordered rule of the `refs` claim. The first rule whose pattern
|
|
27
|
+
matches a ref applies."""
|
|
28
|
+
|
|
29
|
+
# A full ref name (`refs/heads/main`), a branch name (`main`), a prefix that ends with `/*`
|
|
30
|
+
# (`refs/heads/feature/*`), or `*` for every ref. The gateway rejects another form.
|
|
31
|
+
pattern: str
|
|
32
|
+
# The policy operations. The gateway rejects an op that it does not know.
|
|
33
|
+
ops: NotRequired[Sequence[RefPolicyOp]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TokenRequest(TypedDict):
|
|
37
|
+
"""The token that a request needs. `get_token` receives it."""
|
|
38
|
+
|
|
39
|
+
# The scopes of the operation.
|
|
40
|
+
scopes: List[str]
|
|
41
|
+
# The `repo` claim, for a repository-scoped operation.
|
|
42
|
+
repo: NotRequired[str]
|
|
43
|
+
# The lifetime of the token, in seconds.
|
|
44
|
+
ttl: int
|
|
45
|
+
# The rules of the `refs` claim. Absent when the call has none.
|
|
46
|
+
ref_policies: NotRequired[List[RefPolicy]]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
Headers = TypeVar("Headers")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HeadersResult(TypedDict, Generic[Headers]):
|
|
53
|
+
"""The result of an operation whose response has no body. `headers` holds the declared
|
|
54
|
+
response headers, and is empty when the document declares none."""
|
|
55
|
+
|
|
56
|
+
status: int
|
|
57
|
+
headers: Headers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NoHeaders(TypedDict, total=False):
|
|
61
|
+
"""The declared response headers of an operation that declares none."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class RequestOptions:
|
|
66
|
+
"""The keyword arguments of one call."""
|
|
67
|
+
|
|
68
|
+
extra_headers: Union[Mapping[str, str], NotGiven] = NOT_GIVEN
|
|
69
|
+
timeout: Union[float, NotGiven] = NOT_GIVEN
|
|
70
|
+
max_retries: Union[int, NotGiven] = NOT_GIVEN
|
|
71
|
+
idempotency_key: Union[str, NotGiven] = NOT_GIVEN
|
|
72
|
+
token_ttl: Union[int, NotGiven] = NOT_GIVEN
|
|
73
|
+
ref_policies: Union[Sequence[RefPolicy], NotGiven] = NOT_GIVEN
|
pierre/_version.py
ADDED
pierre/auth.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""`pierre.auth`: signs a token locally, with no HTTP call, and lists
|
|
2
|
+
the scopes of the API."""
|
|
3
|
+
|
|
4
|
+
from typing import Optional, Sequence, Union
|
|
5
|
+
|
|
6
|
+
from pierre._client import AsyncPierre, Pierre
|
|
7
|
+
from pierre._compat import TypedDict
|
|
8
|
+
from pierre._credentials import (
|
|
9
|
+
SUBJECT,
|
|
10
|
+
Credentials,
|
|
11
|
+
Signer,
|
|
12
|
+
TokenNeeds,
|
|
13
|
+
check_ref_policies,
|
|
14
|
+
check_ttl,
|
|
15
|
+
)
|
|
16
|
+
from pierre._errors import PierreError
|
|
17
|
+
from pierre._generated.scopes import SCOPES, Scope
|
|
18
|
+
from pierre._types import RefPolicy
|
|
19
|
+
|
|
20
|
+
__all__ = ["SCOPES", "KeyCredentials", "RefPolicy", "Scope", "create_token"]
|
|
21
|
+
|
|
22
|
+
# A token from `create_token` lives 15 minutes by default.
|
|
23
|
+
_DEFAULT_TTL_SECONDS = 15 * 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KeyCredentials(TypedDict):
|
|
27
|
+
"""A private key and its organization, for code with no client."""
|
|
28
|
+
|
|
29
|
+
org: str
|
|
30
|
+
# A PKCS #8 PEM private key.
|
|
31
|
+
private_key: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _signing_credentials(credentials: Union[Pierre, AsyncPierre, KeyCredentials]) -> Credentials:
|
|
35
|
+
if isinstance(credentials, (Pierre, AsyncPierre)):
|
|
36
|
+
signing = credentials._resolved.credentials
|
|
37
|
+
if signing.kind != "private_key":
|
|
38
|
+
raise PierreError(
|
|
39
|
+
"`create_token` needs a client with a private key. "
|
|
40
|
+
"A client with `token` or `get_token` cannot sign."
|
|
41
|
+
)
|
|
42
|
+
return signing
|
|
43
|
+
if isinstance(credentials, dict):
|
|
44
|
+
org = credentials.get("org")
|
|
45
|
+
private_key = credentials.get("private_key")
|
|
46
|
+
if isinstance(org, str) and org != "" and isinstance(private_key, str):
|
|
47
|
+
return Credentials("private_key", org=org, signer=Signer(lambda: private_key))
|
|
48
|
+
raise PierreError('Give a client, or `{"org": ..., "private_key": ...}`.')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_token(
|
|
52
|
+
credentials: Union[Pierre, AsyncPierre, KeyCredentials],
|
|
53
|
+
*,
|
|
54
|
+
scopes: Sequence[str],
|
|
55
|
+
repo: Optional[str] = None,
|
|
56
|
+
ttl: int = _DEFAULT_TTL_SECONDS,
|
|
57
|
+
ref_policies: Optional[Sequence[RefPolicy]] = None,
|
|
58
|
+
subject: Optional[str] = None,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Signs a token with a private key: the key of a client, or `{"org": ..., "private_key":
|
|
61
|
+
...}`. A token in a URL or a config file is a bearer credential, so keep the lifetime short.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
credentials: A client with a private key, or a `KeyCredentials` dict.
|
|
65
|
+
scopes: The scopes of the token, for example `["git:read"]`. `SCOPES` lists the scopes of
|
|
66
|
+
the API.
|
|
67
|
+
repo: The `repo` claim. Leave it out for a token whose scopes are all organization-wide.
|
|
68
|
+
ttl: The lifetime in seconds, from 1 to 3600. Default: 900 (15 minutes).
|
|
69
|
+
ref_policies: The rules of the `refs` claim. Default: the `ref_policies` of a client, and
|
|
70
|
+
none for a `KeyCredentials` dict. An empty list adds no claim.
|
|
71
|
+
subject: The `sub` claim. Default: `pierre-sdk-python`.
|
|
72
|
+
|
|
73
|
+
Docs: search for `createToken` in the docs MCP server, https://code.storage/docs/mcp.
|
|
74
|
+
"""
|
|
75
|
+
signing = _signing_credentials(credentials)
|
|
76
|
+
if isinstance(scopes, str) or len(scopes) == 0:
|
|
77
|
+
raise PierreError("`scopes` must name at least one scope.")
|
|
78
|
+
rules = check_ref_policies(ref_policies, "ref_policies")
|
|
79
|
+
if rules is None and isinstance(credentials, (Pierre, AsyncPierre)):
|
|
80
|
+
rules = credentials._resolved.settings.rules
|
|
81
|
+
needs = TokenNeeds(
|
|
82
|
+
scopes=tuple(scopes), repo=repo, ttl=check_ttl(ttl, "ttl"), rules=rules or ()
|
|
83
|
+
)
|
|
84
|
+
return signing.sign(needs, SUBJECT if subject is None else subject)
|
pierre/git.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""`pierre.git`: builds a Git remote URL with a token in it."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Coroutine, Literal, Optional, Sequence, Tuple, Union, overload
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from pierre._client import AsyncPierre, Pierre
|
|
8
|
+
from pierre._credentials import TokenNeeds, check_ref_policies, check_ttl
|
|
9
|
+
from pierre._errors import PierreError
|
|
10
|
+
from pierre._types import RefPolicy
|
|
11
|
+
|
|
12
|
+
__all__ = ["RefPolicy", "remote_url"]
|
|
13
|
+
|
|
14
|
+
_SUFFIXES = {"default": "", "ephemeral": "+ephemeral", "import": "+import"}
|
|
15
|
+
|
|
16
|
+
_DEFAULT_SCOPES = ("git:read", "git:write")
|
|
17
|
+
|
|
18
|
+
# A token in a remote URL lives 15 minutes by default.
|
|
19
|
+
_DEFAULT_TTL_SECONDS = 15 * 60
|
|
20
|
+
|
|
21
|
+
# A host name or an IP address, with an optional port.
|
|
22
|
+
_LABEL = r"[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?"
|
|
23
|
+
_HOST = re.compile(rf"(?:{_LABEL}(?:\.{_LABEL})*|\[[0-9A-Fa-f:.]+\])(?::([0-9]{{1,5}}))?")
|
|
24
|
+
|
|
25
|
+
# The characters that `encodeURIComponent` keeps, so that a path is the same in every SDK.
|
|
26
|
+
_SEGMENT_SAFE = "!~*'()"
|
|
27
|
+
|
|
28
|
+
Kind = Literal["default", "ephemeral", "import"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _host(git_host: Optional[str]) -> str:
|
|
32
|
+
"""The Git host of the client: a host name with an optional port. A scheme, a path, or another
|
|
33
|
+
URL part would send the token to another host."""
|
|
34
|
+
if git_host is None:
|
|
35
|
+
raise PierreError("`remote_url` needs the Git host. Set `org` or `git_host`.")
|
|
36
|
+
match = _HOST.fullmatch(git_host)
|
|
37
|
+
if match is None:
|
|
38
|
+
raise PierreError(
|
|
39
|
+
"`git_host` must be a host name with an optional port, for example `acme.code.storage`."
|
|
40
|
+
)
|
|
41
|
+
host = git_host.lower()
|
|
42
|
+
# A URL drops the default port.
|
|
43
|
+
return host[: -len(":443")] if match.group(1) == "443" else host
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _repository_path(name: object) -> str:
|
|
47
|
+
"""The URL path of a repository name: each segment percent-encoded, with `/` between them."""
|
|
48
|
+
if not isinstance(name, str) or name == "":
|
|
49
|
+
raise PierreError("`repo_name` must be a non-empty string.")
|
|
50
|
+
segments = name.split("/")
|
|
51
|
+
# A URL parser resolves `.` and `..`, which would name another repository.
|
|
52
|
+
if any(segment in ("", ".", "..") for segment in segments):
|
|
53
|
+
raise PierreError(f"`repo_name` cannot have an empty, `.`, or `..` segment: {name}")
|
|
54
|
+
return "/".join(quote(segment, safe=_SEGMENT_SAFE) for segment in segments)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _prepare(
|
|
58
|
+
client: Union[Pierre, AsyncPierre],
|
|
59
|
+
repo_name: str,
|
|
60
|
+
scopes: Optional[Sequence[str]],
|
|
61
|
+
ttl: int,
|
|
62
|
+
ref_policies: Optional[Sequence[RefPolicy]],
|
|
63
|
+
kind: str,
|
|
64
|
+
) -> Tuple[str, TokenNeeds]:
|
|
65
|
+
"""The URL without the token, as a template, and the token that it needs."""
|
|
66
|
+
if not isinstance(client, (Pierre, AsyncPierre)):
|
|
67
|
+
raise PierreError("Give a client from `Pierre()` or `AsyncPierre()`.")
|
|
68
|
+
if client._resolved.credentials.fixed:
|
|
69
|
+
raise PierreError(
|
|
70
|
+
"`remote_url` needs a client with `private_key` or `get_token`. "
|
|
71
|
+
"A client with a fixed `token` cannot set the claims of its token."
|
|
72
|
+
)
|
|
73
|
+
host = _host(client.options.git_host)
|
|
74
|
+
path = _repository_path(repo_name)
|
|
75
|
+
suffix = _SUFFIXES.get(kind) if isinstance(kind, str) else None
|
|
76
|
+
if suffix is None:
|
|
77
|
+
raise PierreError("`kind` must be `default`, `ephemeral`, or `import`.")
|
|
78
|
+
token_scopes = _DEFAULT_SCOPES if scopes is None else tuple(scopes)
|
|
79
|
+
if isinstance(scopes, str) or len(token_scopes) == 0:
|
|
80
|
+
raise PierreError("`scopes` must name at least one scope.")
|
|
81
|
+
rules = check_ref_policies(ref_policies, "ref_policies")
|
|
82
|
+
if rules is None:
|
|
83
|
+
rules = client._resolved.settings.rules or ()
|
|
84
|
+
# An ephemeral or import remote uses the token of its repository.
|
|
85
|
+
needs = TokenNeeds(scopes=token_scopes, repo=repo_name, ttl=check_ttl(ttl, "ttl"), rules=rules)
|
|
86
|
+
return f"https://t:{{token}}@{host}/{path}{suffix}.git", needs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _url(template: str, token: str) -> str:
|
|
90
|
+
return template.replace("{token}", quote(token, safe=""), 1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _async_remote_url(
|
|
94
|
+
client: AsyncPierre,
|
|
95
|
+
repo_name: str,
|
|
96
|
+
scopes: Optional[Sequence[str]],
|
|
97
|
+
ttl: int,
|
|
98
|
+
ref_policies: Optional[Sequence[RefPolicy]],
|
|
99
|
+
kind: str,
|
|
100
|
+
) -> str:
|
|
101
|
+
template, needs = _prepare(client, repo_name, scopes, ttl, ref_policies, kind)
|
|
102
|
+
return _url(template, await client._resolved.credentials.async_token(needs))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@overload
|
|
106
|
+
def remote_url(
|
|
107
|
+
client: Pierre,
|
|
108
|
+
*,
|
|
109
|
+
repo_name: str,
|
|
110
|
+
scopes: Optional[Sequence[str]] = None,
|
|
111
|
+
ttl: int = _DEFAULT_TTL_SECONDS,
|
|
112
|
+
ref_policies: Optional[Sequence[RefPolicy]] = None,
|
|
113
|
+
kind: Kind = "default",
|
|
114
|
+
) -> str: ...
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@overload
|
|
118
|
+
def remote_url(
|
|
119
|
+
client: AsyncPierre,
|
|
120
|
+
*,
|
|
121
|
+
repo_name: str,
|
|
122
|
+
scopes: Optional[Sequence[str]] = None,
|
|
123
|
+
ttl: int = _DEFAULT_TTL_SECONDS,
|
|
124
|
+
ref_policies: Optional[Sequence[RefPolicy]] = None,
|
|
125
|
+
kind: Kind = "default",
|
|
126
|
+
) -> Coroutine[None, None, str]: ...
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def remote_url(
|
|
130
|
+
client: Union[Pierre, AsyncPierre],
|
|
131
|
+
*,
|
|
132
|
+
repo_name: str,
|
|
133
|
+
scopes: Optional[Sequence[str]] = None,
|
|
134
|
+
ttl: int = _DEFAULT_TTL_SECONDS,
|
|
135
|
+
ref_policies: Optional[Sequence[RefPolicy]] = None,
|
|
136
|
+
kind: Kind = "default",
|
|
137
|
+
) -> Union[str, Coroutine[None, None, str]]:
|
|
138
|
+
"""Builds `https://t:<token>@{git_host}/{repo_name}.git` for `git clone`, `git fetch`, and
|
|
139
|
+
`git push`. The token is a bearer credential that Git copies into `.git/config`, so keep the
|
|
140
|
+
lifetime short. Longer work uses the credential helper (`pierre auth setup-git`).
|
|
141
|
+
|
|
142
|
+
The client signs the token with its private key, or gets it from `get_token`. A client with a
|
|
143
|
+
fixed `token` cannot set the claims of its token, so it raises `PierreError`. With
|
|
144
|
+
`AsyncPierre`, await the result.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
client: A `Pierre` or `AsyncPierre` client.
|
|
148
|
+
repo_name: The repository name, for example `acme/web`.
|
|
149
|
+
scopes: The scopes of the token. Default: `git:read` and `git:write`.
|
|
150
|
+
ttl: The lifetime of the token in seconds, from 1 to 3600. Default: 900 (15 minutes).
|
|
151
|
+
ref_policies: The rules of the `refs` claim. Default: the client's `ref_policies`, so a
|
|
152
|
+
confined client stays confined through Git. An empty list adds no claim.
|
|
153
|
+
kind: The form of the remote: `default`, `ephemeral` (`+ephemeral`), or `import`
|
|
154
|
+
(`+import`).
|
|
155
|
+
|
|
156
|
+
Docs: search for `remoteUrl` in the docs MCP server, https://code.storage/docs/mcp.
|
|
157
|
+
"""
|
|
158
|
+
if isinstance(client, AsyncPierre):
|
|
159
|
+
return _async_remote_url(client, repo_name, scopes, ttl, ref_policies, kind)
|
|
160
|
+
template, needs = _prepare(client, repo_name, scopes, ttl, ref_policies, kind)
|
|
161
|
+
return _url(template, client._resolved.credentials.token(needs))
|
pierre/py.typed
ADDED
|
File without changes
|
pierre/types.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The types of the SDK: the named schemas of the API, with the wire name of each field, the
|
|
2
|
+
declared response headers of each operation, the webhook events, and the types of the arguments
|
|
3
|
+
that the methods take."""
|
|
4
|
+
|
|
5
|
+
from pierre._content import Content as Content
|
|
6
|
+
from pierre._generated.headers import * # noqa: F403
|
|
7
|
+
from pierre._generated.schemas import * # noqa: F403
|
|
8
|
+
from pierre._generated.webhooks import * # noqa: F403
|
|
9
|
+
from pierre._helpers.commits import CommitFile as CommitFile
|
|
10
|
+
from pierre._helpers.commits import FileContent as FileContent
|
|
11
|
+
from pierre._types import HeadersResult as HeadersResult
|
|
12
|
+
from pierre._types import NoHeaders as NoHeaders
|
|
13
|
+
from pierre._types import RefPolicy as RefPolicy
|
|
14
|
+
from pierre._types import TokenRequest as TokenRequest
|
pierre/webhooks.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""`pierre.webhooks`: verifies a webhook delivery and returns the typed event."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Tuple, Union, cast
|
|
9
|
+
|
|
10
|
+
from pierre._compat import TypedDict
|
|
11
|
+
from pierre._errors import PierreError, WebhookVerificationError, WebhookVerificationFailure
|
|
12
|
+
from pierre._generated.webhooks import WEBHOOK_EVENT_TYPES, KnownWebhookEvent, WebhookEventType
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"WEBHOOK_EVENT_TYPES",
|
|
16
|
+
"KnownWebhookEvent",
|
|
17
|
+
"UnknownWebhookEvent",
|
|
18
|
+
"WebhookEventType",
|
|
19
|
+
"WebhookVerificationError",
|
|
20
|
+
"WebhookVerificationFailure",
|
|
21
|
+
"verify_webhook",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_DEFAULT_TOLERANCE_SECONDS = 300
|
|
25
|
+
_KNOWN_TYPES = frozenset(WEBHOOK_EVENT_TYPES)
|
|
26
|
+
# ASCII digits only: `\d` also matches other digits, which `int()` reads.
|
|
27
|
+
_HEX_SIGNATURE = re.compile(r"[0-9a-fA-F]{64}")
|
|
28
|
+
_TIMESTAMP = re.compile(r"[0-9]+")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnknownWebhookEvent(TypedDict):
|
|
32
|
+
"""An event of a type that this SDK version does not know. Handle it in a default branch.
|
|
33
|
+
|
|
34
|
+
`type` is `unknown`, not the type that Pierre sent, so that a check of `type` narrows the
|
|
35
|
+
union. No Pierre event has the type `unknown`, because each type has the form
|
|
36
|
+
`<resource>.<subject>.<verb>`, or is `push`."""
|
|
37
|
+
|
|
38
|
+
type: Literal["unknown"]
|
|
39
|
+
# The event type that Pierre sent, for example a type from a newer API version.
|
|
40
|
+
event_type: str
|
|
41
|
+
# The parsed payload.
|
|
42
|
+
body: Dict[str, Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
HeaderValue = Union[str, Sequence[str], None]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _header(headers: Mapping[str, HeaderValue], name: str) -> Optional[str]:
|
|
49
|
+
"""A header of a mapping such as `httpx.Headers` or a dict, with any case."""
|
|
50
|
+
for key, value in headers.items():
|
|
51
|
+
if key.lower() != name:
|
|
52
|
+
continue
|
|
53
|
+
if value is None or isinstance(value, str):
|
|
54
|
+
return value
|
|
55
|
+
return value[0] if len(value) > 0 else None
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_signature(header: str) -> Tuple[str, List[bytes]]:
|
|
60
|
+
"""The timestamp and the signatures of `t=<unix seconds>,sha256=<hex>[,sha256=<hex>...]`."""
|
|
61
|
+
timestamp: Optional[str] = None
|
|
62
|
+
signatures: List[bytes] = []
|
|
63
|
+
for element in header.split(","):
|
|
64
|
+
name, _, value = element.strip().partition("=")
|
|
65
|
+
if name == "t" and _TIMESTAMP.fullmatch(value):
|
|
66
|
+
timestamp = value
|
|
67
|
+
if name == "sha256" and _HEX_SIGNATURE.fullmatch(value):
|
|
68
|
+
signatures.append(bytes.fromhex(value))
|
|
69
|
+
if timestamp is None or len(signatures) == 0:
|
|
70
|
+
raise WebhookVerificationError(
|
|
71
|
+
"malformed_signature",
|
|
72
|
+
"X-Pierre-Signature must have the form `t=<unix seconds>,sha256=<hex>`.",
|
|
73
|
+
)
|
|
74
|
+
return timestamp, signatures
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def verify_webhook(
|
|
78
|
+
*,
|
|
79
|
+
payload: Union[str, bytes, bytearray, memoryview],
|
|
80
|
+
headers: Mapping[str, HeaderValue],
|
|
81
|
+
secret: str,
|
|
82
|
+
tolerance_seconds: float = _DEFAULT_TOLERANCE_SECONDS,
|
|
83
|
+
) -> Union[KnownWebhookEvent, UnknownWebhookEvent]:
|
|
84
|
+
"""Checks the `X-Pierre-Signature` header of a delivery: an HMAC-SHA256 of `<t>.<payload>`
|
|
85
|
+
with the secret, and a timestamp `t` within `tolerance_seconds` of now. Returns the event, a
|
|
86
|
+
union on `type`. Raises `WebhookVerificationError` when the check fails.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
payload: The raw request body, before a JSON parse changes its bytes.
|
|
90
|
+
headers: The request headers: `httpx.Headers`, or a dict such as the headers of a web
|
|
91
|
+
framework.
|
|
92
|
+
secret: The secret of the webhook subscription.
|
|
93
|
+
tolerance_seconds: The largest difference between the delivery time and now, in seconds.
|
|
94
|
+
Default: 300.
|
|
95
|
+
|
|
96
|
+
Docs: search for `verifyWebhook` in the docs MCP server, https://code.storage/docs/mcp.
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(tolerance_seconds, bool) or not tolerance_seconds >= 0:
|
|
99
|
+
raise PierreError("`tolerance_seconds` must be 0 or more.")
|
|
100
|
+
if secret == "":
|
|
101
|
+
raise PierreError("`secret` must not be empty.")
|
|
102
|
+
header = _header(headers, "x-pierre-signature")
|
|
103
|
+
if header is None:
|
|
104
|
+
raise WebhookVerificationError(
|
|
105
|
+
"missing_signature", "The request has no X-Pierre-Signature header."
|
|
106
|
+
)
|
|
107
|
+
timestamp, signatures = _parse_signature(header)
|
|
108
|
+
body_bytes = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
|
109
|
+
expected = hmac.new(
|
|
110
|
+
secret.encode("utf-8"), timestamp.encode("ascii") + b"." + body_bytes, hashlib.sha256
|
|
111
|
+
).digest()
|
|
112
|
+
# `compare_digest` compares in constant time.
|
|
113
|
+
valid = False
|
|
114
|
+
for signature in signatures:
|
|
115
|
+
if hmac.compare_digest(signature, expected):
|
|
116
|
+
valid = True
|
|
117
|
+
if not valid:
|
|
118
|
+
raise WebhookVerificationError(
|
|
119
|
+
"signature_mismatch", "The signature does not match the payload and the secret."
|
|
120
|
+
)
|
|
121
|
+
age = int(time.time()) - int(timestamp)
|
|
122
|
+
if abs(age) > tolerance_seconds:
|
|
123
|
+
raise WebhookVerificationError(
|
|
124
|
+
"timestamp_out_of_range",
|
|
125
|
+
f"The delivery is {age} seconds old, and the tolerance is {tolerance_seconds:g} seconds.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
body = json.loads(body_bytes)
|
|
130
|
+
except ValueError:
|
|
131
|
+
raise WebhookVerificationError("invalid_payload", "The payload is not JSON.") from None
|
|
132
|
+
if not isinstance(body, dict):
|
|
133
|
+
raise WebhookVerificationError("invalid_payload", "The payload is not a JSON object.")
|
|
134
|
+
# Older push deliveries have no `type`. The `X-Pierre-Event` header is not signed, so the SDK
|
|
135
|
+
# uses it only to name such a push.
|
|
136
|
+
body_type = body.get("type")
|
|
137
|
+
event_type: Optional[str] = body_type if isinstance(body_type, str) else None
|
|
138
|
+
if event_type is None and _header(headers, "x-pierre-event") == "push":
|
|
139
|
+
event_type = "push"
|
|
140
|
+
if event_type is None or event_type == "":
|
|
141
|
+
raise WebhookVerificationError("invalid_payload", "The payload has no event type.")
|
|
142
|
+
if event_type in _KNOWN_TYPES:
|
|
143
|
+
# The type is a known event type, and the payload follows its schema.
|
|
144
|
+
return cast(KnownWebhookEvent, {**body, "type": event_type})
|
|
145
|
+
return {"type": "unknown", "event_type": event_type, "body": body}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pierre-sdk
|
|
3
|
+
Version: 0.1.0.dev4
|
|
4
|
+
Summary: The Pierre SDK for Python
|
|
5
|
+
Author: Pierre
|
|
6
|
+
Author-email: Pierre <support@pierre.co>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: cryptography>=41.0.0
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: typing-extensions>=4.5.0 ; python_full_version < '3.11'
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://pierre.computer
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Pierre SDK for Python
|
|
25
|
+
|
|
26
|
+
The Python SDK of Pierre: `Pierre` for blocking code, and `AsyncPierre` for `asyncio` code. Both
|
|
27
|
+
clients have the same namespaces, helpers, errors, pagination, and retries as `@pierre/sdk` in
|
|
28
|
+
TypeScript.
|
|
29
|
+
|
|
30
|
+
## Use the client
|
|
31
|
+
|
|
32
|
+
`Pierre()` reads `PIERRE_ORG` and `PIERRE_PRIVATE_KEY`, or the other variables in
|
|
33
|
+
[Credentials](#credentials). Each method path is the catalog ID of its operation, with each segment
|
|
34
|
+
in snake_case. Each method takes keyword arguments with the wire names of the HTTP API.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pierre import Pierre
|
|
38
|
+
|
|
39
|
+
pierre = Pierre()
|
|
40
|
+
pierre.repos.create(repo_name="acme/web", default_branch="main")
|
|
41
|
+
pierre.repos.branches.create(repo_name="acme/web", target_branch="feat", base_ref="main")
|
|
42
|
+
pierre.repos.commits.create(
|
|
43
|
+
repo_name="acme/web",
|
|
44
|
+
target_branch="feat",
|
|
45
|
+
commit_message="Add readme",
|
|
46
|
+
author={"name": "Bot", "email": "bot@acme.dev"},
|
|
47
|
+
files=[{"path": "README.md", "content": "# Web"}],
|
|
48
|
+
)
|
|
49
|
+
for repo in pierre.repos.list(q="web"):
|
|
50
|
+
print(repo["repo_name"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`AsyncPierre` takes the same arguments, and each method is async:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from pierre import AsyncPierre
|
|
57
|
+
|
|
58
|
+
pierre = AsyncPierre()
|
|
59
|
+
repo = await pierre.repos.get(repo_name="acme/web")
|
|
60
|
+
async for branch in pierre.repos.branches.list(repo_name="acme/web"):
|
|
61
|
+
print(branch["name"])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A result is a dict with the wire names of the response, typed as a `TypedDict` from `pierre.types`.
|
|
65
|
+
A byte response, such as `repos.files.get`, returns the `httpx.Response`, which the caller reads or
|
|
66
|
+
closes.
|
|
67
|
+
|
|
68
|
+
## Credentials
|
|
69
|
+
|
|
70
|
+
Give one credential. A credential in the options replaces every credential in the environment.
|
|
71
|
+
|
|
72
|
+
| Option | Variable | Use |
|
|
73
|
+
| ------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
74
|
+
| `private_key` | `PIERRE_PRIVATE_KEY`, or the file at `PIERRE_PRIVATE_KEY_FILE` | The SDK signs a 5-minute token for each request, with only the scopes of the operation. |
|
|
75
|
+
| `token` | `PIERRE_TOKEN` | A fixed token. The server checks its scopes. |
|
|
76
|
+
| `get_token` | none | A function that returns a token for each request, for a client with no private key. |
|
|
77
|
+
|
|
78
|
+
With no credential in the options, the SDK uses the first variable that is set: `PIERRE_TOKEN`, then
|
|
79
|
+
`PIERRE_PRIVATE_KEY`, then `PIERRE_PRIVATE_KEY_FILE`.
|
|
80
|
+
|
|
81
|
+
`org` (`PIERRE_ORG`) names the organization. With `token`, the SDK reads it from the `iss` claim.
|
|
82
|
+
`base_url` (`PIERRE_API_BASE_URL`) and `git_host` (`PIERRE_GIT_HOST`) replace the default hosts,
|
|
83
|
+
`https://api.{org}.code.storage` and `{org}.code.storage`. The base URL ends before `/api`, because
|
|
84
|
+
each operation path starts with `/api`: for example `https://api.acme.code.storage`, not
|
|
85
|
+
`https://api.acme.code.storage/api/v1`. A path in front of `/api`, such as the prefix of a proxy,
|
|
86
|
+
stays in the base URL. The Git host is a host name with an optional port and no scheme, for example
|
|
87
|
+
`acme.code.storage`.
|
|
88
|
+
|
|
89
|
+
## Options
|
|
90
|
+
|
|
91
|
+
The client options are `org`, `private_key`, `token`, `get_token`, `base_url`, `git_host`, `timeout`
|
|
92
|
+
(seconds, default 60), `max_retries` (default 2), `default_headers`, `http_client`, and
|
|
93
|
+
`ref_policies`. `pierre.options` holds the settings with no credential, and `pierre.with_options()`
|
|
94
|
+
returns a copy with changed options.
|
|
95
|
+
|
|
96
|
+
A client keeps one httpx client. `with Pierre() as pierre:` and
|
|
97
|
+
`async with AsyncPierre() as pierre:` close the httpx client that the SDK made at the end of the
|
|
98
|
+
block. An `http_client` that you give stays open.
|
|
99
|
+
|
|
100
|
+
Each method also takes the request options `extra_headers`, `timeout`, `max_retries`,
|
|
101
|
+
`idempotency_key`, `token_ttl`, and `ref_policies` for one call.
|
|
102
|
+
|
|
103
|
+
`idempotency_key` applies only to an operation that accepts an `Idempotency-Key`. On any other
|
|
104
|
+
operation, the call raises `PierreError` and sends no request.
|
|
105
|
+
|
|
106
|
+
An optional argument has the default `NOT_GIVEN`, which the SDK does not send. `None` sends `null`.
|
|
107
|
+
|
|
108
|
+
## Responses, pages, and errors
|
|
109
|
+
|
|
110
|
+
- `pierre.with_raw_response` has the namespaces of the client. Each method returns a `RawResponse`
|
|
111
|
+
with `data`, `response` (the `httpx.Response`), `headers` (the declared response headers), and
|
|
112
|
+
`request_id`.
|
|
113
|
+
- A list method returns a `Page` in `Pierre`. In `AsyncPierre`, it returns an `AsyncPaginator`:
|
|
114
|
+
`await` gives the first `AsyncPage`, and `async for` gives every item. A page has `data`,
|
|
115
|
+
`has_more`, `next_cursor`, `body`, `response`, `has_next_page()`, `get_next_page()`, and
|
|
116
|
+
`iter_pages()`. To serialize a page, serialize `page.body`, the response that the API sent.
|
|
117
|
+
- Every error extends `PierreError`. An HTTP error is an `APIError` with `status`, `code`, `title`,
|
|
118
|
+
`detail`, `request_id`, `headers`, `body`, and `doc_url`, with a subclass for each status family,
|
|
119
|
+
and `MergeConflictError` and `RefUpdateError` for codes. `RefUpdateError.result` is a
|
|
120
|
+
`RefUpdateResult`, or a `NotesWriteResult` for a notes write, so `error.result["status"]` gives
|
|
121
|
+
the reason with no cast, for example `conflict`. A failed ref guard, with the code
|
|
122
|
+
`ref_guard_failed`, raises `RefGuardError`, a `ConflictError` with `guard` and `current_sha`: send
|
|
123
|
+
`current_sha` as the guard of the next attempt.
|
|
124
|
+
|
|
125
|
+
## Local utilities
|
|
126
|
+
|
|
127
|
+
Import the modules of the local utilities before you name a client `pierre`, because the client name
|
|
128
|
+
hides the package:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from pierre import Pierre, auth, git, webhooks
|
|
132
|
+
|
|
133
|
+
pierre = Pierre()
|
|
134
|
+
token = auth.create_token(pierre, scopes=["git:read"], repo="acme/web")
|
|
135
|
+
url = git.remote_url(pierre, repo_name="acme/web")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- `auth.create_token(client, scopes=[...])` signs a token with no HTTP call. `auth.SCOPES` lists the
|
|
139
|
+
scopes of the API, and `auth.Scope` is their type.
|
|
140
|
+
- `git.remote_url(client, repo_name=...)` builds a Git remote URL with a 15-minute token. With
|
|
141
|
+
`AsyncPierre`, await the result.
|
|
142
|
+
- `webhooks.verify_webhook(payload=..., headers=..., secret=...)` checks a webhook delivery and
|
|
143
|
+
returns the typed event.
|
|
144
|
+
|
|
145
|
+
## Differences from the TypeScript SDK
|
|
146
|
+
|
|
147
|
+
- A timeout is in seconds, as in `httpx`. It covers each attempt: the token, the upload, the
|
|
148
|
+
response headers, and a JSON body. `Pierre` cannot stop a blocking `get_token`, so the attempt
|
|
149
|
+
fails when the token arrives after the deadline.
|
|
150
|
+
- There is no `signal`. To stop an `AsyncPierre` call, cancel its task.
|
|
151
|
+
- An async method starts its request when the caller awaits it, as a coroutine does.
|
|
152
|
+
- `pierre.with_raw_response` replaces `withResponse()`. The SDK reads the body of a JSON response,
|
|
153
|
+
so there is no `asResponse()`. A byte response stays a stream.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
pierre/__init__.py,sha256=4_4z7hVkZqQrh3qeYkGEGXpWNJrRR4rwPyDcFvM5OgE,1495
|
|
2
|
+
pierre/_client.py,sha256=xK61G2hw3vnykcmx_OfWneLE4wpGIJmMH_tbASwsgwU,21006
|
|
3
|
+
pierre/_compat.py,sha256=yj2hvrYOJKoNniwW8GVGEkB0NUl0najQa3mYukL-Bjo,519
|
|
4
|
+
pierre/_content.py,sha256=GwT6Gi_Ka5yWIE_5Aj3rqY5Kby7EKO9BNkJvQ_1FIdo,5214
|
|
5
|
+
pierre/_credentials.py,sha256=1SBnwepDPZo0xnan_f9MY2vD8Ooo7sTTZxLh2LyJIxY,11393
|
|
6
|
+
pierre/_errors.py,sha256=vJa8zAywAee5PnX3Wz7Q8LtAR14LEvoqpMYYrzCGCwI,7010
|
|
7
|
+
pierre/_generated/__init__.py,sha256=gSsN54e_mcTHjk_7QHTDzkysYvcuDqqBljGpHbdtnjI,84
|
|
8
|
+
pierre/_generated/client.py,sha256=mzDRaNcMjsPD8iDwpx7t15mLEudyTv_ov_HjZB8N3Ts,1772
|
|
9
|
+
pierre/_generated/headers.py,sha256=JCaDyOX6P7R5Isr8Q7jsYT6eX3sHWmySyppvshCJ0Ps,3035
|
|
10
|
+
pierre/_generated/namespaces/__init__.py,sha256=c53ApOKBEFmqvDOw97_M4X0h7xZm3hCLQbb55cuVAfs,93
|
|
11
|
+
pierre/_generated/namespaces/meta.py,sha256=q9ZJJEhiZIHYA25uPgegfxtbd9a4JyGlFNu8vMkQbdw,4688
|
|
12
|
+
pierre/_generated/namespaces/repos.py,sha256=-UXNqt3FRPSDReCtHA5BeQbE4ql43Zl7FDjI8ySD6-I,245623
|
|
13
|
+
pierre/_generated/schemas.py,sha256=2CToWi6qF5l5w-10tsjqE17j1oXdBUBPAK77VGKzjV8,55888
|
|
14
|
+
pierre/_generated/scopes.py,sha256=UsEF3i3kPrZWFvu_YUdTz8w75gItVEVFafwne1qimpg,436
|
|
15
|
+
pierre/_generated/webhooks.py,sha256=xIj_OyyMwSu7i1lpGLitDTEkOqyDmYHNncXTtC_RQVc,845
|
|
16
|
+
pierre/_helpers/__init__.py,sha256=15Bi6tFtWZCEGM9PxIi95lu0Oc7QvCrN_jOjLm9n_XU,45
|
|
17
|
+
pierre/_helpers/commits.py,sha256=bUtnSd9rKClGMHgNH3kIN3rc6kbAD2PXBqil0LXXWco,7691
|
|
18
|
+
pierre/_helpers/repos.py,sha256=Shk4VmtSv6y7LfcM4YxSAxmapbfKQbnfGVME7RNCh8U,2056
|
|
19
|
+
pierre/_operation.py,sha256=_1gBkbzufm44j63lQD5upitaKeQdEQOV7nbqWIc9_mA,1919
|
|
20
|
+
pierre/_page.py,sha256=iXcYsPSkCuL6qHvFzpmUwWNOttbXOQ_chWAAXlbyH_I,5254
|
|
21
|
+
pierre/_response.py,sha256=52IuQt0up-ig7dl-ZQOsTqaWc9zS4U_u0neqqL9Tuv4,3027
|
|
22
|
+
pierre/_retry.py,sha256=1LOp8E54Clkpbi6bujM34EmVYiEbEtXIkVVsoDw7E2Q,2341
|
|
23
|
+
pierre/_runtime.py,sha256=3L8qWvF6JRwxpWxWhEcFodGdLsmnbHJALf949t6RbMA,31014
|
|
24
|
+
pierre/_types.py,sha256=lH_XJ1G56npnfG34GtwZnNxWwpSgdo20ZSLGwLbohGw,2277
|
|
25
|
+
pierre/_version.py,sha256=1-LX3ui0nSM78mxTnqVrkzgOhzHfb0DyhIoXNbT7SX8,103
|
|
26
|
+
pierre/auth.py,sha256=9NZ80xib0schk6LjWxVRfoFQv7nfeEtjA3J8wPbmq6I,3402
|
|
27
|
+
pierre/git.py,sha256=xGdBaxCAVjntS6202FCvuGkqDLnxbQz2Ywel3X6-tzs,6529
|
|
28
|
+
pierre/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
pierre/types.py,sha256=Et4uy1SMITTVDCt56eVLBwsf0OZNoyum-nIdHlH9ydw,760
|
|
30
|
+
pierre/webhooks.py,sha256=2HD7OIMEhbQCJs_znNXX0FQje_zowKsnJhKJpj7SmmI,6049
|
|
31
|
+
pierre_sdk-0.1.0.dev4.dist-info/licenses/LICENSE,sha256=Z2_yAs4OlHHd6hkv_CA5UlN1Qwuy0EyfkFXdEC988dw,10899
|
|
32
|
+
pierre_sdk-0.1.0.dev4.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
|
|
33
|
+
pierre_sdk-0.1.0.dev4.dist-info/METADATA,sha256=pJwn9O-5KRdW3asDw2hegLWbFSTvhFc-S8qddN7tdfE,7638
|
|
34
|
+
pierre_sdk-0.1.0.dev4.dist-info/RECORD,,
|