oneshot-python 0.27.0__tar.gz → 0.29.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 (34) hide show
  1. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/PKG-INFO +2 -2
  2. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/README.md +48 -1
  3. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/__init__.py +2 -0
  4. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/_errors.py +11 -0
  5. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/client.py +49 -4
  6. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/pyproject.toml +2 -2
  7. oneshot_python-0.29.0/tests/conftest.py +21 -0
  8. oneshot_python-0.29.0/tests/test_linkedin.py +171 -0
  9. oneshot_python-0.27.0/tests/test_linkedin.py +0 -72
  10. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/.gitignore +0 -0
  11. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/_types.py +0 -0
  12. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/physical_mail.py +0 -0
  13. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/x402.py +0 -0
  14. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/__init__.py +0 -0
  15. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_access_token.py +0 -0
  16. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_balance.py +0 -0
  17. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_browser.py +0 -0
  18. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_budgets.py +0 -0
  19. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_charge_amount.py +0 -0
  20. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_compute.py +0 -0
  21. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_domains.py +0 -0
  22. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_email_payload.py +0 -0
  23. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_emergency_error.py +0 -0
  24. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_max_cost_header.py +0 -0
  25. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_payment_rejection.py +0 -0
  26. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_phones_pending.py +0 -0
  27. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_physical_mail.py +0 -0
  28. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_poll_backoff.py +0 -0
  29. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_request_id.py +0 -0
  30. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_tag_receipt_value.py +0 -0
  31. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_top_up_credits.py +0 -0
  32. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_wait_false.py +0 -0
  33. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_x402.py +0 -0
  34. {oneshot_python-0.27.0 → oneshot_python-0.29.0}/uv.lock +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.27.0
4
- Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools
3
+ Version: 0.29.0
4
+ Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 45 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Requires-Dist: eth-account>=0.13.0
@@ -217,7 +217,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
217
217
  ## Links
218
218
 
