exa-py 1.12.3__tar.gz → 1.12.4__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.

Potentially problematic release.


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

Files changed (26) hide show
  1. {exa_py-1.12.3 → exa_py-1.12.4}/PKG-INFO +1 -1
  2. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/api.py +4 -39
  3. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/research/__init__.py +3 -2
  4. exa_py-1.12.4/exa_py/research/client.py +232 -0
  5. exa_py-1.12.4/exa_py/research/models.py +98 -0
  6. {exa_py-1.12.3 → exa_py-1.12.4}/pyproject.toml +1 -1
  7. exa_py-1.12.3/exa_py/research/client.py +0 -257
  8. exa_py-1.12.3/exa_py/research/models.py +0 -57
  9. {exa_py-1.12.3 → exa_py-1.12.4}/README.md +0 -0
  10. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/__init__.py +0 -0
  11. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/py.typed +0 -0
  12. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/utils.py +0 -0
  13. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/__init__.py +0 -0
  14. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/_generator/pydantic/BaseModel.jinja2 +0 -0
  15. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/client.py +0 -0
  16. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/core/__init__.py +0 -0
  17. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/core/base.py +0 -0
  18. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/enrichments/__init__.py +0 -0
  19. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/enrichments/client.py +0 -0
  20. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/items/__init__.py +0 -0
  21. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/items/client.py +0 -0
  22. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/searches/__init__.py +0 -0
  23. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/searches/client.py +0 -0
  24. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/types.py +0 -0
  25. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/webhooks/__init__.py +0 -0
  26. {exa_py-1.12.3 → exa_py-1.12.4}/exa_py/websets/webhooks/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: exa-py
3
- Version: 1.12.3
3
+ Version: 1.12.4
4
4
  Summary: Python SDK for Exa API.
5
5
  License: MIT
6
6
  Author: Exa AI
@@ -39,7 +39,6 @@ from exa_py.utils import (
39
39
  from .websets import WebsetsClient
40
40
  from .websets.core.base import ExaJSONEncoder
41
41
  from .research.client import ResearchClient, AsyncResearchClient
42
- from .research.models import ResearchTaskResponse # noqa: E402,F401
43
42
 
44
43
  is_beta = os.getenv("IS_BETA") == "True"
45
44
 
@@ -839,37 +838,6 @@ def nest_fields(original_dict: Dict, fields_to_nest: List[str], new_key: str):
839
838
  return original_dict
840
839
 
841
840
 
842
- @dataclass
843
- class ResearchTaskResponse:
844
- """A class representing the response for a research task.
845
-
846
- Attributes:
847
- id (str): The unique identifier for the research request.
848
- status (str): Status of the research request.
849
- output (Optional[Dict[str, Any]]): The answer structured as JSON, if available.
850
- citations (Optional[Dict[str, List[_Result]]]): List of citations used to generate the answer, grouped by root field in the output schema.
851
- """
852
-
853
- id: str
854
- status: str
855
- output: Optional[Dict[str, Any]]
856
- citations: Dict[str, List[_Result]]
857
-
858
- def __str__(self):
859
- output_repr = (
860
- json.dumps(self.output, indent=2, ensure_ascii=False)
861
- if self.output is not None
862
- else "None"
863
- )
864
- citations_str = "\n\n".join(str(src) for src in self.citations)
865
- return (
866
- f"ID: {self.id}\n"
867
- f"Status: {self.status}\n"
868
- f"Output: {output_repr}\n\n"
869
- f"Citations:\n{citations_str}"
870
- )
871
-
872
-
873
841
  class Exa:
874
842
  """A client for interacting with Exa API."""
875
843
 
@@ -877,7 +845,7 @@ class Exa:
877
845
  self,
878
846
  api_key: Optional[str],
879
847
  base_url: str = "https://api.exa.ai",
880
- user_agent: str = "exa-py 1.12.3",
848
+ user_agent: str = "exa-py 1.12.4",
881
849
  ):
