oneshot-python 0.27.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.
Files changed (33) hide show
  1. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/PKG-INFO +1 -1
  2. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/README.md +25 -0
  3. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/oneshot/__init__.py +2 -0
  4. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/oneshot/_errors.py +11 -0
  5. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/oneshot/client.py +9 -1
  6. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/pyproject.toml +1 -1
  7. oneshot_python-0.28.0/tests/conftest.py +21 -0
  8. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_linkedin.py +46 -0
  9. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/.gitignore +0 -0
  10. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/oneshot/_types.py +0 -0
  11. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/oneshot/physical_mail.py +0 -0
  12. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/oneshot/x402.py +0 -0
  13. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/__init__.py +0 -0
  14. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_access_token.py +0 -0
  15. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_balance.py +0 -0
  16. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_browser.py +0 -0
  17. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_budgets.py +0 -0
  18. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_charge_amount.py +0 -0
  19. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_compute.py +0 -0
  20. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_domains.py +0 -0
  21. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_email_payload.py +0 -0
  22. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_emergency_error.py +0 -0
  23. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_max_cost_header.py +0 -0
  24. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_payment_rejection.py +0 -0
  25. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_phones_pending.py +0 -0
  26. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_physical_mail.py +0 -0
  27. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_poll_backoff.py +0 -0
  28. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_request_id.py +0 -0
  29. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_tag_receipt_value.py +0 -0
  30. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_top_up_credits.py +0 -0
  31. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_wait_false.py +0 -0
  32. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/tests/test_x402.py +0 -0
  33. {oneshot_python-0.27.0 → oneshot_python-0.28.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.27.0
3
+ Version: 0.28.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -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
 
@@ -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.27.0"
60
+ SDK_VERSION = "0.28.0"
60
61
 
61
62
  # ---------------------------------------------------------------------------
62
63
  # Environment configuration
@@ -995,6 +996,13 @@ class OneShotClient:
995
996
  async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
996
997
  resp = await client.post(url, headers=self._read_headers(scope), json=payload or {})
997
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)
998
1006
  raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
999
1007
  return resp.json()
1000
1008
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.27.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()
File without changes