sesame-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sesame
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: sesame-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Sesame human-in-the-loop approval API
5
+ Project-URL: Homepage, https://github.com/lavanpuri1999/sesame
6
+ Project-URL: Repository, https://github.com/lavanpuri1999/sesame
7
+ Project-URL: Issues, https://github.com/lavanpuri1999/sesame/issues
8
+ Author: Sesame
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,approval,authorization,human-in-the-loop,sesame,webhook
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.12
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: pydantic>=2.6
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Requires-Dist: respx>=0.21; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # sesame-sdk
32
+
33
+ Python SDK for the [Sesame](https://getsesame.dev) human-in-the-loop approval API.
34
+
35
+ Drop an approval gate in front of any sensitive operation in your backend. Sesame
36
+ triggers a request that a human approves (via Telegram + push), and your code blocks
37
+ until the decision lands — or reacts asynchronously via a webhook.
38
+
39
+ ```bash
40
+ pip install sesame-sdk
41
+ ```
42
+
43
+ Requires Python 3.12+. Runtime deps: `httpx`, `pydantic`.
44
+
45
+ ## Configuration
46
+
47
+ The client reads credentials from the environment, or you can pass them explicitly:
48
+
49
+ | Env var | Default | Purpose |
50
+ | ------------------- | ------------------------ | -------------------------------- |
51
+ | `SESAME_API_KEY` | _(required)_ | `sk_live_...` API key |
52
+ | `SESAME_BROKER_URL` | `http://localhost:8000` | Broker base URL |
53
+
54
+ ```python
55
+ from sesame_sdk import Sesame
56
+
57
+ client = Sesame() # from env
58
+ client = Sesame(api_key="sk_live_...", base_url="https://broker.example.com")
59
+ ```
60
+
61
+ ## Gate a function with `@require_approval`
62
+
63
+ The wrapped function only runs if a human approves. Denied/expired raises
64
+ `ApprovalDenied`; no decision before `timeout` raises `ApprovalTimeout`.
65
+
66
+ ```python
67
+ from sesame_sdk import require_approval, ApprovalDenied
68
+
69
+ @require_approval(
70
+ "payments.refund",
71
+ summary=lambda amount, **_: f"Refund ${amount/100:.2f} to customer",
72
+ timeout=300,
73
+ )
74
+ def issue_refund(amount: int, customer_id: str) -> None:
75
+ stripe.Refund.create(amount=amount, customer=customer_id)
76
+
77
+ try:
78
+ issue_refund(4200, customer_id="cus_123")
79
+ except ApprovalDenied:
80
+ log.warning("refund rejected by operator")
81
+ ```
82
+
83
+ `summary` may be a static string or a callable receiving the wrapped function's
84
+ arguments. Omit it for a sensible default built from the action and function name.
85
+ Pass `client=Sesame(...)` to use a specific client; otherwise a module-level default
86
+ is built lazily from the environment.
87
+
88
+ ## Trigger and wait explicitly
89
+
90
+ ```python
91
+ from sesame_sdk import Sesame
92
+
93
+ client = Sesame()
94
+ approval = client.approvals.trigger(
95
+ action="db.delete",
96
+ summary="Delete 1,204 rows from prod.orders",
97
+ reason="GDPR erasure request #882",
98
+ context={"table": "orders", "rows": 1204},
99
+ )
100
+
101
+ approval.wait(timeout=300) # blocks on the broker's long-poll until decided
102
+ if approval.approved:
103
+ run_deletion()
104
+ else:
105
+ print("decision:", approval.status) # "denied" or "expired"
106
+ ```
107
+
108
+ ## Receive decisions via webhook
109
+
110
+ When you pass a `callback_url`, the broker POSTs to it on every terminal decision.
111
+ Verify the signature before trusting the body — `verify_webhook` recomputes the
112
+ HMAC-SHA256 over the *raw* request body and rejects stale timestamps.
113
+
114
+ ### Flask
115
+
116
+ ```python
117
+ from flask import Flask, request, abort
118
+ from sesame_sdk import verify_webhook, WebhookVerificationError
119
+
120
+ app = Flask(__name__)
121
+ WEBHOOK_SECRET = os.environ["SESAME_WEBHOOK_SECRET"]
122
+
123
+ @app.post("/sesame/webhook")
124
+ def sesame_webhook():
125
+ try:
126
+ payload = verify_webhook(request.headers, request.get_data(), WEBHOOK_SECRET)
127
+ except WebhookVerificationError:
128
+ abort(400)
129
+ # payload: {"approval_id", "action", "status", "decided_at", "requester_label", "dedup_key"?}
130
+ handle_decision(payload["approval_id"], payload["status"])
131
+ return "", 204
132
+ ```
133
+
134
+ ### FastAPI
135
+
136
+ ```python
137
+ from fastapi import FastAPI, Request, Response, HTTPException
138
+ from sesame_sdk import verify_webhook, WebhookVerificationError
139
+
140
+ app = FastAPI()
141
+
142
+ @app.post("/sesame/webhook")
143
+ async def sesame_webhook(request: Request):
144
+ raw = await request.body() # must be the exact bytes the broker signed
145
+ try:
146
+ payload = verify_webhook(request.headers, raw, WEBHOOK_SECRET)
147
+ except WebhookVerificationError:
148
+ raise HTTPException(status_code=400, detail="bad signature")
149
+ handle_decision(payload["approval_id"], payload["status"])
150
+ return Response(status_code=204)
151
+ ```
152
+
153
+ ## Exceptions
154
+
155
+ All inherit from `ApprovalError`:
156
+
157
+ - `ApprovalDenied` — terminal `denied`/`expired` (carries `.approval_id`, `.status`)
158
+ - `ApprovalTimeout` — no decision before the caller's timeout
159
+ - `SesameAuthError` — broker rejected the API key (HTTP 401)
160
+ - `NotFoundError` — unknown approval id (HTTP 404)
161
+ - `WebhookVerificationError` — bad/missing signature, stale timestamp, or bad JSON
162
+
163
+ ## Notes
164
+
165
+ This SDK is synchronous for v1. An async client may be added later; until then, run
166
+ `approval.wait()` in a worker thread if you need to avoid blocking an event loop.
@@ -0,0 +1,63 @@
1
+ # Publishing `sesame-sdk` to PyPI
2
+
3
+ > These steps are documented but **not run automatically**. Publishing requires a
4
+ > PyPI API token and explicit sign-off from a maintainer.
5
+
6
+ ## Prerequisites
7
+
8
+ - [ ] **A license has been chosen.** No `LICENSE` file currently exists in the repo.
9
+ A license is a product/legal decision and must be made before the first public
10
+ release. Once chosen: add the `LICENSE` file to this package, restore the
11
+ `license` field in `pyproject.toml`, and add the matching `License ::` trove
12
+ classifier.
13
+ - [ ] `version` in `pyproject.toml` bumped (PyPI rejects re-uploading an existing version).
14
+ - [ ] A PyPI account with an API token (`pypi-...`). Store it securely; never commit it.
15
+
16
+ ## 1. Build the artifacts
17
+
18
+ ```bash
19
+ uv build
20
+ ```
21
+
22
+ This produces both distributions in `dist/`:
23
+
24
+ - `dist/sesame_sdk-<version>-py3-none-any.whl`
25
+ - `dist/sesame_sdk-<version>.tar.gz`
26
+
27
+ ## 2. (Recommended) Verify the build
28
+
29
+ ```bash
30
+ uvx twine check dist/*
31
+ ```
32
+
33
+ ## 3. Publish
34
+
35
+ Using uv (preferred):
36
+
37
+ ```bash
38
+ # Requires PyPI token — pass via env or --token. Do NOT commit the token.
39
+ UV_PUBLISH_TOKEN="pypi-..." uv publish
40
+ ```
41
+
42
+ Or with twine:
43
+
44
+ ```bash
45
+ twine upload dist/* # prompts for username (__token__) + password (the pypi-... token)
46
+ ```
47
+
48
+ Optionally test against TestPyPI first:
49
+
50
+ ```bash
51
+ UV_PUBLISH_TOKEN="pypi-..." uv publish --publish-url https://test.pypi.org/legacy/
52
+ ```
53
+
54
+ ## 4. Verify the published package
55
+
56
+ ```bash
57
+ pip install sesame-sdk
58
+ python -c "import sesame_sdk; print(sesame_sdk.__version__)"
59
+ ```
60
+
61
+ ---
62
+
63
+ **Do not run the publish command without explicit maintainer sign-off and a chosen license.**
@@ -0,0 +1,136 @@
1
+ # sesame-sdk
2
+
3
+ Python SDK for the [Sesame](https://getsesame.dev) human-in-the-loop approval API.
4
+
5
+ Drop an approval gate in front of any sensitive operation in your backend. Sesame
6
+ triggers a request that a human approves (via Telegram + push), and your code blocks
7
+ until the decision lands — or reacts asynchronously via a webhook.
8
+
9
+ ```bash
10
+ pip install sesame-sdk
11
+ ```
12
+
13
+ Requires Python 3.12+. Runtime deps: `httpx`, `pydantic`.
14
+
15
+ ## Configuration
16
+
17
+ The client reads credentials from the environment, or you can pass them explicitly:
18
+
19
+ | Env var | Default | Purpose |
20
+ | ------------------- | ------------------------ | -------------------------------- |
21
+ | `SESAME_API_KEY` | _(required)_ | `sk_live_...` API key |
22
+ | `SESAME_BROKER_URL` | `http://localhost:8000` | Broker base URL |
23
+
24
+ ```python
25
+ from sesame_sdk import Sesame
26
+
27
+ client = Sesame() # from env
28
+ client = Sesame(api_key="sk_live_...", base_url="https://broker.example.com")
29
+ ```
30
+
31
+ ## Gate a function with `@require_approval`
32
+
33
+ The wrapped function only runs if a human approves. Denied/expired raises
34
+ `ApprovalDenied`; no decision before `timeout` raises `ApprovalTimeout`.
35
+
36
+ ```python
37
+ from sesame_sdk import require_approval, ApprovalDenied
38
+
39
+ @require_approval(
40
+ "payments.refund",
41
+ summary=lambda amount, **_: f"Refund ${amount/100:.2f} to customer",
42
+ timeout=300,
43
+ )
44
+ def issue_refund(amount: int, customer_id: str) -> None:
45
+ stripe.Refund.create(amount=amount, customer=customer_id)
46
+
47
+ try:
48
+ issue_refund(4200, customer_id="cus_123")
49
+ except ApprovalDenied:
50
+ log.warning("refund rejected by operator")
51
+ ```
52
+
53
+ `summary` may be a static string or a callable receiving the wrapped function's
54
+ arguments. Omit it for a sensible default built from the action and function name.
55
+ Pass `client=Sesame(...)` to use a specific client; otherwise a module-level default
56
+ is built lazily from the environment.
57
+
58
+ ## Trigger and wait explicitly
59
+
60
+ ```python
61
+ from sesame_sdk import Sesame
62
+
63
+ client = Sesame()
64
+ approval = client.approvals.trigger(
65
+ action="db.delete",
66
+ summary="Delete 1,204 rows from prod.orders",
67
+ reason="GDPR erasure request #882",
68
+ context={"table": "orders", "rows": 1204},
69
+ )
70
+
71
+ approval.wait(timeout=300) # blocks on the broker's long-poll until decided
72
+ if approval.approved:
73
+ run_deletion()
74
+ else:
75
+ print("decision:", approval.status) # "denied" or "expired"
76
+ ```
77
+
78
+ ## Receive decisions via webhook
79
+
80
+ When you pass a `callback_url`, the broker POSTs to it on every terminal decision.
81
+ Verify the signature before trusting the body — `verify_webhook` recomputes the
82
+ HMAC-SHA256 over the *raw* request body and rejects stale timestamps.
83
+
84
+ ### Flask
85
+
86
+ ```python
87
+ from flask import Flask, request, abort
88
+ from sesame_sdk import verify_webhook, WebhookVerificationError
89
+
90
+ app = Flask(__name__)
91
+ WEBHOOK_SECRET = os.environ["SESAME_WEBHOOK_SECRET"]
92
+
93
+ @app.post("/sesame/webhook")
94
+ def sesame_webhook():
95
+ try:
96
+ payload = verify_webhook(request.headers, request.get_data(), WEBHOOK_SECRET)
97
+ except WebhookVerificationError:
98
+ abort(400)
99
+ # payload: {"approval_id", "action", "status", "decided_at", "requester_label", "dedup_key"?}
100
+ handle_decision(payload["approval_id"], payload["status"])
101
+ return "", 204
102
+ ```
103
+
104
+ ### FastAPI
105
+
106
+ ```python
107
+ from fastapi import FastAPI, Request, Response, HTTPException
108
+ from sesame_sdk import verify_webhook, WebhookVerificationError
109
+
110
+ app = FastAPI()
111
+
112
+ @app.post("/sesame/webhook")
113
+ async def sesame_webhook(request: Request):
114
+ raw = await request.body() # must be the exact bytes the broker signed
115
+ try:
116
+ payload = verify_webhook(request.headers, raw, WEBHOOK_SECRET)
117
+ except WebhookVerificationError:
118
+ raise HTTPException(status_code=400, detail="bad signature")
119
+ handle_decision(payload["approval_id"], payload["status"])
120
+ return Response(status_code=204)
121
+ ```
122
+
123
+ ## Exceptions
124
+
125
+ All inherit from `ApprovalError`:
126
+
127
+ - `ApprovalDenied` — terminal `denied`/`expired` (carries `.approval_id`, `.status`)
128
+ - `ApprovalTimeout` — no decision before the caller's timeout
129
+ - `SesameAuthError` — broker rejected the API key (HTTP 401)
130
+ - `NotFoundError` — unknown approval id (HTTP 404)
131
+ - `WebhookVerificationError` — bad/missing signature, stale timestamp, or bad JSON
132
+
133
+ ## Notes
134
+
135
+ This SDK is synchronous for v1. An async client may be added later; until then, run
136
+ `approval.wait()` in a worker thread if you need to avoid blocking an event loop.
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "sesame-sdk"
3
+ version = "0.1.0"
4
+ description = "Python SDK for the Sesame human-in-the-loop approval API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [{ name = "Sesame" }]
8
+ keywords = [
9
+ "sesame",
10
+ "approval",
11
+ "human-in-the-loop",
12
+ "webhook",
13
+ "authorization",
14
+ "agents",
15
+ ]
16
+ license = { text = "MIT" }
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Topic :: Security",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = [
30
+ "httpx>=0.27",
31
+ "pydantic>=2.6",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/lavanpuri1999/sesame"
36
+ Repository = "https://github.com/lavanpuri1999/sesame"
37
+ Issues = "https://github.com/lavanpuri1999/sesame/issues"
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=8.0",
42
+ "respx>=0.21",
43
+ "ruff>=0.4",
44
+ ]
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/sesame_sdk"]
52
+
53
+ [tool.hatch.build.targets.sdist]
54
+ include = [
55
+ "src/sesame_sdk",
56
+ "README.md",
57
+ "PUBLISHING.md",
58
+ ]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from .approval import Approval
4
+ from .client import Sesame
5
+ from .decorator import require_approval
6
+ from .exceptions import (
7
+ ApprovalDenied,
8
+ ApprovalError,
9
+ ApprovalTimeout,
10
+ NotFoundError,
11
+ SesameAuthError,
12
+ WebhookVerificationError,
13
+ )
14
+ from .models import ApprovalState, TriggerResponse, WebhookPayload
15
+ from .webhook import verify_webhook
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "Sesame",
21
+ "Approval",
22
+ "require_approval",
23
+ "verify_webhook",
24
+ "ApprovalError",
25
+ "ApprovalDenied",
26
+ "ApprovalTimeout",
27
+ "WebhookVerificationError",
28
+ "SesameAuthError",
29
+ "NotFoundError",
30
+ "ApprovalState",
31
+ "TriggerResponse",
32
+ "WebhookPayload",
33
+ "__version__",
34
+ ]
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .exceptions import ApprovalTimeout
8
+ from .models import TERMINAL_STATUSES, ApprovalState, TriggerResponse
9
+
10
+ if TYPE_CHECKING:
11
+ from .approvals import Approvals
12
+
13
+
14
+ class Approval:
15
+ """A single approval request, with helpers to block until it is decided."""
16
+
17
+ def __init__(
18
+ self,
19
+ approval_id: str,
20
+ status: str,
21
+ approvals: Approvals,
22
+ expires_at: datetime | None = None,
23
+ ) -> None:
24
+ self.approval_id = approval_id
25
+ self.status = status
26
+ self.expires_at = expires_at
27
+ self._approvals = approvals
28
+
29
+ @classmethod
30
+ def _from_trigger(cls, data: TriggerResponse, approvals: Approvals) -> Approval:
31
+ return cls(data.approval_id, data.status, approvals)
32
+
33
+ def _apply(self, state: ApprovalState) -> None:
34
+ self.status = state.status
35
+ self.expires_at = state.expires_at
36
+
37
+ @property
38
+ def approved(self) -> bool:
39
+ return self.status == "approved"
40
+
41
+ @property
42
+ def is_terminal(self) -> bool:
43
+ return self.status in TERMINAL_STATUSES
44
+
45
+ def refresh(self, *, wait: bool = False) -> Approval:
46
+ """Fetch current state from the broker. `wait=True` long-polls server-side."""
47
+ self._apply(self._approvals._get(self.approval_id, wait=wait))
48
+ return self
49
+
50
+ def wait(self, timeout: float = 300.0, poll_interval: float = 2.0) -> Approval:
51
+ """Block until the approval reaches a terminal state or `timeout` seconds elapse.
52
+
53
+ The broker's `?wait=true` already long-polls (~25-30s), so each iteration mostly
54
+ re-issues that long-poll; `poll_interval` is only a floor between cheap returns
55
+ (e.g. an immediate "still pending") to avoid hammering the broker.
56
+ """
57
+ deadline = time.monotonic() + timeout
58
+ while True:
59
+ if self.is_terminal:
60
+ return self
61
+ if time.monotonic() >= deadline:
62
+ raise ApprovalTimeout(
63
+ f"Approval {self.approval_id} not decided within {timeout}s (status={self.status})",
64
+ approval_id=self.approval_id,
65
+ )
66
+ started = time.monotonic()
67
+ self.refresh(wait=True)
68
+ if self.is_terminal:
69
+ return self
70
+ # Floor the loop only if the long-poll returned faster than poll_interval.
71
+ elapsed = time.monotonic() - started
72
+ remaining = min(poll_interval - elapsed, deadline - time.monotonic())
73
+ if remaining > 0:
74
+ time.sleep(remaining)
75
+
76
+ def __repr__(self) -> str:
77
+ return f"Approval(approval_id={self.approval_id!r}, status={self.status!r})"
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .approval import Approval
6
+ from .models import ApprovalState, TriggerResponse
7
+ from .transport import Transport
8
+
9
+
10
+ class Approvals:
11
+ """Resource handle for the /v1/approvals endpoints."""
12
+
13
+ def __init__(self, transport: Transport) -> None:
14
+ self._transport = transport
15
+
16
+ def trigger(
17
+ self,
18
+ action: str,
19
+ summary: str,
20
+ *,
21
+ reason: str | None = None,
22
+ dedup_key: str | None = None,
23
+ callback_url: str | None = None,
24
+ severity: str | None = None,
25
+ context: dict[str, Any] | None = None,
26
+ ) -> Approval:
27
+ """Request a human approval. Returns immediately with a pending Approval."""
28
+ body: dict[str, Any] = {"action": action, "summary": summary}
29
+ optional = {
30
+ "reason": reason,
31
+ "dedup_key": dedup_key,
32
+ "callback_url": callback_url,
33
+ "severity": severity,
34
+ "context": context,
35
+ }
36
+ body.update({k: v for k, v in optional.items() if v is not None})
37
+
38
+ data = self._transport.request("POST", "/v1/approvals", json=body)
39
+ return Approval._from_trigger(TriggerResponse.model_validate(data), self)
40
+
41
+ def get(self, approval_id: str, *, wait: bool = False) -> Approval:
42
+ """Fetch the current state of an approval as an Approval object."""
43
+ state = self._get(approval_id, wait=wait)
44
+ return Approval(
45
+ state.approval_id, state.status, self, expires_at=state.expires_at
46
+ )
47
+
48
+ def _get(self, approval_id: str, *, wait: bool) -> ApprovalState:
49
+ params = {"wait": "true"} if wait else None
50
+ data = self._transport.request(
51
+ "GET", f"/v1/approvals/{approval_id}", params=params
52
+ )
53
+ return ApprovalState.model_validate(data)
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from .approvals import Approvals
6
+ from .transport import Transport
7
+
8
+ DEFAULT_BASE_URL = "http://localhost:8000"
9
+
10
+
11
+ class Sesame:
12
+ """Client for the Sesame human-in-the-loop approval API.
13
+
14
+ api_key falls back to $SESAME_API_KEY, base_url to $SESAME_BROKER_URL (then localhost).
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ api_key: str | None = None,
20
+ base_url: str | None = None,
21
+ timeout: float = 30.0,
22
+ ) -> None:
23
+ api_key = api_key or os.environ.get("SESAME_API_KEY")
24
+ if not api_key:
25
+ raise ValueError(
26
+ "No Sesame API key provided. Pass api_key=... or set the SESAME_API_KEY environment variable."
27
+ )
28
+ base_url = base_url or os.environ.get("SESAME_BROKER_URL") or DEFAULT_BASE_URL
29
+
30
+ self._transport = Transport(base_url=base_url, api_key=api_key, timeout=timeout)
31
+ self.approvals = Approvals(self._transport)
32
+
33
+ def close(self) -> None:
34
+ self._transport.close()
35
+
36
+ def __enter__(self) -> Sesame:
37
+ return self
38
+
39
+ def __exit__(self, *_exc: object) -> None:
40
+ self.close()
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from typing import Any, Callable, TypeVar
5
+
6
+ from .client import Sesame
7
+ from .exceptions import ApprovalDenied
8
+
9
+ F = TypeVar("F", bound=Callable[..., Any])
10
+
11
+ # Lazily-built module-level client, shared by decorators that don't pass their own.
12
+ _default_client: Sesame | None = None
13
+
14
+
15
+ def _get_default_client() -> Sesame:
16
+ global _default_client
17
+ if _default_client is None:
18
+ _default_client = Sesame()
19
+ return _default_client
20
+
21
+
22
+ def require_approval(
23
+ action: str,
24
+ *,
25
+ summary: str | Callable[..., str] | None = None,
26
+ reason: str | None = None,
27
+ timeout: float = 300.0,
28
+ client: Sesame | None = None,
29
+ ) -> Callable[[F], F]:
30
+ """Gate a sync function behind a human approval.
31
+
32
+ Before each call the wrapped function triggers an approval and blocks until decided.
33
+ Denied/expired -> ApprovalDenied; no decision in time -> ApprovalTimeout (from wait()).
34
+ `summary` may be a static string or a callable receiving the same (*args, **kwargs).
35
+ """
36
+
37
+ def decorator(func: F) -> F:
38
+ @functools.wraps(func)
39
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
40
+ active = client or _get_default_client()
41
+
42
+ if callable(summary):
43
+ resolved_summary = summary(*args, **kwargs)
44
+ elif summary is not None:
45
+ resolved_summary = summary
46
+ else:
47
+ resolved_summary = (
48
+ f"Approval required for {action} (via {func.__name__})"
49
+ )
50
+
51
+ approval = active.approvals.trigger(
52
+ action,
53
+ resolved_summary,
54
+ reason=reason,
55
+ )
56
+ approval.wait(timeout=timeout)
57
+ if not approval.approved:
58
+ raise ApprovalDenied(
59
+ f"Approval for {action!r} was {approval.status}",
60
+ approval_id=approval.approval_id,
61
+ status=approval.status,
62
+ )
63
+ return func(*args, **kwargs)
64
+
65
+ return wrapper # type: ignore[return-value]
66
+
67
+ return decorator
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class ApprovalError(Exception):
5
+ """Base class for all Sesame SDK errors."""
6
+
7
+
8
+ class SesameAuthError(ApprovalError):
9
+ """Raised when the broker rejects the API key (HTTP 401)."""
10
+
11
+
12
+ class NotFoundError(ApprovalError):
13
+ """Raised when an approval id is unknown to the broker (HTTP 404)."""
14
+
15
+
16
+ class ApprovalDenied(ApprovalError):
17
+ """Raised when an approval reached a terminal non-approved state (denied/expired)."""
18
+
19
+ def __init__(
20
+ self, message: str, *, approval_id: str | None = None, status: str | None = None
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.approval_id = approval_id
24
+ self.status = status
25
+
26
+
27
+ class ApprovalTimeout(ApprovalError):
28
+ """Raised when waiting for a decision exceeded the caller's timeout."""
29
+
30
+ def __init__(self, message: str, *, approval_id: str | None = None) -> None:
31
+ super().__init__(message)
32
+ self.approval_id = approval_id
33
+
34
+
35
+ class WebhookVerificationError(ApprovalError):
36
+ """Raised when a webhook signature/timestamp fails verification."""
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ # Terminal states: no further decision can change them.
8
+ TERMINAL_STATUSES = frozenset({"approved", "denied", "expired"})
9
+
10
+
11
+ class TriggerResponse(BaseModel):
12
+ """Response body from POST /v1/approvals (202)."""
13
+
14
+ model_config = ConfigDict(extra="ignore")
15
+
16
+ approval_id: str
17
+ status: str
18
+
19
+
20
+ class ApprovalState(BaseModel):
21
+ """Response body from GET /v1/approvals/{id} (200)."""
22
+
23
+ model_config = ConfigDict(extra="ignore")
24
+
25
+ approval_id: str
26
+ status: str
27
+ expires_at: datetime | None = None
28
+
29
+
30
+ class WebhookPayload(BaseModel):
31
+ """Body the broker POSTs to a caller's callback_url on a terminal decision."""
32
+
33
+ model_config = ConfigDict(extra="ignore")
34
+
35
+ approval_id: str
36
+ action: str
37
+ status: str
38
+ decided_at: datetime
39
+ requester_label: str | None = None
40
+ dedup_key: str | None = None
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from .exceptions import ApprovalError, NotFoundError, SesameAuthError
8
+
9
+
10
+ def _detail(response: httpx.Response) -> str:
11
+ """Pull the broker's `{"detail": ...}` message, falling back to raw text."""
12
+ try:
13
+ body = response.json()
14
+ except ValueError:
15
+ return response.text or response.reason_phrase
16
+ if isinstance(body, dict) and "detail" in body:
17
+ return str(body["detail"])
18
+ return response.text or response.reason_phrase
19
+
20
+
21
+ class Transport:
22
+ """Thin httpx.Client wrapper that sets auth once and maps errors to SDK exceptions."""
23
+
24
+ def __init__(self, base_url: str, api_key: str, timeout: float) -> None:
25
+ self._client = httpx.Client(
26
+ base_url=base_url.rstrip("/"),
27
+ headers={"Authorization": f"Bearer {api_key}"},
28
+ timeout=timeout,
29
+ )
30
+
31
+ def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
32
+ response = self._client.request(method, path, **kwargs)
33
+ return self._handle(response)
34
+
35
+ def _handle(self, response: httpx.Response) -> dict[str, Any]:
36
+ if response.is_success:
37
+ return response.json()
38
+ if response.status_code == 401:
39
+ raise SesameAuthError(_detail(response))
40
+ if response.status_code == 404:
41
+ raise NotFoundError(_detail(response))
42
+ raise ApprovalError(
43
+ f"Sesame request failed ({response.status_code}): {_detail(response)}"
44
+ )
45
+
46
+ def close(self) -> None:
47
+ self._client.close()
48
+
49
+ def __enter__(self) -> Transport:
50
+ return self
51
+
52
+ def __exit__(self, *_exc: object) -> None:
53
+ self.close()
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from typing import Any, Mapping
8
+
9
+ from .exceptions import WebhookVerificationError
10
+
11
+ SIGNATURE_HEADER = "X-Sesame-Signature"
12
+ TIMESTAMP_HEADER = "X-Sesame-Timestamp"
13
+
14
+
15
+ def _get_header(headers: Mapping[str, str], name: str) -> str | None:
16
+ # HTTP header lookups are case-insensitive; callers may hand us a plain dict.
17
+ target = name.lower()
18
+ for key, value in headers.items():
19
+ if key.lower() == target:
20
+ return value
21
+ return None
22
+
23
+
24
+ def verify_webhook(
25
+ headers: Mapping[str, str],
26
+ body: bytes | str,
27
+ secret: str,
28
+ *,
29
+ tolerance: int = 300,
30
+ ) -> dict[str, Any]:
31
+ """Verify a Sesame webhook and return its parsed JSON payload.
32
+
33
+ Recomputes HMAC-SHA256 over the *exact* raw body and constant-time compares it to
34
+ X-Sesame-Signature; rejects bodies whose X-Sesame-Timestamp is older than `tolerance`
35
+ seconds. Raises WebhookVerificationError on any failure.
36
+ """
37
+ raw = body.encode("utf-8") if isinstance(body, str) else body
38
+
39
+ signature = _get_header(headers, SIGNATURE_HEADER)
40
+ if not signature:
41
+ raise WebhookVerificationError(f"Missing {SIGNATURE_HEADER} header")
42
+
43
+ timestamp = _get_header(headers, TIMESTAMP_HEADER)
44
+ if not timestamp:
45
+ raise WebhookVerificationError(f"Missing {TIMESTAMP_HEADER} header")
46
+ try:
47
+ ts = int(timestamp)
48
+ except ValueError as exc:
49
+ raise WebhookVerificationError(f"Invalid {TIMESTAMP_HEADER} header") from exc
50
+ if abs(time.time() - ts) > tolerance:
51
+ raise WebhookVerificationError("Webhook timestamp outside tolerance window")
52
+
53
+ expected = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
54
+ if not hmac.compare_digest(expected, signature):
55
+ raise WebhookVerificationError("Webhook signature mismatch")
56
+
57
+ try:
58
+ return json.loads(raw)
59
+ except ValueError as exc:
60
+ raise WebhookVerificationError("Webhook body is not valid JSON") from exc