882
850
  """Initialize the Exa client with the provided API key and optional base URL and user agent.
883
851
 
@@ -909,7 +877,6 @@ class Exa:
909
877
  data: Optional[Union[Dict[str, Any], str]] = None,
910
878
  method: str = "POST",
911
879
  params: Optional[Dict[str, Any]] = None,
912
- force_stream: Optional[bool] = False,
913
880
  ) -> Union[Dict[str, Any], requests.Response]:
914
881
  """Send a request to the Exa API, optionally streaming if data['stream'] is True.
915
882
 
@@ -934,7 +901,7 @@ class Exa:
934
901
  # Otherwise, serialize the dictionary to JSON if it exists
935
902
  json_data = json.dumps(data, cls=ExaJSONEncoder) if data else None
936
903
 
937
- if (data and data.get("stream")) or force_stream:
904
+ if data and data.get("stream"):
938
905
  res = requests.post(
939
906
  self.base_url + endpoint,
940
907
  data=json_data,
@@ -1974,9 +1941,7 @@ class AsyncExa(Exa):
1974
1941
  )
1975
1942
  return self._client
1976
1943
 
1977
- async def async_request(
1978
- self, endpoint: str, data, force_stream: Optional[bool] = False
1979
- ):
1944
+ async def async_request(self, endpoint: str, data):
1980
1945
  """Send a POST request to the Exa API, optionally streaming if data['stream'] is True.
1981
1946
 
1982
1947
  Args:
@@ -1990,7 +1955,7 @@ class AsyncExa(Exa):
1990
1955
  Raises:
1991
1956
  ValueError: If the request fails (non-200 status code).
1992
1957
  """
1993
- if data.get("stream") or force_stream:
1958
+ if data.get("stream"):
1994
1959
  request = httpx.Request(
1995
1960
  "POST", self.base_url + endpoint, json=data, headers=self.headers
1996
1961
  )
@@ -1,8 +1,9 @@
1
1
  from .client import ResearchClient, AsyncResearchClient
2
- from .models import ResearchTaskResponse
2
+ from .models import ResearchTask
3
3
 
4
4
  __all__ = [
5
5
  "ResearchClient",
6
6
  "AsyncResearchClient",
7
- "ResearchTaskResponse",
7
+ "ResearchTaskId",
8
+ "ResearchTask",
8
9
  ]
