oneshot-python 0.37.0__tar.gz → 0.38.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.37.0 → oneshot_python-0.38.0}/PKG-INFO +1 -1
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/README.md +11 -1
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/oneshot/__init__.py +2 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/oneshot/_types.py +1 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/oneshot/client.py +208 -29
- oneshot_python-0.38.0/oneshot/lookup.py +252 -0
- oneshot_python-0.38.0/oneshot/lookup_manifest.py +2050 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/pyproject.toml +1 -1
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_compute.py +46 -0
- oneshot_python-0.38.0/tests/test_compute_manifest.py +142 -0
- oneshot_python-0.38.0/tests/test_declared_routes.py +110 -0
- oneshot_python-0.38.0/tests/test_declared_worker_reads.py +77 -0
- oneshot_python-0.38.0/tests/test_inbox_notifications.py +84 -0
- oneshot_python-0.38.0/tests/test_lookup.py +266 -0
- oneshot_python-0.38.0/tests/test_paid_call_proof.py +185 -0
- oneshot_python-0.38.0/tests/test_update_build.py +38 -0
- oneshot_python-0.37.0/tests/test_paid_call_proof.py +0 -101
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/.gitignore +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/oneshot/_errors.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/oneshot/physical_mail.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/__init__.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/conftest.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_access_token.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_approvals.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_browser.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_compute_outputs.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_domains_signed.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_hunts.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_inbound_voice.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_linkedin.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_payment_rejection.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_signed_job_and_analytics.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_top_up_credits.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.37.0 → oneshot_python-0.38.0}/uv.lock +0 -0
|
@@ -315,6 +315,16 @@ client.hunts.leaderboard() # public predictor leaderboard
|
|
|
315
315
|
|
|
316
316
|
Async variants prefix the method with `a` (`alist`, `astatus`, `apredict`, `acapture`, `acollection`, `aleaderboard`). `list`/`status`/`collection`/`leaderboard` are free reads and work for both wallet and access-token sessions. `predict`/`capture` debit a stake and require Soul Hunt's wallet-signed write proof, which an access-token (credits-only) session cannot produce — those two raise `ValidationError` locally rather than reaching the network. The client never forwards the OneShot bearer token to Soul Hunt; only the wallet address and, for wallet sessions, the signed `x-agent-proof` go out.
|
|
317
317
|
|
|
318
|
+
## Declared routes and inbox methods (#1273)
|
|
319
|
+
|
|
320
|
+
Every route in the API registry states an agent tool. The ones without a typed method are in the lookup
|
|
321
|
+
manifest and reached with `call_lookup(name, **fields)`, for example `call_lookup("company_filings", cik="320193")`
|
|
322
|
+
(`acall_lookup` for async): a free route is a signed
|
|
323
|
+
call, a paid one goes through `call_tool` and its quote. LangChain and GAME build their tools from the same
|
|
324
|
+
manifest. The inbox, SMS inbox and notification routes also have methods: `inbox`, `inbox_get`, `sms_inbox`,
|
|
325
|
+
`sms_inbox_get`, `notifications`, `mark_notification_read`, `mark_all_notifications_read` (each with an `a`
|
|
326
|
+
async form).
|
|
327
|
+
|
|
318
328
|
## Requirements
|
|
319
329
|
|
|
320
330
|
- Python 3.10+
|
|
@@ -324,7 +334,7 @@ Async variants prefix the method with `a` (`alist`, `astatus`, `apredict`, `acap
|
|
|
324
334
|
## Links
|
|
325
335
|
|
|
326
336
|
- [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
|
|
327
|
-
- [LangChain integration](https://pypi.org/project/langchain-oneshot/): 48 tools as LangChain BaseTool
|
|
337
|
+
- [LangChain integration](https://pypi.org/project/langchain-oneshot/): 48 tools plus the lookup tools as LangChain BaseTool
|
|
328
338
|
- [GAME plugin](https://pypi.org/project/game-plugin-oneshot/): Virtuals Protocol integration
|
|
329
339
|
- [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
|
|
330
340
|
- [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
|
|
@@ -39,9 +39,11 @@ from oneshot._types import (
|
|
|
39
39
|
DomainPoolStatusResult,
|
|
40
40
|
)
|
|
41
41
|
from oneshot.client import OneShotClient
|
|
42
|
+
from oneshot.lookup import lookup_tools
|
|
42
43
|
from oneshot.x402 import sign_payment_authorization
|
|
43
44
|
|
|
44
45
|
__all__ = [
|
|
46
|
+
"lookup_tools",
|
|
45
47
|
"PhysicalMail", "PostalAddress", "MailQuote", "MailOrder",
|
|
46
48
|
"OneShotClient",
|
|
47
49
|
"OneShotError",
|
|
@@ -44,6 +44,7 @@ from oneshot._types import (
|
|
|
44
44
|
DomainPoolListResult,
|
|
45
45
|
DomainPoolStatusResult,
|
|
46
46
|
)
|
|
47
|
+
from oneshot.lookup import PAID_PRICING, find_lookup_tool, lookup_query, lookup_request
|
|
47
48
|
from oneshot.x402 import (
|
|
48
49
|
build_zero_cost_authorization,
|
|
49
50
|
encode_payment_header,
|
|
@@ -59,7 +60,7 @@ try:
|
|
|
59
60
|
|
|
60
61
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
61
62
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
62
|
-
SDK_VERSION = "0.
|
|
63
|
+
SDK_VERSION = "0.38.0"
|
|
63
64
|
|
|
64
65
|
_BASE_URL = "https://win.oneshotagent.com"
|
|
65
66
|
_HUNT_BASE_URL = "https://api.soulhunt.ai"
|
|
@@ -74,6 +75,7 @@ _USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
|
74
75
|
_POLL_BACKOFF = (0.3, 0.6, 1.0, 2.0) # seconds
|
|
75
76
|
_POLL_RETRY_BASE = 2.0 # seconds, transient-error retry backoff base
|
|
76
77
|
_MAX_POLL_RETRIES = 3
|
|
78
|
+
_BUILD_TRANSPORT_KWARGS = ("max_cost", "timeout_sec", "wait", "approval_id", "idempotency_key")
|
|
77
79
|
|
|
78
80
|
|
|
79
81
|
def _build_email_payload(
|
|
@@ -159,6 +161,24 @@ def _parse_policy_rejection(resp: Any) -> Optional[OneShotError]:
|
|
|
159
161
|
return None
|
|
160
162
|
|
|
161
163
|
|
|
164
|
+
def _parse_invalid_proof(resp: Any) -> Optional[tuple[str, str]]:
|
|
165
|
+
"""``(reason, message)`` when ``resp`` is the API's 401 ``invalid_proof`` refusal (#1437), else None."""
|
|
166
|
+
if resp.status_code != 401:
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
data = resp.json()
|
|
170
|
+
except Exception: # noqa: BLE001 - non-JSON body
|
|
171
|
+
return None
|
|
172
|
+
if not isinstance(data, dict) or data.get("error") != "invalid_proof":
|
|
173
|
+
return None
|
|
174
|
+
reason = data.get("reason")
|
|
175
|
+
message = data.get("message")
|
|
176
|
+
return (
|
|
177
|
+
reason if isinstance(reason, str) else "invalid",
|
|
178
|
+
message if isinstance(message, str) else "The agent proof could not be verified.",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
162
182
|
def _parse_budget_rejection(resp: Any) -> Optional[BudgetExceededError]:
|
|
163
183
|
"""Map a 403 ``budget_exceeded`` body onto :class:`BudgetExceededError`.
|
|
164
184
|
|
|
@@ -666,6 +686,29 @@ class OneShotClient:
|
|
|
666
686
|
if self.debug:
|
|
667
687
|
print(f"[OneShot] {msg}")
|
|
668
688
|
|
|
689
|
+
async def _post_proved(self, client: httpx.AsyncClient, url: str, build_headers: Any, payload: dict[str, Any]) -> Any:
|
|
690
|
+
"""POST a leg that proves the wallet. ``build_headers`` returns the headers
|
|
691
|
+
with a freshly signed proof each time it is called.
|
|
692
|
+
|
|
693
|
+
The API refuses a request whose proof it rejected, saying why (#1437). A
|
|
694
|
+
proof outside its window or sent twice is cured by signing a new one, so
|
|
695
|
+
the leg is sent again once. Any other reason, or a second refusal, is
|
|
696
|
+
raised with the reason. Nothing is paid in answer to this refusal.
|
|
697
|
+
"""
|
|
698
|
+
resp = await client.post(url, headers=build_headers(), json=payload)
|
|
699
|
+
refusal = _parse_invalid_proof(resp)
|
|
700
|
+
if refusal is None:
|
|
701
|
+
return resp
|
|
702
|
+
reason, message = refusal
|
|
703
|
+
if reason in ("expired", "already_used"):
|
|
704
|
+
self._log(f"Wallet proof refused ({reason}); signing a new proof and retrying once")
|
|
705
|
+
resp = await client.post(url, headers=build_headers(), json=payload)
|
|
706
|
+
refusal = _parse_invalid_proof(resp)
|
|
707
|
+
if refusal is None:
|
|
708
|
+
return resp
|
|
709
|
+
reason, message = refusal
|
|
710
|
+
raise OneShotError(f"The API refused the wallet proof ({reason}): {message}")
|
|
711
|
+
|
|
669
712
|
async def aensure_budgets_synced(self) -> None:
|
|
670
713
|
"""Push ``budgets`` / ``alert_email`` to the server once (async).
|
|
671
714
|
|
|
@@ -774,6 +817,44 @@ class OneShotClient:
|
|
|
774
817
|
)
|
|
775
818
|
)
|
|
776
819
|
|
|
820
|
+
def call_lookup(self, name: str, /, **params: Any) -> Any:
|
|
821
|
+
"""Call a lookup tool by its manifest name, e.g. ``call_lookup("company_filings", cik="320193")``.
|
|
822
|
+
|
|
823
|
+
The tool's name is positional only, so a tool whose own input is called
|
|
824
|
+
``name`` can be given it: ``call_lookup("company_legal_identifier", name="Apple Inc.")``.
|
|
825
|
+
|
|
826
|
+
Sends the params through :meth:`call_tool`. Params that are ``None`` are
|
|
827
|
+
left out. An unknown name raises ``ValidationError``; see
|
|
828
|
+
:func:`oneshot.lookup.lookup_tools` for the names.
|
|
829
|
+
"""
|
|
830
|
+
entry = find_lookup_tool(name)
|
|
831
|
+
method, pricing, path, payload = lookup_request(entry, params)
|
|
832
|
+
if pricing in PAID_PRICING:
|
|
833
|
+
return self.call_tool(path, payload)
|
|
834
|
+
return asyncio.get_event_loop().run_until_complete(self._acall_free_lookup(method, path, payload))
|
|
835
|
+
|
|
836
|
+
async def acall_lookup(self, name: str, /, **params: Any) -> Any:
|
|
837
|
+
"""Async form of :meth:`call_lookup`, sent through :meth:`acall_tool`."""
|
|
838
|
+
entry = find_lookup_tool(name)
|
|
839
|
+
method, pricing, path, payload = lookup_request(entry, params)
|
|
840
|
+
if pricing in PAID_PRICING:
|
|
841
|
+
return await self.acall_tool(path, payload)
|
|
842
|
+
return await self._acall_free_lookup(method, path, payload)
|
|
843
|
+
|
|
844
|
+
async def _acall_free_lookup(self, method: str, path: str, payload: dict[str, Any]) -> Any:
|
|
845
|
+
"""A route with no payment of its own (a goal read or control, an inbox, a notification, a number or
|
|
846
|
+
account setting): a GET sends the params as the query, the other methods are write-scoped signed calls;
|
|
847
|
+
the ``{"success", "data"}`` envelope is stripped as the compute methods do."""
|
|
848
|
+
if method == "GET":
|
|
849
|
+
return self._unwrap_data(await self.acall_free_get(path, lookup_query(payload) or None))
|
|
850
|
+
if method == "PATCH":
|
|
851
|
+
return self._unwrap_data(await self.acall_free_patch(path, payload, scope="write"))
|
|
852
|
+
if method == "PUT":
|
|
853
|
+
return self._unwrap_data(await self.acall_free_put(path, payload))
|
|
854
|
+
if method == "DELETE":
|
|
855
|
+
return self._unwrap_data(await self.acall_free_delete(path, scope="write"))
|
|
856
|
+
return self._unwrap_data(await self.acall_free_post(path, payload, scope="write"))
|
|
857
|
+
|
|
777
858
|
def wait_for_result(
|
|
778
859
|
self,
|
|
779
860
|
request_id: str,
|
|
@@ -870,11 +951,16 @@ class OneShotClient:
|
|
|
870
951
|
# before they ever get to the 402 dance unless x-quote-id rides
|
|
871
952
|
# on this very first attempt, so an already-obtained quote_id
|
|
872
953
|
# must be sent from the start, not just on the paid retry.
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
954
|
+
initial_headers_base = self._with_approval(self._headers(max_cost=max_cost, idempotency_key=idempotency_key), approval_id)
|
|
955
|
+
|
|
956
|
+
def _initial_headers() -> dict[str, str]:
|
|
957
|
+
headers = dict(initial_headers_base)
|
|
958
|
+
self._prove_wallet(headers, endpoint)
|
|
959
|
+
if quote_id:
|
|
960
|
+
headers["x-quote-id"] = quote_id
|
|
961
|
+
return headers
|
|
962
|
+
|
|
963
|
+
resp = await self._post_proved(client, url, _initial_headers, payload)
|
|
878
964
|
|
|
879
965
|
# The spend-budget gate rejects at quote time (403) so nothing is
|
|
880
966
|
# signed; surface it as the typed error.
|
|
@@ -1002,16 +1088,21 @@ class OneShotClient:
|
|
|
1002
1088
|
resource=parsed_req.get("resource"),
|
|
1003
1089
|
extensions=parsed_req.get("extensions"),
|
|
1004
1090
|
)
|
|
1005
|
-
|
|
1091
|
+
zero_cost_base = {
|
|
1006
1092
|
**self._with_approval(self._headers(max_cost=max_cost, idempotency_key=idempotency_key), approval_id),
|
|
1007
1093
|
"payment-signature": encode_payment_header(auth),
|
|
1008
1094
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
headers
|
|
1014
|
-
|
|
1095
|
+
|
|
1096
|
+
def _zero_cost_headers() -> dict[str, str]:
|
|
1097
|
+
# The zero-cost authorization is an unsigned stub, so this leg
|
|
1098
|
+
# still has to prove the wallet for the credits to be spent.
|
|
1099
|
+
headers = dict(zero_cost_base)
|
|
1100
|
+
self._prove_wallet(headers, endpoint)
|
|
1101
|
+
if quote_id:
|
|
1102
|
+
headers["x-quote-id"] = quote_id
|
|
1103
|
+
return headers
|
|
1104
|
+
|
|
1105
|
+
resp2 = await self._post_proved(client, url, _zero_cost_headers, payload)
|
|
1015
1106
|
else:
|
|
1016
1107
|
auth = sign_payment_authorization(
|
|
1017
1108
|
private_key=self._private_key,
|
|
@@ -1429,7 +1520,12 @@ class OneShotClient:
|
|
|
1429
1520
|
return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/budget"))
|
|
1430
1521
|
|
|
1431
1522
|
def cancel_compute_goal(self, goal_id: str, reason: Optional[str] = None) -> Any:
|
|
1432
|
-
"""Cancel a goal; remaining budget is credited. Blocking.
|
|
1523
|
+
"""Cancel a goal; remaining budget is credited. Blocking.
|
|
1524
|
+
|
|
1525
|
+
When reservations are still in flight the server answers 202 and the
|
|
1526
|
+
refund is deferred: the result then has ``status == "pending_settlement"``
|
|
1527
|
+
(no exception is raised).
|
|
1528
|
+
"""
|
|
1433
1529
|
return asyncio.get_event_loop().run_until_complete(
|
|
1434
1530
|
self.acancel_compute_goal(goal_id, reason)
|
|
1435
1531
|
)
|
|
@@ -1437,11 +1533,17 @@ class OneShotClient:
|
|
|
1437
1533
|
async def acancel_compute_goal(
|
|
1438
1534
|
self, goal_id: str, reason: Optional[str] = None
|
|
1439
1535
|
) -> Any:
|
|
1440
|
-
"""Cancel a goal; remaining budget is credited. Async.
|
|
1536
|
+
"""Cancel a goal; remaining budget is credited. Async.
|
|
1537
|
+
|
|
1538
|
+
A 202 ``pending_settlement`` answer is returned as a result, not raised.
|
|
1539
|
+
"""
|
|
1441
1540
|
payload: dict[str, Any] = {"reason": reason} if reason is not None else {}
|
|
1442
|
-
|
|
1541
|
+
result = self._unwrap_data(
|
|
1443
1542
|
await self.acall_free_post(f"/v1/compute/{goal_id}/cancel", payload, scope="write")
|
|
1444
1543
|
)
|
|
1544
|
+
if isinstance(result, dict) and result.get("status") == "pending_settlement":
|
|
1545
|
+
result = {"goal_id": goal_id, **{k: v for k, v in result.items() if k != "success"}}
|
|
1546
|
+
return result
|
|
1445
1547
|
|
|
1446
1548
|
def respond_to_compute_task(
|
|
1447
1549
|
self,
|
|
@@ -1510,7 +1612,11 @@ class OneShotClient:
|
|
|
1510
1612
|
)
|
|
1511
1613
|
|
|
1512
1614
|
async def afund_compute_goal(self, goal_id: str, amount: float) -> Any:
|
|
1513
|
-
"""Top up budget on a recurring goal
|
|
1615
|
+
"""Top up budget on a recurring goal, or on any goal that ended with
|
|
1616
|
+
``end_reason == "budget_exhausted"`` (it is set back to ``active`` and woken, keeping
|
|
1617
|
+
its tasks and workspace). Other endings and passed deadlines are refused; a refund
|
|
1618
|
+
still settling answers a retryable ``refund_pending`` (409). Quote+pay (x402) via
|
|
1619
|
+
`acall_tool`."""
|
|
1514
1620
|
if not amount or amount <= 0:
|
|
1515
1621
|
raise ValidationError("amount must be a positive number", "amount")
|
|
1516
1622
|
return await self.acall_tool(
|
|
@@ -1999,29 +2105,29 @@ class OneShotClient:
|
|
|
1999
2105
|
"""Enrich a company from domain, name, LinkedIn URL, or ticker. Async."""
|
|
2000
2106
|
return await self.acall_tool("/v1/tools/enrich/company", kwargs)
|
|
2001
2107
|
|
|
2002
|
-
def
|
|
2108
|
+
def business_find(self, **kwargs: Any) -> Any:
|
|
2003
2109
|
"""Discover local businesses (restaurants, contractors, practices) by category/keywords x location. Blocking."""
|
|
2004
|
-
return self.call_tool("/v1/tools/
|
|
2110
|
+
return self.call_tool("/v1/tools/find/business", kwargs)
|
|
2005
2111
|
|
|
2006
|
-
async def
|
|
2112
|
+
async def abusiness_find(self, **kwargs: Any) -> Any:
|
|
2007
2113
|
"""Discover local businesses (restaurants, contractors, practices) by category/keywords x location. Async."""
|
|
2008
|
-
return await self.acall_tool("/v1/tools/
|
|
2114
|
+
return await self.acall_tool("/v1/tools/find/business", kwargs)
|
|
2009
2115
|
|
|
2010
|
-
def
|
|
2116
|
+
def business_resolve(self, name: str, **kwargs: Any) -> Any:
|
|
2011
2117
|
"""Resolve a business name + one locating field (address/city/postal_code/phone) to its domain, phone, status. Blocking."""
|
|
2012
|
-
return self.call_tool("/v1/tools/
|
|
2118
|
+
return self.call_tool("/v1/tools/resolve/business", {"name": name, **kwargs})
|
|
2013
2119
|
|
|
2014
|
-
async def
|
|
2120
|
+
async def abusiness_resolve(self, name: str, **kwargs: Any) -> Any:
|
|
2015
2121
|
"""Resolve a business name + one locating field (address/city/postal_code/phone) to its domain, phone, status. Async."""
|
|
2016
|
-
return await self.acall_tool("/v1/tools/
|
|
2122
|
+
return await self.acall_tool("/v1/tools/resolve/business", {"name": name, **kwargs})
|
|
2017
2123
|
|
|
2018
|
-
def
|
|
2124
|
+
def record_solicitations_find(self, naics: list[str], **kwargs: Any) -> Any:
|
|
2019
2125
|
"""Federal Sources Sought / Presolicitation notices (SAM.gov) by NAICS code with the contracting officer's contact. Result carries `data_as_of` (snapshot behind the rows). Blocking."""
|
|
2020
|
-
return self.call_tool("/v1/tools/
|
|
2126
|
+
return self.call_tool("/v1/tools/find/record-solicitations", {"naics": naics, **kwargs})
|
|
2021
2127
|
|
|
2022
|
-
async def
|
|
2128
|
+
async def arecord_solicitations_find(self, naics: list[str], **kwargs: Any) -> Any:
|
|
2023
2129
|
"""Federal Sources Sought / Presolicitation notices (SAM.gov) by NAICS code with the contracting officer's contact. Result carries `data_as_of` (snapshot behind the rows). Async."""
|
|
2024
|
-
return await self.acall_tool("/v1/tools/
|
|
2130
|
+
return await self.acall_tool("/v1/tools/find/record-solicitations", {"naics": naics, **kwargs})
|
|
2025
2131
|
|
|
2026
2132
|
def find_email(self, company_domain: str, *, full_name: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, **kwargs: Any) -> Any:
|
|
2027
2133
|
"""Find a person's email address. Blocking."""
|
|
@@ -2127,6 +2233,16 @@ class OneShotClient:
|
|
|
2127
2233
|
"""Build a website. Async."""
|
|
2128
2234
|
return await self.acall_tool("/v1/tools/build", {"product": product, **kwargs})
|
|
2129
2235
|
|
|
2236
|
+
def update_build(self, build_id: str, changes: str, **kwargs: Any) -> Any:
|
|
2237
|
+
"""Apply natural-language changes to an existing build (same route as build). Blocking."""
|
|
2238
|
+
transport = {k: kwargs.pop(k) for k in _BUILD_TRANSPORT_KWARGS if k in kwargs}
|
|
2239
|
+
return self.call_tool("/v1/tools/build", {"build_id": build_id, "changes": changes, **kwargs}, **transport)
|
|
2240
|
+
|
|
2241
|
+
async def aupdate_build(self, build_id: str, changes: str, **kwargs: Any) -> Any:
|
|
2242
|
+
"""Apply natural-language changes to an existing build (same route as build). Async."""
|
|
2243
|
+
transport = {k: kwargs.pop(k) for k in _BUILD_TRANSPORT_KWARGS if k in kwargs}
|
|
2244
|
+
return await self.acall_tool("/v1/tools/build", {"build_id": build_id, "changes": changes, **kwargs}, **transport)
|
|
2245
|
+
|
|
2130
2246
|
def get_balance(self) -> dict:
|
|
2131
2247
|
"""Get unified balance (on-chain USDC + credits). Blocking."""
|
|
2132
2248
|
return self.call_free_get("/v1/tools/balance")
|
|
@@ -2135,6 +2251,69 @@ class OneShotClient:
|
|
|
2135
2251
|
"""Get unified balance (on-chain USDC + credits). Async."""
|
|
2136
2252
|
return await self.acall_free_get("/v1/tools/balance")
|
|
2137
2253
|
|
|
2254
|
+
# Inboxes and notifications (#1273). Free signed reads and writes of the agent's own data.
|
|
2255
|
+
|
|
2256
|
+
@staticmethod
|
|
2257
|
+
def _inbox_params(**values: Any) -> Optional[dict[str, str]]:
|
|
2258
|
+
params = {k: ("true" if v is True else "false" if v is False else str(v)) for k, v in values.items() if v is not None}
|
|
2259
|
+
return params or None
|
|
2260
|
+
|
|
2261
|
+
def inbox(self, *, since: Optional[str] = None, limit: Optional[int] = None, include_body: Optional[bool] = None) -> Any:
|
|
2262
|
+
"""List emails received by the agent, newest first. Free. Blocking."""
|
|
2263
|
+
return self.call_free_get("/v1/tools/inbox", self._inbox_params(since=since, limit=limit, include_body=include_body))
|
|
2264
|
+
|
|
2265
|
+
async def ainbox(self, *, since: Optional[str] = None, limit: Optional[int] = None, include_body: Optional[bool] = None) -> Any:
|
|
2266
|
+
"""List emails received by the agent, newest first. Free. Async."""
|
|
2267
|
+
return await self.acall_free_get("/v1/tools/inbox", self._inbox_params(since=since, limit=limit, include_body=include_body))
|
|
2268
|
+
|
|
2269
|
+
def inbox_get(self, email_id: str) -> Any:
|
|
2270
|
+
"""One received email with its body and attachments. Free. Blocking."""
|
|
2271
|
+
return self.call_free_get(f"/v1/tools/inbox/{quote(email_id, safe='')}")
|
|
2272
|
+
|
|
2273
|
+
async def ainbox_get(self, email_id: str) -> Any:
|
|
2274
|
+
"""One received email with its body and attachments. Free. Async."""
|
|
2275
|
+
return await self.acall_free_get(f"/v1/tools/inbox/{quote(email_id, safe='')}")
|
|
2276
|
+
|
|
2277
|
+
def sms_inbox(self, *, since: Optional[str] = None, limit: Optional[int] = None, from_number: Optional[str] = None) -> Any:
|
|
2278
|
+
"""List text messages received by the agent. Free. Blocking."""
|
|
2279
|
+
return self.call_free_get("/v1/tools/sms/inbox", self._inbox_params(since=since, limit=limit, **{"from": from_number}))
|
|
2280
|
+
|
|
2281
|
+
async def asms_inbox(self, *, since: Optional[str] = None, limit: Optional[int] = None, from_number: Optional[str] = None) -> Any:
|
|
2282
|
+
"""List text messages received by the agent. Free. Async."""
|
|
2283
|
+
return await self.acall_free_get("/v1/tools/sms/inbox", self._inbox_params(since=since, limit=limit, **{"from": from_number}))
|
|
2284
|
+
|
|
2285
|
+
def sms_inbox_get(self, message_id: str) -> Any:
|
|
2286
|
+
"""One received text message. Free. Blocking."""
|
|
2287
|
+
return self.call_free_get(f"/v1/tools/sms/inbox/{quote(message_id, safe='')}")
|
|
2288
|
+
|
|
2289
|
+
async def asms_inbox_get(self, message_id: str) -> Any:
|
|
2290
|
+
"""One received text message. Free. Async."""
|
|
2291
|
+
return await self.acall_free_get(f"/v1/tools/sms/inbox/{quote(message_id, safe='')}")
|
|
2292
|
+
|
|
2293
|
+
def notifications(self, *, unread: Optional[bool] = None, limit: Optional[int] = None) -> Any:
|
|
2294
|
+
"""List the agent's notifications (job completions, failures, budget warnings). Free. Blocking."""
|
|
2295
|
+
return self.call_free_get("/v1/tools/notifications", self._inbox_params(unread=unread, limit=limit))
|
|
2296
|
+
|
|
2297
|
+
async def anotifications(self, *, unread: Optional[bool] = None, limit: Optional[int] = None) -> Any:
|
|
2298
|
+
"""List the agent's notifications (job completions, failures, budget warnings). Free. Async."""
|
|
2299
|
+
return await self.acall_free_get("/v1/tools/notifications", self._inbox_params(unread=unread, limit=limit))
|
|
2300
|
+
|
|
2301
|
+
def mark_notification_read(self, notification_id: str) -> Any:
|
|
2302
|
+
"""Mark one notification as read. Free. Blocking."""
|
|
2303
|
+
return self.call_free_patch(f"/v1/tools/notifications/{quote(notification_id, safe='')}/read", scope="write")
|
|
2304
|
+
|
|
2305
|
+
async def amark_notification_read(self, notification_id: str) -> Any:
|
|
2306
|
+
"""Mark one notification as read. Free. Async."""
|
|
2307
|
+
return await self.acall_free_patch(f"/v1/tools/notifications/{quote(notification_id, safe='')}/read", scope="write")
|
|
2308
|
+
|
|
2309
|
+
def mark_all_notifications_read(self) -> Any:
|
|
2310
|
+
"""Mark every notification as read. Free. Blocking."""
|
|
2311
|
+
return self.call_free_patch("/v1/tools/notifications/read-all", scope="write")
|
|
2312
|
+
|
|
2313
|
+
async def amark_all_notifications_read(self) -> Any:
|
|
2314
|
+
"""Mark every notification as read. Free. Async."""
|
|
2315
|
+
return await self.acall_free_patch("/v1/tools/notifications/read-all", scope="write")
|
|
2316
|
+
|
|
2138
2317
|
# `pause`/`resume` sit behind the API's paidEndpointLimiter (a rate limiter,
|
|
2139
2318
|
# not a price) — no x402 quote, so they use `call_free_post`, not `call_tool`.
|
|
2140
2319
|
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""The lookup tools: the manifest, and the plain mapping from an entry to a tool.
|
|
2
|
+
|
|
3
|
+
The manifest (``oneshot.lookup_manifest``) is generated from the API's lookup
|
|
4
|
+
registry. ``lookup_tools()`` returns it; ``OneShotClient.call_lookup`` and
|
|
5
|
+
``acall_lookup`` call one entry by name.
|
|
6
|
+
|
|
7
|
+
``lookup_tool_spec`` turns an entry into a name, a description and an argument
|
|
8
|
+
list. It imports no agent framework, so the LangChain and GAME adapters share it
|
|
9
|
+
and it is tested on its own. Lookup inputs are flat by rule: strings, numbers,
|
|
10
|
+
booleans, enums, or arrays of those. Anything else stops with an error that
|
|
11
|
+
names the tool and the field.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from copy import deepcopy
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Mapping, Optional
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
from oneshot._errors import ValidationError
|
|
24
|
+
from oneshot.lookup_manifest import LOOKUP_MANIFEST
|
|
25
|
+
|
|
26
|
+
TOOL_PREFIX = "oneshot_"
|
|
27
|
+
|
|
28
|
+
_SCALARS = ("string", "number", "integer", "boolean")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LookupSchemaError(ValueError):
|
|
32
|
+
"""A manifest entry has an input the adapters cannot map."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, tool: str, field: str, problem: str) -> None:
|
|
35
|
+
super().__init__(f'lookup tool "{tool}", field "{field}": {problem}')
|
|
36
|
+
self.tool = tool
|
|
37
|
+
self.field = field
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class LookupArg:
|
|
42
|
+
"""One input field of a lookup tool."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
#: "string", "number", "integer", "boolean", "array" or "object" (passed through as given).
|
|
46
|
+
type: str
|
|
47
|
+
required: bool
|
|
48
|
+
description: str = ""
|
|
49
|
+
#: Allowed values, when the field (or the array's items) is an enum.
|
|
50
|
+
enum: Optional[tuple[str, ...]] = None
|
|
51
|
+
#: The array's item type; ``None`` unless ``type == "array"``.
|
|
52
|
+
item_type: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class LookupToolSpec:
|
|
57
|
+
"""What an adapter needs to expose one lookup tool."""
|
|
58
|
+
|
|
59
|
+
#: The manifest name, e.g. ``company_filings``; this is what ``call_lookup`` takes.
|
|
60
|
+
lookup_name: str
|
|
61
|
+
#: The adapter-facing name, ``oneshot_<lookup_name>``.
|
|
62
|
+
name: str
|
|
63
|
+
description: str
|
|
64
|
+
args: tuple[LookupArg, ...]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def lookup_tools() -> list[dict[str, Any]]:
|
|
68
|
+
"""The lookup tools, one plain dictionary each: ``name``, ``path``, ``title``,
|
|
69
|
+
``description``, ``input_schema`` and ``example``.
|
|
70
|
+
The list is a copy: changing it does not change the tools the client calls."""
|
|
71
|
+
return deepcopy(LOOKUP_MANIFEST)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_lookup_tool(name: str) -> dict[str, Any]:
|
|
75
|
+
for entry in LOOKUP_MANIFEST:
|
|
76
|
+
if entry["name"] == name:
|
|
77
|
+
return entry
|
|
78
|
+
raise ValidationError(f"Unknown lookup tool: {name!r}", "name")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def lookup_payload(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
82
|
+
"""The request body for a lookup call: the given params without the ``None`` ones."""
|
|
83
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_PATH_PARAM = re.compile(r"\{([a-z0-9_]+)\}")
|
|
87
|
+
|
|
88
|
+
#: Entry ``pricing`` values that are settled with a payment (``call_tool``). Every other value
|
|
89
|
+
#: (``free``, ``included``, ``credits``, ``listed``) is a signed call with no payment of its own.
|
|
90
|
+
PAID_PRICING = frozenset({"quoted", "fixed"})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def lookup_query(params: Mapping[str, Any]) -> dict[str, str]:
|
|
94
|
+
"""The query string values for a ``GET`` entry: booleans as ``true``/``false``, lists and
|
|
95
|
+
objects as JSON, everything else as text."""
|
|
96
|
+
out: dict[str, str] = {}
|
|
97
|
+
for key, value in params.items():
|
|
98
|
+
if isinstance(value, bool):
|
|
99
|
+
out[key] = "true" if value else "false"
|
|
100
|
+
elif isinstance(value, (dict, list)):
|
|
101
|
+
out[key] = json.dumps(value)
|
|
102
|
+
else:
|
|
103
|
+
out[key] = str(value)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def lookup_request(entry: Mapping[str, Any], params: Mapping[str, Any]) -> tuple[str, str, str, dict[str, Any]]:
|
|
108
|
+
"""How to send one manifest entry: ``(method, pricing, path, payload)``.
|
|
109
|
+
|
|
110
|
+
A lookup entry has neither ``method`` nor ``pricing``: it is a quoted POST to its
|
|
111
|
+
``path`` with every param in the body. A registry route entry (Compute, Fund Goal and the
|
|
112
|
+
goal routes) states both, and a ``{goal_id}`` in its ``path`` is taken out of the params and
|
|
113
|
+
put into the path. A missing path param raises ``ValidationError``.
|
|
114
|
+
"""
|
|
115
|
+
payload = lookup_payload(params)
|
|
116
|
+
path = str(entry["path"])
|
|
117
|
+
for field in _PATH_PARAM.findall(path):
|
|
118
|
+
value = payload.pop(field, None)
|
|
119
|
+
if value is None or str(value) == "":
|
|
120
|
+
raise ValidationError(f"{entry['name']}: {field} is required", field)
|
|
121
|
+
path = path.replace("{" + field + "}", quote(str(value), safe=""))
|
|
122
|
+
return str(entry.get("method", "POST")), str(entry.get("pricing", "quoted")), path, payload
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _arg(tool: str, field: str, prop: Any, required: bool) -> LookupArg:
|
|
126
|
+
if not isinstance(prop, dict):
|
|
127
|
+
raise LookupSchemaError(tool, field, "the schema for this field is not an object")
|
|
128
|
+
kind = prop.get("type")
|
|
129
|
+
description = str(prop.get("description") or "")
|
|
130
|
+
enum = prop.get("enum")
|
|
131
|
+
if enum is not None and (not isinstance(enum, list) or not all(isinstance(v, str) for v in enum)):
|
|
132
|
+
raise LookupSchemaError(tool, field, "an enum must list strings")
|
|
133
|
+
if kind in _SCALARS:
|
|
134
|
+
if enum is not None and kind != "string":
|
|
135
|
+
raise LookupSchemaError(tool, field, f"an enum of type {kind!r} is not supported; use a string enum")
|
|
136
|
+
return LookupArg(field, kind, required, description, tuple(enum) if enum else None)
|
|
137
|
+
if kind == "object":
|
|
138
|
+
return LookupArg(field, "object", required, description)
|
|
139
|
+
if kind == "array":
|
|
140
|
+
items = prop.get("items")
|
|
141
|
+
if not isinstance(items, dict):
|
|
142
|
+
items = {}
|
|
143
|
+
item_kind = items.get("type")
|
|
144
|
+
if item_kind not in _SCALARS:
|
|
145
|
+
raise LookupSchemaError(tool, field, f"an array of {item_kind!r} is not supported; items must be strings, numbers or booleans")
|
|
146
|
+
item_enum = items.get("enum")
|
|
147
|
+
if item_enum is not None and (item_kind != "string" or not all(isinstance(v, str) for v in item_enum)):
|
|
148
|
+
raise LookupSchemaError(tool, field, "an array's enum must list strings")
|
|
149
|
+
return LookupArg(field, "array", required, description, tuple(item_enum) if item_enum else None, item_kind)
|
|
150
|
+
raise LookupSchemaError(tool, field, f"type {kind!r} is not supported; inputs are strings, numbers, booleans, enums or arrays of those")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def lookup_tool_spec(entry: Mapping[str, Any]) -> LookupToolSpec:
|
|
154
|
+
"""Map one manifest entry to a name, a description and an argument list."""
|
|
155
|
+
tool = str(entry["name"])
|
|
156
|
+
schema = entry.get("input_schema") or {}
|
|
157
|
+
properties = schema.get("properties") or {}
|
|
158
|
+
required = set(schema.get("required") or [])
|
|
159
|
+
args = tuple(_arg(tool, field, prop, field in required) for field, prop in properties.items())
|
|
160
|
+
return LookupToolSpec(tool, f"{TOOL_PREFIX}{tool}", str(entry["description"]), args)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def lookup_tool_specs() -> list[LookupToolSpec]:
|
|
164
|
+
"""A spec for every tool in the manifest."""
|
|
165
|
+
return [lookup_tool_spec(entry) for entry in LOOKUP_MANIFEST]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _coerce_scalar(spec: LookupToolSpec, arg: LookupArg, kind: str, text: str) -> Any:
|
|
169
|
+
try:
|
|
170
|
+
if kind == "integer":
|
|
171
|
+
return int(text)
|
|
172
|
+
if kind == "number":
|
|
173
|
+
return float(text)
|
|
174
|
+
if kind == "boolean":
|
|
175
|
+
lowered = text.strip().lower()
|
|
176
|
+
if lowered in ("true", "yes", "1"):
|
|
177
|
+
return True
|
|
178
|
+
if lowered in ("false", "no", "0"):
|
|
179
|
+
return False
|
|
180
|
+
raise ValueError(text)
|
|
181
|
+
return text
|
|
182
|
+
except ValueError:
|
|
183
|
+
raise ValidationError(f"{spec.name}: {arg.name} must be a {kind}, got {text!r}", arg.name) from None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
_STATE_CODE = re.compile(r"[A-Z]{2}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_LOCATION_FIELDS = frozenset({"location"})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _split_array_text(text: str, item_type: str, field: str = "") -> list[str]:
|
|
193
|
+
"""Split GAME array text into items.
|
|
194
|
+
|
|
195
|
+
Semicolons or newlines separate items when the text has any, else commas do.
|
|
196
|
+
In a location field, a comma part that is only a two-letter uppercase code
|
|
197
|
+
stays with the part before it, so "Austin, TX" is one item. Every other
|
|
198
|
+
string array (agencies, keywords, NAICS) splits on every comma.
|
|
199
|
+
"""
|
|
200
|
+
if ";" in text or "\n" in text:
|
|
201
|
+
return [part.strip() for part in re.split(r"[;\n]", text) if part.strip()]
|
|
202
|
+
parts = [part.strip() for part in text.split(",") if part.strip()]
|
|
203
|
+
if item_type != "string" or field not in _LOCATION_FIELDS:
|
|
204
|
+
return parts
|
|
205
|
+
items: list[str] = []
|
|
206
|
+
for part in parts:
|
|
207
|
+
if items and _STATE_CODE.fullmatch(part):
|
|
208
|
+
items[-1] = f"{items[-1]}, {part}"
|
|
209
|
+
else:
|
|
210
|
+
items.append(part)
|
|
211
|
+
return items
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _coerce_object(spec: LookupToolSpec, arg: LookupArg, raw: Any) -> dict[str, Any]:
|
|
215
|
+
"""An object field: a mapping as given, or text that is a JSON object (what a GAME worker sends)."""
|
|
216
|
+
if isinstance(raw, Mapping):
|
|
217
|
+
return dict(raw)
|
|
218
|
+
try:
|
|
219
|
+
value = json.loads(str(raw))
|
|
220
|
+
except ValueError:
|
|
221
|
+
value = None
|
|
222
|
+
if not isinstance(value, dict):
|
|
223
|
+
raise ValidationError(f"{spec.name}: {arg.name} must be a JSON object, got {str(raw)!r}", arg.name)
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def coerce_text_args(spec: LookupToolSpec, values: Mapping[str, Any]) -> dict[str, Any]:
|
|
228
|
+
"""Turn text values (what a GAME worker sends) into the types the tool takes.
|
|
229
|
+
|
|
230
|
+
Empty and missing values are dropped; arrays are comma-separated (or semicolon-separated; a "City, ST" location item stays whole). A missing
|
|
231
|
+
required field or a value that does not parse raises ``ValidationError``.
|
|
232
|
+
"""
|
|
233
|
+
out: dict[str, Any] = {}
|
|
234
|
+
for arg in spec.args:
|
|
235
|
+
raw = values.get(arg.name)
|
|
236
|
+
text = "" if raw is None else str(raw).strip()
|
|
237
|
+
if not text:
|
|
238
|
+
if arg.required:
|
|
239
|
+
raise ValidationError(f"{spec.name}: {arg.name} is required", arg.name)
|
|
240
|
+
continue
|
|
241
|
+
if arg.type == "array":
|
|
242
|
+
out[arg.name] = [_coerce_scalar(spec, arg, arg.item_type or "string", part) for part in _split_array_text(text, arg.item_type or "string", arg.name)]
|
|
243
|
+
elif arg.type == "object":
|
|
244
|
+
out[arg.name] = _coerce_object(spec, arg, raw)
|
|
245
|
+
else:
|
|
246
|
+
out[arg.name] = _coerce_scalar(spec, arg, arg.type, text)
|
|
247
|
+
if arg.enum:
|
|
248
|
+
chosen = out[arg.name] if arg.type == "array" else [out[arg.name]]
|
|
249
|
+
bad = [v for v in chosen if v not in arg.enum]
|
|
250
|
+
if bad:
|
|
251
|
+
raise ValidationError(f"{spec.name}: {arg.name} must be one of {', '.join(arg.enum)}", arg.name)
|
|
252
|
+
return out
|