pushary 1.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.
- pushary-1.1.0/.gitignore +86 -0
- pushary-1.1.0/LICENSE +21 -0
- pushary-1.1.0/PKG-INFO +201 -0
- pushary-1.1.0/README.md +175 -0
- pushary-1.1.0/pyproject.toml +50 -0
- pushary-1.1.0/src/pushary/__init__.py +38 -0
- pushary-1.1.0/src/pushary/_types.py +75 -0
- pushary-1.1.0/src/pushary/client.py +116 -0
- pushary-1.1.0/src/pushary/decisions.py +120 -0
- pushary-1.1.0/src/pushary/errors.py +22 -0
- pushary-1.1.0/src/pushary/py.typed +0 -0
- pushary-1.1.0/src/pushary/webhook.py +53 -0
- pushary-1.1.0/tests/test_client.py +270 -0
- pushary-1.1.0/tests/test_webhook.py +67 -0
pushary-1.1.0/.gitignore
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
node_modules/
|
|
5
|
+
.pnp
|
|
6
|
+
.pnp.js
|
|
7
|
+
|
|
8
|
+
# testing
|
|
9
|
+
/coverage
|
|
10
|
+
|
|
11
|
+
# next.js
|
|
12
|
+
.next/
|
|
13
|
+
out/
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
|
|
17
|
+
# python
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.pyc
|
|
20
|
+
*.egg-info/
|
|
21
|
+
|
|
22
|
+
# production
|
|
23
|
+
/build
|
|
24
|
+
|
|
25
|
+
# misc
|
|
26
|
+
.DS_Store
|
|
27
|
+
*.pem
|
|
28
|
+
|
|
29
|
+
# debug
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
yarn-debug.log*
|
|
32
|
+
yarn-error.log*
|
|
33
|
+
|
|
34
|
+
# env
|
|
35
|
+
.env
|
|
36
|
+
.env.local
|
|
37
|
+
.env.development.local
|
|
38
|
+
.env.test.local
|
|
39
|
+
.env.production.local
|
|
40
|
+
|
|
41
|
+
# vercel
|
|
42
|
+
.vercel
|
|
43
|
+
|
|
44
|
+
# local Cursor MCP config (holds the Pushary API key)
|
|
45
|
+
.cursor/mcp.json
|
|
46
|
+
|
|
47
|
+
# generated at build time: Cursor plugin bundled into the agent-hooks package
|
|
48
|
+
packages/agent-hooks/data/cursor-plugin/
|
|
49
|
+
|
|
50
|
+
# typescript
|
|
51
|
+
*.tsbuild info
|
|
52
|
+
|
|
53
|
+
# turbo
|
|
54
|
+
.turbo
|
|
55
|
+
|
|
56
|
+
# Old Shopify extension directory (deprecated)
|
|
57
|
+
shopify-theme-extension/
|
|
58
|
+
*.egg-info/
|
|
59
|
+
|
|
60
|
+
# Prospect/outreach data
|
|
61
|
+
prospect_list.csv
|
|
62
|
+
prospect_research_report.txt
|
|
63
|
+
|
|
64
|
+
# Launch planning docs
|
|
65
|
+
PRODUCTHUNT_LAUNCH_PLAN.md
|
|
66
|
+
AI-CODING-REMODEL-PLAN.md
|
|
67
|
+
|
|
68
|
+
# HyperFrames launch video (separate project)
|
|
69
|
+
video-launch/
|
|
70
|
+
|
|
71
|
+
# Remotion render outputs
|
|
72
|
+
video/public/logos/out/
|
|
73
|
+
.gstack/
|
|
74
|
+
|
|
75
|
+
# local AI-agent tooling (installed skills + plugin config, machine-local)
|
|
76
|
+
.agents/
|
|
77
|
+
.claude/
|
|
78
|
+
.cursor/rules/
|
|
79
|
+
pushary-app/.agents/
|
|
80
|
+
pushary-app/skills-lock.json
|
|
81
|
+
|
|
82
|
+
# stray empty Expo stub at repo root (real config is pushary-app/app.config.ts)
|
|
83
|
+
/app.json
|
|
84
|
+
|
|
85
|
+
# Play Store listing assets (large, iterating screenshots - keep local)
|
|
86
|
+
pushary-app/play-assets/
|
pushary-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pushary
|
|
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.
|
pushary-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pushary
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Human-in-the-loop decisions SDK for Pushary: create a decision, ask a specific end-user to approve, and resume on their answer via webhook or poll.
|
|
5
|
+
Project-URL: Homepage, https://pushary.com
|
|
6
|
+
Project-URL: Documentation, https://pushary.com/docs
|
|
7
|
+
Author-email: Pushary <aadil@pushary.com>
|
|
8
|
+
Maintainer-email: Pushary <aadil@pushary.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,approvals,human-in-the-loop,notifications,webhooks
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
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.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pushary
|
|
28
|
+
|
|
29
|
+
Human-in-the-loop decisions for AI products, in Python. Create a decision, ask a
|
|
30
|
+
specific end-user to approve it, and resume on their answer via webhook or poll.
|
|
31
|
+
|
|
32
|
+
This is the Python counterpart of the `@pushary/server` SDK. It is zero
|
|
33
|
+
dependency (Python standard library only) and targets Python 3.9 and newer.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install pushary
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API key
|
|
42
|
+
|
|
43
|
+
The SDK needs your full API key (`pk_xxx.sk_xxx`), which includes the secret
|
|
44
|
+
half. Never expose it in client-side code. Get your key from your
|
|
45
|
+
[Pushary dashboard](https://pushary.com/dashboard/settings).
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
from pushary import PusharyServer
|
|
50
|
+
|
|
51
|
+
pushary = PusharyServer(api_key=os.environ["PUSHARY_API_KEY"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Human-in-the-loop decisions
|
|
55
|
+
|
|
56
|
+
A decision asks one of your end-users to approve something and then lets your
|
|
57
|
+
product resume once they answer. Use it for the moments where a human should be
|
|
58
|
+
in the loop: releasing funds, sending an outbound message, running a
|
|
59
|
+
destructive action, or confirming an AI-proposed change.
|
|
60
|
+
|
|
61
|
+
The flow has three parts:
|
|
62
|
+
|
|
63
|
+
1. Create a decision and notify the end-user.
|
|
64
|
+
2. Resume when they answer, either by handling the webhook or by polling.
|
|
65
|
+
3. Optionally answer or cancel on their behalf from your own surface.
|
|
66
|
+
|
|
67
|
+
### Create a decision
|
|
68
|
+
|
|
69
|
+
By default `create` is async: it returns right away with a `decisionId` and a
|
|
70
|
+
`pollUrl`, which suits serverless functions that cannot hold a request open
|
|
71
|
+
while a human decides. Pass `wait=True` to block for up to about 55 seconds in
|
|
72
|
+
case the human answers quickly.
|
|
73
|
+
|
|
74
|
+
Always pass `idempotency_key` so a retried call does not ask the same human
|
|
75
|
+
twice.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
decision = pushary.decisions.create(
|
|
79
|
+
"Approve the $4,200 payout to Acme Corp?",
|
|
80
|
+
type="confirm",
|
|
81
|
+
external_id="user_123",
|
|
82
|
+
agent_name="Billing Agent",
|
|
83
|
+
context="Invoice INV-8842, net-30, first payout to this vendor.",
|
|
84
|
+
callback_url="https://your-app.com/webhooks/pushary",
|
|
85
|
+
expires_in_seconds=3600,
|
|
86
|
+
wait=True,
|
|
87
|
+
timeout_seconds=50,
|
|
88
|
+
idempotency_key="payout-INV-8842",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if decision["answered"]:
|
|
92
|
+
print("Resolved fast:", decision["value"])
|
|
93
|
+
else:
|
|
94
|
+
print("Still pending, poll:", decision["pollUrl"])
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For a multiple choice decision, pass `type="select"` with at least two
|
|
98
|
+
`options`. For free text, pass `type="input"` and an optional `placeholder`.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
decision = pushary.decisions.create(
|
|
102
|
+
"Which shipping speed should we book?",
|
|
103
|
+
type="select",
|
|
104
|
+
options=["Standard", "Express", "Overnight"],
|
|
105
|
+
external_id="user_123",
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Poll for the answer
|
|
110
|
+
|
|
111
|
+
If you did not wait, or the wait window closed before the human answered, poll
|
|
112
|
+
for the outcome. Pass `wait=N` to long-poll for up to N seconds so the call
|
|
113
|
+
returns as soon as they answer rather than on your next loop.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
result = pushary.decisions.get(decision["decisionId"], wait=50)
|
|
117
|
+
|
|
118
|
+
if result["status"] == "answered":
|
|
119
|
+
print("Answer:", result["value"])
|
|
120
|
+
elif result["status"] == "expired":
|
|
121
|
+
print("The decision expired before anyone answered.")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Answer or cancel on their behalf
|
|
125
|
+
|
|
126
|
+
If your own interface collected the answer, record it so any waiting call
|
|
127
|
+
resolves. Cancel a decision to close it when it is no longer needed.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
pushary.decisions.answer(decision["decisionId"], "yes")
|
|
131
|
+
|
|
132
|
+
pushary.decisions.cancel(decision["decisionId"])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Webhooks
|
|
136
|
+
|
|
137
|
+
When the end-user answers, Pushary POSTs the result to your `callback_url`. The
|
|
138
|
+
request carries an `X-Pushary-Signature` header, an HMAC-SHA256 hex digest of
|
|
139
|
+
the raw request body signed with your webhook secret. Verify it against the raw
|
|
140
|
+
bytes you received, before parsing the JSON, so a change to spacing or key order
|
|
141
|
+
cannot slip past the check.
|
|
142
|
+
|
|
143
|
+
Fetch or rotate the secret from the SDK:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
secret = pushary.decisions.get_webhook_secret()
|
|
147
|
+
rotated = pushary.decisions.rotate_webhook_secret()
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A Flask handler that verifies the signature and resumes your work:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
import os
|
|
154
|
+
from flask import Flask, request, abort
|
|
155
|
+
from pushary import verify_webhook_signature
|
|
156
|
+
|
|
157
|
+
app = Flask(__name__)
|
|
158
|
+
WEBHOOK_SECRET = os.environ["PUSHARY_WEBHOOK_SECRET"]
|
|
159
|
+
|
|
160
|
+
@app.post("/webhooks/pushary")
|
|
161
|
+
def pushary_webhook():
|
|
162
|
+
signature = request.headers.get("X-Pushary-Signature")
|
|
163
|
+
if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
|
|
164
|
+
abort(401)
|
|
165
|
+
|
|
166
|
+
payload = request.get_json()
|
|
167
|
+
decision_id = payload["decisionId"]
|
|
168
|
+
answer = payload.get("value")
|
|
169
|
+
# Resume your work now that the human has answered.
|
|
170
|
+
return "", 204
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`request.data` is the raw request body Flask captured, which is exactly what the
|
|
174
|
+
signature was computed over. Do not re-serialize the parsed JSON before
|
|
175
|
+
verifying.
|
|
176
|
+
|
|
177
|
+
## Errors
|
|
178
|
+
|
|
179
|
+
Every method returns the parsed JSON body as a `dict`. A non-2xx response raises
|
|
180
|
+
`PusharyError`, which carries the HTTP `status` and the reason the server
|
|
181
|
+
reported.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from pushary import PusharyError
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
pushary.decisions.get("does-not-exist")
|
|
188
|
+
except PusharyError as error:
|
|
189
|
+
print(error.status, error.message)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Security
|
|
193
|
+
|
|
194
|
+
- Keep your API key and webhook secret in environment variables.
|
|
195
|
+
- Rotate the webhook secret from the dashboard or via
|
|
196
|
+
`rotate_webhook_secret()` if it is ever exposed.
|
|
197
|
+
- API keys are site-scoped, so a key can only reach its own decisions.
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
MIT. See `LICENSE`.
|
pushary-1.1.0/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# pushary
|
|
2
|
+
|
|
3
|
+
Human-in-the-loop decisions for AI products, in Python. Create a decision, ask a
|
|
4
|
+
specific end-user to approve it, and resume on their answer via webhook or poll.
|
|
5
|
+
|
|
6
|
+
This is the Python counterpart of the `@pushary/server` SDK. It is zero
|
|
7
|
+
dependency (Python standard library only) and targets Python 3.9 and newer.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install pushary
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## API key
|
|
16
|
+
|
|
17
|
+
The SDK needs your full API key (`pk_xxx.sk_xxx`), which includes the secret
|
|
18
|
+
half. Never expose it in client-side code. Get your key from your
|
|
19
|
+
[Pushary dashboard](https://pushary.com/dashboard/settings).
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import os
|
|
23
|
+
from pushary import PusharyServer
|
|
24
|
+
|
|
25
|
+
pushary = PusharyServer(api_key=os.environ["PUSHARY_API_KEY"])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Human-in-the-loop decisions
|
|
29
|
+
|
|
30
|
+
A decision asks one of your end-users to approve something and then lets your
|
|
31
|
+
product resume once they answer. Use it for the moments where a human should be
|
|
32
|
+
in the loop: releasing funds, sending an outbound message, running a
|
|
33
|
+
destructive action, or confirming an AI-proposed change.
|
|
34
|
+
|
|
35
|
+
The flow has three parts:
|
|
36
|
+
|
|
37
|
+
1. Create a decision and notify the end-user.
|
|
38
|
+
2. Resume when they answer, either by handling the webhook or by polling.
|
|
39
|
+
3. Optionally answer or cancel on their behalf from your own surface.
|
|
40
|
+
|
|
41
|
+
### Create a decision
|
|
42
|
+
|
|
43
|
+
By default `create` is async: it returns right away with a `decisionId` and a
|
|
44
|
+
`pollUrl`, which suits serverless functions that cannot hold a request open
|
|
45
|
+
while a human decides. Pass `wait=True` to block for up to about 55 seconds in
|
|
46
|
+
case the human answers quickly.
|
|
47
|
+
|
|
48
|
+
Always pass `idempotency_key` so a retried call does not ask the same human
|
|
49
|
+
twice.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
decision = pushary.decisions.create(
|
|
53
|
+
"Approve the $4,200 payout to Acme Corp?",
|
|
54
|
+
type="confirm",
|
|
55
|
+
external_id="user_123",
|
|
56
|
+
agent_name="Billing Agent",
|
|
57
|
+
context="Invoice INV-8842, net-30, first payout to this vendor.",
|
|
58
|
+
callback_url="https://your-app.com/webhooks/pushary",
|
|
59
|
+
expires_in_seconds=3600,
|
|
60
|
+
wait=True,
|
|
61
|
+
timeout_seconds=50,
|
|
62
|
+
idempotency_key="payout-INV-8842",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if decision["answered"]:
|
|
66
|
+
print("Resolved fast:", decision["value"])
|
|
67
|
+
else:
|
|
68
|
+
print("Still pending, poll:", decision["pollUrl"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
For a multiple choice decision, pass `type="select"` with at least two
|
|
72
|
+
`options`. For free text, pass `type="input"` and an optional `placeholder`.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
decision = pushary.decisions.create(
|
|
76
|
+
"Which shipping speed should we book?",
|
|
77
|
+
type="select",
|
|
78
|
+
options=["Standard", "Express", "Overnight"],
|
|
79
|
+
external_id="user_123",
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Poll for the answer
|
|
84
|
+
|
|
85
|
+
If you did not wait, or the wait window closed before the human answered, poll
|
|
86
|
+
for the outcome. Pass `wait=N` to long-poll for up to N seconds so the call
|
|
87
|
+
returns as soon as they answer rather than on your next loop.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
result = pushary.decisions.get(decision["decisionId"], wait=50)
|
|
91
|
+
|
|
92
|
+
if result["status"] == "answered":
|
|
93
|
+
print("Answer:", result["value"])
|
|
94
|
+
elif result["status"] == "expired":
|
|
95
|
+
print("The decision expired before anyone answered.")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Answer or cancel on their behalf
|
|
99
|
+
|
|
100
|
+
If your own interface collected the answer, record it so any waiting call
|
|
101
|
+
resolves. Cancel a decision to close it when it is no longer needed.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
pushary.decisions.answer(decision["decisionId"], "yes")
|
|
105
|
+
|
|
106
|
+
pushary.decisions.cancel(decision["decisionId"])
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Webhooks
|
|
110
|
+
|
|
111
|
+
When the end-user answers, Pushary POSTs the result to your `callback_url`. The
|
|
112
|
+
request carries an `X-Pushary-Signature` header, an HMAC-SHA256 hex digest of
|
|
113
|
+
the raw request body signed with your webhook secret. Verify it against the raw
|
|
114
|
+
bytes you received, before parsing the JSON, so a change to spacing or key order
|
|
115
|
+
cannot slip past the check.
|
|
116
|
+
|
|
117
|
+
Fetch or rotate the secret from the SDK:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
secret = pushary.decisions.get_webhook_secret()
|
|
121
|
+
rotated = pushary.decisions.rotate_webhook_secret()
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
A Flask handler that verifies the signature and resumes your work:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import os
|
|
128
|
+
from flask import Flask, request, abort
|
|
129
|
+
from pushary import verify_webhook_signature
|
|
130
|
+
|
|
131
|
+
app = Flask(__name__)
|
|
132
|
+
WEBHOOK_SECRET = os.environ["PUSHARY_WEBHOOK_SECRET"]
|
|
133
|
+
|
|
134
|
+
@app.post("/webhooks/pushary")
|
|
135
|
+
def pushary_webhook():
|
|
136
|
+
signature = request.headers.get("X-Pushary-Signature")
|
|
137
|
+
if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
|
|
138
|
+
abort(401)
|
|
139
|
+
|
|
140
|
+
payload = request.get_json()
|
|
141
|
+
decision_id = payload["decisionId"]
|
|
142
|
+
answer = payload.get("value")
|
|
143
|
+
# Resume your work now that the human has answered.
|
|
144
|
+
return "", 204
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`request.data` is the raw request body Flask captured, which is exactly what the
|
|
148
|
+
signature was computed over. Do not re-serialize the parsed JSON before
|
|
149
|
+
verifying.
|
|
150
|
+
|
|
151
|
+
## Errors
|
|
152
|
+
|
|
153
|
+
Every method returns the parsed JSON body as a `dict`. A non-2xx response raises
|
|
154
|
+
`PusharyError`, which carries the HTTP `status` and the reason the server
|
|
155
|
+
reported.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from pushary import PusharyError
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
pushary.decisions.get("does-not-exist")
|
|
162
|
+
except PusharyError as error:
|
|
163
|
+
print(error.status, error.message)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Security
|
|
167
|
+
|
|
168
|
+
- Keep your API key and webhook secret in environment variables.
|
|
169
|
+
- Rotate the webhook secret from the dashboard or via
|
|
170
|
+
`rotate_webhook_secret()` if it is ever exposed.
|
|
171
|
+
- API keys are site-scoped, so a key can only reach its own decisions.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pushary"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Human-in-the-loop decisions SDK for Pushary: create a decision, ask a specific end-user to approve, and resume on their answer via webhook or poll."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Pushary", email = "aadil@pushary.com" }]
|
|
13
|
+
maintainers = [{ name = "Pushary", email = "aadil@pushary.com" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"human-in-the-loop",
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"approvals",
|
|
18
|
+
"webhooks",
|
|
19
|
+
"notifications",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 5 - Production/Stable",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
dependencies = []
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://pushary.com"
|
|
39
|
+
Documentation = "https://pushary.com/docs"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/pushary"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = [
|
|
46
|
+
"src/pushary",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"tests",
|
|
50
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pushary Python SDK.
|
|
2
|
+
|
|
3
|
+
Human-in-the-loop decisions for AI products: create a decision, ask a specific
|
|
4
|
+
end-user to approve it, and resume on their answer via webhook or poll. Zero
|
|
5
|
+
runtime dependencies, stdlib only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._types import (
|
|
11
|
+
CancelDecisionResponse,
|
|
12
|
+
CreateDecisionResponse,
|
|
13
|
+
Decision,
|
|
14
|
+
DecisionStatus,
|
|
15
|
+
DecisionType,
|
|
16
|
+
WebhookSecretResponse,
|
|
17
|
+
)
|
|
18
|
+
from .client import PusharyServer
|
|
19
|
+
from .decisions import DecisionsResource
|
|
20
|
+
from .errors import PusharyError
|
|
21
|
+
from .webhook import SIGNATURE_HEADER, verify_webhook_signature
|
|
22
|
+
|
|
23
|
+
__version__ = "1.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"PusharyServer",
|
|
27
|
+
"DecisionsResource",
|
|
28
|
+
"PusharyError",
|
|
29
|
+
"verify_webhook_signature",
|
|
30
|
+
"SIGNATURE_HEADER",
|
|
31
|
+
"Decision",
|
|
32
|
+
"DecisionType",
|
|
33
|
+
"DecisionStatus",
|
|
34
|
+
"CreateDecisionResponse",
|
|
35
|
+
"CancelDecisionResponse",
|
|
36
|
+
"WebhookSecretResponse",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Typed shapes for the decision payloads.
|
|
2
|
+
|
|
3
|
+
The client methods return plain ``dict`` objects parsed straight from the API
|
|
4
|
+
response. These ``TypedDict`` definitions describe the keys those dicts carry so
|
|
5
|
+
editors and type checkers can help, without forcing any runtime conversion.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
try: # TypedDict and Literal live in typing on Python 3.9+
|
|
13
|
+
from typing import Literal, TypedDict
|
|
14
|
+
except ImportError: # pragma: no cover - defensive for very old runtimes
|
|
15
|
+
from typing_extensions import Literal, TypedDict # type: ignore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DecisionType = Literal["confirm", "select", "input"]
|
|
19
|
+
"""How the human answers: a yes/no confirm, a fixed set of options, or free text."""
|
|
20
|
+
|
|
21
|
+
DecisionStatus = Literal["pending", "answered", "expired", "cancelled"]
|
|
22
|
+
"""Lifecycle state of a decision."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CreateDecisionResponse(TypedDict, total=False):
|
|
26
|
+
"""Shape returned by ``decisions.create``.
|
|
27
|
+
|
|
28
|
+
Without ``wait`` the API returns immediately with ``status`` "pending" and
|
|
29
|
+
``answered`` False. With ``wait`` it may already carry the resolved
|
|
30
|
+
``value`` when the human answered inside the window, or a ``hint`` telling
|
|
31
|
+
you to poll ``pollUrl`` if it did not.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
decisionId: str
|
|
35
|
+
question: str
|
|
36
|
+
type: DecisionType
|
|
37
|
+
decisionPageUrl: str
|
|
38
|
+
pollUrl: str
|
|
39
|
+
expiresInSeconds: int
|
|
40
|
+
status: DecisionStatus
|
|
41
|
+
answered: bool
|
|
42
|
+
value: Optional[str]
|
|
43
|
+
hint: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Decision(TypedDict, total=False):
|
|
47
|
+
"""Shape returned by ``decisions.get``, the durable view of one decision."""
|
|
48
|
+
|
|
49
|
+
decisionId: str
|
|
50
|
+
status: DecisionStatus
|
|
51
|
+
answered: bool
|
|
52
|
+
value: Optional[str]
|
|
53
|
+
type: DecisionType
|
|
54
|
+
question: str
|
|
55
|
+
options: Optional[List[str]]
|
|
56
|
+
externalId: Optional[str]
|
|
57
|
+
createdAt: str
|
|
58
|
+
answeredAt: Optional[str]
|
|
59
|
+
expiresAt: Optional[str]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CancelDecisionResponse(TypedDict, total=False):
|
|
63
|
+
"""Shape returned by ``decisions.cancel``."""
|
|
64
|
+
|
|
65
|
+
decisionId: str
|
|
66
|
+
cancelled: bool
|
|
67
|
+
status: DecisionStatus
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WebhookSecretResponse(TypedDict, total=False):
|
|
71
|
+
"""Shape returned by ``decisions.get_webhook_secret`` and
|
|
72
|
+
``decisions.rotate_webhook_secret``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
secret: str
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The Pushary server client.
|
|
2
|
+
|
|
3
|
+
Zero dependencies: the transport is stdlib ``urllib.request`` and JSON is parsed
|
|
4
|
+
with stdlib ``json``. Construct once with your full API key and reuse the frozen
|
|
5
|
+
instance across calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
|
|
16
|
+
from .decisions import DecisionsResource
|
|
17
|
+
from .errors import PusharyError
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://pushary.com/api/v1/server"
|
|
20
|
+
|
|
21
|
+
# The create call and get(wait=N) can hold the connection open while a human
|
|
22
|
+
# answers. The server caps that wait around 55s, so the socket timeout sits a
|
|
23
|
+
# little above it to leave room for the round trip without hanging forever.
|
|
24
|
+
DEFAULT_TIMEOUT_SECONDS = 65.0
|
|
25
|
+
|
|
26
|
+
_SETTINGS_URL = "https://pushary.com/dashboard/settings"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PusharyServer:
|
|
30
|
+
"""Client for the Pushary human-in-the-loop decisions API.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
api_key: The full API key in the form ``pk_xxx.sk_xxx``.
|
|
34
|
+
base_url: Override the API base URL. Defaults to the public endpoint.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If the API key is empty or is missing the secret half.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None) -> None:
|
|
41
|
+
self._validate_api_key(api_key)
|
|
42
|
+
|
|
43
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
44
|
+
self._headers: Dict[str, str] = {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"Authorization": f"Bearer {api_key}",
|
|
47
|
+
}
|
|
48
|
+
self._timeout = DEFAULT_TIMEOUT_SECONDS
|
|
49
|
+
self.decisions = DecisionsResource(self._request)
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _validate_api_key(api_key: str) -> None:
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"API key is required. Get your API key from {_SETTINGS_URL}"
|
|
56
|
+
)
|
|
57
|
+
if "." not in api_key:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"Invalid API key format. Use the full API key (pk_xxx.sk_xxx). "
|
|
60
|
+
f"Get your API key from {_SETTINGS_URL}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _request(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
*,
|
|
68
|
+
body: Optional[Dict[str, Any]] = None,
|
|
69
|
+
params: Optional[Dict[str, Any]] = None,
|
|
70
|
+
) -> Dict[str, Any]:
|
|
71
|
+
url = self._base_url + path
|
|
72
|
+
if params:
|
|
73
|
+
query = {k: v for k, v in params.items() if v is not None}
|
|
74
|
+
if query:
|
|
75
|
+
url = f"{url}?{urlencode(query)}"
|
|
76
|
+
|
|
77
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
78
|
+
request = urllib.request.Request(
|
|
79
|
+
url, data=data, method=method, headers=dict(self._headers)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
84
|
+
raw = response.read()
|
|
85
|
+
except urllib.error.HTTPError as exc:
|
|
86
|
+
raise self._error_from_http(exc) from None
|
|
87
|
+
except urllib.error.URLError as exc:
|
|
88
|
+
raise PusharyError(f"Request to Pushary failed: {exc.reason}") from exc
|
|
89
|
+
|
|
90
|
+
return self._parse_json(raw)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _error_from_http(exc: urllib.error.HTTPError) -> PusharyError:
|
|
94
|
+
status = exc.code
|
|
95
|
+
message: Optional[str] = None
|
|
96
|
+
try:
|
|
97
|
+
parsed = json.loads(exc.read().decode("utf-8"))
|
|
98
|
+
if isinstance(parsed, dict):
|
|
99
|
+
error = parsed.get("error")
|
|
100
|
+
fallback = parsed.get("message")
|
|
101
|
+
message = error if isinstance(error, str) else None
|
|
102
|
+
if message is None and isinstance(fallback, str):
|
|
103
|
+
message = fallback
|
|
104
|
+
except Exception:
|
|
105
|
+
message = None
|
|
106
|
+
return PusharyError(message or f"HTTP {status}", status=status)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _parse_json(raw: bytes) -> Dict[str, Any]:
|
|
110
|
+
if not raw:
|
|
111
|
+
return {}
|
|
112
|
+
try:
|
|
113
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
114
|
+
except (ValueError, UnicodeDecodeError):
|
|
115
|
+
return {}
|
|
116
|
+
return parsed if isinstance(parsed, dict) else {"data": parsed}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Decisions resource: create, poll, answer, and cancel human-in-the-loop
|
|
2
|
+
decisions, plus manage the webhook secret.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
RequestFn = Callable[..., Dict[str, Any]]
|
|
11
|
+
|
|
12
|
+
class DecisionsResource:
|
|
13
|
+
"""Ask a specific end-user to approve something, then resume on their answer.
|
|
14
|
+
|
|
15
|
+
Obtain an instance from ``PusharyServer(...).decisions``. Every method
|
|
16
|
+
returns the parsed JSON body as a ``dict`` and raises ``PusharyError`` on a
|
|
17
|
+
non-2xx response.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, request: RequestFn) -> None:
|
|
21
|
+
self._request = request
|
|
22
|
+
|
|
23
|
+
def create(
|
|
24
|
+
self,
|
|
25
|
+
question: str,
|
|
26
|
+
*,
|
|
27
|
+
type: str = "confirm",
|
|
28
|
+
options: Optional[List[str]] = None,
|
|
29
|
+
external_id: Optional[str] = None,
|
|
30
|
+
callback_url: Optional[str] = None,
|
|
31
|
+
agent_name: Optional[str] = None,
|
|
32
|
+
context: Optional[str] = None,
|
|
33
|
+
placeholder: Optional[str] = None,
|
|
34
|
+
expires_in_seconds: Optional[int] = None,
|
|
35
|
+
wait: Optional[bool] = None,
|
|
36
|
+
timeout_seconds: Optional[int] = None,
|
|
37
|
+
idempotency_key: Optional[str] = None,
|
|
38
|
+
powered_by: Optional[bool] = None,
|
|
39
|
+
) -> Dict[str, Any]:
|
|
40
|
+
"""Create a decision and ask one end-user to approve it.
|
|
41
|
+
|
|
42
|
+
Defaults to async (returns immediately with a decisionId); pass
|
|
43
|
+
wait=True to block up to ~55s for a fast answer. Always pass
|
|
44
|
+
idempotency_key so a retried call does not ask the same human twice.
|
|
45
|
+
|
|
46
|
+
``type`` is "confirm" (yes/no), "select" (needs ``options``), or "input"
|
|
47
|
+
(free text). ``external_id`` targets the specific end-user to notify.
|
|
48
|
+
``callback_url`` receives the signed webhook when the human answers.
|
|
49
|
+
``expires_in_seconds`` sets how long the decision stays open.
|
|
50
|
+
``timeout_seconds`` bounds the server-side wait when ``wait`` is True.
|
|
51
|
+
|
|
52
|
+
Returns a dict with ``decisionId``, ``pollUrl``, ``decisionPageUrl``, and
|
|
53
|
+
the current ``status``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# Wire keys are camelCase even though the arguments are snake_case, and
|
|
57
|
+
# only keys with a value are sent so unset options stay absent.
|
|
58
|
+
body: Dict[str, Any] = {"question": question, "type": type}
|
|
59
|
+
optional = {
|
|
60
|
+
"options": options,
|
|
61
|
+
"externalId": external_id,
|
|
62
|
+
"callbackUrl": callback_url,
|
|
63
|
+
"agentName": agent_name,
|
|
64
|
+
"context": context,
|
|
65
|
+
"placeholder": placeholder,
|
|
66
|
+
"expiresInSeconds": expires_in_seconds,
|
|
67
|
+
"wait": wait,
|
|
68
|
+
"timeoutSeconds": timeout_seconds,
|
|
69
|
+
"idempotencyKey": idempotency_key,
|
|
70
|
+
"poweredBy": powered_by,
|
|
71
|
+
}
|
|
72
|
+
for key, value in optional.items():
|
|
73
|
+
if value is not None:
|
|
74
|
+
body[key] = value
|
|
75
|
+
return self._request("POST", "/decisions", body=body)
|
|
76
|
+
|
|
77
|
+
def get(self, decision_id: str, *, wait: Optional[int] = None) -> Dict[str, Any]:
|
|
78
|
+
"""Fetch the current state of a decision.
|
|
79
|
+
|
|
80
|
+
Pass wait=N to long-poll for up to N seconds (capped server-side around
|
|
81
|
+
55s) so the call returns as soon as the human answers instead of on the
|
|
82
|
+
next poll. Returns a dict with ``status``, ``answered``, and ``value``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
params = {"wait": wait} if wait is not None else None
|
|
86
|
+
return self._request("GET", f"/decisions/{_seg(decision_id)}", params=params)
|
|
87
|
+
|
|
88
|
+
def answer(self, decision_id: str, answer: str) -> Dict[str, Any]:
|
|
89
|
+
"""Record an answer for a decision on the human's behalf.
|
|
90
|
+
|
|
91
|
+
Useful when your own surface (not the Pushary decision page) collected
|
|
92
|
+
the answer. Any waiting ``get`` or ``create`` call resolves immediately.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
return self._request("POST", f"/decisions/{_seg(decision_id)}", body={"answer": answer})
|
|
96
|
+
|
|
97
|
+
def cancel(self, decision_id: str) -> Dict[str, Any]:
|
|
98
|
+
"""Cancel a still-open decision so it can no longer be answered."""
|
|
99
|
+
|
|
100
|
+
return self._request("DELETE", f"/decisions/{_seg(decision_id)}")
|
|
101
|
+
|
|
102
|
+
def get_webhook_secret(self) -> Dict[str, Any]:
|
|
103
|
+
"""Return the current webhook secret used to sign decision callbacks."""
|
|
104
|
+
|
|
105
|
+
return self._request("GET", "/webhook-secret")
|
|
106
|
+
|
|
107
|
+
def rotate_webhook_secret(self) -> Dict[str, Any]:
|
|
108
|
+
"""Rotate the webhook secret and return the new value.
|
|
109
|
+
|
|
110
|
+
The previous secret stops verifying once rotated, so update every
|
|
111
|
+
receiver before you rely on the new one.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
return self._request("POST", "/webhook-secret")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _seg(value: str) -> str:
|
|
118
|
+
"""Percent-encode a single path segment so an id cannot alter the route."""
|
|
119
|
+
|
|
120
|
+
return quote(str(value), safe="")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Error type raised by the Pushary SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PusharyError(Exception):
|
|
9
|
+
"""Raised when the Pushary API returns a non-2xx response.
|
|
10
|
+
|
|
11
|
+
Carries the HTTP ``status`` and the reason the server reported (its
|
|
12
|
+
``error`` or ``message`` field), so callers can classify the failure
|
|
13
|
+
instead of parsing a bare status code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, status: Optional[int] = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.status = status
|
|
20
|
+
|
|
21
|
+
def __repr__(self) -> str:
|
|
22
|
+
return f"PusharyError(status={self.status!r}, message={self.message!r})"
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Webhook signature verification.
|
|
2
|
+
|
|
3
|
+
When a human answers a decision, Pushary can POST the result to your
|
|
4
|
+
``callback_url``. The request carries an ``X-Pushary-Signature`` header, which is
|
|
5
|
+
an HMAC-SHA256 hex digest computed over the raw request body using your webhook
|
|
6
|
+
secret. Verify it before you trust the payload.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
from typing import Optional, Union
|
|
14
|
+
|
|
15
|
+
SIGNATURE_HEADER = "X-Pushary-Signature"
|
|
16
|
+
"""Name of the header that carries the signature on inbound webhooks."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_bytes(value: Union[str, bytes]) -> bytes:
|
|
20
|
+
"""Encode a value to bytes with utf-8, leaving bytes untouched."""
|
|
21
|
+
|
|
22
|
+
if isinstance(value, bytes):
|
|
23
|
+
return value
|
|
24
|
+
return value.encode("utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verify_webhook_signature(
|
|
28
|
+
raw_body: Union[str, bytes],
|
|
29
|
+
signature: Optional[str],
|
|
30
|
+
secret: str,
|
|
31
|
+
) -> bool:
|
|
32
|
+
"""Return True only if ``signature`` is a valid signature for ``raw_body``.
|
|
33
|
+
|
|
34
|
+
Compute ``hmac.new(secret, raw_body, sha256).hexdigest()`` and compare it to
|
|
35
|
+
the ``X-Pushary-Signature`` header in constant time. ``raw_body`` must be the
|
|
36
|
+
exact bytes you received, not a re-serialized copy, because any change to the
|
|
37
|
+
JSON (key order, spacing) changes the digest.
|
|
38
|
+
|
|
39
|
+
Returns False when either ``signature`` or ``secret`` is empty, or when the
|
|
40
|
+
lengths do not match, before doing the constant-time compare.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if not signature or not secret:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
expected = hmac.new(_to_bytes(secret), _to_bytes(raw_body), hashlib.sha256).hexdigest()
|
|
47
|
+
expected_bytes = expected.encode("ascii")
|
|
48
|
+
provided_bytes = _to_bytes(signature)
|
|
49
|
+
|
|
50
|
+
if len(expected_bytes) != len(provided_bytes):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
return hmac.compare_digest(expected_bytes, provided_bytes)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Tests for the client and decisions resource. No network: the transport is
|
|
2
|
+
either a recording stub or a patched urlopen.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import unittest
|
|
8
|
+
import urllib.error
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
from pushary import PusharyError, PusharyServer
|
|
12
|
+
from pushary.client import DEFAULT_BASE_URL
|
|
13
|
+
from pushary.decisions import DecisionsResource
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
VALID_KEY = "pk_live_abc.sk_live_xyz"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Recorder:
|
|
20
|
+
"""Stand-in for the client request function that records every call."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, result=None):
|
|
23
|
+
self.calls = []
|
|
24
|
+
self.result = {} if result is None else result
|
|
25
|
+
|
|
26
|
+
def __call__(self, method, path, *, body=None, params=None):
|
|
27
|
+
self.calls.append(
|
|
28
|
+
{"method": method, "path": path, "body": body, "params": params}
|
|
29
|
+
)
|
|
30
|
+
return self.result
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def last(self):
|
|
34
|
+
return self.calls[-1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiKeyValidationTests(unittest.TestCase):
|
|
38
|
+
def test_empty_key_raises_value_error(self):
|
|
39
|
+
with self.assertRaises(ValueError) as ctx:
|
|
40
|
+
PusharyServer(api_key="")
|
|
41
|
+
self.assertIn("dashboard/settings", str(ctx.exception))
|
|
42
|
+
|
|
43
|
+
def test_key_without_dot_raises_value_error(self):
|
|
44
|
+
with self.assertRaises(ValueError) as ctx:
|
|
45
|
+
PusharyServer(api_key="pk_live_only")
|
|
46
|
+
self.assertIn("pk_xxx.sk_xxx", str(ctx.exception))
|
|
47
|
+
|
|
48
|
+
def test_valid_key_constructs(self):
|
|
49
|
+
client = PusharyServer(api_key=VALID_KEY)
|
|
50
|
+
self.assertIsInstance(client.decisions, DecisionsResource)
|
|
51
|
+
|
|
52
|
+
def test_default_and_custom_base_url(self):
|
|
53
|
+
default_client = PusharyServer(api_key=VALID_KEY)
|
|
54
|
+
self.assertEqual(default_client._base_url, DEFAULT_BASE_URL)
|
|
55
|
+
|
|
56
|
+
custom = PusharyServer(api_key=VALID_KEY, base_url="https://example.com/api/")
|
|
57
|
+
self.assertEqual(custom._base_url, "https://example.com/api")
|
|
58
|
+
|
|
59
|
+
def test_authorization_header_carries_full_key(self):
|
|
60
|
+
client = PusharyServer(api_key=VALID_KEY)
|
|
61
|
+
self.assertEqual(client._headers["Authorization"], f"Bearer {VALID_KEY}")
|
|
62
|
+
self.assertEqual(client._headers["Content-Type"], "application/json")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DecisionsResourceTests(unittest.TestCase):
|
|
66
|
+
def test_create_maps_snake_to_camel_and_omits_none(self):
|
|
67
|
+
recorder = Recorder({"decisionId": "d_1", "status": "pending"})
|
|
68
|
+
decisions = DecisionsResource(recorder)
|
|
69
|
+
|
|
70
|
+
decisions.create(
|
|
71
|
+
"Approve payout?",
|
|
72
|
+
type="confirm",
|
|
73
|
+
external_id="user_123",
|
|
74
|
+
callback_url="https://app.test/hook",
|
|
75
|
+
agent_name="Billing Agent",
|
|
76
|
+
context="Invoice INV-1",
|
|
77
|
+
placeholder=None,
|
|
78
|
+
expires_in_seconds=3600,
|
|
79
|
+
wait=True,
|
|
80
|
+
timeout_seconds=50,
|
|
81
|
+
idempotency_key="idem-1",
|
|
82
|
+
powered_by=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
call = recorder.last
|
|
86
|
+
self.assertEqual(call["method"], "POST")
|
|
87
|
+
self.assertEqual(call["path"], "/decisions")
|
|
88
|
+
self.assertEqual(
|
|
89
|
+
call["body"],
|
|
90
|
+
{
|
|
91
|
+
"question": "Approve payout?",
|
|
92
|
+
"type": "confirm",
|
|
93
|
+
"externalId": "user_123",
|
|
94
|
+
"callbackUrl": "https://app.test/hook",
|
|
95
|
+
"agentName": "Billing Agent",
|
|
96
|
+
"context": "Invoice INV-1",
|
|
97
|
+
"expiresInSeconds": 3600,
|
|
98
|
+
"wait": True,
|
|
99
|
+
"timeoutSeconds": 50,
|
|
100
|
+
"idempotencyKey": "idem-1",
|
|
101
|
+
"poweredBy": False,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
self.assertNotIn("placeholder", call["body"])
|
|
105
|
+
|
|
106
|
+
def test_create_minimal_only_sends_question_and_type(self):
|
|
107
|
+
recorder = Recorder()
|
|
108
|
+
DecisionsResource(recorder).create("Ship it?")
|
|
109
|
+
self.assertEqual(
|
|
110
|
+
recorder.last["body"], {"question": "Ship it?", "type": "confirm"}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def test_create_select_includes_options(self):
|
|
114
|
+
recorder = Recorder()
|
|
115
|
+
DecisionsResource(recorder).create(
|
|
116
|
+
"Pick one", type="select", options=["A", "B"]
|
|
117
|
+
)
|
|
118
|
+
self.assertEqual(recorder.last["body"]["options"], ["A", "B"])
|
|
119
|
+
|
|
120
|
+
def test_get_without_wait_sends_no_params(self):
|
|
121
|
+
recorder = Recorder()
|
|
122
|
+
DecisionsResource(recorder).get("d_1")
|
|
123
|
+
self.assertEqual(recorder.last["method"], "GET")
|
|
124
|
+
self.assertEqual(recorder.last["path"], "/decisions/d_1")
|
|
125
|
+
self.assertIsNone(recorder.last["params"])
|
|
126
|
+
|
|
127
|
+
def test_get_with_wait_sends_param(self):
|
|
128
|
+
recorder = Recorder()
|
|
129
|
+
DecisionsResource(recorder).get("d_1", wait=50)
|
|
130
|
+
self.assertEqual(recorder.last["params"], {"wait": 50})
|
|
131
|
+
|
|
132
|
+
def test_answer_posts_body(self):
|
|
133
|
+
recorder = Recorder()
|
|
134
|
+
DecisionsResource(recorder).answer("d_1", "yes")
|
|
135
|
+
self.assertEqual(recorder.last["method"], "POST")
|
|
136
|
+
self.assertEqual(recorder.last["path"], "/decisions/d_1")
|
|
137
|
+
self.assertEqual(recorder.last["body"], {"answer": "yes"})
|
|
138
|
+
|
|
139
|
+
def test_cancel_deletes(self):
|
|
140
|
+
recorder = Recorder()
|
|
141
|
+
DecisionsResource(recorder).cancel("d_1")
|
|
142
|
+
self.assertEqual(recorder.last["method"], "DELETE")
|
|
143
|
+
self.assertEqual(recorder.last["path"], "/decisions/d_1")
|
|
144
|
+
|
|
145
|
+
def test_decision_id_is_percent_encoded(self):
|
|
146
|
+
recorder = Recorder()
|
|
147
|
+
DecisionsResource(recorder).get("a/b?c")
|
|
148
|
+
self.assertEqual(recorder.last["path"], "/decisions/a%2Fb%3Fc")
|
|
149
|
+
|
|
150
|
+
def test_webhook_secret_methods(self):
|
|
151
|
+
recorder = Recorder()
|
|
152
|
+
resource = DecisionsResource(recorder)
|
|
153
|
+
|
|
154
|
+
resource.get_webhook_secret()
|
|
155
|
+
self.assertEqual(recorder.last["method"], "GET")
|
|
156
|
+
self.assertEqual(recorder.last["path"], "/webhook-secret")
|
|
157
|
+
|
|
158
|
+
resource.rotate_webhook_secret()
|
|
159
|
+
self.assertEqual(recorder.last["method"], "POST")
|
|
160
|
+
self.assertEqual(recorder.last["path"], "/webhook-secret")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _fake_response(payload):
|
|
164
|
+
body = json.dumps(payload).encode("utf-8")
|
|
165
|
+
|
|
166
|
+
class _Resp:
|
|
167
|
+
def read(self):
|
|
168
|
+
return body
|
|
169
|
+
|
|
170
|
+
def __enter__(self):
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
def __exit__(self, *args):
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
return _Resp()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TransportTests(unittest.TestCase):
|
|
180
|
+
def test_get_builds_url_and_returns_dict(self):
|
|
181
|
+
captured = {}
|
|
182
|
+
|
|
183
|
+
def fake_urlopen(request, timeout=None):
|
|
184
|
+
captured["url"] = request.full_url
|
|
185
|
+
captured["method"] = request.get_method()
|
|
186
|
+
captured["timeout"] = timeout
|
|
187
|
+
captured["auth"] = request.get_header("Authorization")
|
|
188
|
+
return _fake_response({"decisionId": "d_1", "status": "answered", "value": "yes"})
|
|
189
|
+
|
|
190
|
+
client = PusharyServer(api_key=VALID_KEY)
|
|
191
|
+
with mock.patch("pushary.client.urllib.request.urlopen", fake_urlopen):
|
|
192
|
+
result = client.decisions.get("d_1", wait=50)
|
|
193
|
+
|
|
194
|
+
self.assertEqual(captured["url"], f"{DEFAULT_BASE_URL}/decisions/d_1?wait=50")
|
|
195
|
+
self.assertEqual(captured["method"], "GET")
|
|
196
|
+
self.assertEqual(captured["auth"], f"Bearer {VALID_KEY}")
|
|
197
|
+
self.assertGreater(captured["timeout"], 55)
|
|
198
|
+
self.assertEqual(result["value"], "yes")
|
|
199
|
+
|
|
200
|
+
def test_post_sends_json_body(self):
|
|
201
|
+
captured = {}
|
|
202
|
+
|
|
203
|
+
def fake_urlopen(request, timeout=None):
|
|
204
|
+
captured["url"] = request.full_url
|
|
205
|
+
captured["method"] = request.get_method()
|
|
206
|
+
captured["data"] = request.data
|
|
207
|
+
captured["content_type"] = request.get_header("Content-type")
|
|
208
|
+
return _fake_response({"decisionId": "d_1", "status": "pending"})
|
|
209
|
+
|
|
210
|
+
client = PusharyServer(api_key=VALID_KEY)
|
|
211
|
+
with mock.patch("pushary.client.urllib.request.urlopen", fake_urlopen):
|
|
212
|
+
client.decisions.create("Ship it?", external_id="user_9")
|
|
213
|
+
|
|
214
|
+
self.assertEqual(captured["method"], "POST")
|
|
215
|
+
self.assertEqual(captured["url"], f"{DEFAULT_BASE_URL}/decisions")
|
|
216
|
+
self.assertEqual(captured["content_type"], "application/json")
|
|
217
|
+
self.assertEqual(
|
|
218
|
+
json.loads(captured["data"].decode("utf-8")),
|
|
219
|
+
{"question": "Ship it?", "type": "confirm", "externalId": "user_9"},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def test_http_error_raises_pushary_error_with_status_and_reason(self):
|
|
223
|
+
def fake_urlopen(request, timeout=None):
|
|
224
|
+
raise urllib.error.HTTPError(
|
|
225
|
+
url=request.full_url,
|
|
226
|
+
code=404,
|
|
227
|
+
msg="Not Found",
|
|
228
|
+
hdrs=None,
|
|
229
|
+
fp=io.BytesIO(b'{"error":"Decision not found or expired"}'),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
client = PusharyServer(api_key=VALID_KEY)
|
|
233
|
+
with mock.patch("pushary.client.urllib.request.urlopen", fake_urlopen):
|
|
234
|
+
with self.assertRaises(PusharyError) as ctx:
|
|
235
|
+
client.decisions.get("missing")
|
|
236
|
+
|
|
237
|
+
error = ctx.exception
|
|
238
|
+
self.assertEqual(error.status, 404)
|
|
239
|
+
self.assertEqual(error.message, "Decision not found or expired")
|
|
240
|
+
|
|
241
|
+
def test_http_error_without_body_falls_back_to_status(self):
|
|
242
|
+
def fake_urlopen(request, timeout=None):
|
|
243
|
+
raise urllib.error.HTTPError(
|
|
244
|
+
url=request.full_url,
|
|
245
|
+
code=500,
|
|
246
|
+
msg="Server Error",
|
|
247
|
+
hdrs=None,
|
|
248
|
+
fp=io.BytesIO(b""),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
client = PusharyServer(api_key=VALID_KEY)
|
|
252
|
+
with mock.patch("pushary.client.urllib.request.urlopen", fake_urlopen):
|
|
253
|
+
with self.assertRaises(PusharyError) as ctx:
|
|
254
|
+
client.decisions.cancel("d_1")
|
|
255
|
+
|
|
256
|
+
self.assertEqual(ctx.exception.status, 500)
|
|
257
|
+
self.assertEqual(ctx.exception.message, "HTTP 500")
|
|
258
|
+
|
|
259
|
+
def test_url_error_raises_pushary_error(self):
|
|
260
|
+
def fake_urlopen(request, timeout=None):
|
|
261
|
+
raise urllib.error.URLError("connection refused")
|
|
262
|
+
|
|
263
|
+
client = PusharyServer(api_key=VALID_KEY)
|
|
264
|
+
with mock.patch("pushary.client.urllib.request.urlopen", fake_urlopen):
|
|
265
|
+
with self.assertRaises(PusharyError):
|
|
266
|
+
client.decisions.get("d_1")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
unittest.main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Tests for webhook signature verification. No network."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from pushary import SIGNATURE_HEADER, verify_webhook_signature
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SECRET = "whsec_test_secret"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sign(raw_body, secret=SECRET):
|
|
14
|
+
body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
|
|
15
|
+
key = secret if isinstance(secret, bytes) else secret.encode("utf-8")
|
|
16
|
+
return hmac.new(key, body, hashlib.sha256).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VerifyWebhookSignatureTests(unittest.TestCase):
|
|
20
|
+
def test_valid_signature_str_body(self):
|
|
21
|
+
body = '{"decisionId":"d_1","value":"yes"}'
|
|
22
|
+
self.assertTrue(verify_webhook_signature(body, sign(body), SECRET))
|
|
23
|
+
|
|
24
|
+
def test_valid_signature_bytes_body(self):
|
|
25
|
+
body = b'{"decisionId":"d_1","value":"yes"}'
|
|
26
|
+
self.assertTrue(verify_webhook_signature(body, sign(body), SECRET))
|
|
27
|
+
|
|
28
|
+
def test_str_and_bytes_body_agree(self):
|
|
29
|
+
text = '{"a":1}'
|
|
30
|
+
self.assertEqual(sign(text), sign(text.encode("utf-8")))
|
|
31
|
+
|
|
32
|
+
def test_tampered_body_fails(self):
|
|
33
|
+
body = '{"value":"yes"}'
|
|
34
|
+
signature = sign(body)
|
|
35
|
+
tampered = '{"value":"no"}'
|
|
36
|
+
self.assertFalse(verify_webhook_signature(tampered, signature, SECRET))
|
|
37
|
+
|
|
38
|
+
def test_wrong_secret_fails(self):
|
|
39
|
+
body = '{"value":"yes"}'
|
|
40
|
+
signature = sign(body, "another_secret")
|
|
41
|
+
self.assertFalse(verify_webhook_signature(body, signature, SECRET))
|
|
42
|
+
|
|
43
|
+
def test_missing_signature_returns_false(self):
|
|
44
|
+
body = '{"value":"yes"}'
|
|
45
|
+
self.assertFalse(verify_webhook_signature(body, None, SECRET))
|
|
46
|
+
self.assertFalse(verify_webhook_signature(body, "", SECRET))
|
|
47
|
+
|
|
48
|
+
def test_missing_secret_returns_false(self):
|
|
49
|
+
body = '{"value":"yes"}'
|
|
50
|
+
self.assertFalse(verify_webhook_signature(body, sign(body), ""))
|
|
51
|
+
|
|
52
|
+
def test_length_mismatch_returns_false(self):
|
|
53
|
+
body = '{"value":"yes"}'
|
|
54
|
+
self.assertFalse(verify_webhook_signature(body, "abc123", SECRET))
|
|
55
|
+
|
|
56
|
+
def test_non_ascii_signature_does_not_raise(self):
|
|
57
|
+
body = '{"value":"yes"}'
|
|
58
|
+
# A malicious header with multibyte characters must be rejected, not
|
|
59
|
+
# raise, so it can never crash the receiver.
|
|
60
|
+
self.assertFalse(verify_webhook_signature(body, "é" * 64, SECRET))
|
|
61
|
+
|
|
62
|
+
def test_signature_header_constant(self):
|
|
63
|
+
self.assertEqual(SIGNATURE_HEADER, "X-Pushary-Signature")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
unittest.main()
|