pushary-langgraph 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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/
@@ -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.
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: pushary-langgraph
3
+ Version: 0.1.0
4
+ Summary: Human-in-the-loop for LangGraph and LangChain: a blocking ask_human, plus a durable interrupt()/Command resume that reaches your user on their phone.
5
+ Project-URL: Homepage, https://pushary.com
6
+ Project-URL: Documentation, https://pushary.com/docs/agents/adapters
7
+ Author-email: Pushary <business@pushary.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-agents,approvals,human-in-the-loop,interrupt,langchain,langgraph
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: langgraph<2,>=1.0
24
+ Requires-Dist: pushary>=1.3.2
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pushary-langgraph
28
+
29
+ Human-in-the-loop for [LangGraph](https://langchain-ai.github.io/langgraph/) and
30
+ LangChain. Ask a real human to approve, and get the answer on their phone. Two seams:
31
+
32
+ - **A blocking `ask_human`** you call from inside a node.
33
+ - **A durable `pushary_interrupt`** that parks the graph with LangGraph's native
34
+ `interrupt()` and resumes on a signed webhook, so a long wait holds no compute and
35
+ survives a restart.
36
+
37
+ Requires the Pushary [Partner plan](https://pushary.com/agent-notifications-integration).
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install pushary-langgraph
43
+ ```
44
+
45
+ Set `PUSHARY_API_KEY` (get it in your [dashboard](https://pushary.com/dashboard/settings)).
46
+
47
+ ## Connect a phone once
48
+
49
+ ```python
50
+ from pushary_langgraph import connect
51
+
52
+ link = connect("user_123") # show this to your end-user; one tap connects their phone
53
+ ```
54
+
55
+ ## Ask a human inside a node
56
+
57
+ ```python
58
+ from pushary_langgraph import ask_human
59
+
60
+ def approval_node(state):
61
+ d = ask_human("Approve this transfer?", external_id=state["user_id"], node="approval")
62
+ return {"approved": d["approved"]}
63
+ ```
64
+
65
+ `ask_human` blocks, polls durably, and fails closed. The idempotency key is derived
66
+ from `external_id + node + question`, so a node that re-runs on resume hits the same
67
+ decision instead of paging the human twice.
68
+
69
+ ## Durable interrupt
70
+
71
+ ```python
72
+ from pushary_langgraph import pushary_interrupt
73
+
74
+ def approval_node(state):
75
+ answer = pushary_interrupt(
76
+ "Approve this transfer?",
77
+ external_id=state["user_id"],
78
+ node="approval",
79
+ callback_url=os.environ["PUSHARY_CALLBACK_URL"], # omit to block instead of park
80
+ )
81
+ return {"approved": answer == "yes"}
82
+ ```
83
+
84
+ With a `callback_url`, the node opens the decision and calls LangGraph's `interrupt()`
85
+ to park the graph (a checkpointer is required). Keep any code before the call
86
+ idempotent, the whole node re-runs on resume.
87
+
88
+ ### Resume from the webhook
89
+
90
+ ```python
91
+ from pushary_langgraph import resolve_pushary_callback, SIGNATURE_HEADER
92
+ from langgraph.types import Command
93
+
94
+ # POST /pushary/callback
95
+ def callback(request):
96
+ raw = request.body
97
+ cb = resolve_pushary_callback(raw, request.headers.get(SIGNATURE_HEADER), os.environ["PUSHARY_WEBHOOK_SECRET"])
98
+ if not cb:
99
+ return ("bad signature", 401)
100
+ thread_id = lookup_thread(cb["correlationId"]) # your own correlationId -> thread_id map
101
+ graph.invoke(Command(resume=cb["answer"]), {"configurable": {"thread_id": thread_id}})
102
+ return ("ok", 200)
103
+ ```
104
+
105
+ ## API
106
+
107
+ - `connect(external_id, *, api_key=None, base_url=None)` — enroll an end-user's phone, returns the link.
108
+ - `ask_human(question, *, external_id, type="confirm", options=None, node=..., ...)` — blocking, returns the decision dict.
109
+ - `pushary_interrupt(question, *, external_id, node=..., callback_url=None, ...)` — blocking, or durable when `callback_url` is set.
110
+ - `resolve_pushary_callback(raw_body, signature, secret)` — verify + parse a callback into `{correlationId, answer, approved, ...}`.
111
+ - `describe_answer(type, result)`, `is_affirmative(answer)`, `deterministic_key(parts)`, `SIGNATURE_HEADER`.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,89 @@
1
+ # pushary-langgraph
2
+
3
+ Human-in-the-loop for [LangGraph](https://langchain-ai.github.io/langgraph/) and
4
+ LangChain. Ask a real human to approve, and get the answer on their phone. Two seams:
5
+
6
+ - **A blocking `ask_human`** you call from inside a node.
7
+ - **A durable `pushary_interrupt`** that parks the graph with LangGraph's native
8
+ `interrupt()` and resumes on a signed webhook, so a long wait holds no compute and
9
+ survives a restart.
10
+
11
+ Requires the Pushary [Partner plan](https://pushary.com/agent-notifications-integration).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install pushary-langgraph
17
+ ```
18
+
19
+ Set `PUSHARY_API_KEY` (get it in your [dashboard](https://pushary.com/dashboard/settings)).
20
+
21
+ ## Connect a phone once
22
+
23
+ ```python
24
+ from pushary_langgraph import connect
25
+
26
+ link = connect("user_123") # show this to your end-user; one tap connects their phone
27
+ ```
28
+
29
+ ## Ask a human inside a node
30
+
31
+ ```python
32
+ from pushary_langgraph import ask_human
33
+
34
+ def approval_node(state):
35
+ d = ask_human("Approve this transfer?", external_id=state["user_id"], node="approval")
36
+ return {"approved": d["approved"]}
37
+ ```
38
+
39
+ `ask_human` blocks, polls durably, and fails closed. The idempotency key is derived
40
+ from `external_id + node + question`, so a node that re-runs on resume hits the same
41
+ decision instead of paging the human twice.
42
+
43
+ ## Durable interrupt
44
+
45
+ ```python
46
+ from pushary_langgraph import pushary_interrupt
47
+
48
+ def approval_node(state):
49
+ answer = pushary_interrupt(
50
+ "Approve this transfer?",
51
+ external_id=state["user_id"],
52
+ node="approval",
53
+ callback_url=os.environ["PUSHARY_CALLBACK_URL"], # omit to block instead of park
54
+ )
55
+ return {"approved": answer == "yes"}
56
+ ```
57
+
58
+ With a `callback_url`, the node opens the decision and calls LangGraph's `interrupt()`
59
+ to park the graph (a checkpointer is required). Keep any code before the call
60
+ idempotent, the whole node re-runs on resume.
61
+
62
+ ### Resume from the webhook
63
+
64
+ ```python
65
+ from pushary_langgraph import resolve_pushary_callback, SIGNATURE_HEADER
66
+ from langgraph.types import Command
67
+
68
+ # POST /pushary/callback
69
+ def callback(request):
70
+ raw = request.body
71
+ cb = resolve_pushary_callback(raw, request.headers.get(SIGNATURE_HEADER), os.environ["PUSHARY_WEBHOOK_SECRET"])
72
+ if not cb:
73
+ return ("bad signature", 401)
74
+ thread_id = lookup_thread(cb["correlationId"]) # your own correlationId -> thread_id map
75
+ graph.invoke(Command(resume=cb["answer"]), {"configurable": {"thread_id": thread_id}})
76
+ return ("ok", 200)
77
+ ```
78
+
79
+ ## API
80
+
81
+ - `connect(external_id, *, api_key=None, base_url=None)` — enroll an end-user's phone, returns the link.
82
+ - `ask_human(question, *, external_id, type="confirm", options=None, node=..., ...)` — blocking, returns the decision dict.
83
+ - `pushary_interrupt(question, *, external_id, node=..., callback_url=None, ...)` — blocking, or durable when `callback_url` is set.
84
+ - `resolve_pushary_callback(raw_body, signature, secret)` — verify + parse a callback into `{correlationId, answer, approved, ...}`.
85
+ - `describe_answer(type, result)`, `is_affirmative(answer)`, `deterministic_key(parts)`, `SIGNATURE_HEADER`.
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pushary-langgraph"
7
+ version = "0.1.0"
8
+ description = "Human-in-the-loop for LangGraph and LangChain: a blocking ask_human, plus a durable interrupt()/Command resume that reaches your user on their phone."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Pushary", email = "business@pushary.com" }]
13
+ keywords = [
14
+ "langgraph",
15
+ "langchain",
16
+ "human-in-the-loop",
17
+ "ai-agents",
18
+ "approvals",
19
+ "interrupt",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
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.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Typing :: Typed",
33
+ ]
34
+ dependencies = [
35
+ "pushary>=1.3.2",
36
+ "langgraph>=1.0,<2",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://pushary.com"
41
+ Documentation = "https://pushary.com/docs/agents/adapters"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/pushary_langgraph"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = [
48
+ "src/pushary_langgraph",
49
+ "README.md",
50
+ "LICENSE",
51
+ "tests",
52
+ ]
@@ -0,0 +1,191 @@
1
+ """Human-in-the-loop for LangGraph and LangChain, powered by Pushary.
2
+
3
+ Two seams over the durable two-call contract (``enroll`` + ``decisions.ask``):
4
+
5
+ - ``ask_human`` / ``pushary_interrupt`` without a callback: a blocking approval you
6
+ call from inside a node. It polls durably and fails closed.
7
+ - ``pushary_interrupt`` with a ``callback_url``: parks the graph with LangGraph's
8
+ native ``interrupt()`` and resumes on Pushary's signed webhook, so an hour-long
9
+ wait holds no compute and survives a restart.
10
+
11
+ Zero framework import at module load: LangGraph is imported lazily, only on the
12
+ durable path, so the blocking helpers work (and test) without it installed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from pushary import (
21
+ PusharyServer,
22
+ SIGNATURE_HEADER,
23
+ deterministic_key,
24
+ is_approved,
25
+ parse_decision_callback,
26
+ verify_webhook_signature,
27
+ )
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "connect",
33
+ "ask_human",
34
+ "pushary_interrupt",
35
+ "describe_answer",
36
+ "resolve_pushary_callback",
37
+ "is_affirmative",
38
+ "deterministic_key",
39
+ "SIGNATURE_HEADER",
40
+ "__version__",
41
+ ]
42
+
43
+
44
+ def _client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> PusharyServer:
45
+ key = api_key or os.environ.get("PUSHARY_API_KEY")
46
+ if not key:
47
+ raise ValueError("Pushary: set PUSHARY_API_KEY or pass api_key=... to the LangGraph helpers.")
48
+ return PusharyServer(api_key=key, base_url=base_url)
49
+
50
+
51
+ def _idempotency_key(external_id: str, node: str, question: str) -> str:
52
+ return deterministic_key([external_id, node, question])
53
+
54
+
55
+ def is_affirmative(answer: Optional[str]) -> bool:
56
+ """Fail-closed yes/no check for a confirm answer."""
57
+ return is_approved("answered", "confirm", answer)
58
+
59
+
60
+ def connect(external_id: str, *, api_key: Optional[str] = None, base_url: Optional[str] = None) -> str:
61
+ """Connect one end-user's phone (keyless). Returns a single-use link to show them."""
62
+ return _client(api_key, base_url).enroll(external_id)["universalLink"]
63
+
64
+
65
+ def ask_human(
66
+ question: str,
67
+ *,
68
+ external_id: str,
69
+ type: str = "confirm",
70
+ options: Optional[List[str]] = None,
71
+ node: str = "ask-human",
72
+ context: Optional[str] = None,
73
+ agent_name: Optional[str] = None,
74
+ timeout_seconds: Optional[float] = None,
75
+ api_key: Optional[str] = None,
76
+ base_url: Optional[str] = None,
77
+ ) -> Dict[str, Any]:
78
+ """Blocking ask (Pattern A): create a decision and poll durably until answered.
79
+
80
+ Returns the decision dict (``decisionId``, ``status``, ``answered``, ``value``,
81
+ ``type``, fail-closed ``approved``). The idempotency key is derived from
82
+ external_id + node + question, so a node that re-runs on resume hits the same
83
+ decision instead of paging the human twice.
84
+ """
85
+ return _client(api_key, base_url).decisions.ask(
86
+ question,
87
+ type=type,
88
+ options=options,
89
+ external_id=external_id,
90
+ context=context,
91
+ agent_name=agent_name,
92
+ timeout_seconds=timeout_seconds,
93
+ idempotency_key=_idempotency_key(external_id, node, question),
94
+ )
95
+
96
+
97
+ def describe_answer(type: str, result: Dict[str, Any]) -> str:
98
+ """Turn a decision outcome into an unambiguous instruction for the model."""
99
+ if not result.get("answered"):
100
+ return (
101
+ f"No answer (status: {result.get('status')}). "
102
+ "Treat this as NOT approved and do not proceed."
103
+ )
104
+ if type == "confirm":
105
+ return (
106
+ "The human approved. You may proceed."
107
+ if result.get("approved")
108
+ else "The human declined. Do not proceed."
109
+ )
110
+ return f"The human answered: {result.get('value') or ''}"
111
+
112
+
113
+ def resolve_pushary_callback(
114
+ raw_body: Any, signature: Optional[str], secret: str
115
+ ) -> Optional[Dict[str, Any]]:
116
+ """Verify a callback signature and parse it, or return None.
117
+
118
+ Feed ``answer`` into ``graph.invoke(Command(resume=answer), config)``.
119
+ """
120
+ if not verify_webhook_signature(raw_body, signature, secret):
121
+ return None
122
+ cb = parse_decision_callback(raw_body)
123
+ if not cb:
124
+ return None
125
+ return {
126
+ "correlationId": cb.get("correlationId"),
127
+ "answer": cb.get("answer"),
128
+ "value": cb.get("value"),
129
+ "approved": is_affirmative(cb.get("answer")),
130
+ "context": cb.get("context"),
131
+ "answeredAt": cb.get("answeredAt"),
132
+ }
133
+
134
+
135
+ def pushary_interrupt(
136
+ question: str,
137
+ *,
138
+ external_id: str,
139
+ node: str = "hitl",
140
+ type: str = "confirm",
141
+ options: Optional[List[str]] = None,
142
+ callback_url: Optional[str] = None,
143
+ context: Optional[str] = None,
144
+ agent_name: Optional[str] = None,
145
+ timeout_seconds: Optional[float] = None,
146
+ api_key: Optional[str] = None,
147
+ base_url: Optional[str] = None,
148
+ ) -> Optional[str]:
149
+ """Ask a human from inside a LangGraph node.
150
+
151
+ - ``callback_url`` omitted (Pattern A): blocks, polls durably, returns the answer
152
+ (or None if fail-closed). Zero extra infra, holds the run open for the wait.
153
+ - ``callback_url`` set (Pattern B): opens the decision, then calls LangGraph's
154
+ ``interrupt()`` to park the graph in your checkpointer. Resume with
155
+ ``Command(resume=answer)`` from the signed webhook. Holds no idle compute.
156
+
157
+ The whole node re-runs on resume, so keep code before this call idempotent. The
158
+ decision's idempotency key is derived from external_id + node + question, so the
159
+ re-run lands on the same decision.
160
+ """
161
+ idem = _idempotency_key(external_id, node, question)
162
+ px = _client(api_key, base_url)
163
+
164
+ if not callback_url:
165
+ d = px.decisions.ask(
166
+ question,
167
+ type=type,
168
+ options=options,
169
+ external_id=external_id,
170
+ context=context,
171
+ agent_name=agent_name,
172
+ timeout_seconds=timeout_seconds,
173
+ idempotency_key=idem,
174
+ )
175
+ return d.get("value") if d.get("answered") else None
176
+
177
+ px.decisions.create(
178
+ question,
179
+ type=type,
180
+ options=options,
181
+ external_id=external_id,
182
+ context=context,
183
+ agent_name=agent_name,
184
+ callback_url=callback_url,
185
+ idempotency_key=idem,
186
+ wait=False,
187
+ )
188
+ # Lazy import: only the durable path needs LangGraph installed.
189
+ from langgraph.types import interrupt
190
+
191
+ return interrupt({"pushary": "decision", "question": question, "external_id": external_id})
File without changes
@@ -0,0 +1,149 @@
1
+ """Tests for pushary_langgraph. Framework-free: LangGraph is never imported here,
2
+ so the blocking helpers are exercised without it installed. HTTP is mocked by
3
+ swapping the module's ``_client`` for a stub.
4
+ """
5
+
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import unittest
10
+
11
+ import pushary_langgraph as plg
12
+
13
+
14
+ class FakeDecisions:
15
+ def __init__(self, ask_result=None, create_result=None):
16
+ self.ask_calls = []
17
+ self.create_calls = []
18
+ self._ask_result = ask_result or {}
19
+ self._create_result = create_result or {}
20
+
21
+ def ask(self, question, **kwargs):
22
+ self.ask_calls.append({"question": question, **kwargs})
23
+ return self._ask_result
24
+
25
+ def create(self, question, **kwargs):
26
+ self.create_calls.append({"question": question, **kwargs})
27
+ return self._create_result
28
+
29
+
30
+ class FakeClient:
31
+ def __init__(self, decisions=None, enroll_result=None):
32
+ self.decisions = decisions or FakeDecisions()
33
+ self._enroll_result = enroll_result or {}
34
+ self.enroll_calls = []
35
+
36
+ def enroll(self, external_id):
37
+ self.enroll_calls.append(external_id)
38
+ return self._enroll_result
39
+
40
+
41
+ class WithFakeClient:
42
+ """Patch plg._client to return a supplied FakeClient for the duration of a test."""
43
+
44
+ def __init__(self, client):
45
+ self.client = client
46
+ self._orig = None
47
+
48
+ def __enter__(self):
49
+ self._orig = plg._client
50
+ plg._client = lambda *a, **k: self.client
51
+ return self.client
52
+
53
+ def __exit__(self, *exc):
54
+ plg._client = self._orig
55
+
56
+
57
+ SECRET = "whsec_test"
58
+
59
+
60
+ def sign(body: str) -> str:
61
+ return hmac.new(SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
62
+
63
+
64
+ class ConnectTests(unittest.TestCase):
65
+ def test_connect_returns_universal_link(self):
66
+ client = FakeClient(enroll_result={"universalLink": "https://pushary.com/e/tok"})
67
+ with WithFakeClient(client):
68
+ link = plg.connect("user_1")
69
+ self.assertEqual(link, "https://pushary.com/e/tok")
70
+ self.assertEqual(client.enroll_calls, ["user_1"])
71
+
72
+
73
+ class AskHumanTests(unittest.TestCase):
74
+ def test_ask_human_forwards_deterministic_idempotency_key(self):
75
+ decisions = FakeDecisions(ask_result={"status": "answered", "answered": True, "value": "yes", "approved": True})
76
+ with WithFakeClient(FakeClient(decisions=decisions)):
77
+ out = plg.ask_human("Approve?", external_id="user_1", node="approval")
78
+ self.assertTrue(out["approved"])
79
+ call = decisions.ask_calls[0]
80
+ self.assertEqual(call["external_id"], "user_1")
81
+ self.assertTrue(call["idempotency_key"])
82
+ # same input -> same key (re-run safe)
83
+ expected = plg.deterministic_key(["user_1", "approval", "Approve?"])
84
+ self.assertEqual(call["idempotency_key"], expected)
85
+
86
+
87
+ class PusharyInterruptTests(unittest.TestCase):
88
+ def test_blocking_pattern_returns_value_when_answered(self):
89
+ decisions = FakeDecisions(ask_result={"answered": True, "value": "yes"})
90
+ with WithFakeClient(FakeClient(decisions=decisions)):
91
+ answer = plg.pushary_interrupt("Approve?", external_id="user_1", node="n")
92
+ self.assertEqual(answer, "yes")
93
+
94
+ def test_blocking_pattern_fails_closed_when_unanswered(self):
95
+ decisions = FakeDecisions(ask_result={"answered": False, "status": "expired"})
96
+ with WithFakeClient(FakeClient(decisions=decisions)):
97
+ answer = plg.pushary_interrupt("Approve?", external_id="user_1", node="n")
98
+ self.assertIsNone(answer)
99
+
100
+ def test_durable_pattern_opens_decision_with_callback(self):
101
+ # callback_url set -> Pattern B calls create() then imports langgraph.interrupt.
102
+ # langgraph is not installed in this test env, so we assert the create call happened
103
+ # and that the lazy import is what raises (not our code path).
104
+ decisions = FakeDecisions(create_result={"decisionId": "d1", "status": "pending"})
105
+ with WithFakeClient(FakeClient(decisions=decisions)):
106
+ with self.assertRaises(ImportError):
107
+ plg.pushary_interrupt(
108
+ "Approve?", external_id="user_1", node="n", callback_url="https://x/cb"
109
+ )
110
+ create = decisions.create_calls[0]
111
+ self.assertEqual(create["callback_url"], "https://x/cb")
112
+ self.assertEqual(create["wait"], False)
113
+
114
+
115
+ class DescribeAnswerTests(unittest.TestCase):
116
+ def test_formats_every_outcome(self):
117
+ self.assertIn("approved", plg.describe_answer("confirm", {"answered": True, "approved": True}))
118
+ self.assertIn("declined", plg.describe_answer("confirm", {"answered": True, "approved": False}))
119
+ self.assertIn("NOT approved", plg.describe_answer("confirm", {"answered": False, "status": "expired"}))
120
+ self.assertIn("B", plg.describe_answer("select", {"answered": True, "value": "B"}))
121
+
122
+
123
+ class ResolveCallbackTests(unittest.TestCase):
124
+ def test_verifies_parses_and_folds_approved(self):
125
+ body = json.dumps({"correlationId": "d1", "answer": "yes", "answeredAt": "", "context": "t-1"})
126
+ out = plg.resolve_pushary_callback(body, sign(body), SECRET)
127
+ self.assertEqual(out["correlationId"], "d1")
128
+ self.assertTrue(out["approved"])
129
+ self.assertEqual(out["context"], "t-1")
130
+
131
+ def test_rejects_bad_signature(self):
132
+ body = json.dumps({"correlationId": "d1", "answer": "yes", "answeredAt": ""})
133
+ self.assertIsNone(plg.resolve_pushary_callback(body, "nope", SECRET))
134
+
135
+ def test_decline_is_not_approved(self):
136
+ body = json.dumps({"correlationId": "d2", "answer": "no", "answeredAt": ""})
137
+ out = plg.resolve_pushary_callback(body, sign(body), SECRET)
138
+ self.assertFalse(out["approved"])
139
+
140
+
141
+ class IsAffirmativeTests(unittest.TestCase):
142
+ def test_fail_closed(self):
143
+ self.assertTrue(plg.is_affirmative("yes"))
144
+ self.assertFalse(plg.is_affirmative("no"))
145
+ self.assertFalse(plg.is_affirmative(None))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()