exa-py 1.14.20__py3-none-any.whl → 1.15.0__py3-none-any.whl

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.

Potentially problematic release.


This version of exa-py might be problematic. Click here for more details.

exa_py/research/client.py DELETED
@@ -1,358 +0,0 @@
1
- """Lightweight research client wrappers for the Exa REST API.
2
-
3
- This module purposefully keeps its import surface minimal to avoid circular
4
- import problems with :pymod:`exa_py.api`. Any heavy dependencies (including
5
- `exa_py.api` itself) are imported lazily **inside** functions. This means
6
- that type-checkers still see the full, precise types via the ``TYPE_CHECKING``
7
- block, but at runtime we only pay the cost if/when a helper is actually used.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from typing import TYPE_CHECKING, Any, Dict, Optional, Literal
13
-
14
- from exa_py.utils import JSONSchemaInput
15
- from ..api import _convert_schema_input
16
-
17
- if TYPE_CHECKING: # pragma: no cover – only for static analysers
18
- # Import with full type info when static type-checking. `_Result` still
19
- # lives in ``exa_py.api`` but the response model moved to
20
- # ``exa_py.research.models``.
21
- from .models import (
22
- ResearchTask,
23
- ResearchTaskId,
24
- ListResearchTasksResponse,
25
- ) # noqa: F401
26
-
27
- # ---------------------------------------------------------------------------
28
- # Public, user-facing clients
29
- # ---------------------------------------------------------------------------
30
-
31
-
32
- class ResearchClient:
33
- """Synchronous helper namespace accessed via :pyattr:`Exa.research`."""
34
-
35
- def __init__(self, parent_client):
36
- # A reference to the *already-constructed* ``Exa`` instance so that we
37
- # can piggy-back on its HTTP plumbing (headers, base URL, retries, …).
38
- self._client = parent_client
39
-
40
- def create_task(
41
- self,
42
- *,
43
- instructions: str,
44
- model: Literal["exa-research", "exa-research-pro"] = "exa-research",
45
- output_infer_schema: bool = None,
46
- output_schema: "Optional[JSONSchemaInput]" = None,
47
- ) -> "ResearchTaskId":
48
- """Submit a research request and return the *task identifier*."""
49
- payload = {"instructions": instructions}
50
- if model is not None:
51
- payload["model"] = model
52
- if output_schema is not None or output_infer_schema is not None:
53
- payload["output"] = {}
54
- if output_schema is not None:
55
- payload["output"]["schema"] = _convert_schema_input(output_schema)
56
- if output_infer_schema is not None:
57
- payload["output"]["inferSchema"] = output_infer_schema
58
-
59
- raw_response: Dict[str, Any] = self._client.request(
60
- "/research/v0/tasks", payload
61
- )
62
-
63
- # Defensive checks so that we fail loudly if the contract changes.
64
- if not isinstance(raw_response, dict) or "id" not in raw_response:
65
- raise RuntimeError(
66
- f"Unexpected response while creating research task: {raw_response}"
67
- )
68
-
69
- # Lazily import to avoid circular deps at runtime.
70
- from .models import ResearchTaskId # noqa: WPS433 – runtime import
71
-
72
- return ResearchTaskId(id=raw_response["id"])
73
-
74
- def get_task(self, id: str) -> "ResearchTask": # noqa: D401 – imperative mood is fine
75
- """Fetch the current status / result for a research task."""
76
- endpoint = f"/research/v0/tasks/{id}"
77
-
78
- # The new endpoint is a simple GET.
79
- raw_response: Dict[str, Any] = self._client.request(endpoint, method="GET")
80
-
81
- return _build_research_task(raw_response)
82
-
83
- # ------------------------------------------------------------------
84
- # Convenience helpers
85
- # ------------------------------------------------------------------
86
-
87
- def poll_task(
88
- self,
89
- id: str,
90
- *,
91
- poll_interval: float = 1.0,
92
- timeout_seconds: int = 15 * 60,
93
- ) -> "ResearchTask":
94
- """Blocking helper that polls until task completes or fails.
95
-
96
- Parameters
97
- ----------
98
- id:
99
- The ID of the research task to poll.
100
- poll_interval:
101
- Seconds to wait between successive polls (default 1s).
102
- timeout_seconds:
103
- Maximum time to wait before raising :class:`TimeoutError` (default 15 min).
104
- """
105
-
106
- import time
107
-
108
- deadline = time.monotonic() + timeout_seconds
109
-
110
- while True:
111
- task = self.get_task(id)
112
- status = task.status.lower() if isinstance(task.status, str) else ""
113
-
114
- if status in {"completed", "failed", "complete", "finished", "done"}:
115
- return task
116
-
117
- if time.monotonic() > deadline:
118
- raise TimeoutError(
119
- f"Research task {id} did not finish within {timeout_seconds} seconds"
120
- )
121
-
122
- time.sleep(poll_interval)
123
-
124
- # ------------------------------------------------------------------
125
- # Listing helpers
126
- # ------------------------------------------------------------------
127
-
128
- def list(
129
- self,
130
- *,
131
- cursor: Optional[str] = None,
132
- limit: Optional[int] = None,
133
- ) -> "ListResearchTasksResponse":
134
- """List research tasks with optional pagination.
135
-
136
- Parameters
137
- ----------
138
- cursor:
139
- Pagination cursor returned by a previous call (optional).
140
- limit:
141
- Maximum number of tasks to return (optional).
142
- """
143
-
144
- params = {
145
- k: v for k, v in {"cursor": cursor, "limit": limit}.items() if v is not None
146
- }
147
-
148
- raw_response: Dict[str, Any] = self._client.request(
149
- "/research/v0/tasks",
150
- data=None,
151
- method="GET",
152
- params=params or None,
153
- )
154
-
155
- # Defensive checks so that we fail loudly if the contract changes.
156
- if not isinstance(raw_response, dict) or "data" not in raw_response:
157
- raise RuntimeError(
158
- f"Unexpected response while listing research tasks: {raw_response}"
159
- )
160
-
161
- tasks = [_build_research_task(t) for t in raw_response.get("data", [])]
162
-
163
- # Lazy import to avoid cycles.
164
- from .models import ListResearchTasksResponse # noqa: WPS433 – runtime import
165
-
166
- return ListResearchTasksResponse(
167
- data=tasks,
168
- has_more=raw_response.get("hasMore", False),
169
- next_cursor=raw_response.get("nextCursor"),
170
- )
171
-
172
-
173
- class AsyncResearchClient:
174
- """Async counterpart used via :pyattr:`AsyncExa.research`."""
175
-
176
- def __init__(self, parent_client):
177
- self._client = parent_client
178
-
179
- async def create_task(
180
- self,
181
- *,
182
- instructions: str,
183
- model: Literal["exa-research", "exa-research-pro"] = "exa-research",
184
- output_schema: "JSONSchemaInput",
185
- ) -> "ResearchTaskId":
186
- """Submit a research request and return the *task identifier* (async)."""
187
-
188
- # Convert schema using the same conversion logic as main API
189
- from ..api import _convert_schema_input # noqa: WPS433 – runtime import
190
-
191
- payload = {
192
- "instructions": instructions,
193
- "model": model,
194
- "output": {"schema": _convert_schema_input(output_schema)},
195
- }
196
-
197
- raw_response: Dict[str, Any] = await self._client.async_request(
198
- "/research/v0/tasks", payload
199
- )
200
-
201
- # Defensive checks so that we fail loudly if the contract changes.
202
- if not isinstance(raw_response, dict) or "id" not in raw_response:
203
- raise RuntimeError(
204
- f"Unexpected response while creating research task: {raw_response}"
205
- )
206
-
207
- # Lazily import to avoid circular deps at runtime.
208
- from .models import ResearchTaskId # noqa: WPS433 – runtime import
209
-
210
- return ResearchTaskId(id=raw_response["id"])
211
-
212
- async def get_task(self, id: str) -> "ResearchTask": # noqa: D401
213
- """Fetch the current status / result for a research task (async)."""
214
-
215
- endpoint = f"/research/v0/tasks/{id}"
216
-
217
- # Perform GET using the underlying HTTP client because `async_request`
218
- # only supports POST semantics.
219
- resp = await self._client.client.get(
220
- self._client.base_url + endpoint, headers=self._client.headers
221
- )
222
-
223
- if resp.status_code >= 400:
224
- raise RuntimeError(
225
- f"Request failed with status code {resp.status_code}: {resp.text}"
226
- )
227
-
228
- raw_response: Dict[str, Any] = resp.json()
229
-
230
- return _build_research_task(raw_response)
231
-
232
- # ------------------------------------------------------------------
233
- # Convenience helpers
234
- # ------------------------------------------------------------------
235
-
236
- async def poll_task(
237
- self,
238
- id: str,
239
- *,
240
- poll_interval: float = 1.0,
241
- timeout_seconds: int = 15 * 60,
242
- ) -> "ResearchTask":
243
- """Async helper that polls until task completes or fails.
244
-
245
- Mirrors :py:meth:`ResearchClient.poll_task` but uses ``await`` and
246
- :pyfunc:`asyncio.sleep`. Raises :class:`TimeoutError` on timeout.
247
- """
248
-
249
- import asyncio
250
- import time
251
-
252
- deadline = time.monotonic() + timeout_seconds
253
-
254
- while True:
255
- task = await self.get_task(id)
256
- status = task.status.lower() if isinstance(task.status, str) else ""
257
-
258
- if status in {"completed", "failed", "complete", "finished", "done"}:
259
- return task
260
-
261
- if time.monotonic() > deadline:
262
- raise TimeoutError(
263
- f"Research task {id} did not finish within {timeout_seconds} seconds"
264
- )
265
-
266
- await asyncio.sleep(poll_interval)
267
-
268
- # ------------------------------------------------------------------
269
- # Listing helpers
270
- # ------------------------------------------------------------------
271
-
272
- async def list(
273
- self,
274
- *,
275
- cursor: Optional[str] = None,
276
- limit: Optional[int] = None,
277
- ) -> "ListResearchTasksResponse":
278
- """Async list of research tasks with optional pagination."""
279
-
280
- params = {
281
- k: v for k, v in {"cursor": cursor, "limit": limit}.items() if v is not None
282
- }
283
-
284
- resp = await self._client.client.get(
285
- self._client.base_url + "/research/v0/tasks",
286
- headers=self._client.headers,
287
- params=params or None,
288
- )
289
-
290
- if resp.status_code >= 400:
291
- raise RuntimeError(
292
- f"Request failed with status code {resp.status_code}: {resp.text}"
293
- )
294
-
295
- raw_response: Dict[str, Any] = resp.json()
296
-
297
- if not isinstance(raw_response, dict) or "data" not in raw_response:
298
- raise RuntimeError(
299
- f"Unexpected response while listing research tasks: {raw_response}"
300
- )
301
-
302
- tasks = [_build_research_task(t) for t in raw_response.get("data", [])]
303
-
304
- from .models import ListResearchTasksResponse # noqa: WPS433 – runtime import
305
-
306
- return ListResearchTasksResponse(
307
- data=tasks,
308
- has_more=raw_response.get("hasMore", False),
309
- next_cursor=raw_response.get("nextCursor"),
310
- )
311
-
312
-
313
- # ---------------------------------------------------------------------------
314
- # Internal helpers (lazy imports to avoid cycles)
315
- # ---------------------------------------------------------------------------
316
-
317
-
318
- def _build_research_task(raw: Dict[str, Any]):
319
- """Convert raw API response into a :class:`ResearchTask` instance."""
320
-
321
- # Defensive check – fail loudly if the API contract changes.
322
- if not isinstance(raw, dict) or "id" not in raw:
323
- raise RuntimeError(f"Unexpected response while fetching research task: {raw}")
324
-
325
- # Lazily import heavy deps to avoid cycles and unnecessary startup cost.
326
- from .models import ResearchTask # noqa: WPS433 – runtime import
327
- from ..api import _Result, to_snake_case # noqa: WPS433 – runtime import
328
-
329
- citations_raw = raw.get("citations", {}) or {}
330
- citations_parsed = {}
331
- for key, cites in citations_raw.items():
332
- results = []
333
- for c in cites:
334
- snake_c = to_snake_case(c)
335
- results.append(
336
- _Result(
337
- url=snake_c.get("url"),
338
- id=snake_c.get("id"),
339
- title=snake_c.get("title"),
340
- score=snake_c.get("score"),
341
- published_date=snake_c.get("published_date"),
342
- author=snake_c.get("author"),
343
- image=snake_c.get("image"),
344
- favicon=snake_c.get("favicon"),
345
- subpages=snake_c.get("subpages"),
346
- extras=snake_c.get("extras"),
347
- )
348
- )
349
- citations_parsed[key] = results
350
-
351
- return ResearchTask(
352
- id=raw["id"],
353
- status=raw["status"],
354
- instructions=raw.get("instructions", ""),
355
- schema=raw.get("schema", {}),
356
- data=raw.get("data"),
357
- citations=citations_parsed,
358
- )