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.
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/PKG-INFO +2 -2
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/README.md +48 -1
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/__init__.py +2 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/_errors.py +11 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/client.py +49 -4
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/pyproject.toml +2 -2
- oneshot_python-0.29.0/tests/conftest.py +21 -0
- oneshot_python-0.29.0/tests/test_linkedin.py +171 -0
- oneshot_python-0.27.0/tests/test_linkedin.py +0 -72
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/.gitignore +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/_types.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/physical_mail.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/__init__.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_access_token.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_browser.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_payment_rejection.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_top_up_credits.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.27.0 → oneshot_python-0.29.0}/tests/test_x402.py +0 -0
- {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.
|
|
4
|
-
Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling —
|
|
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/) —
|
|
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.
|
|
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.
|
|
4
|
-
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling —
|
|
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
|
|
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
|
|
File without changes
|