tavily-python 0.7.21__tar.gz → 0.7.23__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 (25) hide show
  1. {tavily_python-0.7.21 → tavily_python-0.7.23}/PKG-INFO +64 -2
  2. {tavily_python-0.7.21 → tavily_python-0.7.23}/README.md +63 -1
  3. {tavily_python-0.7.21 → tavily_python-0.7.23}/setup.py +1 -1
  4. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily/async_tavily.py +46 -27
  5. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily/hybrid_rag/hybrid_rag.py +6 -3
  6. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily/tavily.py +18 -7
  7. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily_python.egg-info/PKG-INFO +64 -2
  8. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily_python.egg-info/SOURCES.txt +1 -0
  9. tavily_python-0.7.23/tests/test_custom_session.py +410 -0
  10. {tavily_python-0.7.21 → tavily_python-0.7.23}/tests/test_search.py +57 -2
  11. {tavily_python-0.7.21 → tavily_python-0.7.23}/LICENSE +0 -0
  12. {tavily_python-0.7.21 → tavily_python-0.7.23}/setup.cfg +0 -0
  13. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily/__init__.py +0 -0
  14. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily/config.py +0 -0
  15. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily/errors.py +0 -0
  16. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily/hybrid_rag/__init__.py +0 -0
  17. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily/utils.py +0 -0
  18. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily_python.egg-info/dependency_links.txt +0 -0
  19. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily_python.egg-info/requires.txt +0 -0
  20. {tavily_python-0.7.21 → tavily_python-0.7.23}/tavily_python.egg-info/top_level.txt +0 -0
  21. {tavily_python-0.7.21 → tavily_python-0.7.23}/tests/test_crawl.py +0 -0
  22. {tavily_python-0.7.21 → tavily_python-0.7.23}/tests/test_errors.py +0 -0
  23. {tavily_python-0.7.21 → tavily_python-0.7.23}/tests/test_map.py +0 -0
  24. {tavily_python-0.7.21 → tavily_python-0.7.23}/tests/test_research.py +0 -0
  25. {tavily_python-0.7.21 → tavily_python-0.7.23}/tests/test_session_pooling.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tavily-python
3
- Version: 0.7.21
3
+ Version: 0.7.23
4
4
  Summary: Python wrapper for the Tavily API
5
5
  Home-page: https://github.com/tavily-ai/tavily-python
6
6
  Author: Tavily AI
@@ -25,7 +25,7 @@ Dynamic: requires-dist
25
25
  Dynamic: requires-python
26
26
  Dynamic: summary
27
27
 
28
- # Tavily Python Wrapper
28
+ # Tavily Python SDK
29
29
 
