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.
Files changed (33) hide show
  1. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/PKG-INFO +2 -2
  2. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/README.md +23 -1
  3. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/client.py +41 -4
  4. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/pyproject.toml +2 -2
  5. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_linkedin.py +53 -0
  6. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/.gitignore +0 -0
  7. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/__init__.py +0 -0
  8. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/_errors.py +0 -0
  9. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/_types.py +0 -0
  10. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/physical_mail.py +0 -0
  11. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/oneshot/x402.py +0 -0
  12. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/__init__.py +0 -0
  13. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/conftest.py +0 -0
  14. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_access_token.py +0 -0
  15. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_balance.py +0 -0
  16. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_browser.py +0 -0
  17. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_budgets.py +0 -0
  18. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_charge_amount.py +0 -0
  19. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_compute.py +0 -0
  20. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_domains.py +0 -0
  21. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_email_payload.py +0 -0
  22. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_emergency_error.py +0 -0
  23. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_max_cost_header.py +0 -0
  24. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_payment_rejection.py +0 -0
  25. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_phones_pending.py +0 -0
  26. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_physical_mail.py +0 -0
  27. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_poll_backoff.py +0 -0
  28. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_request_id.py +0 -0
  29. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_tag_receipt_value.py +0 -0
  30. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_top_up_credits.py +0 -0
  31. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_wait_false.py +0 -0
  32. {oneshot_python-0.28.0 → oneshot_python-0.29.0}/tests/test_x402.py +0 -0
  33. {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.28.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)
@@ -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.28.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.28.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"
@@ -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