letsping 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.
letsping-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cordia Labs
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,4 @@
1
+ include README.md
2
+ include LICENSE
3
+ include py.typed
4
+ global-exclude *.py[cod]
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: letsping
3
+ Version: 0.1.0
4
+ Summary: The Control Plane for Autonomous Agents. Add Human-in-the-Loop approval with one line of code.
5
+ Author-email: Cordia Labs <security@letsping.co>
6
+ License: MIT
7
+ Project-URL: Homepage, https://letsping.co
8
+ Project-URL: Documentation, https://letsping.co/docs
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: httpx>=0.23.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
22
+ Requires-Dist: respx>=0.20.0; extra == "dev"
23
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
24
+ Requires-Dist: mypy>=1.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # LetsPing Python SDK
29
+
30
+ The official state management infrastructure for Human-in-the-Loop (HITL) AI agents.
31
+
32
+ LetsPing provides a durable "pause button" for autonomous agents, decoupling the agent's execution logic from the human's response time. It handles state serialization, secure polling, and notification routing (Slack, Email) automatically.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install letsping
38
+
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ Set your API key as an environment variable (recommended) or pass it directly.
44
+
45
+ ```bash
46
+ export LETSPING_API_KEY="lp_live_..."
47
+
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ### 1. The "Ask" Primitive (Blocking)
53
+
54
+ Use this when you want to pause a script until a human approves.
55
+
56
+ ```python
57
+ from letsping import LetsPing
58
+
59
+ client = LetsPing()
60
+
61
+ # Pauses here for up to 24 hours (default)
62
+ decision = client.ask(
63
+ service="payments-agent",
64
+ action="transfer_funds",
65
+ payload={
66
+ "amount": 5000,
67
+ "currency": "USD",
68
+ "recipient": "acct_99"
69
+ },
70
+ priority="critical"
71
+ )
72
+
73
+ # Execution resumes only after approval
74
+ print(f"Transfer approved by {decision['metadata']['actor_id']}")
75
+
76
+ ```
77
+
78
+ ### 2. Async / Non-Blocking (FastAPI/LangGraph)
79
+
80
+ For high-concurrency environments or event loops.
81
+
82
+ ```python
83
+ import asyncio
84
+ from letsping import LetsPing
85
+
86
+ async def main():
87
+ client = LetsPing()
88
+
89
+ # Non-blocking wait
90
+ decision = await client.aask(
91
+ service="github-agent",
92
+ action="merge_pr",
93
+ payload={"pr_id": 42},
94
+ timeout=3600 # 1 hour timeout
95
+ )
96
+
97
+ asyncio.run(main())
98
+
99
+ ```
100
+
101
+ ### 3. LangChain / Agent Integration
102
+
103
+ LetsPing provides a compliant tool interface that can be injected directly into LLM agent toolkits (LangChain, CrewAI, etc). This allows the LLM to *decide* when to ask for help.
104
+
105
+ ```python
106
+ from letsping import LetsPing
107
+
108
+ client = LetsPing()
109
+
110
+ tools = [
111
+ # ... your other tools (search, calculator) ...
112
+
113
+ # Inject the human as a tool
114
+ client.tool(
115
+ service="research-agent",
116
+ action="review_draft",
117
+ priority="high"
118
+ )
119
+ ]
120
+
121
+ ```
122
+
123
+ ## Error Handling
124
+
125
+ The SDK uses typed exceptions for control flow.
126
+
127
+ * `ApprovalRejectedError`: Raised when the human explicitly clicks "Reject".
128
+ * `TimeoutError`: Raised when the duration (default 24h) expires without a decision.
129
+ * `LetsPingError`: Base class for API or network failures.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,106 @@
1
+ # LetsPing Python SDK
2
+
3
+ The official state management infrastructure for Human-in-the-Loop (HITL) AI agents.
4
+
5
+ LetsPing provides a durable "pause button" for autonomous agents, decoupling the agent's execution logic from the human's response time. It handles state serialization, secure polling, and notification routing (Slack, Email) automatically.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install letsping
11
+
12
+ ```
13
+
14
+ ## Configuration
15
+
16
+ Set your API key as an environment variable (recommended) or pass it directly.
17
+
18
+ ```bash
19
+ export LETSPING_API_KEY="lp_live_..."
20
+
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### 1. The "Ask" Primitive (Blocking)
26
+
27
+ Use this when you want to pause a script until a human approves.
28
+
29
+ ```python
30
+ from letsping import LetsPing
31
+
32
+ client = LetsPing()
33
+
34
+ # Pauses here for up to 24 hours (default)
35
+ decision = client.ask(
36
+ service="payments-agent",
37
+ action="transfer_funds",
38
+ payload={
39
+ "amount": 5000,
40
+ "currency": "USD",
41
+ "recipient": "acct_99"
42
+ },
43
+ priority="critical"
44
+ )
45
+
46
+ # Execution resumes only after approval
47
+ print(f"Transfer approved by {decision['metadata']['actor_id']}")
48
+
49
+ ```
50
+
51
+ ### 2. Async / Non-Blocking (FastAPI/LangGraph)
52
+
53
+ For high-concurrency environments or event loops.
54
+
55
+ ```python
56
+ import asyncio
57
+ from letsping import LetsPing
58
+
59
+ async def main():
60
+ client = LetsPing()
61
+
62
+ # Non-blocking wait
63
+ decision = await client.aask(
64
+ service="github-agent",
65
+ action="merge_pr",
66
+ payload={"pr_id": 42},
67
+ timeout=3600 # 1 hour timeout
68
+ )
69
+
70
+ asyncio.run(main())
71
+
72
+ ```
73
+
74
+ ### 3. LangChain / Agent Integration
75
+
76
+ LetsPing provides a compliant tool interface that can be injected directly into LLM agent toolkits (LangChain, CrewAI, etc). This allows the LLM to *decide* when to ask for help.
77
+
78
+ ```python
79
+ from letsping import LetsPing
80
+
81
+ client = LetsPing()
82
+
83
+ tools = [
84
+ # ... your other tools (search, calculator) ...
85
+
86
+ # Inject the human as a tool
87
+ client.tool(
88
+ service="research-agent",
89
+ action="review_draft",
90
+ priority="high"
91
+ )
92
+ ]
93
+
94
+ ```
95
+
96
+ ## Error Handling
97
+
98
+ The SDK uses typed exceptions for control flow.
99
+
100
+ * `ApprovalRejectedError`: Raised when the human explicitly clicks "Reject".
101
+ * `TimeoutError`: Raised when the duration (default 24h) expires without a decision.
102
+ * `LetsPingError`: Base class for API or network failures.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: letsping
3
+ Version: 0.1.0
4
+ Summary: The Control Plane for Autonomous Agents. Add Human-in-the-Loop approval with one line of code.
5
+ Author-email: Cordia Labs <security@letsping.co>
6
+ License: MIT
7
+ Project-URL: Homepage, https://letsping.co
8
+ Project-URL: Documentation, https://letsping.co/docs
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: httpx>=0.23.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
22
+ Requires-Dist: respx>=0.20.0; extra == "dev"
23
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
24
+ Requires-Dist: mypy>=1.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # LetsPing Python SDK
29
+
30
+ The official state management infrastructure for Human-in-the-Loop (HITL) AI agents.
31
+
32
+ LetsPing provides a durable "pause button" for autonomous agents, decoupling the agent's execution logic from the human's response time. It handles state serialization, secure polling, and notification routing (Slack, Email) automatically.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install letsping
38
+
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ Set your API key as an environment variable (recommended) or pass it directly.
44
+
45
+ ```bash
46
+ export LETSPING_API_KEY="lp_live_..."
47
+
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ### 1. The "Ask" Primitive (Blocking)
53
+
54
+ Use this when you want to pause a script until a human approves.
55
+
56
+ ```python
57
+ from letsping import LetsPing
58
+
59
+ client = LetsPing()
60
+
61
+ # Pauses here for up to 24 hours (default)
62
+ decision = client.ask(
63
+ service="payments-agent",
64
+ action="transfer_funds",
65
+ payload={
66
+ "amount": 5000,
67
+ "currency": "USD",
68
+ "recipient": "acct_99"
69
+ },
70
+ priority="critical"
71
+ )
72
+
73
+ # Execution resumes only after approval
74
+ print(f"Transfer approved by {decision['metadata']['actor_id']}")
75
+
76
+ ```
77
+
78
+ ### 2. Async / Non-Blocking (FastAPI/LangGraph)
79
+
80
+ For high-concurrency environments or event loops.
81
+
82
+ ```python
83
+ import asyncio
84
+ from letsping import LetsPing
85
+
86
+ async def main():
87
+ client = LetsPing()
88
+
89
+ # Non-blocking wait
90
+ decision = await client.aask(
91
+ service="github-agent",
92
+ action="merge_pr",
93
+ payload={"pr_id": 42},
94
+ timeout=3600 # 1 hour timeout
95
+ )
96
+
97
+ asyncio.run(main())
98
+
99
+ ```
100
+
101
+ ### 3. LangChain / Agent Integration
102
+
103
+ LetsPing provides a compliant tool interface that can be injected directly into LLM agent toolkits (LangChain, CrewAI, etc). This allows the LLM to *decide* when to ask for help.
104
+
105
+ ```python
106
+ from letsping import LetsPing
107
+
108
+ client = LetsPing()
109
+
110
+ tools = [
111
+ # ... your other tools (search, calculator) ...
112
+
113
+ # Inject the human as a tool
114
+ client.tool(
115
+ service="research-agent",
116
+ action="review_draft",
117
+ priority="high"
118
+ )
119
+ ]
120
+
121
+ ```
122
+
123
+ ## Error Handling
124
+
125
+ The SDK uses typed exceptions for control flow.
126
+
127
+ * `ApprovalRejectedError`: Raised when the human explicitly clicks "Reject".
128
+ * `TimeoutError`: Raised when the duration (default 24h) expires without a decision.
129
+ * `LetsPingError`: Base class for API or network failures.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ letsping.py
5
+ py.typed
6
+ pyproject.toml
7
+ letsping.egg-info/PKG-INFO
8
+ letsping.egg-info/SOURCES.txt
9
+ letsping.egg-info/dependency_links.txt
10
+ letsping.egg-info/requires.txt
11
+ letsping.egg-info/top_level.txt
12
+ tests/test_core.py
@@ -0,0 +1,9 @@
1
+ httpx>=0.23.0
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-asyncio>=0.21.0
6
+ respx>=0.20.0
7
+ pytest-cov>=4.0
8
+ mypy>=1.0
9
+ ruff>=0.1.0
@@ -0,0 +1 @@
1
+ letsping
@@ -0,0 +1,217 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import logging
5
+ import asyncio
6
+ from typing import Optional, Dict, Any, Literal, TypedDict, Callable
7
+
8
+ import httpx
9
+
10
+ logger = logging.getLogger("letsping")
11
+
12
+ DEFAULT_BASE_URL = "https://letsping.co/api"
13
+ VERSION = "0.1.0"
14
+
15
+ Priority = Literal["low", "medium", "high", "critical"]
16
+ Status = Literal["APPROVED", "REJECTED", "PENDING"]
17
+
18
+ class Decision(TypedDict):
19
+ status: Status
20
+ payload: Dict[str, Any]
21
+ patched_payload: Optional[Dict[str, Any]]
22
+ metadata: Dict[str, Any]
23
+
24
+ class LetsPingError(Exception):
25
+ """Base class for all LetsPing SDK errors."""
26
+ pass
27
+
28
+ class AuthenticationError(LetsPingError):
29
+ pass
30
+
31
+ class ApprovalRejectedError(LetsPingError):
32
+ def __init__(self, reason: str):
33
+ super().__init__(f"Request rejected: {reason}")
34
+ self.reason = reason
35
+
36
+ class TimeoutError(LetsPingError):
37
+ pass
38
+
39
+ class LetsPing:
40
+ """
41
+ The official state management client for Human-in-the-Loop AI agents.
42
+ Thread-safe and async-native.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ api_key: Optional[str] = None,
48
+ base_url: Optional[str] = None,
49
+ timeout: float = 30.0
50
+ ):
51
+ self._api_key = api_key or os.getenv("LETSPING_API_KEY")
52
+ if not self._api_key:
53
+ raise ValueError("LetsPing API Key must be provided via arg or LETSPING_API_KEY env var.")
54
+
55
+ self._base_url = (base_url or os.getenv("LETSPING_BASE_URL", DEFAULT_BASE_URL)).rstrip('/')
56
+ self._timeout = timeout
57
+ self._headers = {
58
+ "Authorization": f"Bearer {self._api_key}",
59
+ "Content-Type": "application/json",
60
+ "User-Agent": f"letsping-python/{VERSION}",
61
+ "Accept": "application/json"
62
+ }
63
+
64
+ def ask(
65
+ self,
66
+ service: str,
67
+ action: str,
68
+ payload: Dict[str, Any],
69
+ priority: Priority = "medium",
70
+ timeout: int = 86400
71
+ ) -> Decision:
72
+ """Blocking call: Pauses execution until a human decision is rendered."""
73
+ request_id = self.defer(service, action, payload, priority)
74
+ return self.wait(request_id, timeout=timeout)
75
+
76
+ def defer(
77
+ self,
78
+ service: str,
79
+ action: str,
80
+ payload: Dict[str, Any],
81
+ priority: Priority = "medium",
82
+ callback_url: Optional[str] = None
83
+ ) -> str:
84
+ """Non-blocking: Registers the request and returns the Request ID immediately."""
85
+ body = {
86
+ "service": service,
87
+ "action": action,
88
+ "payload": payload,
89
+ "priority": priority,
90
+ "metadata": {"sdk": "python", "callback_url": callback_url} if callback_url else {"sdk": "python"}
91
+ }
92
+
93
+ with httpx.Client(base_url=self._base_url, headers=self._headers, timeout=self._timeout) as client:
94
+ resp = self._handle_response(client.post("/ingest", json=body))
95
+ return resp["id"]
96
+
97
+ def wait(self, request_id: str, timeout: int = 86400) -> Decision:
98
+ """Resumes waiting for an existing request ID."""
99
+ start_time = time.time()
100
+ attempt = 0
101
+
102
+ with httpx.Client(base_url=self._base_url, headers=self._headers, timeout=self._timeout) as client:
103
+ while time.time() - start_time < timeout:
104
+ attempt += 1
105
+ try:
106
+ resp = client.get(f"/status/{request_id}")
107
+ if resp.status_code == 200:
108
+ decision = resp.json()
109
+ if decision["status"] in ("APPROVED", "REJECTED"):
110
+ return self._parse_decision(decision)
111
+ elif resp.status_code not in (404, 429):
112
+ self._handle_response(resp)
113
+ except (httpx.RequestError, json.JSONDecodeError) as e:
114
+ logger.warning(f"LetsPing polling transient error: {e}")
115
+
116
+ sleep_time = min(1.0 * (1.5 ** attempt), 10.0)
117
+ time.sleep(sleep_time)
118
+
119
+ raise TimeoutError(f"Wait timed out after {timeout}s for request {request_id}")
120
+
121
+ async def aask(
122
+ self,
123
+ service: str,
124
+ action: str,
125
+ payload: Dict[str, Any],
126
+ priority: Priority = "medium",
127
+ timeout: int = 86400
128
+ ) -> Decision:
129
+ """Async non-blocking wait. Compatible with asyncio event loops."""
130
+ request_id = await self.adefer(service, action, payload, priority)
131
+ return await self.await_(request_id, timeout=timeout)
132
+
133
+ async def adefer(
134
+ self,
135
+ service: str,
136
+ action: str,
137
+ payload: Dict[str, Any],
138
+ priority: Priority = "medium"
139
+ ) -> str:
140
+ body = {
141
+ "service": service,
142
+ "action": action,
143
+ "payload": payload,
144
+ "priority": priority,
145
+ "metadata": {"sdk": "python"}
146
+ }
147
+ async with httpx.AsyncClient(base_url=self._base_url, headers=self._headers, timeout=self._timeout) as client:
148
+ resp = await client.post("/ingest", json=body)
149
+ data = self._handle_response(resp)
150
+ return data["id"]
151
+
152
+ async def await_(self, request_id: str, timeout: int = 86400) -> Decision:
153
+ start_time = time.time()
154
+ attempt = 0
155
+
156
+ async with httpx.AsyncClient(base_url=self._base_url, headers=self._headers, timeout=self._timeout) as client:
157
+ while time.time() - start_time < timeout:
158
+ attempt += 1
159
+ try:
160
+ resp = await client.get(f"/status/{request_id}")
161
+ if resp.status_code == 200:
162
+ decision = resp.json()
163
+ if decision["status"] in ("APPROVED", "REJECTED"):
164
+ return self._parse_decision(decision)
165
+ except (httpx.RequestError, json.JSONDecodeError):
166
+ pass
167
+
168
+ sleep_time = min(1.0 * (1.5 ** attempt), 10.0)
169
+ await asyncio.sleep(sleep_time)
170
+
171
+ raise TimeoutError(f"Async wait timed out after {timeout}s for request {request_id}")
172
+
173
+ def tool(self, service: str, action: str, priority: Priority = "medium") -> Callable:
174
+ """Returns a callable 'Tool' compatible with LangChain/CrewAI."""
175
+ def human_approval_tool(context: str) -> str:
176
+ try:
177
+ payload = json.loads(context)
178
+ except json.JSONDecodeError:
179
+ payload = {"raw_context": context}
180
+
181
+ try:
182
+ result = self.ask(service, action, payload, priority)
183
+ return json.dumps(result["approved_payload"])
184
+ except ApprovalRejectedError as e:
185
+ return f"ACTION_REJECTED: {e.reason}"
186
+ except Exception as e:
187
+ return f"ERROR: {str(e)}"
188
+
189
+ return human_approval_tool
190
+
191
+ def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
192
+ if response.status_code == 401 or response.status_code == 403:
193
+ raise AuthenticationError("Invalid API Key or unauthorized access.")
194
+
195
+ try:
196
+ response.raise_for_status()
197
+ return response.json()
198
+ except httpx.HTTPStatusError as e:
199
+ error_msg = response.text
200
+ try:
201
+ error_data = response.json()
202
+ error_msg = error_data.get("message", response.text)
203
+ except:
204
+ pass
205
+ raise LetsPingError(f"API Error {response.status_code}: {error_msg}") from e
206
+
207
+ def _parse_decision(self, data: Dict[str, Any]) -> Decision:
208
+ status = data.get("status")
209
+ if status == "REJECTED":
210
+ raise ApprovalRejectedError(data.get("reason", "No reason provided"))
211
+
212
+ return {
213
+ "status": "APPROVED",
214
+ "payload": data.get("payload", {}),
215
+ "patched_payload": data.get("patched_payload"),
216
+ "metadata": data.get("metadata", {})
217
+ }
File without changes
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "letsping"
7
+ version = "0.1.0"
8
+ description = "The Control Plane for Autonomous Agents. Add Human-in-the-Loop approval with one line of code."
9
+ readme = "README.md"
10
+ authors = [{ name = "Cordia Labs", email = "security@letsping.co" }]
11
+ license = { text = "MIT" }
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+ requires-python = ">=3.8"
21
+ dependencies = [
22
+ "httpx>=0.23.0"
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=7.0",
28
+ "pytest-asyncio>=0.21.0",
29
+ "respx>=0.20.0",
30
+ "pytest-cov>=4.0",
31
+ "mypy>=1.0",
32
+ "ruff>=0.1.0"
33
+ ]
34
+
35
+ [project.urls]
36
+ "Homepage" = "https://letsping.co"
37
+ "Documentation" = "https://letsping.co/docs"
38
+
39
+ [tool.setuptools]
40
+ py-modules = ["letsping"]
41
+
42
+ [tool.setuptools.package-data]
43
+ "*" = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,66 @@
1
+ import pytest
2
+ import respx
3
+ from httpx import Response
4
+ from letsping import LetsPing, ApprovalRejectedError, DEFAULT_BASE_URL
5
+
6
+ MOCK_API_KEY = "lp_test_mock_key"
7
+
8
+ @pytest.fixture
9
+ def client():
10
+ return LetsPing(api_key=MOCK_API_KEY)
11
+
12
+ @respx.mock
13
+ def test_ask_approval_flow(client):
14
+ """Verifies the standard approval lifecycle (Ingest -> Pending -> Approved)."""
15
+ ingest_route = respx.post(f"{DEFAULT_BASE_URL}/ingest")
16
+ ingest_route.mock(return_value=Response(200, json={"id": "req_123"}))
17
+
18
+ status_route = respx.get(f"{DEFAULT_BASE_URL}/status/req_123")
19
+ status_route.side_effect = [
20
+ Response(200, json={"status": "PENDING"}),
21
+ Response(200, json={
22
+ "status": "APPROVED",
23
+ "payload": {"amount": 100},
24
+ "patched_payload": {"amount": 100},
25
+ "metadata": {"actor_id": "user_1", "resolved_at": "2024-01-01"}
26
+ })
27
+ ]
28
+
29
+ result = client.ask("test-service", "test-action", {"amount": 100}, timeout=2)
30
+
31
+ assert result["status"] == "APPROVED"
32
+ assert result["metadata"]["actor_id"] == "user_1"
33
+ assert ingest_route.called
34
+ assert status_route.call_count == 2
35
+
36
+ @respx.mock
37
+ def test_rejection_error(client):
38
+ """Verifies that human rejection raises the specific exception."""
39
+ respx.post(f"{DEFAULT_BASE_URL}/ingest").mock(return_value=Response(200, json={"id": "req_999"}))
40
+
41
+ respx.get(f"{DEFAULT_BASE_URL}/status/req_999").mock(return_value=Response(200, json={
42
+ "status": "REJECTED",
43
+ "reason": "Risk score too high",
44
+ "metadata": {}
45
+ }))
46
+
47
+ with pytest.raises(ApprovalRejectedError) as exc:
48
+ client.ask("test", "test", {}, timeout=2)
49
+
50
+ assert "Risk score too high" in str(exc.value)
51
+
52
+ @respx.mock
53
+ @pytest.mark.asyncio
54
+ async def test_async_flow():
55
+ """Verifies the async/await implementation works correctly."""
56
+ client = LetsPing(api_key=MOCK_API_KEY)
57
+
58
+ respx.post(f"{DEFAULT_BASE_URL}/ingest").mock(return_value=Response(200, json={"id": "req_async"}))
59
+ respx.get(f"{DEFAULT_BASE_URL}/status/req_async").mock(return_value=Response(200, json={
60
+ "status": "APPROVED",
61
+ "payload": {},
62
+ "metadata": {}
63
+ }))
64
+
65
+ result = await client.aask("async-service", "run", {})
66
+ assert result["status"] == "APPROVED"