oneshot-python 0.28.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.28.0 → oneshot_python-0.29.0}/PKG-INFO +2 -2
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/README.md +23 -1
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/client.py +41 -4
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/pyproject.toml +2 -2
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_linkedin.py +53 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/.gitignore +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/__init__.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/_errors.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/_types.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/physical_mail.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/__init__.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/conftest.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_access_token.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_browser.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_payment_rejection.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_top_up_credits.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.28.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)
|
|
@@ -264,3 +264,25 @@ Missing accounts are excluded from the active list; `include_revoked=True`
|
|
|
264
264
|
lets you inspect their `deleted_upstream` status and `reconnect_required=False`.
|
|
265
265
|
Authentication failures and timeouts remain ordinary errors and do not instruct
|
|
266
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.
|
|
@@ -57,7 +57,7 @@ try:
|
|
|
57
57
|
|
|
58
58
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
59
59
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
60
|
-
SDK_VERSION = "0.
|
|
60
|
+
SDK_VERSION = "0.29.0"
|
|
61
61
|
|
|
62
62
|
# ---------------------------------------------------------------------------
|
|
63
63
|
# Environment configuration
|
|
@@ -789,7 +789,7 @@ class OneShotClient:
|
|
|
789
789
|
"processing",
|
|
790
790
|
):
|
|
791
791
|
if not wait:
|
|
792
|
-
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"]}
|
|
793
793
|
return await self._poll_job(
|
|
794
794
|
client,
|
|
795
795
|
result["request_id"],
|
|
@@ -855,7 +855,7 @@ class OneShotClient:
|
|
|
855
855
|
result = resp2.json()
|
|
856
856
|
if isinstance(result, dict) and result.get("request_id") and result.get("status") in ("pending", "processing"):
|
|
857
857
|
if not wait:
|
|
858
|
-
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"]}
|
|
859
859
|
return await self._poll_job(
|
|
860
860
|
client, result["request_id"], timeout_sec,
|
|
861
861
|
wait_for_phones=wait_for_phones, phone_timeout_sec=phone_timeout_sec,
|
|
@@ -934,7 +934,7 @@ class OneShotClient:
|
|
|
934
934
|
):
|
|
935
935
|
self._log(f"Job queued: {result['request_id']}")
|
|
936
936
|
if not wait:
|
|
937
|
-
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"]}
|
|
938
938
|
return await self._poll_job(
|
|
939
939
|
client,
|
|
940
940
|
result["request_id"],
|
|
@@ -1495,6 +1495,43 @@ class OneShotClient:
|
|
|
1495
1495
|
async def alinkedin_messages(self, account_id: str, **params: Any) -> dict:
|
|
1496
1496
|
return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/messages", _linkedin_query(params))
|
|
1497
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
|
+
|
|
1498
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:
|
|
1499
1536
|
"""Send a reply through the connected account. An Idempotency-Key is generated when not supplied so a retry never double-sends. Blocking."""
|
|
1500
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"
|
|
@@ -116,3 +116,56 @@ def test_reconnect_recovery_error_over_http(monkeypatch, asynchronous, status, b
|
|
|
116
116
|
assert "x-agent-proof" in requests[0].headers
|
|
117
117
|
finally:
|
|
118
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|