30
30
  [![GitHub stars](https://img.shields.io/github/stars/tavily-ai/tavily-python?style=social)](https://github.com/tavily-ai/tavily-python/stargazers)
31
31
  [![PyPI - Downloads](https://img.shields.io/pypi/dm/tavily-python)](https://pypi.org/project/tavily-python/)
@@ -63,6 +63,21 @@ response = tavily_client.search("Who is Leo Messi?")
63
63
  print(response)
64
64
  ```
65
65
 
66
+ ### Using exact match to find specific names or phrases
67
+
68
+ ```python
69
+ from tavily import TavilyClient
70
+
71
+ client = TavilyClient(api_key="tvly-YOUR_API_KEY")
72
+
73
+ # Use exact_match=True to only return results containing the exact phrase(s) inside quotes
74
+ response = client.search(
75
+ query='"John Smith" CEO Acme Corp',
76
+ exact_match=True
77
+ )
78
+ print(response)
79
+ ```
80
+
66
81
  This is equivalent to directly querying our REST API.
67
82
 
68
83
  ### Generating context for a RAG Application
@@ -258,6 +273,53 @@ for chunk in stream:
258
273
  print(chunk.decode('utf-8'))
259
274
  ```
260
275
 
276
+ ## Advanced: Custom Session / Client Injection
277
+
278
+ For enterprise environments that proxy Tavily traffic through an API gateway (e.g., for centralized auth, logging, or policy enforcement), you can pass a pre-configured HTTP session instead of a Tavily API key.
279
+
280
+ ### Sync (custom `requests.Session`)
281
+
282
+ ```python
283
+ import requests
284
+ from tavily import TavilyClient
285
+
286
+ # Pre-configure a session with your gateway's auth
287
+ session = requests.Session()
288
+ session.headers["Authorization"] = "Bearer your-gateway-token"
289
+ session.headers["X-Subscription-Key"] = "your-subscription-key"
290
+
291
+ # No Tavily API key needed — auth is handled by the session
292
+ client = TavilyClient(
293
+ session=session,
294
+ api_base_url="https://your-gateway.com/tavily",
295
+ )
296
+
297
+ response = client.search("latest AI research")
298
+ ```
299
+
300
+ ### Async (custom `httpx.AsyncClient`)
301
+
302
+ ```python
303
+ import httpx
304
+ from tavily import AsyncTavilyClient
305
+
306
+ # Pre-configure an async client with your gateway's auth
307
+ custom_client = httpx.AsyncClient(
308
+ headers={"Authorization": "Bearer your-gateway-token"},
309
+ base_url="https://your-gateway.com/tavily",
310
+ )
311
+
312
+ client = AsyncTavilyClient(client=custom_client)
313
+
314
+ response = await client.search("latest AI research")
315
+ ```
316
+
317
+ **Key behaviors:**
318
+ - If a custom session/client is provided, `api_key` is optional
319
+ - Custom session headers take precedence over SDK defaults (e.g., your `Authorization` won't be overwritten)
320
+ - Custom session proxies take precedence over SDK proxy settings
321
+ - The SDK will **not** close externally-provided sessions — you manage the lifecycle
322
+
261
323
  ## Documentation
262
324
 
263
325
  For a complete guide on how to use the different endpoints and their parameters, please head to our [Python API Reference](https://docs.tavily.com/sdk/python/reference).
@@ -1,4 +1,4 @@
1
- # Tavily Python Wrapper
1
+ # Tavily Python SDK
2
2
 
3
3
  [![GitHub stars](https://img.shields.io/github/stars/tavily-ai/tavily-python?style=social)](https://github.com/tavily-ai/tavily-python/stargazers)
4
4
  [![PyPI - Downloads](https://img.shields.io/pypi/dm/tavily-python)](https://pypi.org/project/tavily-python/)
@@ -36,6 +36,21 @@ response = tavily_client.search("Who is Leo Messi?")
36
36
  print(response)
37
37
  ```
38
38
 
39
+ ### Using exact match to find specific names or phrases
40
+
41
+ ```python
42
+ from tavily import TavilyClient
43
+
44
+ client = TavilyClient(api_key="tvly-YOUR_API_KEY")
45
+
46
+ # Use exact_match=True to only return results containing the exact phrase(s) inside quotes
47
+ response = client.search(
48
+ query='"John Smith" CEO Acme Corp',
49
+ exact_match=True
50
+ )
51
+ print(response)
52
+ ```
53
+
39
54
  This is equivalent to directly querying our REST API.
40
55
 
41
56
  ### Generating context for a RAG Application
@@ -231,6 +246,53 @@ for chunk in stream:
231
246
  print(chunk.decode('utf-8'))
232
247
  ```
233
248
 
249
+ ## Advanced: Custom Session / Client Injection
250
+
251
+ For enterprise environments that proxy Tavily traffic through an API gateway (e.g., for centralized auth, logging, or policy enforcement), you can pass a pre-configured HTTP session instead of a Tavily API key.
252
+
253
+ ### Sync (custom `requests.Session`)
254
+
255
+ ```python
256
+ import requests
257
+ from tavily import TavilyClient
258
+
259
+ # Pre-configure a session with your gateway's auth
260
+ session = requests.Session()
261
+ session.headers["Authorization"] = "Bearer your-gateway-token"
262
+ session.headers["X-Subscription-Key"] = "your-subscription-key"
263
+
264
+ # No Tavily API key needed — auth is handled by the session
265
+ client = TavilyClient(
266
+ session=session,
267
+ api_base_url="https://your-gateway.com/tavily",
268
+ )
269
+
270
+ response = client.search("latest AI research")
271
+ ```
272
+
273
+ ### Async (custom `httpx.AsyncClient`)
274
+
275
+ ```python
276
+ import httpx
277
+ from tavily import AsyncTavilyClient
278
+
279
+ # Pre-configure an async client with your gateway's auth
280
+ custom_client = httpx.AsyncClient(
281
+ headers={"Authorization": "Bearer your-gateway-token"},
282
+ base_url="https://your-gateway.com/tavily",
283
+ )
284
+
285
+ client = AsyncTavilyClient(client=custom_client)
286
+
287
+ response = await client.search("latest AI research")
288
+ ```
289
+
290
+ **Key behaviors:**
291
+ - If a custom session/client is provided, `api_key` is optional
292
+ - Custom session headers take precedence over SDK defaults (e.g., your `Authorization` won't be overwritten)
293
+ - Custom session proxies take precedence over SDK proxy settings
294
+ - The SDK will **not** close externally-provided sessions — you manage the lifecycle
295
+
234
296
  ## Documentation
235
297
 
236
298
  For a complete guide on how to use the different endpoints and their parameters, please head to our [Python API Reference](https://docs.tavily.com/sdk/python/reference).
@@ -5,7 +5,7 @@ with open('README.md', 'r', encoding='utf-8') as f:
5
5
 
6
6
  setup(
7
7
  name='tavily-python',
8
- version='0.7.21',
8
+ version='0.7.23',
9
9
  url='https://github.com/tavily-ai/tavily-python',
10
10
  author='Tavily AI',
11
11
  author_email='support@tavily.com',
@@ -19,48 +19,63 @@ class AsyncTavilyClient:
19
19
  proxies: Optional[dict[str, str]] = None,
20
20
  api_base_url: Optional[str] = None,
21
21
  client_source: Optional[str] = None,
22
- project_id: Optional[str] = None):
22
+ project_id: Optional[str] = None,
23
+ client: Optional[httpx.AsyncClient] = None):
23
24
  if api_key is None:
24
25
  api_key = os.getenv("TAVILY_API_KEY")
25
26
 
26
- if not api_key:
27
+ if not api_key and client is None:
27
28
  raise MissingAPIKeyError()
28
29
 
29
- proxies = proxies or {}
30
+ tavily_project = project_id or os.getenv("TAVILY_PROJECT")
30
31
 
31
- mapped_proxies = {
32
- "http://": proxies.get("http", os.getenv("TAVILY_HTTP_PROXY")),
33
- "https://": proxies.get("https", os.getenv("TAVILY_HTTPS_PROXY")),
32
+ self._api_base_url = api_base_url or "https://api.tavily.com"
33
+ self._company_info_tags = company_info_tags
34
+
35
+ default_headers = {
36
+ "Content-Type": "application/json",
37
+ **({"Authorization": f"Bearer {api_key}"} if api_key else {}),
38
+ "X-Client-Source": client_source or "tavily-python",
39
+ **({"X-Project-ID": tavily_project} if tavily_project else {})
34
40
  }
35
41
 
36
- mapped_proxies = {key: value for key, value in mapped_proxies.items() if value}
42
+ self._external_client = client is not None
43
+
44
+ if client is not None:
45
+ self._client = client
46
+ # Only set headers that aren't already configured on the external client
47
+ for key, value in default_headers.items():
48
+ if key not in self._client.headers:
49
+ self._client.headers[key] = value
50
+ # Set base_url if the external client doesn't have one
51
+ if not str(self._client.base_url):
52
+ self._client.base_url = self._api_base_url
53
+ else:
54
+ proxies = proxies or {}
37
55
 
38
- proxy_mounts = (
39
- {scheme: httpx.AsyncHTTPTransport(proxy=proxy) for scheme, proxy in mapped_proxies.items()}
40
- if mapped_proxies
41
- else None
42
- )
56
+ mapped_proxies = {
57
+ "http://": proxies.get("http", os.getenv("TAVILY_HTTP_PROXY")),
58
+ "https://": proxies.get("https", os.getenv("TAVILY_HTTPS_PROXY")),
59
+ }
43
60
 
44
- tavily_project = project_id or os.getenv("TAVILY_PROJECT")
61
+ mapped_proxies = {key: value for key, value in mapped_proxies.items() if value}
45
62
 
46
- self._api_base_url = api_base_url or "https://api.tavily.com"
63
+ proxy_mounts = (
64
+ {scheme: httpx.AsyncHTTPTransport(proxy=proxy) for scheme, proxy in mapped_proxies.items()}
65
+ if mapped_proxies
66
+ else None
67
+ )
47
68
 
48
- # Create a persistent client for connection pooling
49
- self._client = httpx.AsyncClient(
50
- headers={
51
- "Content-Type": "application/json",
52
- "Authorization": f"Bearer {api_key}",
53
- "X-Client-Source": client_source or "tavily-python",
54
- **({"X-Project-ID": tavily_project} if tavily_project else {})
55
- },
56
- base_url=self._api_base_url,
57
- mounts=proxy_mounts
58
- )
59
- self._company_info_tags = company_info_tags
69
+ self._client = httpx.AsyncClient(
70
+ headers=default_headers,
71
+ base_url=self._api_base_url,
72
+ mounts=proxy_mounts
73
+ )
60
74
 
61
75
  async def close(self):
62
76
  """Close the client and release connection pool resources."""
63
- await self._client.aclose()
77
+ if not self._external_client:
78
+ await self._client.aclose()
64
79
 
65
80
  async def __aenter__(self):
66
81
  return self
@@ -88,6 +103,7 @@ class AsyncTavilyClient:
88
103
  auto_parameters: bool = None,
89
104
  include_favicon: bool = None,
90
105
  include_usage: bool = None,
106
+ exact_match: bool = None,
91
107
  **kwargs,
92
108
  ) -> dict:
93
109
  """
@@ -111,6 +127,7 @@ class AsyncTavilyClient:
111
127
  "auto_parameters": auto_parameters,
112
128
  "include_favicon": include_favicon,
113
129
  "include_usage": include_usage,
130
+ "exact_match": exact_match,
114
131
  }
115
132
 
116
133
  data = {k: v for k, v in data.items() if v is not None}
@@ -164,6 +181,7 @@ class AsyncTavilyClient:
164
181
  auto_parameters: bool = None,
165
182
  include_favicon: bool = None,
166
183
  include_usage: bool = None,
184
+ exact_match: bool = None,
167
185
  **kwargs, # Accept custom arguments
168
186
  ) -> dict:
169
187
  """
@@ -188,6 +206,7 @@ class AsyncTavilyClient:
188
206
  auto_parameters=auto_parameters,
189
207
  include_favicon=include_favicon,
190
208
  include_usage=include_usage,
209
+ exact_match=exact_match,
191
210
  **kwargs,
192
211
  )
193
212
 
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  from typing import Union, Optional, Literal
3
3
 
4
+ import requests
4
5
  from tavily import TavilyClient
5
6
 
6
7
  try:
@@ -76,7 +77,8 @@ class TavilyHybridClient():
76
77
  embeddings_field: str = 'embeddings',
77
78
  content_field: str = 'content',
78
79
  embedding_function: Optional[callable] = None,
79
- ranking_function: Optional[callable] = None
80
+ ranking_function: Optional[callable] = None,
81
+ session: Optional[requests.Session] = None
80
82
  ):
81
83
  '''
82
84
  A client for performing hybrid RAG using both the Tavily API and a local database collection.
@@ -90,9 +92,10 @@ class TavilyHybridClient():
90
92
  content_field (str): The name of the field in the collection that contains the content.
91
93
  embedding_function (callable): If provided, this function will be used to generate embeddings for the search query and documents.
92
94
  ranking_function (callable): If provided, this function will be used to rerank the combined results.
95
+ session (requests.Session): If provided, this pre-configured session will be used for HTTP requests. When set, api_key is optional.
93
96
  '''
94
-
95
- self.tavily = TavilyClient(api_key)
97
+
98
+ self.tavily = TavilyClient(api_key, session=session)
96
99
 
97
100
  if db_provider != 'mongodb':
98
101
  raise ValueError("Only MongoDB is currently supported as a database provider.")
@@ -11,11 +11,11 @@ class TavilyClient:
11
11
  Tavily API client class.
12
12
  """
13
13
 
14
- def __init__(self, api_key: Optional[str] = None, proxies: Optional[dict[str, str]] = None, api_base_url: Optional[str] = None, client_source: Optional[str] = None, project_id: Optional[str] = None):
14
+ def __init__(self, api_key: Optional[str] = None, proxies: Optional[dict[str, str]] = None, api_base_url: Optional[str] = None, client_source: Optional[str] = None, project_id: Optional[str] = None, session: Optional[requests.Session] = None):
15
15
  if api_key is None:
16
16
  api_key = os.getenv("TAVILY_API_KEY")
17
17
 
18
- if not api_key:
18
+ if not api_key and session is None:
19
19
  raise MissingAPIKeyError()
20
20
 
21
21
  resolved_proxies = {
@@ -32,19 +32,26 @@ class TavilyClient:
32
32
 
33
33
  self.headers = {
34
34
  "Content-Type": "application/json",
35
- "Authorization": f"Bearer {self.api_key}",
35
+ **({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}),
36
36
  "X-Client-Source": client_source or "tavily-python",
37
37
  **({"X-Project-ID": tavily_project} if tavily_project else {})
38
38
  }
39
39
 
40
- self.session = requests.Session()
41
- self.session.headers.update(self.headers)
40
+ self._external_session = session is not None
41
+ self.session = session if session is not None else requests.Session()
42
+ # For external sessions, only set headers that aren't already configured
43
+ for key, value in self.headers.items():
44
+ if key not in self.session.headers:
45
+ self.session.headers[key] = value
42
46
  if self.proxies:
43
- self.session.proxies.update(self.proxies)
47
+ for protocol, url in self.proxies.items():
48
+ if protocol not in self.session.proxies:
49
+ self.session.proxies[protocol] = url
44
50
 
45
51
  def close(self):
46
52
  """Close the session and release resources."""
47
- self.session.close()
53
+ if not self._external_session:
54
+ self.session.close()
48
55
 
49
56
  def __enter__(self):
50
57
  return self
@@ -71,6 +78,7 @@ class TavilyClient:
71
78
  auto_parameters: bool = None,
72
79
  include_favicon: bool = None,
73
80
  include_usage: bool = None,
81
+ exact_match: bool = None,
74
82
  **kwargs
75
83
  ) -> dict:
76
84
  """
@@ -95,6 +103,7 @@ class TavilyClient:
95
103
  "auto_parameters": auto_parameters,
96
104
  "include_favicon": include_favicon,
97
105
  "include_usage": include_usage,
106
+ "exact_match": exact_match,
98
107
  }
99
108
 
100
109
  data = {k: v for k, v in data.items() if v is not None}
@@ -151,6 +160,7 @@ class TavilyClient:
151
160
  auto_parameters: bool = None,
152
161
  include_favicon: bool = None,
153
162
  include_usage: bool = None,
163
+ exact_match: bool = None,
154
164
  **kwargs, # Accept custom arguments
155
165
  ) -> dict:
156
166
  """
@@ -175,6 +185,7 @@ class TavilyClient:
175
185
  auto_parameters=auto_parameters,
176
186
  include_favicon=include_favicon,
177
187
  include_usage=include_usage,
188
+ exact_match=exact_match,
178
189
  **kwargs)
179
190
  response_dict.setdefault("results", [])
180
191
  return response_dict
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tavily-python
3
- Version: 0.7.21
3
+ Version: 0.7.23
4
4
  Summary: Python wrapper for the Tavily API
5
5
  Home-page: https://github.com/tavily-ai/tavily-python
6
6
  Author: Tavily AI
@@ -25,7 +25,7 @@ Dynamic: requires-dist
25
25
  Dynamic: requires-python
26
26
  Dynamic: summary
27
27
 
28
- # Tavily Python Wrapper
28
+ # Tavily Python SDK
29
29
 
30
30
  [![GitHub stars](https://img.shields.io/github/stars/tavily-ai/tavily-python?style=social)](https://github.com/tavily-ai/tavily-python/stargazers)
31
31
  [![PyPI - Downloads](https://img.shields.io/pypi/dm/tavily-python)](https://pypi.org/project/tavily-python/)
@@ -63,6 +63,21 @@ response = tavily_client.search("Who is Leo Messi?")
63
63
  print(response)
64
64
  ```
65
65
 
66
+ ### Using exact match to find specific names or phrases
67
+
68
+ ```python
69
+ from tavily import TavilyClient
70
+
71
+ client = TavilyClient(api_key="tvly-YOUR_API_KEY")
72
+
73
+ # Use exact_match=True to only return results containing the exact phrase(s) inside quotes
74
+ response = client.search(
75
+ query='"John Smith" CEO Acme Corp',
76
+ exact_match=True
77
+ )
78
+ print(response)
79
+ ```
80
+
66
81
  This is equivalent to directly querying our REST API.
67
82
 
68
83
  ### Generating context for a RAG Application
@@ -258,6 +273,53 @@ for chunk in stream:
258
273
  print(chunk.decode('utf-8'))
259
274
  ```
260
275
 
276
+ ## Advanced: Custom Session / Client Injection
277
+
278
+ For enterprise environments that proxy Tavily traffic through an API gateway (e.g., for centralized auth, logging, or policy enforcement), you can pass a pre-configured HTTP session instead of a Tavily API key.
279
+
280
+ ### Sync (custom `requests.Session`)
281
+
282
+ ```python
283
+ import requests
284
+ from tavily import TavilyClient
285
+
286
+ # Pre-configure a session with your gateway's auth
287
+ session = requests.Session()
288
+ session.headers["Authorization"] = "Bearer your-gateway-token"
289
+ session.headers["X-Subscription-Key"] = "your-subscription-key"
290
+
291
+ # No Tavily API key needed — auth is handled by the session
292
+ client = TavilyClient(
293
+ session=session,
294
+ api_base_url="https://your-gateway.com/tavily",
295
+ )
296
+
297
+ response = client.search("latest AI research")
298
+ ```
299
+
300
+ ### Async (custom `httpx.AsyncClient`)
301
+
302
+ ```python
303
+ import httpx
304
+ from tavily import AsyncTavilyClient
305
+
306
+ # Pre-configure an async client with your gateway's auth
307
+ custom_client = httpx.AsyncClient(
308
+ headers={"Authorization": "Bearer your-gateway-token"},
309
+ base_url="https://your-gateway.com/tavily",
310
+ )
311
+
312
+ client = AsyncTavilyClient(client=custom_client)
313
+
314
+ response = await client.search("latest AI research")
315
+ ```
316
+
317
+ **Key behaviors:**
318
+ - If a custom session/client is provided, `api_key` is optional
319
+ - Custom session headers take precedence over SDK defaults (e.g., your `Authorization` won't be overwritten)
320
+ - Custom session proxies take precedence over SDK proxy settings
321
+ - The SDK will **not** close externally-provided sessions — you manage the lifecycle
322
+
261
323
  ## Documentation
262
324
 
263
325
  For a complete guide on how to use the different endpoints and their parameters, please head to our [Python API Reference](https://docs.tavily.com/sdk/python/reference).
@@ -15,6 +15,7 @@ tavily_python.egg-info/dependency_links.txt
15
15
  tavily_python.egg-info/requires.txt
16
16
  tavily_python.egg-info/top_level.txt
17
17
  tests/test_crawl.py
18
+ tests/test_custom_session.py
18
19
  tests/test_errors.py
19
20
  tests/test_map.py
20
21
  tests/test_research.py
@@ -0,0 +1,410 @@
1
+ import asyncio
2
+ import httpx
3
+ from tests.request_intercept import intercept_requests, clear_interceptor, MockSession
4
+ import tavily.tavily as sync_tavily
5
+ import tavily.async_tavily as async_tavily
6
+ import pytest
7
+ from tavily.errors import MissingAPIKeyError
8
+
9
+
10
+ @pytest.fixture
11
+ def sync_interceptor():
12
+ yield intercept_requests(sync_tavily)
13
+ clear_interceptor(sync_tavily)
14
+
15
+
16
+ @pytest.fixture
17
+ def async_interceptor():
18
+ yield intercept_requests(async_tavily)
19
+ clear_interceptor(async_tavily)
20
+
21
+
22
+ # --- Sync TavilyClient tests ---
23
+
24
+ class TestSyncCustomSession:
25
+ def test_default_session_created_when_none_provided(self, sync_interceptor):
26
+ client = sync_tavily.TavilyClient(api_key="tvly-test")
27
+ assert not client._external_session
28
+
29
+ def test_custom_session_used(self, sync_interceptor):
30
+ custom_session = MockSession(sync_interceptor)
31
+ client = sync_tavily.TavilyClient(api_key="tvly-test", session=custom_session)
32
+ assert client._external_session
33
+ assert client.session is custom_session
34
+
35
+ def test_custom_session_preserves_existing_headers(self, sync_interceptor):
36
+ custom_session = MockSession(sync_interceptor)
37
+ custom_session.headers["Authorization"] = "Bearer apim-token-123"
38
+ custom_session.headers["X-Custom"] = "custom-value"
39
+
40
+ client = sync_tavily.TavilyClient(api_key="tvly-test", session=custom_session)
41
+
42
+ # Custom Authorization should be preserved (not overwritten by Tavily's)
43
+ assert client.session.headers["Authorization"] == "Bearer apim-token-123"
44
+ # Custom header should be preserved
45
+ assert client.session.headers["X-Custom"] == "custom-value"
46
+ # Tavily defaults should fill in missing headers
47
+ assert client.session.headers["Content-Type"] == "application/json"
48
+ assert client.session.headers["X-Client-Source"] == "tavily-python"
49
+
50
+ def test_custom_session_gets_default_headers_when_empty(self, sync_interceptor):
51
+ custom_session = MockSession(sync_interceptor)
52
+ client = sync_tavily.TavilyClient(api_key="tvly-test", session=custom_session)
53
+
54
+ assert client.session.headers["Authorization"] == "Bearer tvly-test"
55
+ assert client.session.headers["Content-Type"] == "application/json"
56
+
57
+ def test_custom_session_preserves_existing_proxies(self, sync_interceptor):
58
+ custom_session = MockSession(sync_interceptor)
59
+ custom_session.proxies["https"] = "http://my-proxy:8080"
60
+
61
+ client = sync_tavily.TavilyClient(
62
+ api_key="tvly-test",
63
+ session=custom_session,
64
+ proxies={"https": "http://tavily-proxy:9090"},
65
+ )
66
+
67
+ # Custom session proxy should take precedence
68
+ assert client.session.proxies["https"] == "http://my-proxy:8080"
69
+
70
+ def test_close_does_not_close_external_session(self, sync_interceptor):
71
+ closed = []
72
+ custom_session = MockSession(sync_interceptor)
73
+ custom_session.close = lambda: closed.append(True)
74
+
75
+ client = sync_tavily.TavilyClient(api_key="tvly-test", session=custom_session)
76
+ client.close()
77
+ assert len(closed) == 0
78
+
79
+ def test_close_closes_internal_session(self, sync_interceptor):
80
+ client = sync_tavily.TavilyClient(api_key="tvly-test")
81
+ # Should not raise — just verifies close() is called on internal session
82
+ client.close()
83
+
84
+ def test_context_manager_does_not_close_external_session(self, sync_interceptor):
85
+ closed = []
86
+ custom_session = MockSession(sync_interceptor)
87
+ custom_session.close = lambda: closed.append(True)
88
+
89
+ with sync_tavily.TavilyClient(api_key="tvly-test", session=custom_session):
90
+ pass
91
+ assert len(closed) == 0
92
+
93
+ def test_custom_session_sends_request(self, sync_interceptor):
94
+ sync_interceptor.set_response(200, json={"results": []})
95
+ custom_session = MockSession(sync_interceptor)
96
+ custom_session.headers["Authorization"] = "Bearer apim-token"
97
+
98
+ client = sync_tavily.TavilyClient(api_key="tvly-test", session=custom_session)
99
+ client.search("test query")
100
+
101
+ req = sync_interceptor.get_request()
102
+ assert req is not None
103
+ assert req.headers["Authorization"] == "Bearer apim-token"
104
+
105
+ # --- API key validation edge cases ---
106
+
107
+ def test_no_api_key_no_session_raises(self, monkeypatch):
108
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
109
+ with pytest.raises(MissingAPIKeyError):
110
+ sync_tavily.TavilyClient()
111
+
112
+ def test_no_api_key_with_session_allowed(self, sync_interceptor, monkeypatch):
113
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
114
+ custom_session = MockSession(sync_interceptor)
115
+ custom_session.headers["Authorization"] = "Bearer apim-token"
116
+ client = sync_tavily.TavilyClient(session=custom_session)
117
+ assert client.api_key is None
118
+ assert "Authorization" not in client.headers
119
+ assert client.session.headers["Authorization"] == "Bearer apim-token"
120
+
121
+ def test_no_api_key_with_session_no_auth_header_on_defaults(self, sync_interceptor, monkeypatch):
122
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
123
+ custom_session = MockSession(sync_interceptor)
124
+ client = sync_tavily.TavilyClient(session=custom_session)
125
+ # No api_key means no Authorization in defaults
126
+ assert "Authorization" not in client.headers
127
+ # Session shouldn't get an Authorization header either
128
+ assert "Authorization" not in client.session.headers
129
+
130
+ def test_no_api_key_with_session_sends_request(self, sync_interceptor, monkeypatch):
131
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
132
+ sync_interceptor.set_response(200, json={"results": []})
133
+ custom_session = MockSession(sync_interceptor)
134
+ custom_session.headers["Authorization"] = "Bearer apim-token"
135
+
136
+ client = sync_tavily.TavilyClient(session=custom_session)
137
+ client.search("test query")
138
+
139
+ req = sync_interceptor.get_request()
140
+ assert req is not None
141
+ assert req.headers["Authorization"] == "Bearer apim-token"
142
+
143
+ def test_empty_string_api_key_no_session_raises(self, monkeypatch):
144
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
145
+ with pytest.raises(MissingAPIKeyError):
146
+ sync_tavily.TavilyClient(api_key="")
147
+
148
+ def test_empty_string_api_key_with_session_allowed(self, sync_interceptor):
149
+ custom_session = MockSession(sync_interceptor)
150
+ client = sync_tavily.TavilyClient(api_key="", session=custom_session)
151
+ assert "Authorization" not in client.headers
152
+
153
+ def test_api_key_and_session_both_provided(self, sync_interceptor):
154
+ custom_session = MockSession(sync_interceptor)
155
+ client = sync_tavily.TavilyClient(api_key="tvly-test", session=custom_session)
156
+ # api_key provided and session has no Authorization, so default fills it in
157
+ assert client.session.headers["Authorization"] == "Bearer tvly-test"
158
+
159
+ def test_custom_session_with_all_endpoints(self, sync_interceptor):
160
+ custom_session = MockSession(sync_interceptor)
161
+ custom_session.headers["Authorization"] = "Bearer apim-token"
162
+
163
+ client = sync_tavily.TavilyClient(session=custom_session)
164
+
165
+ # search
166
+ sync_interceptor.set_response(200, json={"results": []})
167
+ client.search("test")
168
+ assert sync_interceptor.get_request().headers["Authorization"] == "Bearer apim-token"
169
+
170
+ # extract
171
+ sync_interceptor.set_response(200, json={"results": [], "failed_results": []})
172
+ client.extract(urls=["https://example.com"])
173
+ assert sync_interceptor.get_request().headers["Authorization"] == "Bearer apim-token"
174
+
175
+ # crawl
176
+ sync_interceptor.set_response(200, json={"results": []})
177
+ client.crawl(url="https://example.com")
178
+ assert sync_interceptor.get_request().headers["Authorization"] == "Bearer apim-token"
179
+
180
+ # map
181
+ sync_interceptor.set_response(200, json={"results": []})
182
+ client.map(url="https://example.com")
183
+ assert sync_interceptor.get_request().headers["Authorization"] == "Bearer apim-token"
184
+
185
+ def test_custom_session_with_custom_base_url(self, sync_interceptor):
186
+ sync_interceptor.set_response(200, json={"results": []})
187
+ custom_session = MockSession(sync_interceptor)
188
+
189
+ client = sync_tavily.TavilyClient(
190
+ api_key="tvly-test",
191
+ session=custom_session,
192
+ api_base_url="https://apim.corp.com/tavily",
193
+ )
194
+ client.search("test")
195
+
196
+ req = sync_interceptor.get_request()
197
+ assert req.url == "https://apim.corp.com/tavily/search"
198
+
199
+ def test_custom_session_proxies_fill_missing_protocols(self, sync_interceptor):
200
+ custom_session = MockSession(sync_interceptor)
201
+ custom_session.proxies["http"] = "http://session-proxy:8080"
202
+
203
+ client = sync_tavily.TavilyClient(
204
+ api_key="tvly-test",
205
+ session=custom_session,
206
+ proxies={"http": "http://arg-proxy:9090", "https": "http://arg-proxy:9091"},
207
+ )
208
+
209
+ # http: session proxy wins
210
+ assert client.session.proxies["http"] == "http://session-proxy:8080"
211
+ # https: session didn't have it, so arg fills it in
212
+ assert client.session.proxies["https"] == "http://arg-proxy:9091"
213
+
214
+ def test_custom_session_project_id_header(self, sync_interceptor):
215
+ custom_session = MockSession(sync_interceptor)
216
+ client = sync_tavily.TavilyClient(
217
+ api_key="tvly-test",
218
+ session=custom_session,
219
+ project_id="my-project",
220
+ )
221
+ assert client.session.headers["X-Project-ID"] == "my-project"
222
+
223
+ def test_shared_session_across_multiple_clients(self, sync_interceptor):
224
+ sync_interceptor.set_response(200, json={"results": []})
225
+ shared_session = MockSession(sync_interceptor)
226
+ shared_session.headers["Authorization"] = "Bearer shared-token"
227
+
228
+ client1 = sync_tavily.TavilyClient(session=shared_session)
229
+ client2 = sync_tavily.TavilyClient(session=shared_session)
230
+
231
+ assert client1.session is client2.session
232
+
233
+ client1.search("query1")
234
+ assert sync_interceptor.get_request().headers["Authorization"] == "Bearer shared-token"
235
+
236
+ client2.search("query2")
237
+ assert sync_interceptor.get_request().headers["Authorization"] == "Bearer shared-token"
238
+
239
+ # Closing one client should not close the shared session
240
+ client1.close()
241
+ client2.search("query3")
242
+ assert sync_interceptor.get_request() is not None
243
+
244
+
245
+ # --- Async AsyncTavilyClient tests ---
246
+
247
+ class TestAsyncCustomClient:
248
+ def test_default_client_created_when_none_provided(self):
249
+ client = async_tavily.AsyncTavilyClient(api_key="tvly-test")
250
+ assert not client._external_client
251
+
252
+ def test_custom_client_used(self):
253
+ custom_client = httpx.AsyncClient()
254
+ client = async_tavily.AsyncTavilyClient(api_key="tvly-test", client=custom_client)
255
+ assert client._external_client
256
+ assert client._client is custom_client
257
+
258
+ def test_custom_client_preserves_existing_headers(self):
259
+ custom_client = httpx.AsyncClient(headers={
260
+ "Authorization": "Bearer apim-token-123",
261
+ "X-Custom": "custom-value",
262
+ })
263
+ client = async_tavily.AsyncTavilyClient(api_key="tvly-test", client=custom_client)
264
+
265
+ assert client._client.headers["Authorization"] == "Bearer apim-token-123"
266
+ assert client._client.headers["X-Custom"] == "custom-value"
267
+ assert client._client.headers["Content-Type"] == "application/json"
268
+ assert client._client.headers["X-Client-Source"] == "tavily-python"
269
+
270
+ def test_custom_client_gets_default_headers_when_empty(self):
271
+ custom_client = httpx.AsyncClient()
272
+ client = async_tavily.AsyncTavilyClient(api_key="tvly-test", client=custom_client)
273
+
274
+ assert client._client.headers["Authorization"] == "Bearer tvly-test"
275
+ assert client._client.headers["Content-Type"] == "application/json"
276
+
277
+ def test_custom_client_base_url_set_when_missing(self):
278
+ custom_client = httpx.AsyncClient()
279
+ client = async_tavily.AsyncTavilyClient(api_key="tvly-test", client=custom_client)
280
+ assert "api.tavily.com" in str(client._client.base_url)
281
+
282
+ def test_custom_client_base_url_preserved_when_set(self):
283
+ custom_client = httpx.AsyncClient(base_url="https://apim.example.com/tavily")
284
+ client = async_tavily.AsyncTavilyClient(api_key="tvly-test", client=custom_client)
285
+ assert "apim.example.com" in str(client._client.base_url)
286
+
287
+ def test_close_does_not_close_external_client(self):
288
+ closed = []
289
+ custom_client = httpx.AsyncClient()
290
+
291
+ async def run():
292
+ client = async_tavily.AsyncTavilyClient(api_key="tvly-test", client=custom_client)
293
+ original_aclose = custom_client.aclose
294
+
295
+ async def track_close():
296
+ closed.append(True)
297
+ await original_aclose()
298
+
299
+ custom_client.aclose = track_close
300
+ await client.close()
301
+
302
+ asyncio.run(run())
303
+ assert len(closed) == 0
304
+
305
+ def test_context_manager_does_not_close_external_client(self):
306
+ closed = []
307
+ custom_client = httpx.AsyncClient()
308
+
309
+ async def run():
310
+ original_aclose = custom_client.aclose
311
+
312
+ async def track_close():
313
+ closed.append(True)
314
+ await original_aclose()
315
+
316
+ custom_client.aclose = track_close
317
+ async with async_tavily.AsyncTavilyClient(api_key="tvly-test", client=custom_client):
318
+ pass
319
+
320
+ asyncio.run(run())
321
+ assert len(closed) == 0
322
+
323
+ def test_custom_client_sends_request(self, async_interceptor):
324
+ async_interceptor.set_response(200, json={"results": []})
325
+ custom_client = httpx.AsyncClient(
326
+ headers={"Authorization": "Bearer apim-token"},
327
+ base_url="https://api.tavily.com",
328
+ )
329
+
330
+ client = async_tavily.AsyncTavilyClient(api_key="tvly-test", client=custom_client)
331
+ asyncio.run(client.search("test query"))
332
+
333
+ req = async_interceptor.get_request()
334
+ assert req is not None
335
+ assert req.headers["Authorization"] == "Bearer apim-token"
336
+
337
+ # --- API key validation edge cases ---
338
+
339
+ def test_no_api_key_no_client_raises(self, monkeypatch):
340
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
341
+ with pytest.raises(MissingAPIKeyError):
342
+ async_tavily.AsyncTavilyClient()
343
+
344
+ def test_no_api_key_with_client_allowed(self, monkeypatch):
345
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
346
+ custom_client = httpx.AsyncClient(
347
+ headers={"Authorization": "Bearer apim-token"},
348
+ )
349
+ client = async_tavily.AsyncTavilyClient(client=custom_client)
350
+ assert client._client.headers["Authorization"] == "Bearer apim-token"
351
+
352
+ def test_no_api_key_with_client_no_auth_header_on_defaults(self, monkeypatch):
353
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
354
+ custom_client = httpx.AsyncClient()
355
+ client = async_tavily.AsyncTavilyClient(client=custom_client)
356
+ # httpx always has headers dict but Authorization shouldn't be added
357
+ assert "authorization" not in [k.lower() for k in client._client.headers.keys()
358
+ if k.lower() == "authorization"
359
+ and client._client.headers[k].startswith("Bearer None")]
360
+
361
+ def test_no_api_key_with_client_sends_request(self, async_interceptor, monkeypatch):
362
+ monkeypatch.delenv("TAVILY_API_KEY", raising=False)
363
+ async_interceptor.set_response(200, json={"results": []})
364
+ custom_client = httpx.AsyncClient(
365
+ headers={"Authorization": "Bearer apim-token"},
366
+ base_url="https://api.tavily.com",
367
+ )
368
+
369
+ client = async_tavily.AsyncTavilyClient(client=custom_client)
370
+ asyncio.run(client.search("test query"))
371
+
372
+ req = async_interceptor.get_request()
373
+ assert req is not None
374
+ assert req.headers["Authorization"] == "Bearer apim-token"
375
+
376
+ def test_custom_client_with_all_endpoints(self, async_interceptor):
377
+ custom_client = httpx.AsyncClient(
378
+ headers={"Authorization": "Bearer apim-token"},
379
+ base_url="https://api.tavily.com",
380
+ )
381
+ client = async_tavily.AsyncTavilyClient(client=custom_client)
382
+
383
+ # search
384
+ async_interceptor.set_response(200, json={"results": []})
385
+ asyncio.run(client.search("test"))
386
+ assert async_interceptor.get_request().headers["Authorization"] == "Bearer apim-token"
387
+
388
+ # extract
389
+ async_interceptor.set_response(200, json={"results": [], "failed_results": []})
390
+ asyncio.run(client.extract(urls=["https://example.com"]))
391
+ assert async_interceptor.get_request().headers["Authorization"] == "Bearer apim-token"
392
+
393
+ # crawl
394
+ async_interceptor.set_response(200, json={"results": []})
395
+ asyncio.run(client.crawl(url="https://example.com"))
396
+ assert async_interceptor.get_request().headers["Authorization"] == "Bearer apim-token"
397
+
398
+ # map
399
+ async_interceptor.set_response(200, json={"results": []})
400
+ asyncio.run(client.map(url="https://example.com"))
401
+ assert async_interceptor.get_request().headers["Authorization"] == "Bearer apim-token"
402
+
403
+ def test_custom_client_project_id_header(self):
404
+ custom_client = httpx.AsyncClient()
405
+ client = async_tavily.AsyncTavilyClient(
406
+ api_key="tvly-test",
407
+ client=custom_client,
408
+ project_id="my-project",
409
+ )
410
+ assert client._client.headers["X-Project-ID"] == "my-project"
@@ -32,7 +32,7 @@ def validate_specific(request, response):
32
32
  assert request.headers["Authorization"] == "Bearer tvly-test"
33
33
  assert request.headers["X-Client-Source"] == "tavily-python"
34
34
  assert request.timeout == 10
35
-
35
+
36
36
  request_json = request.json()
37
37
  for key, value in {
38
38
  "query": "What is Tavily?",
@@ -44,7 +44,8 @@ def validate_specific(request, response):
44
44
  "exclude_domains": ["example.com"],
45
45
  "include_answer": "advanced",
46
46
  "include_raw_content": True,
47
- "include_images": True
47
+ "include_images": True,
48
+ "exact_match": True
48
49
  }.items():
49
50
  assert request_json.get(key) == value
50
51
 
@@ -69,6 +70,7 @@ def test_sync_search_specific(sync_interceptor, sync_client):
69
70
  include_answer="advanced",
70
71
  include_raw_content=True,
71
72
  include_images=True,
73
+ exact_match=True,
72
74
  timeout=10
73
75
  )
74
76
 
@@ -94,8 +96,61 @@ def test_async_search_specific(async_interceptor, async_client):
94
96
  include_answer="advanced",
95
97
  include_raw_content=True,
96
98
  include_images=True,
99
+ exact_match=True,
97
100
  timeout=10
98
101
  ))
99
102
 
100
103
  request = async_interceptor.get_request()
101
104
  validate_specific(request, response)
105
+
106
+ def test_sync_search_exact_match_not_sent_by_default(sync_interceptor, sync_client):
107
+ sync_interceptor.set_response(200, json=dummy_response)
108
+ sync_client.search("What is Tavily?")
109
+ request = sync_interceptor.get_request()
110
+ assert "exact_match" not in request.json()
111
+
112
+ def test_sync_search_exact_match_true(sync_interceptor, sync_client):
113
+ sync_interceptor.set_response(200, json=dummy_response)
114
+ sync_client.search("What is Tavily?", exact_match=True)
115
+ request = sync_interceptor.get_request()
116
+ assert request.json()["exact_match"] is True
117
+
118
+ def test_sync_search_exact_match_false(sync_interceptor, sync_client):
119
+ sync_interceptor.set_response(200, json=dummy_response)
120
+ sync_client.search("What is Tavily?", exact_match=False)
121
+ request = sync_interceptor.get_request()
122
+ assert request.json()["exact_match"] is False
123
+
124
+ def test_async_search_exact_match_not_sent_by_default(async_interceptor, async_client):
125
+ async_interceptor.set_response(200, json=dummy_response)
126
+ asyncio.run(async_client.search("What is Tavily?"))
127
+ request = async_interceptor.get_request()
128
+ assert "exact_match" not in request.json()
129
+
130
+ def test_async_search_exact_match_true(async_interceptor, async_client):
131
+ async_interceptor.set_response(200, json=dummy_response)
132
+ asyncio.run(async_client.search("What is Tavily?", exact_match=True))
133
+ request = async_interceptor.get_request()
134
+ assert request.json()["exact_match"] is True
135
+
136
+ def test_async_search_exact_match_false(async_interceptor, async_client):
137
+ async_interceptor.set_response(200, json=dummy_response)
138
+ asyncio.run(async_client.search("What is Tavily?", exact_match=False))
139
+ request = async_interceptor.get_request()
140
+ assert request.json()["exact_match"] is False
141
+
142
+ def test_sync_search_exact_match_query_quotes_escaped_in_payload(sync_interceptor, sync_client):
143
+ sync_interceptor.set_response(200, json=dummy_response)
144
+ sync_client.search('"John Smith" CEO Acme Corp', exact_match=True)
145
+ request = sync_interceptor.get_request()
146
+ # The raw JSON payload should have escaped quotes for the quoted phrase
147
+ assert r'\"John Smith\"' in request.body
148
+ # But the parsed query should preserve the original quotes
149
+ assert request.json()["query"] == '"John Smith" CEO Acme Corp'
150
+
151
+ def test_async_search_exact_match_query_quotes_escaped_in_payload(async_interceptor, async_client):
152
+ async_interceptor.set_response(200, json=dummy_response)
153
+ asyncio.run(async_client.search('"John Smith" CEO Acme Corp', exact_match=True))
154
+ request = async_interceptor.get_request()
155
+ assert r'\"John Smith\"' in request.body
156
+ assert request.json()["query"] == '"John Smith" CEO Acme Corp'
File without changes
File without changes