@@ -0,0 +1,232 @@
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
13
+
14
+ if TYPE_CHECKING: # pragma: no cover – only for static analysers
15
+ # Import with full type info when static type-checking. `_Result` still
16
+ # lives in ``exa_py.api`` but the response model moved to
17
+ # ``exa_py.research.models``.
18
+ from ..api import _Result # noqa: F401
19
+ from .models import ResearchTask, ResearchTaskId # noqa: F401
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Public, user-facing clients
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ class ResearchClient:
27
+ """Synchronous helper namespace accessed via :pyattr:`Exa.research`."""
28
+
29
+ def __init__(self, parent_client):
30
+ # A reference to the *already-constructed* ``Exa`` instance so that we
31
+ # can piggy-back on its HTTP plumbing (headers, base URL, retries, …).
32
+ self._client = parent_client
33
+
34
+ def create_task(
35
+ self,
36
+ *,
37
+ input_instructions: str,
38
+ output_schema: Dict[str, Any],
39
+ ) -> "ResearchTaskId":
40
+ """Submit a research request and return the *task identifier*."""
41
+ payload = {
42
+ "input": {"instructions": input_instructions},
43
+ "output": {"schema": output_schema},
44
+ }
45
+
46
+ raw_response: Dict[str, Any] = self._client.request("/research/tasks", payload)
47
+
48
+ # Defensive checks so that we fail loudly if the contract changes.
49
+ if not isinstance(raw_response, dict) or "id" not in raw_response:
50
+ raise RuntimeError(
51
+ f"Unexpected response while creating research task: {raw_response}"
52
+ )
53
+
54
+ # Lazily import to avoid circular deps at runtime.
55
+ from .models import ResearchTaskId # noqa: WPS433 – runtime import
56
+
57
+ return ResearchTaskId(id=raw_response["id"])
58
+
59
+ def get_task(
60
+ self, id: str
61
+ ) -> "ResearchTask": # noqa: D401 – imperative mood is fine
62
+ """Fetch the current status / result for a research task."""
63
+ endpoint = f"/research/tasks/{id}"
64
+
65
+ # The new endpoint is a simple GET.
66
+ raw_response: Dict[str, Any] = self._client.request(endpoint, method="GET")
67
+
68
+ return _build_research_task(raw_response)
69
+
70
+ # ------------------------------------------------------------------
71
+ # Convenience helpers
72
+ # ------------------------------------------------------------------
73
+
74
+ def poll_task(
75
+ self,
76
+ id: str,
77
+ *,
78
+ poll_interval: float = 1.0,
79
+ timeout_seconds: int = 15 * 60,
80
+ ) -> "ResearchTask":
81
+ """Blocking helper that polls until task completes or fails.
82
+
83
+ Parameters
84
+ ----------
85
+ id:
86
+ The ID of the research task to poll.
87
+ poll_interval:
88
+ Seconds to wait between successive polls (default 1s).
89
+ timeout_seconds:
90
+ Maximum time to wait before raising :class:`TimeoutError` (default 15 min).
91
+ """
92
+
93
+ import time
94
+
95
+ deadline = time.monotonic() + timeout_seconds
96
+
97
+ while True:
98
+ task = self.get_task(id)
99
+ status = task.status.lower() if isinstance(task.status, str) else ""
100
+
101
+ if status in {"completed", "failed", "complete", "finished", "done"}:
102
+ return task
103
+
104
+ if time.monotonic() > deadline:
105
+ raise TimeoutError(
106
+ f"Research task {id} did not finish within {timeout_seconds} seconds"
107
+ )
108
+
109
+ time.sleep(poll_interval)
110
+
111
+
112
+ class AsyncResearchClient:
113
+ """Async counterpart used via :pyattr:`AsyncExa.research`."""
114
+
115
+ def __init__(self, parent_client):
116
+ self._client = parent_client
117
+
118
+ async def create_task(
119
+ self,
120
+ *,
121
+ input_instructions: str,
122
+ output_schema: Dict[str, Any],
123
+ ) -> "ResearchTaskId":
124
+ """Submit a research request and return the *task identifier* (async)."""
125
+
126
+ payload = {
127
+ "input": {"instructions": input_instructions},
128
+ "output": {"schema": output_schema},
129
+ }
130
+
131
+ raw_response: Dict[str, Any] = await self._client.async_request(
132
+ "/research/tasks", payload
133
+ )
134
+
135
+ # Defensive checks so that we fail loudly if the contract changes.
136
+ if not isinstance(raw_response, dict) or "id" not in raw_response:
137
+ raise RuntimeError(
138
+ f"Unexpected response while creating research task: {raw_response}"
139
+ )
140
+
141
+ # Lazily import to avoid circular deps at runtime.
142
+ from .models import ResearchTaskId # noqa: WPS433 – runtime import
143
+
144
+ return ResearchTaskId(id=raw_response["id"])
145
+
146
+ async def get_task(self, id: str) -> "ResearchTask": # noqa: D401
147
+ """Fetch the current status / result for a research task (async)."""
148
+
149
+ endpoint = f"/research/tasks/{id}"
150
+
151
+ # Perform GET using the underlying HTTP client because `async_request`
152
+ # only supports POST semantics.
153
+ resp = await self._client.client.get(
154
+ self._client.base_url + endpoint, headers=self._client.headers
155
+ )
156
+
157
+ if resp.status_code >= 400:
158
+ raise RuntimeError(
159
+ f"Request failed with status code {resp.status_code}: {resp.text}"
160
+ )
161
+
162
+ raw_response: Dict[str, Any] = resp.json()
163
+
164
+ return _build_research_task(raw_response)
165
+
166
+ # ------------------------------------------------------------------
167
+ # Convenience helpers
168
+ # ------------------------------------------------------------------
169
+
170
+ async def poll_task(
171
+ self,
172
+ id: str,
173
+ *,
174
+ poll_interval: float = 1.0,
175
+ timeout_seconds: int = 15 * 60,
176
+ ) -> "ResearchTask":
177
+ """Async helper that polls until task completes or fails.
178
+
179
+ Mirrors :py:meth:`ResearchClient.poll_task` but uses ``await`` and
180
+ :pyfunc:`asyncio.sleep`. Raises :class:`TimeoutError` on timeout.
181
+ """
182
+
183
+ import asyncio
184
+ import time
185
+
186
+ deadline = time.monotonic() + timeout_seconds
187
+
188
+ while True:
189
+ task = await self.get_task(id)
190
+ status = task.status.lower() if isinstance(task.status, str) else ""
191
+
192
+ if status in {"completed", "failed", "complete", "finished", "done"}:
193
+ return task
194
+
195
+ if time.monotonic() > deadline:
196
+ raise TimeoutError(
197
+ f"Research task {id} did not finish within {timeout_seconds} seconds"
198
+ )
199
+
200
+ await asyncio.sleep(poll_interval)
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Internal helpers (lazy imports to avoid cycles)
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ def _build_research_task(raw: Dict[str, Any]):
209
+ """Convert raw API response into a :class:`ResearchTask` instance."""
210
+
211
+ # Defensive check – fail loudly if the API contract changes.
212
+ if not isinstance(raw, dict) or "id" not in raw:
213
+ raise RuntimeError(f"Unexpected response while fetching research task: {raw}")
214
+
215
+ # Lazily import heavy deps to avoid cycles and unnecessary startup cost.
216
+ from .models import ResearchTask # noqa: WPS433 – runtime import
217
+ from ..api import _Result, to_snake_case # noqa: WPS433 – runtime import
218
+
219
+ citations_raw = raw.get("citations", {}) or {}
220
+ citations_parsed = {
221
+ key: [_Result(**to_snake_case(c)) for c in cites]
222
+ for key, cites in citations_raw.items()
223
+ }
224
+
225
+ return ResearchTask(
226
+ id=raw["id"],
227
+ status=raw["status"],
228
+ instructions=raw.get("instructions", ""),
229
+ schema=raw.get("schema", {}),
230
+ data=raw.get("data"),
231
+ citations=citations_parsed,
232
+ )
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ # Local import placed inside TYPE_CHECKING block to avoid runtime cycles.
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING: # pragma: no cover – for static analysers only
11
+ from ..api import _Result # noqa: F401
12
+
13
+
14
+ @dataclass
15
+ class ResearchTaskId:
16
+ """Structured research task ID.
17
+
18
+ Attributes
19
+ ----------
20
+ id:
21
+ Unique identifier for the research task.
22
+ """
23
+
24
+ id: str
25
+
26
+ # ---------------------------------------------------------------------
27
+ # Pretty representation helpers
28
+ # ---------------------------------------------------------------------
29
+ def __str__(self) -> str: # pragma: no cover – convenience only
30
+ return f"ID: {self.id}\n"
31
+
32
+
33
+ @dataclass
34
+ class ResearchTask:
35
+ """Structured research task.
36
+
37
+ Attributes
38
+ ----------
39
+ id:
40
+ Unique identifier for the research task.
41
+ status:
42
+ Current task status
43
+ instructions:
44
+ Instructions for the task
45
+ schema:
46
+ Output schema defining the task
47
+ data:
48
+ JSON-serialisable answer generated by Exa (may be ``None`` until the task
49
+ completes).
50
+ citations:
51
+ Mapping from *root field* in the output schema to the list of search
52
+ results that were used to generate that part of the answer.
53
+ """
54
+
55
+ id: str
56
+ status: str
57
+ instructions: str
58
+ schema: Dict[str, Any]
59
+ data: Optional[Dict[str, Any]]
60
+ citations: Dict[str, List["_Result"]]
61
+
62
+ # ---------------------------------------------------------------------
63
+ # Pretty representation helpers
64
+ # ---------------------------------------------------------------------
65
+ def __str__(self) -> str: # pragma: no cover – convenience only
66
+ """Human-readable representation including *all* relevant fields."""
67
+ schema_repr = json.dumps(self.schema, indent=2, ensure_ascii=False)
68
+ data_repr = (
69
+ json.dumps(self.data, indent=2, ensure_ascii=False)
70
+ if self.data is not None
71
+ else "None"
72
+ )
73
+
74
+ # Render citations grouped by the root field they belong to.
75
+ if self.citations:
76
+ # Each key is a root field, each value is a list of _Result objects.
77
+ citations_lines = []
78
+ for field, sources in self.citations.items():
79
+ rendered_sources = "\n ".join(str(src) for src in sources)
80
+ citations_lines.append(f"{field}:\n {rendered_sources}")
81
+ citations_str = "\n\n".join(citations_lines)
82
+ else:
83
+ citations_str = "None"
84
+
85
+ return (
86
+ f"ID: {self.id}\n"
87
+ f"Status: {self.status}\n"
88
+ f"Instructions: {self.instructions}\n"
89
+ f"Schema:\n{schema_repr}\n"
90
+ f"Data:\n{data_repr}\n\n"
91
+ f"Citations:\n{citations_str}"
92
+ )
93
+
94
+
95
+ __all__ = [
96
+ "ResearchTaskId",
97
+ "ResearchTask",
98
+ ]
@@ -32,7 +32,7 @@ in-project = true
32
32
 
