oneshot-python 0.26.0__tar.gz → 0.28.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.
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/PKG-INFO +1 -1
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/README.md +25 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/__init__.py +2 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/_errors.py +17 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/client.py +17 -3
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/pyproject.toml +1 -1
- oneshot_python-0.28.0/tests/conftest.py +21 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_linkedin.py +46 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_payment_rejection.py +22 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/uv.lock +1 -1
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/.gitignore +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/_types.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/physical_mail.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/__init__.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_access_token.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_browser.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_top_up_credits.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_x402.py +0 -0
|
@@ -239,3 +239,28 @@ Use `create_browser_profile(name)`, then `start_browser_profile_setup(profile_id
|
|
|
239
239
|
Finish inspects cookie metadata in a fresh browser before navigation, without returning values. Later `browser(profile_id=..., task=...)` calls reuse the saved profile; verify site authentication with a read before relying on it. Setup expires after 15 minutes, with abandoned-session cleanup by the lifecycle sweep; the profile is retained for reconnection. Never log private live URLs or put credentials/codes in task prompts.
|
|
240
240
|
|
|
241
241
|
Alternatively, `create_browser_profile(name, cookies=[...])` or `storage_state={...}` imports cookies and localStorage. The legacy browser `secrets` field is rejected, and `max_steps` must be an integer from 25 through 100. Failed `JobError`s expose `code` and `partial_result`. Browser `cost` is provider cost; `billed_cost` is the reconciled customer charge, or null while pending.
|
|
242
|
+
|
|
243
|
+
### Missing LinkedIn connections
|
|
244
|
+
|
|
245
|
+
Both `reconnect_linkedin_account()` and `areconnect_linkedin_account()` raise
|
|
246
|
+
`LinkedInConnectRequiredError` when the API confirms that the saved connection
|
|
247
|
+
no longer exists. It remains a `ToolError` subclass and exposes
|
|
248
|
+
`code == "connect_required"`, `next_action == "connect"`, `status_code == 409`,
|
|
249
|
+
and the original `response_body`.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from oneshot import LinkedInConnectRequiredError
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
intent = client.reconnect_linkedin_account(account_id)
|
|
256
|
+
except LinkedInConnectRequiredError:
|
|
257
|
+
# Show “Connect LinkedIn” and ask the human to choose grants again.
|
|
258
|
+
# After confirmation: client.linkedin_connect(["read"])
|
|
259
|
+
pass
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Account reads stop offering reconnect after confirming upstream deletion.
|
|
263
|
+
Missing accounts are excluded from the active list; `include_revoked=True`
|
|
264
|
+
lets you inspect their `deleted_upstream` status and `reconnect_required=False`.
|
|
265
|
+
Authentication failures and timeouts remain ordinary errors and do not instruct
|
|
266
|
+
the consumer to create a new connection.
|
|
@@ -13,6 +13,7 @@ from oneshot._errors import (
|
|
|
13
13
|
OneShotError,
|
|
14
14
|
PaymentError,
|
|
15
15
|
ToolError,
|
|
16
|
+
LinkedInConnectRequiredError,
|
|
16
17
|
ValidationError,
|
|
17
18
|
)
|
|
18
19
|
from oneshot._types import (
|
|
@@ -43,6 +44,7 @@ __all__ = [
|
|
|
43
44
|
"OneShotClient",
|
|
44
45
|
"OneShotError",
|
|
45
46
|
"ToolError",
|
|
47
|
+
"LinkedInConnectRequiredError",
|
|
46
48
|
"PaymentError",
|
|
47
49
|
"BudgetExceededError",
|
|
48
50
|
"BudgetSyncError",
|
|
@@ -14,6 +14,17 @@ class ToolError(OneShotError):
|
|
|
14
14
|
self.response_body = response_body
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
class LinkedInConnectRequiredError(ToolError):
|
|
18
|
+
"""The saved connection is gone; ask the human to authorize a fresh connect."""
|
|
19
|
+
|
|
20
|
+
code = "connect_required"
|
|
21
|
+
next_action = "connect"
|
|
22
|
+
|
|
23
|
+
def __init__(self, response_body: str) -> None:
|
|
24
|
+
super().__init__("This LinkedIn connection no longer exists. Start a new connection.",
|
|
25
|
+
409, response_body)
|
|
26
|
+
|
|
27
|
+
|
|
17
28
|
class PaymentError(OneShotError):
|
|
18
29
|
"""The facilitator rejected the payment signed for this request.
|
|
19
30
|
|
|
@@ -33,12 +44,18 @@ class PaymentError(OneShotError):
|
|
|
33
44
|
expected: dict | None = None,
|
|
34
45
|
received: dict | None = None,
|
|
35
46
|
quote_id: str | None = None,
|
|
47
|
+
payment_attempt_id: str | None = None,
|
|
48
|
+
stage: str | None = None,
|
|
49
|
+
diagnostics: dict | None = None,
|
|
36
50
|
) -> None:
|
|
37
51
|
super().__init__(message)
|
|
38
52
|
self.reason = reason
|
|
39
53
|
self.expected = expected or {}
|
|
40
54
|
self.received = received or {}
|
|
41
55
|
self.quote_id = quote_id
|
|
56
|
+
self.payment_attempt_id = payment_attempt_id
|
|
57
|
+
self.stage = stage
|
|
58
|
+
self.diagnostics = diagnostics
|
|
42
59
|
|
|
43
60
|
|
|
44
61
|
class JobError(OneShotError):
|
|
@@ -31,6 +31,7 @@ from oneshot._errors import (
|
|
|
31
31
|
OneShotError,
|
|
32
32
|
PaymentError,
|
|
33
33
|
ToolError,
|
|
34
|
+
LinkedInConnectRequiredError,
|
|
34
35
|
ValidationError,
|
|
35
36
|
)
|
|
36
37
|
from oneshot._types import (
|
|
@@ -56,7 +57,7 @@ try:
|
|
|
56
57
|
|
|
57
58
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
58
59
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
59
|
-
SDK_VERSION = "0.
|
|
60
|
+
SDK_VERSION = "0.28.0"
|
|
60
61
|
|
|
61
62
|
# ---------------------------------------------------------------------------
|
|
62
63
|
# Environment configuration
|
|
@@ -206,13 +207,13 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
|
|
|
206
207
|
payment is refused. Returns None for any other response so the caller can
|
|
207
208
|
fall back to ``ToolError``.
|
|
208
209
|
"""
|
|
209
|
-
if resp.status_code
|
|
210
|
+
if resp.status_code not in (402, 500):
|
|
210
211
|
return None
|
|
211
212
|
try:
|
|
212
213
|
data = resp.json()
|
|
213
214
|
except Exception: # noqa: BLE001 - non-JSON body
|
|
214
215
|
return None
|
|
215
|
-
if not isinstance(data, dict) or data.get("error")
|
|
216
|
+
if not isinstance(data, dict) or data.get("error") not in ("payment_verification_failed", "payment_processing_error"):
|
|
216
217
|
return None
|
|
217
218
|
|
|
218
219
|
reason = data.get("reason") or "unknown"
|
|
@@ -230,6 +231,9 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
|
|
|
230
231
|
if data.get("message"):
|
|
231
232
|
message += f" ({data['message']})"
|
|
232
233
|
|
|
234
|
+
if data.get("payment_attempt_id"):
|
|
235
|
+
message += f" [payment attempt {data['payment_attempt_id']}]"
|
|
236
|
+
|
|
233
237
|
return PaymentError(
|
|
234
238
|
message,
|
|
235
239
|
reason,
|
|
@@ -241,6 +245,9 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
|
|
|
241
245
|
},
|
|
242
246
|
received={"amount": received.get("amount")},
|
|
243
247
|
quote_id=data.get("quote_id"),
|
|
248
|
+
payment_attempt_id=data.get("payment_attempt_id"),
|
|
249
|
+
stage=data.get("stage"),
|
|
250
|
+
diagnostics=data.get("diagnostics"),
|
|
244
251
|
)
|
|
245
252
|
|
|
246
253
|
|
|
@@ -989,6 +996,13 @@ class OneShotClient:
|
|
|
989
996
|
async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
|
|
990
997
|
resp = await client.post(url, headers=self._read_headers(scope), json=payload or {})
|
|
991
998
|
if not resp.is_success:
|
|
999
|
+
if endpoint.startswith("/v1/tools/linkedin/") and resp.status_code == 409:
|
|
1000
|
+
try:
|
|
1001
|
+
body = resp.json()
|
|
1002
|
+
except ValueError:
|
|
1003
|
+
body = None
|
|
1004
|
+
if isinstance(body, dict) and body.get("error") == "connect_required":
|
|
1005
|
+
raise LinkedInConnectRequiredError(resp.text)
|
|
992
1006
|
raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
|
|
993
1007
|
return resp.json()
|
|
994
1008
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.28.0"
|
|
4
4
|
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools"
|
|
5
5
|
readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
|
|
6
6
|
license = "MIT"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Give blocking SDK tests an event loop independent of pytest-asyncio cleanup."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(autouse=True)
|
|
9
|
+
def blocking_test_event_loop(request):
|
|
10
|
+
if inspect.iscoroutinefunction(request.function):
|
|
11
|
+
yield
|
|
12
|
+
return
|
|
13
|
+
# Blocking SDK methods call run_until_complete. Async tests may have closed
|
|
14
|
+
# or cleared the thread's previous loop; no test should depend on its order.
|
|
15
|
+
loop = asyncio.new_event_loop()
|
|
16
|
+
asyncio.set_event_loop(loop)
|
|
17
|
+
try:
|
|
18
|
+
yield
|
|
19
|
+
finally:
|
|
20
|
+
loop.close()
|
|
21
|
+
asyncio.set_event_loop(None)
|
|
@@ -70,3 +70,49 @@ def test_idempotency_key_header_is_set_on_paid_requests():
|
|
|
70
70
|
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
71
71
|
assert client._headers(idempotency_key="k1")["Idempotency-Key"] == "k1"
|
|
72
72
|
assert "Idempotency-Key" not in client._headers()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
76
|
+
@pytest.mark.parametrize("status,body,typed", [
|
|
77
|
+
(409, {"error": "connect_required", "details": {"next_action": "connect"}}, True),
|
|
78
|
+
(409, {"error": "grant_widening_not_allowed"}, False),
|
|
79
|
+
(502, {"error": "linkedin_link_unavailable"}, False),
|
|
80
|
+
(401, {"error": "connect_required"}, False),
|
|
81
|
+
(409, ["connect_required"], False),
|
|
82
|
+
(409, "not JSON", False),
|
|
83
|
+
])
|
|
84
|
+
def test_reconnect_recovery_error_over_http(monkeypatch, asynchronous, status, body, typed):
|
|
85
|
+
import asyncio
|
|
86
|
+
import httpx
|
|
87
|
+
from oneshot import LinkedInConnectRequiredError, ToolError
|
|
88
|
+
|
|
89
|
+
requests = []
|
|
90
|
+
def handler(request):
|
|
91
|
+
requests.append(request)
|
|
92
|
+
if isinstance(body, str):
|
|
93
|
+
return httpx.Response(status, text=body)
|
|
94
|
+
return httpx.Response(status, json=body)
|
|
95
|
+
|
|
96
|
+
original = httpx.AsyncClient
|
|
97
|
+
monkeypatch.setattr("oneshot.client.httpx.AsyncClient", lambda **kw: original(transport=httpx.MockTransport(handler), **kw))
|
|
98
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
99
|
+
loop = asyncio.new_event_loop()
|
|
100
|
+
monkeypatch.setattr(asyncio, "get_event_loop", lambda: loop)
|
|
101
|
+
try:
|
|
102
|
+
with pytest.raises(ToolError) as caught:
|
|
103
|
+
if asynchronous:
|
|
104
|
+
loop.run_until_complete(client.areconnect_linkedin_account("account/id"))
|
|
105
|
+
else:
|
|
106
|
+
client.reconnect_linkedin_account("account/id")
|
|
107
|
+
error = caught.value
|
|
108
|
+
assert isinstance(error, LinkedInConnectRequiredError) is typed
|
|
109
|
+
assert error.status_code == status
|
|
110
|
+
if typed:
|
|
111
|
+
assert error.code == "connect_required"
|
|
112
|
+
assert error.next_action == "connect"
|
|
113
|
+
assert '"next_action":"connect"' in error.response_body.replace(" ", "")
|
|
114
|
+
assert len(requests) == 1 # No automatic reconnect or new grant.
|
|
115
|
+
assert requests[0].url.raw_path.endswith(b"/account%2Fid/reconnect")
|
|
116
|
+
assert "x-agent-proof" in requests[0].headers
|
|
117
|
+
finally:
|
|
118
|
+
loop.close()
|
|
@@ -86,3 +86,25 @@ class TestParsePaymentRejection:
|
|
|
86
86
|
err = _parse_payment_rejection(_Resp(402, REJECTION))
|
|
87
87
|
assert isinstance(err, OneShotError)
|
|
88
88
|
assert isinstance(err, Exported)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_diagnostics_and_attempt_correlation():
|
|
92
|
+
diagnostics = {"status": "complete", "findings": [{"code": "unknown"}]}
|
|
93
|
+
error = _parse_payment_rejection(_Resp(402, {
|
|
94
|
+
**REJECTION, "payment_attempt_id": "attempt-123", "stage": "verification", "diagnostics": diagnostics,
|
|
95
|
+
}))
|
|
96
|
+
assert error.payment_attempt_id == "attempt-123"
|
|
97
|
+
assert "attempt-123" in str(error)
|
|
98
|
+
assert error.stage == "verification"
|
|
99
|
+
assert error.diagnostics == diagnostics
|
|
100
|
+
assert PaymentError("old", "unknown").diagnostics is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_transport_and_settlement_correlation():
|
|
104
|
+
error = _parse_payment_rejection(_Resp(500, {
|
|
105
|
+
"error": "payment_processing_error", "payment_attempt_id": "transport-123", "stage": "verification_transport",
|
|
106
|
+
}))
|
|
107
|
+
assert error.payment_attempt_id == "transport-123"
|
|
108
|
+
assert error.stage == "verification_transport"
|
|
109
|
+
error = _parse_payment_rejection(_Resp(402, {**REJECTION, "stage": "settlement"}))
|
|
110
|
+
assert error.stage == "settlement"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|