219
219
  - [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
220
- - [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 43 tools as LangChain BaseTool
220
+ - [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 45 tools as LangChain BaseTool
221
221
  - [GAME plugin](https://pypi.org/project/game-plugin-oneshot/) — Virtuals Protocol integration
222
222
  - [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
223
223
  - [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
@@ -239,3 +239,50 @@ 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.
267
+
268
+ ### LinkedIn invitations and withdrawal
269
+
270
+ Available in `oneshot-python` 0.29.0 and later. Both operations require a connected account with the `invite` grant.
271
+
272
+ ```python
273
+ invitation = client.linkedin_invite(
274
+ account_id,
275
+ "https://www.linkedin.com/in/jane-doe/",
276
+ note="Enjoyed your presentation.",
277
+ idempotency_key="event-jane-invite",
278
+ )
279
+
280
+ # To withdraw that pending invitation later:
281
+ client.linkedin_withdraw_invitation(
282
+ account_id,
283
+ invitation["invitation_id"],
284
+ idempotency_key="event-jane-withdraw",
285
+ )
286
+ ```
287
+
288
+ Async equivalents are `alinkedin_invite()` and `alinkedin_withdraw_invitation()`. Use the OneShot `invitation_id`, not the provider invitation ID. Withdrawal can cancel a queued invitation and never disconnects an established connection. Invitations default to 25 per account per UTC day; withdrawal does not restore capacity for an invitation already sent. Reuse the same key when retrying an operation.
@@ -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.29.0"
60
61
 
61
62
  # ---------------------------------------------------------------------------
62
63
  # Environment configuration
@@ -788,7 +789,7 @@ class OneShotClient:
788
789
  "processing",
789
790
  ):
790
791
  if not wait:
791
- return {"request_id": result["request_id"], "status": result["status"]}
792
+ return result if endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
792
793
  return await self._poll_job(
793
794
  client,
794
795
  result["request_id"],
@@ -854,7 +855,7 @@ class OneShotClient:
854
855
  result = resp2.json()
855
856
  if isinstance(result, dict) and result.get("request_id") and result.get("status") in ("pending", "processing"):
856
857
  if not wait:
857
- return {"request_id": result["request_id"], "status": result["status"]}
858
+ return result if endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
858
859
  return await self._poll_job(
859
860
  client, result["request_id"], timeout_sec,
860
861
  wait_for_phones=wait_for_phones, phone_timeout_sec=phone_timeout_sec,
@@ -933,7 +934,7 @@ class OneShotClient:
933
934
  ):
934
935
  self._log(f"Job queued: {result['request_id']}")
935
936
  if not wait:
936
- return {"request_id": result["request_id"], "status": result["status"]}
937
+ return result if endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
937
938
  return await self._poll_job(
938
939
  client,
939
940
  result["request_id"],
@@ -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
 
@@ -1487,6 +1495,43 @@ class OneShotClient:
1487
1495
  async def alinkedin_messages(self, account_id: str, **params: Any) -> dict:
1488
1496
  return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/messages", _linkedin_query(params))
1489
1497
 
1498
+ def _linkedin_invitation_call(self, endpoint: str, payload: dict, **kwargs: Any) -> Any:
1499
+ return asyncio.get_event_loop().run_until_complete(self._alinkedin_invitation_call(endpoint, payload, **kwargs))
1500
+
1501
+ async def _alinkedin_invitation_call(self, endpoint: str, payload: dict, **kwargs: Any) -> Any:
1502
+ try:
1503
+ return await self.acall_tool(endpoint, payload, **kwargs)
1504
+ except (ToolError, httpx.HTTPStatusError) as error:
1505
+ status_code = error.status_code if isinstance(error, ToolError) else error.response.status_code
1506
+ response_body = error.response_body if isinstance(error, ToolError) else error.response.text
1507
+ try:
1508
+ body = json.loads(response_body)
1509
+ except (ValueError, TypeError):
1510
+ raise error
1511
+ details = body.get("details") if isinstance(body, dict) else None
1512
+ if status_code != 409 or not isinstance(details, dict) or body.get("error") != "duplicate_request" or not details.get("request_id"):
1513
+ raise
1514
+ if not kwargs.get("wait", True):
1515
+ return {"request_id": details["request_id"], "receipt_id": details.get("receipt_id"), "status": "processing",
1516
+ "linkedin": {"invitation_id": details.get("invitation_id"), "action_request_id": details.get("action_request_id")}}
1517
+ return await self.await_result(details["request_id"], timeout_sec=kwargs.get("timeout_sec", 180))
1518
+
1519
+ def linkedin_invite(self, account_id: str, profile: str, *, note: Optional[str] = None, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1520
+ return self._linkedin_invitation_call("/v1/tools/linkedin/invite", {"account_id": account_id, "profile": profile, **({"note": note} if note is not None else {}), **kwargs},
1521
+ idempotency_key=idempotency_key or _new_idempotency_key(), max_cost=max_cost, timeout_sec=timeout_sec, wait=wait)
1522
+
1523
+ async def alinkedin_invite(self, account_id: str, profile: str, *, note: Optional[str] = None, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1524
+ return await self._alinkedin_invitation_call("/v1/tools/linkedin/invite", {"account_id": account_id, "profile": profile, **({"note": note} if note is not None else {}), **kwargs},
1525
+ idempotency_key=idempotency_key or _new_idempotency_key(), max_cost=max_cost, timeout_sec=timeout_sec, wait=wait)
1526
+
1527
+ def linkedin_withdraw_invitation(self, account_id: str, invitation_id: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1528
+ return self._linkedin_invitation_call("/v1/tools/linkedin/invite/withdraw", {"account_id": account_id, "invitation_id": invitation_id, **kwargs},
1529
+ idempotency_key=idempotency_key or _new_idempotency_key(), max_cost=max_cost, timeout_sec=timeout_sec, wait=wait)
1530
+
1531
+ async def alinkedin_withdraw_invitation(self, account_id: str, invitation_id: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1532
+ return await self._alinkedin_invitation_call("/v1/tools/linkedin/invite/withdraw", {"account_id": account_id, "invitation_id": invitation_id, **kwargs},
1533
+ idempotency_key=idempotency_key or _new_idempotency_key(), max_cost=max_cost, timeout_sec=timeout_sec, wait=wait)
1534
+
1490
1535
  def linkedin_reply(self, account_id: str, conversation_id: str, text: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
1491
1536
  """Send a reply through the connected account. An Idempotency-Key is generated when not supplied so a retry never double-sends. Blocking."""
1492
1537
  return self.call_tool("/v1/tools/linkedin/reply", {"account_id": account_id, "conversation_id": conversation_id, "text": text, **kwargs},
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.27.0"
4
- description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools"
3
+ version = "0.29.0"
4
+ description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 45 tools"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
7
7
  requires-python = ">=3.10"
@@ -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)
@@ -0,0 +1,171 @@
1
+ """LinkedIn client methods route to the right endpoints; writes always carry an Idempotency-Key."""
2
+ from unittest.mock import AsyncMock
3
+
4
+ import pytest
5
+
6
+ from oneshot.client import OneShotClient
7
+
8
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_connect_and_reads_use_free_endpoints():
13
+ client = OneShotClient(TEST_PRIVATE_KEY)
14
+ client.acall_free_post = AsyncMock(return_value={"intent_id": "i1", "url": "https://account.unipile.com/x"})
15
+ client.acall_free_get = AsyncMock(return_value={"accounts": []})
16
+ client.acall_free_delete = AsyncMock(return_value={"status": "revoked"})
17
+
18
+ await client.alinkedin_connect(["read", "reply"], success_redirect_url="https://app/ok")
19
+ client.acall_free_post.assert_awaited_once_with(
20
+ "/v1/tools/linkedin/connect", {"requested_actions": ["read", "reply"], "success_redirect_url": "https://app/ok"}, scope="write"
21
+ )
22
+ await client.aget_linkedin_connection("i 1")
23
+ client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/connect/i%201")
24
+ await client.alist_linkedin_accounts(include_revoked=True)
25
+ client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/accounts", {"include_revoked": "true"})
26
+ await client.alinkedin_messages("acc", direction="inbound", since="2026-09-15T00:00:00Z", include_deleted=False, limit=10)
27
+ client.acall_free_get.assert_awaited_with(
28
+ "/v1/tools/linkedin/accounts/acc/messages",
29
+ {"direction": "inbound", "since": "2026-09-15T00:00:00Z", "include_deleted": "false", "limit": "10"},
30
+ )
31
+ await client.arevoke_linkedin_account("acc")
32
+ client.acall_free_delete.assert_awaited_once_with("/v1/tools/linkedin/accounts/acc", scope="write")
33
+ await client.areconnect_linkedin_account("acc", requested_actions=["read"])
34
+ client.acall_free_post.assert_awaited_with("/v1/tools/linkedin/accounts/acc/reconnect", {"requested_actions": ["read"]}, scope="write")
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_writes_are_paid_and_idempotency_keyed():
39
+ client = OneShotClient(TEST_PRIVATE_KEY)
40
+ client.acall_tool = AsyncMock(return_value={"status": "sent"})
41
+
42
+ await client.alinkedin_reply("acc", "conv", "hi", memo="m")
43
+ args, kwargs = client.acall_tool.await_args
44
+ assert args == ("/v1/tools/linkedin/reply", {"account_id": "acc", "conversation_id": "conv", "text": "hi", "memo": "m"})
45
+ assert len(kwargs["idempotency_key"]) == 32 # auto-generated
46
+
47
+ await client.alinkedin_reply("acc", "conv", "hi", idempotency_key="fixed")
48
+ assert client.acall_tool.await_args.kwargs["idempotency_key"] == "fixed"
49
+
50
+ await client.alinkedin_view_profile("acc", "https://linkedin.com/in/jane", notify=True)
51
+ args, kwargs = client.acall_tool.await_args
52
+ assert args[0] == "/v1/tools/linkedin/profile-view" and args[1]["notify"] is True and kwargs["idempotency_key"]
53
+
54
+ await client.alinkedin_react("acc", "urn:li:activity:1")
55
+ args, _ = client.acall_tool.await_args
56
+ assert args == ("/v1/tools/linkedin/react", {"account_id": "acc", "post_id": "urn:li:activity:1", "reaction_type": "like"})
57
+
58
+ await client.alinkedin_sync("acc", mode="continue", max_pages=5)
59
+ args, kwargs = client.acall_tool.await_args
60
+ assert args == ("/v1/tools/linkedin/sync", {"account_id": "acc", "mode": "continue", "max_pages": 5})
61
+ assert "idempotency_key" not in kwargs # a sync run is not a side-effecting write
62
+
63
+
64
+ def test_free_mutations_sign_a_write_scoped_proof():
65
+ client = OneShotClient(TEST_PRIVATE_KEY)
66
+ assert client._read_headers("write")["x-agent-proof"] != client._read_headers("read")["x-agent-proof"]
67
+
68
+
69
+ def test_idempotency_key_header_is_set_on_paid_requests():
70
+ client = OneShotClient(TEST_PRIVATE_KEY)
71
+ assert client._headers(idempotency_key="k1")["Idempotency-Key"] == "k1"
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()
119
+
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_invite_and_withdraw_generate_keys_and_preserve_note():
123
+ client = OneShotClient(TEST_PRIVATE_KEY)
124
+ client.acall_tool = AsyncMock(return_value={"invitation_id": "invite"})
125
+ await client.alinkedin_invite('account', 'profile', note=' note ')
126
+ args, kwargs = client.acall_tool.await_args
127
+ assert args == ('/v1/tools/linkedin/invite', {'account_id': 'account', 'profile': 'profile', 'note': ' note '})
128
+ assert len(kwargs['idempotency_key']) == 32
129
+ await client.alinkedin_withdraw_invitation('account', 'invite', idempotency_key='withdraw')
130
+ args, kwargs = client.acall_tool.await_args
131
+ assert args == ('/v1/tools/linkedin/invite/withdraw', {'account_id': 'account', 'invitation_id': 'invite'})
132
+ assert kwargs['idempotency_key'] == 'withdraw'
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_invitation_durable_duplicate_recovery():
136
+ import json
137
+ from oneshot._errors import ToolError
138
+ client = OneShotClient(TEST_PRIVATE_KEY)
139
+ client.acall_tool = AsyncMock(side_effect=ToolError('duplicate', 409, json.dumps({'error': 'duplicate_request', 'details': {'request_id': 'job', 'invitation_id': 'invite'}})))
140
+ result = await client.alinkedin_invite('account', 'profile', wait=False)
141
+ assert result['linkedin']['invitation_id'] == 'invite'
142
+ client.await_result = AsyncMock(return_value={'status': 'sent', 'invitation_id': 'invite'})
143
+ await client.alinkedin_invite('account', 'profile')
144
+ client.await_result.assert_awaited_once_with('job', timeout_sec=180)
145
+
146
+
147
+ @pytest.mark.asyncio
148
+ @pytest.mark.parametrize('withdraw', [False, True])
149
+ async def test_invitation_recovers_http_status_error(withdraw):
150
+ import httpx
151
+ client = OneShotClient(TEST_PRIVATE_KEY)
152
+ response = httpx.Response(409, json={'error': 'duplicate_request', 'details': {'request_id': 'job', 'invitation_id': 'invite'}}, request=httpx.Request('POST', 'https://example.test'))
153
+ client.acall_tool = AsyncMock(side_effect=httpx.HTTPStatusError('duplicate', request=response.request, response=response))
154
+ method = client.alinkedin_withdraw_invitation if withdraw else client.alinkedin_invite
155
+ assert (await method('account', 'target', wait=False))['request_id'] == 'job'
156
+ client.await_result = AsyncMock(return_value={'status': 'sent'})
157
+ await method('account', 'target')
158
+ client.await_result.assert_awaited_once_with('job', timeout_sec=180)
159
+
160
+
161
+ @pytest.mark.asyncio
162
+ @pytest.mark.parametrize('status,body', [(409, {'error': 'idempotency_conflict'}), (502, {'error': 'duplicate_request', 'details': {'request_id': 'job'}}), (409, []), (409, {'error': 'duplicate_request', 'details': []}), (409, 'not json')])
163
+ async def test_invitation_preserves_unrelated_http_errors(status, body):
164
+ import httpx
165
+ client = OneShotClient(TEST_PRIVATE_KEY)
166
+ response = httpx.Response(status, json=body, request=httpx.Request('POST', 'https://example.test'))
167
+ error = httpx.HTTPStatusError('original', request=response.request, response=response)
168
+ client.acall_tool = AsyncMock(side_effect=error)
169
+ with pytest.raises(httpx.HTTPStatusError) as caught:
170
+ await client.alinkedin_invite('account', 'target')
171
+ assert caught.value is error
@@ -1,72 +0,0 @@
1
- """LinkedIn client methods route to the right endpoints; writes always carry an Idempotency-Key."""
2
- from unittest.mock import AsyncMock
3
-
4
- import pytest
5
-
6
- from oneshot.client import OneShotClient
7
-
8
- TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
9
-
10
-
11
- @pytest.mark.asyncio
12
- async def test_connect_and_reads_use_free_endpoints():
13
- client = OneShotClient(TEST_PRIVATE_KEY)
14
- client.acall_free_post = AsyncMock(return_value={"intent_id": "i1", "url": "https://account.unipile.com/x"})
15
- client.acall_free_get = AsyncMock(return_value={"accounts": []})
16
- client.acall_free_delete = AsyncMock(return_value={"status": "revoked"})
17
-
18
- await client.alinkedin_connect(["read", "reply"], success_redirect_url="https://app/ok")
19
- client.acall_free_post.assert_awaited_once_with(
20
- "/v1/tools/linkedin/connect", {"requested_actions": ["read", "reply"], "success_redirect_url": "https://app/ok"}, scope="write"
21
- )
22
- await client.aget_linkedin_connection("i 1")
23
- client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/connect/i%201")
24
- await client.alist_linkedin_accounts(include_revoked=True)
25
- client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/accounts", {"include_revoked": "true"})
26
- await client.alinkedin_messages("acc", direction="inbound", since="2026-09-15T00:00:00Z", include_deleted=False, limit=10)
27
- client.acall_free_get.assert_awaited_with(
28
- "/v1/tools/linkedin/accounts/acc/messages",
29
- {"direction": "inbound", "since": "2026-09-15T00:00:00Z", "include_deleted": "false", "limit": "10"},
30
- )
31
- await client.arevoke_linkedin_account("acc")
32
- client.acall_free_delete.assert_awaited_once_with("/v1/tools/linkedin/accounts/acc", scope="write")
33
- await client.areconnect_linkedin_account("acc", requested_actions=["read"])
34
- client.acall_free_post.assert_awaited_with("/v1/tools/linkedin/accounts/acc/reconnect", {"requested_actions": ["read"]}, scope="write")
35
-
36
-
37
- @pytest.mark.asyncio
38
- async def test_writes_are_paid_and_idempotency_keyed():
39
- client = OneShotClient(TEST_PRIVATE_KEY)
40
- client.acall_tool = AsyncMock(return_value={"status": "sent"})
41
-
42
- await client.alinkedin_reply("acc", "conv", "hi", memo="m")
43
- args, kwargs = client.acall_tool.await_args
44
- assert args == ("/v1/tools/linkedin/reply", {"account_id": "acc", "conversation_id": "conv", "text": "hi", "memo": "m"})
45
- assert len(kwargs["idempotency_key"]) == 32 # auto-generated
46
-
47
- await client.alinkedin_reply("acc", "conv", "hi", idempotency_key="fixed")
48
- assert client.acall_tool.await_args.kwargs["idempotency_key"] == "fixed"
49
-
50
- await client.alinkedin_view_profile("acc", "https://linkedin.com/in/jane", notify=True)
51
- args, kwargs = client.acall_tool.await_args
52
- assert args[0] == "/v1/tools/linkedin/profile-view" and args[1]["notify"] is True and kwargs["idempotency_key"]
53
-
54
- await client.alinkedin_react("acc", "urn:li:activity:1")
55
- args, _ = client.acall_tool.await_args
56
- assert args == ("/v1/tools/linkedin/react", {"account_id": "acc", "post_id": "urn:li:activity:1", "reaction_type": "like"})
57
-
58
- await client.alinkedin_sync("acc", mode="continue", max_pages=5)
59
- args, kwargs = client.acall_tool.await_args
60
- assert args == ("/v1/tools/linkedin/sync", {"account_id": "acc", "mode": "continue", "max_pages": 5})
61
- assert "idempotency_key" not in kwargs # a sync run is not a side-effecting write
62
-
63
-
64
- def test_free_mutations_sign_a_write_scoped_proof():
65
- client = OneShotClient(TEST_PRIVATE_KEY)
66
- assert client._read_headers("write")["x-agent-proof"] != client._read_headers("read")["x-agent-proof"]
67
-
68
-
69
- def test_idempotency_key_header_is_set_on_paid_requests():
70
- client = OneShotClient(TEST_PRIVATE_KEY)
71
- assert client._headers(idempotency_key="k1")["Idempotency-Key"] == "k1"
72
- assert "Idempotency-Key" not in client._headers()
File without changes