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.
@@ -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`.
@@ -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()