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/_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
@@ -0,0 +1,3 @@
1
+ """The version of the package. It equals the version in pyproject.toml."""
2
+
3
+ __version__ = "0.1.0.dev4"
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,,