33
33
  [project]
34
34
  name = "exa-py"
35
- version = "1.12.3"
35
+ version = "1.12.4"
36
36
  description = "Python SDK for Exa API."
37
37
  readme = "README.md"
38
38
  requires-python = ">=3.9"
@@ -1,257 +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, Tuple
13
-
14
- if TYPE_CHECKING: # pragma: no cover – only for static analysers
15
- # Import with full type info when static type-checking. `_Result` still
16
- # lives in ``exa_py.api`` but the response model moved to
17
- # ``exa_py.research.models``.
18
- from ..api import _Result # noqa: F401
19
- from .models import ResearchTaskResponse # noqa: F401
20
-
21
- # ---------------------------------------------------------------------------
22
- # Public, user-facing clients
23
- # ---------------------------------------------------------------------------
24
-
25
-
26
- class ResearchClient:
27
- """Synchronous helper namespace accessed via :pyattr:`Exa.research`."""
28
-
29
- def __init__(self, parent_client):
30
- # A reference to the *already-constructed* ``Exa`` instance so that we
31
- # can piggy-back on its HTTP plumbing (headers, base URL, retries, …).
32
- self._client = parent_client
33
-
34
- # ------------------------------------------------------------------
35
- # API surface
36
- # ------------------------------------------------------------------
37
- def create_task(
38
- self,
39
- *,
40
- input_instructions: str,
41
- output_schema: Dict[str, Any],
42
- ) -> "ResearchTaskResponse":
43
- """Submit a research request to the Exa backend.
44
-
45
- The public API remains synchronous – the function only returns once
46
- the task has finished and the final structured answer is available.
47
- Internally, however, the endpoint now streams *progress* updates via
48
- Server-Sent Events (SSE). We therefore initiate a streaming request
49
- and keep reading until we receive the terminal ``{"tag": "complete"}``
50
- chunk, which carries the exact same payload shape that the blocking
51
- variant returned previously. Any ``{"tag": "progress"}`` chunks are
52
- ignored, while ``{"tag": "error"}`` chunks result in an exception.
53
-
54
- Parameters
55
- ----------
56
- input_instructions:
57
- Natural-language instructions that describe *what* should be
58
- researched or extracted.
59
- output_schema:
60
- JSON-schema describing the desired structured output format.
61
- """
62
-
63
- import json
64
-
65
- payload = {
66
- "input": {"instructions": input_instructions},
67
- "output": {"schema": output_schema},
68
- }
69
-
70
- raw_response = self._client.request(
71
- "/research/tasks", payload, force_stream=True
72
- )
73
-
74
- def _handle_payload(tag: Optional[str], payload_dict: Dict[str, Any]):
75
- """Inner helper handling decoded JSON chunks."""
76
- if tag is None:
77
- tag_local = payload_dict.get("tag")
78
- else:
79
- tag_local = tag
80
-
81
- if tag_local == "progress":
82
- return None # ignore
83
- if tag_local == "error":
84
- msg = payload_dict.get("error", {}).get("message", "Unknown error")
85
- raise RuntimeError(f"Research task failed: {msg}")
86
- if tag_local == "complete":
87
- data_obj = payload_dict.get("data")
88
- if data_obj is None:
89
- raise RuntimeError("Malformed 'complete' chunk with no data")
90
- return _parse_research_response(data_obj)
91
-
92
- # Fallback: if looks like final object
93
- if {"id", "status"}.issubset(payload_dict.keys()):
94
- return _parse_research_response(payload_dict)
95
- return None
96
-
97
- # ------------------------------------------------------------------
98
- # Minimal SSE parser (sync)
99
- # ------------------------------------------------------------------
100
- event_name: Optional[str] = None
101
- data_buf: str = ""
102
-
103
- for raw_line in raw_response.iter_lines(decode_unicode=True):
104
- line = raw_line
105
- if line == "":
106
- if data_buf:
107
- try:
108
- payload_dict = json.loads(data_buf)
109
- except json.JSONDecodeError:
110
- data_buf = ""
111
- event_name = None
112
- continue
113
- maybe_resp = _handle_payload(event_name, payload_dict)
114
- if maybe_resp is not None:
115
- raw_response.close()
116
- return maybe_resp
117
- # reset after event
118
- data_buf = ""
119
- event_name = None
120
- continue
121
-
122
- if line.startswith("event:"):
123
- event_name = line[len("event:") :].strip()
124
- elif line.startswith("data:"):
125
- data_buf += line[len("data:") :].strip()
126
-
127
- # Process any remaining buffer (in case stream closed without blank line)
128
- if data_buf:
129
- try:
130
- payload_dict = json.loads(data_buf)
131
- maybe_resp = _handle_payload(event_name, payload_dict)
132
- if maybe_resp is not None:
133
- raw_response.close()
134
- return maybe_resp
135
- except json.JSONDecodeError:
136
- pass
137
-
138
- raise RuntimeError("Stream ended before completion of research task")
139
-
140
- def get_task(self, id: str): # noqa: D401 – imperative mood is fine
141
- """Placeholder endpoint – not yet implemented on the server side."""
142
- raise NotImplementedError(
143
- "`exa.research.get_task` is not available yet. Please open an "
144
- "issue if you need this sooner."
145
- )
146
-
147
-
148
- class AsyncResearchClient:
149
- """Async counterpart used via :pyattr:`AsyncExa.research`."""
150
-
151
- def __init__(self, parent_client):
152
- self._client = parent_client
153
-
154
- async def create_task(
155
- self,
156
- *,
157
- input_instructions: str,
158
- output_schema: Dict[str, Any],
159
- ) -> "ResearchTaskResponse":
160
- """Async variant mirroring the synchronous implementation above."""
161
-
162
- import json
163
-
164
- payload = {
165
- "input": {"instructions": input_instructions},
166
- "output": {"schema": output_schema},
167
- }
168
-
169
- raw_response = await self._client.async_request(
170
- "/research/tasks", payload, force_stream=True
171
- )
172
-
173
- async def _handle_payload_async(
174
- tag: Optional[str], payload_dict: Dict[str, Any]
175
- ):
176
- if tag is None:
177
- tag_local = payload_dict.get("tag")
178
- else:
179
- tag_local = tag
180
-
181
- if tag_local == "progress":
182
- return None
183
- if tag_local == "error":
184
- msg = payload_dict.get("error", {}).get("message", "Unknown error")
185
- raise RuntimeError(f"Research task failed: {msg}")
186
- if tag_local == "complete":
187
- data_obj = payload_dict.get("data")
188
- if data_obj is None:
189
- raise RuntimeError("Malformed 'complete' chunk with no data")
190
- return _parse_research_response(data_obj)
191
- if {"id", "status"}.issubset(payload_dict.keys()):
192
- return _parse_research_response(payload_dict)
193
- return None
194
-
195
- event_name: Optional[str] = None
196
- data_buf: str = ""
197
-
198
- async for line in raw_response.aiter_lines():
199
- if line == "":
200
- if data_buf:
201
- try:
202
- payload_dict = json.loads(data_buf)
203
- except json.JSONDecodeError:
204
- data_buf = ""
205
- event_name = None
206
- continue
207
- maybe_resp = await _handle_payload_async(event_name, payload_dict)
208
- if maybe_resp is not None:
209
- await raw_response.aclose()
210
- return maybe_resp
211
- data_buf = ""
212
- event_name = None
213
- continue
214
-
215
- if line.startswith("event:"):
216
- event_name = line[len("event:") :].strip()
217
- elif line.startswith("data:"):
218
- data_buf += line[len("data:") :].strip()
219
-
220
- if data_buf:
221
- try:
222
- payload_dict = json.loads(data_buf)
223
- maybe_resp = await _handle_payload_async(event_name, payload_dict)
224
- if maybe_resp is not None:
225
- await raw_response.aclose()
226
- return maybe_resp
227
- except json.JSONDecodeError:
228
- pass
229
-
230
- raise RuntimeError("Stream ended before completion of research task")
231
-
232
- async def get_task(self, id: str): # noqa: D401
233
- raise NotImplementedError(
234
- "`exa.research.get_task` is not available yet. Please open an "
235
- "issue if you need this sooner."
236
- )
237
-
238
-
239
- # ---------------------------------------------------------------------------
240
- # Internal helpers (lazy imports to avoid cycles)
241
- # ---------------------------------------------------------------------------
242
-
243
-
244
- def _parse_research_response(raw: Dict[str, Any]):
245
- """Transform camel-case API payload into rich Python objects."""
246
- from .models import ResearchTaskResponse
247
- from ..api import _Result, to_snake_case
248
-
249
- return ResearchTaskResponse(
250
- id=raw["id"],
251
- status=raw["status"],
252
- output=raw.get("output"),
253
- citations={
254
- key: [_Result(**to_snake_case(c)) for c in citations]
255
- for key, citations in raw.get("citations", {}).items()
256
- },
257
- )
@@ -1,57 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from dataclasses import dataclass
5
- from typing import Any, Dict, List, Optional
6
-
7
- # Local import placed inside TYPE_CHECKING block to avoid runtime cycles.
8
- from typing import TYPE_CHECKING
9
-
10
- if TYPE_CHECKING: # pragma: no cover – for static analysers only
11
- from ..api import _Result # noqa: F401
12
-
13
-
14
- @dataclass
15
- class ResearchTaskResponse:
16
- """Structured response returned from the /research/tasks endpoint.
17
-
18
- Attributes
19
- ----------
20
- id:
21
- Unique identifier for the research task.
22
- status:
23
- Current task status
24
- output:
25
- JSON-serialisable answer generated by Exa (may be ``None`` until the task
26
- completes).
27
- citations:
28
- Mapping from *root field* in the output schema to the list of search
29
- results that were used to generate that part of the answer.
30
- """
31
-
32
- id: str
33
- status: str
34
- output: Optional[Dict[str, Any]]
35
- citations: Dict[str, List["_Result"]]
36
-
37
- # ---------------------------------------------------------------------
38
- # Pretty representation helpers
39
- # ---------------------------------------------------------------------
40
- def __str__(self) -> str: # pragma: no cover – convenience only
41
- output_repr = (
42
- json.dumps(self.output, indent=2, ensure_ascii=False)
43
- if self.output is not None
44
- else "None"
45
- )
46
- citations_str = "\n\n".join(str(src) for src in self.citations)
47
- return (
48
- f"ID: {self.id}\n"
49
- f"Status: {self.status}\n"
50
- f"Output: {output_repr}\n\n"
51
- f"Citations:\n{citations_str}"
52
- )
53
-
54
-
55
- __all__ = [
56
- "ResearchTaskResponse",
57
- ]
File without changes
File without changes
File without changes
File without changes
File without changes